diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..016a1e9 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# Default review owner +* @xxg1413 diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..a60cc3f --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,12 @@ +# These are supported funding model platforms + +github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +patreon: pxiaoer +open_collective: # Replace with a single Open Collective username +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +otechie: # Replace with a single Otechie username +custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/.github/ISSUE_TEMPLATE/broken-link.yml b/.github/ISSUE_TEMPLATE/broken-link.yml new file mode 100644 index 0000000..2ca94a8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/broken-link.yml @@ -0,0 +1,66 @@ +name: Broken or moved link +description: Report a catalog URL that is permanently unavailable or has moved. +title: "[Broken link]: " +labels: + - broken-link +body: + - type: markdown + attributes: + value: | + A single 403, 429, timeout, or transient 5xx does not prove a resource is broken. Include repeatable evidence so maintainers can distinguish blocking and rate limiting from removal. + - type: input + id: page + attributes: + label: FlyPython page or catalog ID + placeholder: ai-agents / openai-agents-sdk + validations: + required: true + - type: input + id: url + attributes: + label: Affected URL + placeholder: https://example.org/old-page + validations: + required: true + - type: input + id: observed-on + attributes: + label: Observation date + description: Use YYYY-MM-DD. + placeholder: "2026-08-31" + validations: + required: true + - type: dropdown + id: result + attributes: + label: Observed result + options: + - 404 Not Found + - 410 Gone + - Redirects to unrelated content + - DNS or TLS failure + - 403 Forbidden + - 429 Too Many Requests + - Timeout or 5xx + - Other + validations: + required: true + - type: textarea + id: evidence + attributes: + label: Reproduction and evidence + description: Include what you tried, whether it reproduced, and any final redirect URL. Do not include cookies, tokens, or personal data. + validations: + required: true + - type: input + id: replacement + attributes: + label: Official replacement URL + description: Optional. Provide only a canonical replacement from the same publisher or project. + - type: checkboxes + id: confirmation + attributes: + label: Confirmation + options: + - label: I checked that this report does not expose a credential or security vulnerability. + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..297a377 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Python learning questions and discussion + url: https://github.com/flypythoncom/python/discussions + about: Ask for learning advice, compare resources, or start a general discussion. + - name: Private security report + url: https://github.com/flypythoncom/python/security/advisories/new + about: Report vulnerabilities privately. Do not disclose them in a public issue. diff --git a/.github/ISSUE_TEMPLATE/course-feedback.yml b/.github/ISSUE_TEMPLATE/course-feedback.yml new file mode 100644 index 0000000..36ced26 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/course-feedback.yml @@ -0,0 +1,46 @@ +name: Course feedback +description: Report teaching drift, an unclear lesson, or a verify.py mismatch in a course folder. +title: "[Course]: " +labels: ["course-feedback"] +body: + - type: input + id: course + attributes: + label: Course folder + description: Which courses// folder did you take? + placeholder: courses/hands-on-python-with-claude-code/ + validations: + required: true + - type: dropdown + id: kind + attributes: + label: What went wrong? + options: + - The agent drifted from COURSE.md (ignored rules, scope, or lesson order) + - A lesson was unclear or wrong + - verify.py result did not match the lesson's checkpoint + - Starter/solution/tests problem + - Something else + validations: + required: true + - type: input + id: lesson + attributes: + label: Lesson + description: Which lesson (L01–L05), if lesson-specific? + validations: + required: false + - type: textarea + id: detail + attributes: + label: What happened? + description: Describe what the agent did or what confused you. Include the exact command and output when reporting a verify.py mismatch. + validations: + required: true + - type: input + id: environment + attributes: + label: Environment + description: Tool and version (e.g. Claude Code 2.x), Python version, operating system. + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/project-proposal.yml b/.github/ISSUE_TEMPLATE/project-proposal.yml new file mode 100644 index 0000000..4f7ccc2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/project-proposal.yml @@ -0,0 +1,46 @@ +name: Propose a Python project +description: Suggest a maintained Python project for human review in Project Radar. +title: "[Project]: " +labels: ["project-proposal"] +body: + - type: input + id: source + attributes: + label: Official project URL + description: Link to the source repository or official project home. + validations: + required: true + - type: textarea + id: outcome + attributes: + label: What can a Python developer accomplish with it? + description: Describe a concrete user outcome, not marketing copy. + validations: + required: true + - type: textarea + id: evidence + attributes: + label: Maintenance and quality evidence + description: Include release, documentation, tests, license, and security evidence you checked. + validations: + required: true + - type: dropdown + id: area + attributes: + label: Primary area + options: + - Python engineering + - Web and APIs + - Automation + - AI coding + - AI agents + - Skills and MCP + validations: + required: true + - type: checkboxes + id: declaration + attributes: + label: Declaration + options: + - label: I disclosed any affiliation and wrote this proposal from sources I checked. + required: true diff --git a/.github/ISSUE_TEMPLATE/resource-proposal.yml b/.github/ISSUE_TEMPLATE/resource-proposal.yml new file mode 100644 index 0000000..4e16d2e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/resource-proposal.yml @@ -0,0 +1,99 @@ +name: Resource proposal +description: Propose an official Python or AI-agent resource for the reviewed catalog. +title: "[Resource]: " +labels: + - resource-proposal +body: + - type: markdown + attributes: + value: | + Thanks for helping improve FlyPython. Read the [curation policy](https://github.com/flypythoncom/python/blob/master/docs/CURATION_POLICY.md) before submitting. General learning questions belong in Discussions. + - type: input + id: title + attributes: + label: Resource name + placeholder: Python Packaging User Guide + validations: + required: true + - type: input + id: url + attributes: + label: Canonical URL + description: Use the official publisher, standard, documentation, or project URL. + placeholder: https://packaging.python.org/ + validations: + required: true + - type: dropdown + id: path + attributes: + label: Learning path + options: + - foundations + - web-apis + - automation + - ai-agents + validations: + required: true + - type: dropdown + id: source-type + attributes: + label: Source type + options: + - official-docs + - official-standard + - official-project + validations: + required: true + - type: dropdown + id: level + attributes: + label: Learner level + options: + - beginner + - intermediate + - advanced + - all-levels + validations: + required: true + - type: dropdown + id: language + attributes: + label: Resource language + description: Select the catalog's machine-readable language value. + options: + - en + - zh + - multilingual + validations: + required: true + - type: textarea + id: rationale + attributes: + label: Why it belongs + description: Explain the reader outcome, why this source is authoritative, and where it fits. Use your own words. + placeholder: This official guide helps intermediate readers... + validations: + required: true + - type: textarea + id: evidence + attributes: + label: Maintenance, licensing, and safety evidence + description: Include the review date, ownership or license, current maintenance evidence, API-key or paid-access requirements, and material risks. + validations: + required: true + - type: checkboxes + id: confirmations + attributes: + label: Submission checks + options: + - label: I read the curation policy and verified the canonical URL. + required: true + - label: The rationale and classification were reviewed by a person and were not generated by an LLM or web-search API. + required: true + - label: I disclosed any affiliation, sponsorship, or self-promotion in the additional context below. + required: true + - type: textarea + id: context + attributes: + label: Additional context or affiliation + description: State whether you maintain, work for, sponsor, or receive compensation from the resource. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..b88f09a --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,45 @@ +## Summary + +Describe the problem, the change, and the reader or maintainer outcome. + +## Change type + +- [ ] Catalog resource or classification +- [ ] Bilingual editorial content +- [ ] Catalog export or consumer contract +- [ ] Tests, tooling, or workflow +- [ ] Governance or documentation + +## Evidence and scope + +- Related issue: +- Source or maintenance evidence: +- Website consumer impact: +- Intentionally out of scope: + +For catalog changes, explain the official source, review date, license or +ownership, maintenance state, API-key or payment requirements, and safety risk. +Disclose any affiliation or self-promotion. + +## Validation + +List the actual results, not only the commands you intended to run. + +- [ ] `python -m pytest` +- [ ] `python tools/validate_catalog.py` +- [ ] `python tools/export_catalog.py --check` +- [ ] `python tools/render_readmes.py --check` +- [ ] `python tools/build_content_manifest.py --check` +- [ ] `python tools/verify_examples.py` +- [ ] English and Chinese output remain consistent +- [ ] No secrets, generated reports, local build output, or analytics IDs were committed + +Catalog links are fetched only by the scheduled or manually dispatched +workflow, not by pull-request CI. + +## Permissions and review + +- [ ] I have the right to submit this contribution. +- [ ] I retained third-party notices and did not relicense third-party material. +- [ ] Human reviewers wrote or verified all resource descriptions and classifications. +- [ ] I read `CONTRIBUTING.md` and `docs/CURATION_POLICY.md`. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..39ea102 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,27 @@ +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/link-audit.yml b/.github/workflows/link-audit.yml new file mode 100644 index 0000000..8fc6814 --- /dev/null +++ b/.github/workflows/link-audit.yml @@ -0,0 +1,45 @@ +name: Catalog link audit + +on: + schedule: + - cron: "23 1 * * 1" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: catalog-link-audit + cancel-in-progress: false + +jobs: + audit: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version-file: ".python-version" + cache: pip + + - name: Install Python dependencies + run: python -m pip install --disable-pip-version-check -r requirements.lock.txt + + - name: Prepare report directory + run: mkdir -p reports + + - name: Check every catalog link + run: python tools/check_links.py --mode all --output reports/link-check.json + + - name: Upload link report + if: ${{ always() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: catalog-link-report-${{ github.run_id }} + path: reports/link-check.json + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/notify-site.yml b/.github/workflows/notify-site.yml new file mode 100644 index 0000000..c05e404 --- /dev/null +++ b/.github/workflows/notify-site.yml @@ -0,0 +1,24 @@ +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 new file mode 100644 index 0000000..c7862b0 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,65 @@ +name: Validate + +on: + pull_request: + push: + branches: + - master + - main + +permissions: + contents: read + +concurrency: + group: validate-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + validate: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11", "3.12", "3.13"] + timeout-minutes: 15 + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Set up Python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} + + - name: Install dependencies and dev tools + run: | + uv venv --python ${{ matrix.python-version }} + uv pip install -r requirements-dev.lock.txt ruff + + - name: Lint with ruff + run: .venv/bin/ruff check . + + - 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..88de114 --- /dev/null +++ b/.gitignore @@ -0,0 +1,54 @@ +# OS +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Editor +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Logs +*.log + +# Runtime data +pids +*.pid +*.seed + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Temporary files +tmp/ +temp/ +.tmp/ + +# Python +.venv/ +venv/ +__pycache__/ +*.pyc + +# Generated reports +reports/ + +# Course scenario output written by learners running report_tool.py +courses/*/scenario/*/report.json +courses/*/scenario/*/report.json.tmp +courses/*/out/ +paths/*/capstone/out/ +courses/*/scenario/*/results.json + +# Test caches +.pytest_cache/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..939f85b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,81 @@ +# FlyPython repository guide + +## Mission + +This repository is the community-maintained source for building good Python +products in the AI-coding era. It is not a website. Keep `flypython.com` +focused on presentation, discovery, and conversion; keep this repository +focused on practical guides, playbooks, runnable examples, reusable templates, +reviewed catalog data, and stable public JSON contracts. + +## Content rules + +- Prefer official documentation and primary sources. +- Keep first-party Python engineering and AI-coding guidance specific, testable, + and honest about what automation can and cannot prove. +- Every catalog entry must include a stable ID, path, path-local order, source + type, level, rationale, review date, status, and any key or safety requirements. +- Do not call a project production-ready without current maintenance, licensing, + security, and adoption evidence. +- Do not use an LLM or web-search API to generate resource descriptions. + Descriptions and classifications require human review. +- Keep English and Chinese website output generated from the same canonical + resource data. Do not hand-copy catalog content into website source files. +- Keep paired English and Chinese first-party guides aligned in scope, version, + review date, and factual meaning. All content (guides, playbooks, examples, + courses, radar entries) ships EN+ZH in the same change. +- Governance documents (`AGENTS.md`, `CONTRIBUTING.md`, `docs/CONSUMING.md`, + `docs/CURATION_POLICY.md`, `docs/REPO_TO_WEBSITE.md`) are + English-canonical. Version plans (`docs/repo-plan-*.md`) are maintained + in Chinese by owner decision (2026-09-12); their English history stays + in git. +- Add first-party guides, playbooks, and courses to `content-manifest.json`; + keep each locale pair aligned and verify its source checksum. +- Courses live one folder per course under `courses//` and are taught by + an AI coding agent from the files themselves: `COURSE.md` (metadata plus the + teaching contract: audience, prerequisites, exact tool and version, + lesson order, teaching-style rules, when to stop, how to use `verify.py`, + and what the course does not cover) with `id: course-` and `badge` + frontmatter, `lessons/L01.md` with `L01_cn.md` + 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 + 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 + deviations). + A course is incomplete until every lesson ships EN+ZH in the same change; + `COURSE.md` must name the exact tool version it was taught with, and a tool + major release triggers re-review. Never claim guaranteed learning outcomes. +- Treat HTTP 403, 429, and transient 5xx responses as review-needed states, not + automatic proof that a resource is broken. + +## Change workflow + +- Work on a feature branch and preserve unrelated contributor changes. +- Run the repository validation workflow before committing. +- Regenerate `catalog.json` and `radar.json` after source changes and verify + them with `python tools/export_catalog.py --check --target both`. +- Regenerate both README catalog indexes and the Project Radar table, then + verify them with `python tools/render_readmes.py --check`. +- Regenerate `content-manifest.json` and verify it with + `python tools/build_content_manifest.py --check`. +- Verify every runnable example with `python tools/verify_examples.py`, + every course folder with `python tools/verify_courses.py`, and every + learning path with `python tools/verify_paths.py`. +- Website consumers must pin a full repository commit and verify the catalog + checksum. Do not make production builds depend on a moving branch. +- Keep external-link checks read-only, rate-limited, retryable, and blocked from + private or loopback network targets. +- Do not add API keys, tokens, analytics IDs, or generated reports to git. +- After merge, verify the immutable raw `catalog.json`. Verify flypython.com only + after a separate, deliberate consumer-version update. + +## Review priorities + +1. Content accuracy and source quality. +2. Deterministic export and bilingual parity. +3. Schema, consumer-contract, and safe link checks. +4. Clear contribution and licensing boundaries. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3c0a179 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,178 @@ +# Changelog + +This file records notable catalog-contract and maintenance changes. + +## [Unreleased] + +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`. + +### Changed + +- FP-1012: `docs/repo-plan-0.1.0.md` renamed to `docs/repo-plan-0.1.x.md` + — community courses are a 0.1.x track that starts after 0.1.0 ships, + not part of the launch version. + +0.0.9 — optional entry points (companion-repository half; see the +website plan `docs/product-and-growth-plan-0.0.9.md`). + +### Changed + +- FP-983: all six agent-tool courses' lesson 1 (EN+ZH) makes the + FlyPython Skill install step optional-but-recommended, and the named + failure count is unified as "five categories plus two end-to-end — + seven named failures" everywhere. +- FP-984: the foundation path's M0 orientation module is optional + extra credit (`paths/foundation/path.json`, `tools/verify_paths.py` + badge math updated); the route challenge moved off repository files + onto the site's `/tracks//` pages. + +0.0.8 — Skill as the main entry (companion-repository half; see the +website plan `docs/product-and-growth-plan-0.0.8.md`). + +### Added + +- FP-820: bare `python verify.py` is the learner's default command on + every course and the data-analysis capstone — runs the suites, prints + per-checkpoint status and earned claim codes (English first, Chinese + after), exits non-zero while gated checkpoints are open. The four + explicit usages remain maintainer checks; the published + `progress --json` / `--receipt-out` formats are unchanged. +- FP-821: `tools/verify_courses.py` now copies each course folder plus + the shared `tools/claim_receipt.py` into an isolated tree and runs the + default command and the receipts contract there — a course fetched via + the files endpoint verifies alone. + +### Changed + +- FP-822: the six agent-tool courses' lesson 1 (EN+ZH) is rewritten + around the fixed order get-the-tool-running → install the FlyPython + Skill (network access included) → let the agent fetch the course + files; every "download this folder" wording is gone from `COURSE.md`, + `COURSE_cn.md`, and the lessons, and `verify.py solution` is no longer + presented as a completion standard. +- FP-823: the prose checkpoints of `agent-rules-single-source`, + `mcp-server-in-python`, and `verifying-ai-generated-code` are question + lists the agent can ask directly at the human-in-the-loop gate; + data-analysis courses keep their concrete checkpoint criteria and + frontmatter hint questions. +- FP-824: both READMEs' "Start in three minutes" leads with the Skill + 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 — challenge-platform rework on top of 0.1.0. + +### Added + +- Per-checkpoint claim codes, badge frontmatter contracts, and the + deterministic `verify.py progress` subcommand (FP-411/412/415), enforced + by `tools/verify_courses.py`. +- The data-analysis course track: `da-eda`, `da-visualization`, and + `da-report` (EN+ZH), each with pandas/matplotlib `requirements.txt` where + needed and a stdlib-only report capstone. +- `paths/` learning paths sequencing courses into badge routes, plus + `tools/verify_paths.py` enforcing the path contract (FP-413/416/417/418, + wired into the Makefile and the validation workflow). +- `content-manifest.json` path entries (`type: "path"`) for the website. +- Checkpoint hints, the branch-path contract, and agent-solvability review + records across all eight courses (2026-09-13). + +### Changed + +- Repository narrative moved from agent-taught courses to challenge courses + with `TASK.md` contracts and `verify.py` claim codes; `COURSE.md` remains + the guided mode. README, README_cn, and `llms.txt` now reflect it. + +## [0.1.0] - 2026-09-12 + +First versioned release: agent-taught courses, the Project Radar +system, and the 0.0.3 contract updates. + + +### Added + +- A `courses/` content type: agent-taught folders with COURSE.md teaching + contracts, bilingual lesson pairs, scenario skins, task contracts, runnable + starter/solution pairs, and objective `verify.py` completion evidence. +- The five-course 0.0.3 batch: "Hands-on Python with Claude Code" + (flagship), "Hands-on with OpenAI Codex CLI" (reuses the C1 core), "One + source of truth for agent rules" (rule-drift checker core), "From 'it + runs' to 'it ships'" (release-evidence builder core), and "Give your + agent tools with MCP" (reuses the reviewed mcp-server contract) — each + EN+ZH with scenario skins and REVIEW.md run-through records. +- `tools/verify_courses.py` enforcing the course folder contract, wired into + the Makefile and the validation workflow. +- Project Radar per-project YAML records (`catalog/projects/*.yml`) with + lifecycle status, maintenance evidence, and `ai_familiarity` grading. +- Deterministic `radar.json` export with `schema/radar-v1.schema.json` and + `--check` support in `tools/export_catalog.py` (`--target both`). +- Bilingual generated Radar tables in `catalog/projects/README.md` and + `README_cn.md`. +- `tools/radar_scan.py`: read-only, rate-limited discovery of Radar review + candidates from GitHub Search, the PyPI feed, and Hacker News — candidates + only, never descriptions or status. +- A `course-feedback` issue template for teaching drift and verify mismatches. +- `content-manifest.json` and its schema now carry `course` documents. +- Courses and Radar sections in `llms.txt`. + +### Changed + +- README/README_cn gained a course banner, a courses row, and contextual + flypython.com footers on every guide, playbook, and example (first-party + continuation links per `docs/REPO_TO_WEBSITE.md`). +- The validation workflow now verifies the radar export and every course + folder in addition to the existing gates. +- Bilingual guide-URL tests now allow first-party flypython.com footer links + alongside reviewed catalog URLs. + +### Added (0.0.2 and earlier) + +- A complete bilingual Python AI-coding workflow covering task contracts, + repository inspection, reproducible environments, bounded changes, tests, + runtime verification, side-effect review, and evidence-based delivery. +- Browsable English and Chinese README indexes containing every reviewed + resource, its rationale, level, access requirements, risk, and review date. +- Deterministic README generation and drift checks backed by canonical catalog + data. +- One source file per reviewed resource under `catalog/resources/`. +- A deterministic, versioned `catalog.json` export for pinned website consumers. +- A JSON Schema describing the public catalog v1 contract. +- A pinned-revision and checksum contract for website consumers. +- Export drift checks in tests and pull-request validation. +- Positive, unique, consecutive ordering within each learning path. +- Catalog validation and a safe external-link auditor with retry, report, and + SSRF/DNS-rebinding protection. +- Contribution, conduct, security, issue, and resource-curation policies. +- A product-quality guide and five bilingual task playbooks for bug fixes, API + work, external integrations, dependency upgrades, and releases. +- A standard-library-only example with a deliberately failing starter, verified + solution, and task contract. +- Reusable task, plan, review, verification, agent-instruction, and pyproject + templates. +- A versioned `content-manifest.json` with bilingual paths, summaries, review + state, and source checksums for pinned website consumers. +- A human-review contribution queue for current Python Project Radar entries. + +### Changed (0.0.2 and earlier) + +- Defined this repository as the canonical Python product-engineering content, + catalog-data, and review layer behind flypython.com, rather than a second + public website. +- Split catalog metadata, paths, and resources into independently reviewable + files while preserving the 21 existing human-reviewed resource records. +- Required website consumers to pin a full repository commit and verify the + exported catalog checksum instead of following a moving branch. +- Reduced the required local toolchain to Python 3.12 and locked Python + dependencies. + +### Removed (0.0.2 and earlier) + +- Removed Jekyll, Ruby, page templates, styles, scripts, social assets, CNAME, + robots configuration, and site-rendering tests. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..3e295ed --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,124 @@ +# Contributor Covenant Code of Conduct + +## Our pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic +status, nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our standards + +Examples of behavior that contributes to a positive environment include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +- Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or advances + of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, + without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement responsibilities + +Community leaders are responsible for clarifying and enforcing our standards +of acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official email address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +hello@flypython.com. All complaints will be reviewed and investigated promptly +and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary ban + +**Community impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent ban + +**Community impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +https://www.contributor-covenant.org/version/2/1/code_of_conduct.html. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][mozilla-coc]. + +[homepage]: https://www.contributor-covenant.org +[mozilla-coc]: https://github.com/mozilla/diversity diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..eac0d39 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,136 @@ +# Contributing to FlyPython + +FlyPython maintains bilingual Python engineering guides, task playbooks, +runnable examples, templates, and a reviewed source catalog used by +[flypython.com](https://flypython.com/). Contributions should help a visitor +complete a real Python task or improve factual accuracy and maintainability. +General Python questions belong in +[GitHub Discussions](https://github.com/flypythoncom/python/discussions). + +Read the [curation policy](docs/CURATION_POLICY.md) before contributing. + +## Permissions + +This repository is dual-licensed as described in [LICENSE](LICENSE): code +(examples, tools, templates, course starters/solutions/verifiers) under the MIT +License, and written content (guides, playbooks, lessons, catalog records, +READMEs) under CC BY 4.0 with attribution "FlyPython (flypython.com)". +Contributors must submit only material they have the right to submit and retain +required third-party notices; a file's own license header, when present, wins. + +## Propose a change + +Use the matching issue form before a larger change: + +- **Resource proposal** for a new official source. +- **Project proposal** for a current Python project that should receive human + review for Project Radar. +- **Course feedback** for running a course or reporting a course problem. +- **Broken link** for an unreachable or replaced resource. +- **Security report** for a vulnerability; follow [SECURITY.md](SECURITY.md) + instead of opening a public issue. + +Small typo, metadata, or tooling fixes may go directly to a focused pull request. + +First-party guides and playbooks must remain practical, testable, and aligned +in English and Chinese. A content change must update both language files with +the same content version and review date, then regenerate +`content-manifest.json`. Do not present generated code, a passing test, or a +deployment command as proof of user value or production readiness. + +## Catalog sources + +The canonical source is the `catalog/` directory: + +- `catalog/catalog.yml` contains catalog-level review state. +- `catalog/paths.yml` defines the four bilingual learning paths. +- `catalog/resources/.yml` contains one reviewed resource. +- `catalog.json` is generated output and must not be edited by hand. + +Every resource file must include: + +- `id`, `path`, `order`, `title`, and `url` +- `source_type`, `level`, and `language` +- `why_en` and `why_zh` +- `reviewed_on` and `status` +- `requires_key`, `risk`, and `featured` + +The filename must match the stable resource ID. Resource `order` values must be +unique and consecutive within each path. + +Descriptions and classifications require human review. Do not use an LLM or a +web-search API to generate them. Prefer official documentation, official +standards, and official project pages. + +A 403, 429, timeout, or transient 5xx response is not enough to delete a +resource. Mark it for human review and provide repeatable evidence. + +## Local setup + +Install the exact Python version from `.python-version` and the locked +development dependencies: + +```bash +python -m venv .venv +. .venv/bin/activate +python -m pip install -r requirements-dev.lock.txt +``` + +After changing catalog sources, regenerate the public export: + +```bash +python tools/export_catalog.py +python tools/render_readmes.py +python tools/build_content_manifest.py +``` + +Run the same deterministic checks as 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 +``` + +Maintainers can run the networked link audit through GitHub Actions. For a +deliberate local audit of every catalog entry: + +```bash +python tools/check_links.py --mode all --output reports/link-check.json +``` + +Network fetching is excluded from pull-request CI. Any review-needed result +fails the scheduled audit for maintainer inspection, but does not by itself +justify removing a resource. + +## Pull request checklist + +- Keep source content and generated JSON exports consistent. +- Preserve English and Chinese meaning. +- Include evidence for maintenance, ownership, access, and safety claims. +- Do not call a project production-ready without current evidence. +- Do not commit secrets, generated reports, caches, or local environments. +- Explain consumer-visible changes and list the validation results. + +Passing automation does not replace editorial review. After merge, a website +consumer must deliberately update its pinned catalog commit before the new data +appears on flypython.com. + +## Release and tag rules + +- A pushed tag is **immutable**: never delete, move, or reuse it. `v0.1.0` + stays pointed at its original commit forever. +- This repository and the website (`flypythoncom/flypython.com`) are + independent release artifacts. They may ship different version numbers in + the same release train; the compatibility mapping is recorded in both + CHANGELOGs and in the GitHub Releases (e.g. website `v0.1.0` ↔ repository + `v0.1.1`), each naming the exact commit SHA. +- The website consumes this repository at a pinned full commit SHA + (`content-pin.json`) — never a moving branch or tag. +- `pyproject.toml` `version` tracks the repository release; bump it in the + change that cuts the release. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6330b01 --- /dev/null +++ b/LICENSE @@ -0,0 +1,53 @@ +FlyPython repository licensing +============================== + +This repository contains two kinds of material, under two licenses: + +1. Code — example sources, verification scripts, tools, and templates + (`examples/`, `tools/`, `templates/*.toml`, `courses/*/starter/`, + `courses/*/solution/`, `courses/*/verify.py`, and all other *.py / + configuration files) are licensed under the MIT License. + +2. Written content — guides, playbooks, course lessons, catalog records, + READMEs, and documentation (all *.md prose and `catalog/` YAML files) are + licensed under the Creative Commons Attribution 4.0 International License + (CC BY 4.0). Attribution: "FlyPython (flypython.com)". + +Where a directory contains both kinds of material, each file follows the rule +above by file type. If a file states its own license header, that header wins. + + +MIT License +----------- + +Copyright (c) 2026 FlyPython contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +Creative Commons Attribution 4.0 International +---------------------------------------------- + +Written content in this repository is licensed under CC BY 4.0: +https://creativecommons.org/licenses/by/4.0/ + +You are free to share and adapt the material for any purpose, including +commercially, provided you give appropriate credit to "FlyPython +(flypython.com)", provide a link to the license, and indicate if changes were +made. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7759721 --- /dev/null +++ b/Makefile @@ -0,0 +1,47 @@ +.PHONY: help check export render manifest test verify courses paths lint typecheck all + +PYTHON ?= python3 + +help: + @echo "FlyPython Development Workflow:" + @echo " make check - Run all catalog, radar, export, readme, manifest, example, and course checks" + @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 manifest - Regenerate content-manifest.json" + @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 all - Regenerate all exports and run all checks and tests" + +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 + +export: + $(PYTHON) tools/export_catalog.py --target both + +render: + $(PYTHON) tools/render_readmes.py + +manifest: + $(PYTHON) tools/build_content_manifest.py + +test: + $(PYTHON) -m pytest + +verify: + $(PYTHON) tools/verify_examples.py + +courses: + $(PYTHON) tools/verify_courses.py + +paths: + $(PYTHON) tools/verify_paths.py + +all: export render manifest test check diff --git a/README.md b/README.md index 47f8cbc..b7aaec5 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,282 @@ -python -====== -学习Python时的代码 +# 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. +[![GitHub stars](https://img.shields.io/github/stars/flypythoncom/python?style=flat-square&label=stars)](https://github.com/flypythoncom/python/stargazers) +[![Validate](https://github.com/flypythoncom/python/actions/workflows/validate.yml/badge.svg)](https://github.com/flypythoncom/python/actions/workflows/validate.yml) +[![Website](https://img.shields.io/badge/Website-flypython.com-blue?style=flat-square)](https://flypython.com) +[English](README.md) · [中文](README_cn.md) · [🌐 Online Portal](https://flypython.com) +> **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/). +FlyPython is a practical, bilingual repository for writing good Python and +turning it into products people can rely on. It combines AI-coding methods, +task playbooks, runnable examples, reusable templates, and reviewed primary +sources for APIs, automation, agents, Skills, and MCP. -####2016-03-20 update +It is not a beginner link dump. The goal is to help you move from “the agent +wrote code” to “a user outcome is verified.” The repository owns the reviewed +source content and data; [flypython.com](https://flypython.com/) turns pinned +versions into a browsable learning experience. -[Machine Learning with Python](https://github.com/xxg1413/MachineLearning) -- [Building Machine Learning Systems](https://github.com/xxg1413/MachineLearning/tree/master/Building%20Machine%20Learning%20Systems) -- [CS229 Machine Learning](https://github.com/xxg1413/MachineLearning/tree/master/CS229%20Machine%20Learning) -- [Machine Learning in Action](https://github.com/xxg1413/MachineLearning/tree/master/Machine%20Learning%20in%20Action) -- [NumPy Beginner's Guide](https://github.com/xxg1413/MachineLearning/tree/master/NumPy%20Beginner's%20Guide) -- [Web Scraping with Python](https://github.com/xxg1413/MachineLearning/tree/master/Web%20Scraping%20with%20Python) +## 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 +your agent, then paste this one sentence: +> Read https://flypython.com/skills/flypython/SKILL.md and start the FlyPython course `da-eda`. +The agent authorizes you with a one-time link (you never hand it a +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), +[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). + +Maintainers can still reproduce an example locally without any agent: + +```bash +git clone https://github.com/flypythoncom/python.git +cd python +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/`: `[open]` means the task is unfinished, `[passed]` gives a claim +code, and `[pending]` is a reflection checkpoint. Answer that lesson's +questions, then run `python verify.py --attest l01` (repeat for each completed +reflection) to get its 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 | +| --- | --- | --- | +| Learn by solving challenges | [Challenge courses](courses/) · [Learning paths](paths/README.md) | A verified project + checkpoint claim codes from `verify.py` | +| Write and change Python safely | [AI Coding workflow](guides/ai-coding/workflow.md) | A bounded change with explicit context and evidence | +| Turn Python into a reliable product | [Product quality guide](guides/python-engineering/product-quality.md) | A tested, observable, reversible product path | +| 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 | +| 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, +make the smallest testable change, verify behavior, review side effects, and +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 +source; the website helps you choose the next useful step. + +## For AI Agents and LLMs + +If you are an LLM agent or coding assistant (Cursor, Windsurf, Claude Code, Copilot, Perplexity): +- Ingest repository index: [`llms.txt`](llms.txt) or [`catalog.json`](catalog.json) +- Universal IDE rules template: [`templates/AGENT_RULES.example.md`](templates/AGENT_RULES.example.md) +- Machine-readable manifest: [`content-manifest.json`](content-manifest.json) +- Official web knowledge dump: `https://flypython.com/llms-full.txt` + +## Browse the 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. + + + +> **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). + + +## What this repository owns + +- First-party bilingual guides and task playbooks. +- Runnable, verifiable Python examples and reusable agent-work templates. +- Reviewed official documentation, standards, and project sources. +- Deterministic manifests, validation, exports, and safe link audits. +- A human-review queue for the [Python Project Radar](catalog/projects/README.md). + +The website owns presentation, navigation, search, newsletter, and future paid +experiences. It consumes a deliberate pinned version of this repository; it +must not silently fork or rewrite the source claims. +Maintainers can follow the [repository-to-website operating model](docs/REPO_TO_WEBSITE.md) +to add measurable calls to action without inventing unavailable products. + +## Repository structure + +```text +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 +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 +playbooks/ repeatable task procedures and definitions of done +examples/ small runnable projects with automated verification +templates/ task, plan, review, and verification starters +schema/ + *.schema.json versioned machine-readable contracts +catalog.json deterministic public export for consumers +radar.json deterministic Project Radar export for consumers +content-manifest.json versioned paths, summaries, and checksums for the website +tools/ generation, validation, example, and link-audit commands +tests/ content consistency and behavior tests +docs/ consumer contract and curation policy +``` + +`catalog.json` and `content-manifest.json` are generated; do not edit them by +hand. Website consumers read both from a pinned commit, verify checksums, and +record that revision in their own lock file. They must not fetch a moving branch +during a production build. + +Example immutable URL: + +```text +https://raw.githubusercontent.com/flypythoncom/python//catalog.json +``` + +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 +``` + +Network link fetching is intentionally excluded from pull-request validation. +Maintainers run it through the scheduled or manual **Catalog link audit** +workflow. + +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). diff --git a/README_cn.md b/README_cn.md new file mode 100644 index 0000000..3f4a18d --- /dev/null +++ b/README_cn.md @@ -0,0 +1,266 @@ +# FlyPython:用 AI 编码 Agent 把 Python 真正交付 + +用 AI 编码 Agent 把 Python 真正交付——真实项目;Agent 负责敲键盘, +`verify.py` 决定你什么时候算完成。 +[![GitHub stars](https://img.shields.io/github/stars/flypythoncom/python?style=flat-square&label=stars)](https://github.com/flypythoncom/python/stargazers) +[![Validate](https://github.com/flypythoncom/python/actions/workflows/validate.yml/badge.svg)](https://github.com/flypythoncom/python/actions/workflows/validate.yml) +[![Website](https://img.shields.io/badge/Website-flypython.com-blue?style=flat-square)](https://flypython.com) + +[English](README.md) · [中文](README_cn.md) · [🌐 官方在线门户](https://flypython.com) + +> **带客观验证的挑战课程。** 从 [`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 的一手资料。 + +这不是初学者链接合集。目标是帮助你从“Agent 写出了代码”走到“用户结果已经验证”。本仓库 +维护经过审核的源内容与数据,[flypython.com](https://flypython.com/) 把固定版本呈现为更适合 +阅读和发现的学习体验。 + +## 3 分钟开始 + +你需要一个能执行命令并联网的编码 Agent(Claude Code、Codex 应用、 +Cursor、DeepSeek Harness、Kimi Code 或 ZCode——只能聊天的网页 AI 做 +不了这些课程)。在你的 Agent 里装上 +[FlyPython Skill](https://flypython.com/skills/flypython/SKILL.md), +然后粘贴这一句话: + +> Read https://flypython.com/skills/flypython/SKILL.md and start the FlyPython course `da-eda`. + +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)、 +[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)。 + +维护者仍然可以不借助任何 Agent 在本地复现示例: + +```bash +git clone https://github.com/flypythoncom/python.git +cd python +python examples/product-slug/verify.py starter --expect-failure +python examples/product-slug/verify.py solution +``` + +## 验证并记录课程进度 + +在课程文件夹里,每次修改后运行 `python verify.py`。它只测试 `starter/`: +`[open]` 表示任务未完成,`[passed]` 会给出认领码,`[pending]` 是需要 +本人回答课后问题的自报检查点。回答后运行 `python verify.py --attest l01` +(每个已完成的自报检查点分别确认),取得 `[attested]` 认领码。只通过 +Agent 或工作台提交 `[passed]` 和 `[attested]` 的码。`check --json` 是 +版本化的 v2 接口;`progress --json` 只为旧版签名回执集成保留。 + +## 选择你现在要完成的事 + +| 目标 | 从这里开始 | 最终产出 | +| --- | --- | --- | +| 解题式动手实战 | [挑战课程](courses/) · [学习路线](paths/README.md) | 一个通过验证的项目 + `verify.py` 的检查点认领码 | +| 安全地编写和修改 Python | [AI Coding 工作方法](guides/ai-coding/workflow_cn.md) | 上下文明确、有证据的边界修改 | +| 把 Python 变成可靠产品 | [产品质量指南](guides/python-engineering/product-quality_cn.md) | 可测试、可观测、可回退的产品路径 | +| 完成反复出现的工程任务 | [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) | 经维护者审核、带证据的项目短名单 | + +工作循环很简单:定义用户结果、检查真实上下文、完成最小可测试修改、验证行为、审查副作用, +并记录尚未验证的部分。AI 可以加速这个循环,但不能替代工程判断。 + +希望按路径阅读并持续获得更新,可以继续前往 +[flypython.com](https://flypython.com/)。仓库仍然是可检查的源内容,网站帮助你选择下一步。 + +## 面向 AI 智能体与 LLM + +如果你是 LLM 智能体或 AI 编程助手(Cursor、Windsurf、Claude Code、Copilot、Perplexity): +- 快速读取结构化仓库索引:[`llms.txt`](llms.txt) 或 [`catalog.json`](catalog.json) +- 通用 IDE 行为守则模板:[`templates/AGENT_RULES.example_cn.md`](templates/AGENT_RULES.example_cn.md) +- 机器可读内容清单:[`content-manifest.json`](content-manifest.json) +- 官网无噪纯文本知识汇总:`https://flypython.com/llms-full.txt` + +## 浏览经过审核的一手资料 + +先选择一条学习路径,再打开符合当前目标的官方资料。目录会说明每项资源为什么被收录、 +适合什么阶段,以及需要关注的访问条件和安全边界。 + + + +> **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)。 + + +## 本仓库维护什么 + +- 中英文第一方指南和任务 Playbook; +- 可运行、可验证的 Python 示例与 agent 工作模板; +- 经过审核的官方文档、正式标准与项目一手来源; +- 确定性的 manifest、校验、导出和安全链接审计; +- [最新 Python 项目 Radar](catalog/projects/README.md) 的人工审核入口。 + +网站负责展示、导航、搜索、Newsletter 和未来的付费体验,并主动同步本仓库某个固定版本; +网站不能暗中复制或重写这里的源内容结论。 +维护者可以按照[仓库到网站的运营模型](docs/REPO_TO_WEBSITE.md)增加可衡量的下一步,但不能 +把尚未提供的产品包装成现货。 + +## 仓库结构 + +```text +catalog/ + catalog.yml 目录状态和审核日期 + paths.yml 中英文学习路径定义 + resources/ 每项资源一个 YAML 文件 + projects/ 最新 Python 项目的人工审核队列 +courses/ 带 TASK.md 契约和 verify.py 认领码的挑战课程 +paths/ 把课程串成徽章路线的学习路径 +guides/ Python 工程与 AI Coding 方法 +playbooks/ 可重复任务步骤与完成标准 +examples/ 带自动验证的可运行小项目 +templates/ 任务、计划、Review 和验证模板 +schema/ + *.schema.json 带版本的机器可读契约 +catalog.json 提供给网站使用的确定性公开导出 +radar.json 提供给网站使用的确定性 Project Radar 导出 +content-manifest.json 网站使用的版本、路径、摘要与校验值 +tools/ 生成、校验、示例和链接审计工具 +tests/ 内容一致性与行为测试 +docs/ 消费方契约和策展政策 +``` + +`catalog.json` 和 `content-manifest.json` 都由工具生成,不能手工修改。网站应从完整 +commit SHA 读取两份导出、校验文件摘要,并在自己的锁文件中记录该版本;生产构建不能 +直接追随持续变化的分支。 + +固定版本地址示例: + +```text +https://raw.githubusercontent.com/flypythoncom/python//catalog.json +``` + +内容 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)。网站接入还应遵循 +[消费方契约](docs/CONSUMING.md)。 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..1d4ece8 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,47 @@ +# Security policy + +## Supported versions + +Security fixes apply to the default branch, the current catalog schema, and the +checked-in public export. Historical commits, forks, and copied exports are not +supported releases. + +## Report a vulnerability privately + +Do not open a public issue for a security vulnerability. Use +[GitHub's private vulnerability reporting](https://github.com/flypythoncom/python/security/advisories/new). +If that form is unavailable, email hello@flypython.com with the subject +`FlyPython security report`. + +Include: + +- The affected URL, file, workflow, or commit +- Clear reproduction steps and impact +- Any proof of concept needed to confirm the issue +- Whether the issue is already public +- A safe way to contact you + +Remove credentials and personal data that are not required to reproduce the +issue. We will acknowledge reports on a best-effort basis, investigate, and +coordinate disclosure after a fix is available. + +## In scope + +Examples include: + +- Catalog parsing or export behavior that permits unsafe or ambiguous data +- A link-checking path that can reach private, loopback, or metadata services +- Workflow permission escalation or untrusted-code execution +- Exposed credentials or dependency-chain compromise with demonstrated impact + +Broken links, outdated descriptions, ordinary 403/429 responses, and resource +quality disagreements are not security vulnerabilities. Use the broken-link or +resource-proposal issue form for those reports. + +## Safe harbor + +Make a good-faith effort to avoid privacy violations, service disruption, data +destruction, and access beyond what is needed to demonstrate the issue. Do not +perform denial-of-service testing or interact with third-party resources beyond +their published policies. We will not pursue action against good-faith research +that follows this policy. diff --git a/catalog.json b/catalog.json new file mode 100644 index 0000000..5f55e51 --- /dev/null +++ b/catalog.json @@ -0,0 +1,605 @@ +{ + "$schema": "./schema/catalog-v1.schema.json", + "schema_version": 1, + "catalog": { + "reviewed_on": "2026-09-06", + "status": "active" + }, + "paths": [ + { + "id": "foundations", + "order": 1, + "title_en": "Python foundations", + "title_zh": "Python 基础", + "summary_en": "Learn the language, environments, dependencies, typing, and tests that reliable Python work depends on.", + "summary_zh": "掌握语言基础、环境、依赖、类型与测试,为可靠的 Python 项目打好根基。" + }, + { + "id": "web-apis", + "order": 2, + "title_en": "Web and APIs", + "title_zh": "Web 与 API", + "summary_en": "Build typed services and applications that connect Python logic to users and other systems.", + "summary_zh": "构建有类型约束的服务和应用,把 Python 逻辑连接到用户与其他系统。" + }, + { + "id": "automation", + "order": 3, + "title_en": "Automation", + "title_zh": "自动化", + "summary_en": "Turn repeatable work into maintainable scripts, browser workflows, and data pipelines.", + "summary_zh": "把重复工作变成可维护的脚本、浏览器工作流和数据管道。" + }, + { + "id": "ai-agents", + "order": 4, + "title_en": "AI agents", + "title_zh": "AI Agent", + "summary_en": "Learn tools, structured output, state, evaluation, and the safety boundaries of agent systems.", + "summary_zh": "理解工具调用、结构化输出、状态、评测以及 Agent 系统的安全边界。" + } + ], + "resources": [ + { + "id": "python-tutorial", + "path": "foundations", + "title": "The Python Tutorial", + "url": "https://docs.python.org/3/tutorial/", + "source_type": "official-docs", + "level": "beginner", + "language": "en", + "why_en": "The primary introduction to Python syntax, data structures, modules, errors, classes, and the standard library.", + "why_zh": "Python 官方入门教程,覆盖语法、数据结构、模块、异常、类与标准库。", + "reviewed_on": "2026-08-31", + "status": "active", + "requires_key": false, + "risk": "low", + "featured": true, + "order": 1 + }, + { + "id": "python-venv", + "path": "foundations", + "title": "venv — Creation of virtual environments", + "url": "https://docs.python.org/3/library/venv.html", + "source_type": "official-docs", + "level": "beginner", + "language": "en", + "why_en": "The standard-library reference for creating isolated Python environments and understanding their behavior.", + "why_zh": "Python 标准库的虚拟环境参考,说明如何创建隔离环境及其运行方式。", + "reviewed_on": "2026-08-31", + "status": "active", + "requires_key": false, + "risk": "low", + "featured": false, + "order": 2 + }, + { + "id": "python-packaging-guide", + "path": "foundations", + "title": "Python Packaging User Guide", + "url": "https://packaging.python.org/en/latest/tutorials/packaging-projects/", + "source_type": "official-docs", + "level": "intermediate", + "language": "en", + "why_en": "The PyPA tutorial for project layout, build metadata, distributions, and publishing Python packages.", + "why_zh": "PyPA 官方教程,覆盖项目结构、构建元数据、分发包与 Python 包发布。", + "reviewed_on": "2026-08-31", + "status": "active", + "requires_key": false, + "risk": "low", + "featured": false, + "order": 3 + }, + { + "id": "uv-docs", + "path": "foundations", + "title": "uv documentation", + "url": "https://docs.astral.sh/uv/", + "source_type": "official-project", + "level": "all-levels", + "language": "en", + "why_en": "A modern workflow for Python versions, virtual environments, dependencies, lockfiles, and project commands.", + "why_zh": "现代 Python 工作流,统一管理版本、虚拟环境、依赖、锁文件和项目命令。", + "reviewed_on": "2026-08-31", + "status": "active", + "requires_key": false, + "risk": "low", + "featured": true, + "order": 4 + }, + { + "id": "pytest-docs", + "path": "foundations", + "title": "pytest documentation", + "url": "https://docs.pytest.org/en/stable/", + "source_type": "official-project", + "level": "intermediate", + "language": "en", + "why_en": "The official guide to test discovery, assertions, fixtures, parametrization, and maintainable test suites.", + "why_zh": "pytest 官方指南,覆盖测试发现、断言、fixture、参数化和可维护测试套件。", + "reviewed_on": "2026-08-31", + "status": "active", + "requires_key": false, + "risk": "low", + "featured": false, + "order": 5 + }, + { + "id": "typing-docs", + "path": "foundations", + "title": "Python typing documentation", + "url": "https://typing.python.org/en/latest/", + "source_type": "official-docs", + "level": "intermediate", + "language": "en", + "why_en": "The canonical home for Python's type-system specification, guides, and typing best practices.", + "why_zh": "Python 类型系统规范、指南与类型标注实践的权威入口。", + "reviewed_on": "2026-08-31", + "status": "active", + "requires_key": false, + "risk": "low", + "featured": false, + "order": 6 + }, + { + "id": "ruff-docs", + "path": "foundations", + "title": "Ruff documentation", + "url": "https://docs.astral.sh/ruff/", + "source_type": "official-project", + "level": "all-levels", + "language": "en", + "why_en": "An extremely fast Python linter and code formatter written in Rust, replacing Flake8, Black, isort, and more.", + "why_zh": "极速的 Rust 驱动 Python 代码检查与格式化工具,统一替代 Flake8、Black 与 isort。", + "reviewed_on": "2026-09-02", + "status": "active", + "requires_key": false, + "risk": "low", + "featured": true, + "order": 7 + }, + { + "id": "mypy-docs", + "path": "foundations", + "title": "Mypy documentation", + "url": "https://mypy.readthedocs.io/en/stable/", + "source_type": "official-project", + "level": "intermediate", + "language": "en", + "why_en": "The standard static type checker for Python to catch bugs and verify type contracts before running code.", + "why_zh": "Python 官方与社区主流的静态类型检查器,在运行前拦截类型错误并验证代码契约。", + "reviewed_on": "2026-09-02", + "status": "active", + "requires_key": false, + "risk": "low", + "featured": false, + "order": 8 + }, + { + "id": "fastapi-docs", + "path": "web-apis", + "title": "FastAPI documentation", + "url": "https://fastapi.tiangolo.com/", + "source_type": "official-docs", + "level": "intermediate", + "language": "en", + "why_en": "A direct route from typed Python functions to tested APIs with validation and generated documentation.", + "why_zh": "从带类型的 Python 函数出发,构建具备验证、测试和自动文档的 API。", + "reviewed_on": "2026-08-31", + "status": "active", + "requires_key": false, + "risk": "low", + "featured": true, + "order": 1 + }, + { + "id": "django-tutorial", + "path": "web-apis", + "title": "Django documentation: First steps", + "url": "https://docs.djangoproject.com/en/stable/intro/tutorial01/", + "source_type": "official-docs", + "level": "beginner", + "language": "en", + "why_en": "The official path through Django projects, models, views, templates, forms, tests, and reusable apps.", + "why_zh": "Django 官方入门路径,覆盖项目、模型、视图、模板、表单、测试和可复用应用。", + "reviewed_on": "2026-08-31", + "status": "active", + "requires_key": false, + "risk": "low", + "featured": false, + "order": 2 + }, + { + "id": "flask-docs", + "path": "web-apis", + "title": "Flask documentation", + "url": "https://flask.palletsprojects.com/en/stable/", + "source_type": "official-docs", + "level": "beginner", + "language": "en", + "why_en": "The maintained reference and tutorial for Flask applications, requests, templates, testing, and deployment.", + "why_zh": "Flask 官方参考与教程,覆盖应用、请求、模板、测试和部署。", + "reviewed_on": "2026-08-31", + "status": "active", + "requires_key": false, + "risk": "low", + "featured": false, + "order": 3 + }, + { + "id": "pydantic-docs", + "path": "web-apis", + "title": "Pydantic documentation", + "url": "https://pydantic.dev/docs/validation/latest/get-started/", + "source_type": "official-docs", + "level": "intermediate", + "language": "en", + "why_en": "The primary guide to validating untrusted data and expressing typed contracts at Python system boundaries.", + "why_zh": "在 Python 系统边界验证不可信数据、定义类型契约的官方指南。", + "reviewed_on": "2026-08-31", + "status": "active", + "requires_key": false, + "risk": "low", + "featured": false, + "order": 4 + }, + { + "id": "httpx-docs", + "path": "web-apis", + "title": "HTTPX documentation", + "url": "https://www.python-httpx.org/", + "source_type": "official-project", + "level": "intermediate", + "language": "en", + "why_en": "The official reference for synchronous and asynchronous HTTP clients, timeouts, streaming, and transport control.", + "why_zh": "同步与异步 HTTP 客户端、超时、流式响应和传输控制的官方参考。", + "reviewed_on": "2026-08-31", + "status": "active", + "requires_key": false, + "risk": "low", + "featured": false, + "order": 5 + }, + { + "id": "sqlalchemy-docs", + "path": "web-apis", + "title": "SQLAlchemy documentation", + "url": "https://docs.sqlalchemy.org/en/20/", + "source_type": "official-project", + "level": "intermediate", + "language": "en", + "why_en": "The definitive Python SQL toolkit and Object Relational Mapper, providing type-safe database access and migrations.", + "why_zh": "Python 权威的 SQL 工具包与对象关系映射器(ORM),提供类型安全的数据持久化与访问。", + "reviewed_on": "2026-09-02", + "status": "active", + "requires_key": false, + "risk": "low", + "featured": false, + "order": 6 + }, + { + "id": "alembic-docs", + "path": "web-apis", + "title": "Alembic documentation", + "url": "https://alembic.sqlalchemy.org/en/latest/", + "source_type": "official-project", + "level": "intermediate", + "language": "en", + "why_en": "Database migration tool for SQLAlchemy, providing transactional schema changes and rollback support.", + "why_zh": "基于 SQLAlchemy 的数据库迁移事实标准工具,提供事务性架构演进与版本回滚支持。", + "reviewed_on": "2026-09-02", + "status": "active", + "requires_key": false, + "risk": "medium", + "featured": false, + "order": 7 + }, + { + "id": "pathlib-docs", + "path": "automation", + "title": "pathlib — Object-oriented filesystem paths", + "url": "https://docs.python.org/3/library/pathlib.html", + "source_type": "official-docs", + "level": "beginner", + "language": "en", + "why_en": "The standard-library reference for readable, cross-platform file and directory automation.", + "why_zh": "使用可读、跨平台的对象式路径完成文件与目录自动化的标准库参考。", + "reviewed_on": "2026-08-31", + "status": "active", + "requires_key": false, + "risk": "low", + "featured": false, + "order": 1 + }, + { + "id": "subprocess-docs", + "path": "automation", + "title": "subprocess — Subprocess management", + "url": "https://docs.python.org/3/library/subprocess.html", + "source_type": "official-docs", + "level": "intermediate", + "language": "en", + "why_en": "The standard-library contract for launching processes, capturing output, handling failures, and avoiding unsafe shell usage.", + "why_zh": "启动进程、捕获输出、处理失败并避免不安全 shell 用法的标准库契约。", + "reviewed_on": "2026-08-31", + "status": "active", + "requires_key": false, + "risk": "medium", + "featured": false, + "order": 2 + }, + { + "id": "playwright-python", + "path": "automation", + "title": "Playwright for Python", + "url": "https://playwright.dev/python/docs/intro", + "source_type": "official-docs", + "level": "intermediate", + "language": "en", + "why_en": "The official Python guide to reliable browser automation, locators, assertions, traces, and isolated contexts.", + "why_zh": "可靠浏览器自动化、定位器、断言、追踪和隔离上下文的 Python 官方指南。", + "reviewed_on": "2026-08-31", + "status": "active", + "requires_key": false, + "risk": "medium", + "featured": false, + "order": 3 + }, + { + "id": "scrapy-tutorial", + "path": "automation", + "title": "Scrapy tutorial", + "url": "https://docs.scrapy.org/en/latest/intro/tutorial.html", + "source_type": "official-docs", + "level": "intermediate", + "language": "en", + "why_en": "The official introduction to crawl structure, extraction, item pipelines, persistence, and responsible operation.", + "why_zh": "爬虫结构、数据提取、item pipeline、持久化与负责任运行的官方入门。", + "reviewed_on": "2026-08-31", + "status": "active", + "requires_key": false, + "risk": "medium", + "featured": false, + "order": 4 + }, + { + "id": "pandas-getting-started", + "path": "automation", + "title": "pandas getting started guides", + "url": "https://pandas.pydata.org/docs/getting_started/index.html", + "source_type": "official-docs", + "level": "beginner", + "language": "en", + "why_en": "The primary entry point for tabular data loading, cleaning, transformation, analysis, and export.", + "why_zh": "表格数据读取、清洗、转换、分析和导出的官方入门入口。", + "reviewed_on": "2026-08-31", + "status": "active", + "requires_key": false, + "risk": "low", + "featured": false, + "order": 5 + }, + { + "id": "polars-docs", + "path": "automation", + "title": "Polars documentation", + "url": "https://docs.pola.rs/", + "source_type": "official-project", + "level": "intermediate", + "language": "en", + "why_en": "Lightning-fast DataFrame library built on Apache Arrow and Rust, optimized for efficient data pipelines and parallel processing.", + "why_zh": "基于 Apache Arrow 和 Rust 构建的极速 DataFrame 库,专为并行计算与现代数据管道优化。", + "reviewed_on": "2026-09-02", + "status": "active", + "requires_key": false, + "risk": "low", + "featured": false, + "order": 6 + }, + { + "id": "duckdb-docs", + "path": "automation", + "title": "DuckDB Python API documentation", + "url": "https://duckdb.org/docs/api/python/overview", + "source_type": "official-project", + "level": "intermediate", + "language": "en", + "why_en": "Fast in-process analytical SQL engine for automated data transformation, Parquet querying, and embedded analytics.", + "why_zh": "进程内高性能分析型 SQL 引擎,适用于自动化数据转换、Parquet 文件快速查询与嵌入式分析。", + "reviewed_on": "2026-09-02", + "status": "active", + "requires_key": false, + "risk": "low", + "featured": false, + "order": 7 + }, + { + "id": "openai-agents-sdk", + "path": "ai-agents", + "title": "OpenAI Agents SDK", + "url": "https://openai.github.io/openai-agents-python/", + "source_type": "official-docs", + "level": "intermediate", + "language": "en", + "why_en": "The official Python toolkit for agents, tools, handoffs, guardrails, sessions, tracing, and orchestration.", + "why_zh": "面向 Agent、工具、handoff、guardrail、session、追踪与编排的官方 Python 工具包。", + "reviewed_on": "2026-08-31", + "status": "active", + "requires_key": true, + "risk": "medium", + "featured": true, + "order": 1 + }, + { + "id": "pydantic-ai", + "path": "ai-agents", + "title": "Pydantic AI", + "url": "https://pydantic.dev/docs/ai/overview/", + "source_type": "official-docs", + "level": "intermediate", + "language": "en", + "why_en": "Typed agent development built around validated inputs, structured output, tools, testing, and model portability.", + "why_zh": "以输入验证、结构化输出、工具、测试和模型可移植性为核心的类型安全 Agent 开发。", + "reviewed_on": "2026-08-31", + "status": "active", + "requires_key": true, + "risk": "medium", + "featured": true, + "order": 2 + }, + { + "id": "model-context-protocol", + "path": "ai-agents", + "title": "Model Context Protocol", + "url": "https://modelcontextprotocol.io/docs/getting-started/intro", + "source_type": "official-standard", + "level": "intermediate", + "language": "en", + "why_en": "The open protocol and reference documentation for connecting AI applications to tools, data, and reusable context.", + "why_zh": "连接 AI 应用、工具、数据与可复用上下文的开放协议和权威参考。", + "reviewed_on": "2026-08-31", + "status": "active", + "requires_key": false, + "risk": "medium", + "featured": true, + "order": 3 + }, + { + "id": "langgraph-python", + "path": "ai-agents", + "title": "LangGraph overview", + "url": "https://docs.langchain.com/oss/python/langgraph/overview", + "source_type": "official-docs", + "level": "advanced", + "language": "en", + "why_en": "The official Python guide to stateful graph workflows, durable execution, memory, interrupts, and human review.", + "why_zh": "有状态图工作流、持久执行、记忆、中断与人工审核的 Python 官方指南。", + "reviewed_on": "2026-08-31", + "status": "active", + "requires_key": true, + "risk": "medium", + "featured": false, + "order": 4 + }, + { + "id": "google-adk", + "path": "ai-agents", + "title": "Google Agent Development Kit", + "url": "https://adk.dev/", + "source_type": "official-docs", + "level": "advanced", + "language": "en", + "why_en": "Google's official documentation for developing, evaluating, and deploying modular agent systems.", + "why_zh": "Google 官方模块化 Agent 系统开发、评测与部署文档。", + "reviewed_on": "2026-08-31", + "status": "active", + "requires_key": true, + "risk": "medium", + "featured": false, + "order": 5 + }, + { + "id": "instructor-docs", + "path": "ai-agents", + "title": "Instructor documentation", + "url": "https://python.useinstructor.com/", + "source_type": "official-project", + "level": "intermediate", + "language": "en", + "why_en": "Structured outputs and validation for LLMs powered by Pydantic, enabling predictable tool calling and schema enforcement.", + "why_zh": "基于 Pydantic 的大模型结构化输出与严格验证框架,确保工具调用与数据提取的确定性。", + "reviewed_on": "2026-09-02", + "status": "active", + "requires_key": true, + "risk": "medium", + "featured": false, + "order": 6 + }, + { + "id": "anthropic-mcp-docs", + "path": "ai-agents", + "title": "Model Context Protocol Python SDK", + "url": "https://github.com/modelcontextprotocol/python-sdk", + "source_type": "official-project", + "level": "intermediate", + "language": "en", + "why_en": "Official Python implementation of the Model Context Protocol for building MCP servers and clients.", + "why_zh": "Model Context Protocol 官方 Python SDK,用于快速构建和集成标准的 MCP 工具与服务。", + "reviewed_on": "2026-09-02", + "status": "active", + "requires_key": false, + "risk": "medium", + "featured": false, + "order": 7 + }, + { + "id": "mcp-2026-07-28-spec-release", + "path": "ai-agents", + "title": "MCP 2026-07-28 specification release", + "url": "https://blog.modelcontextprotocol.io/posts/2026-07-28/", + "source_type": "official-docs", + "level": "intermediate", + "language": "en", + "why_en": "The official release announcement for the 2026-07-28 Model Context Protocol specification, covering the stateless redesign and breaking changes.", + "why_zh": "Model Context Protocol 2026-07-28 规范的官方发布公告,说明无状态重构与破坏性变更。", + "reviewed_on": "2026-09-06", + "status": "active", + "requires_key": false, + "risk": "low", + "featured": false, + "order": 8 + }, + { + "id": "mcp-2026-07-28-release-candidate", + "path": "ai-agents", + "title": "MCP 2026-07-28 release candidate overview", + "url": "https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/", + "source_type": "official-docs", + "level": "intermediate", + "language": "en", + "why_en": "The official release-candidate post that enumerates the 2026-07-28 breaking changes and the migration window for SDK maintainers.", + "why_zh": "官方 RC 发布说明,逐项列出 2026-07-28 的破坏性变更以及留给 SDK 维护者的迁移窗口。", + "reviewed_on": "2026-09-06", + "status": "active", + "requires_key": false, + "risk": "low", + "featured": false, + "order": 9 + }, + { + "id": "anthropic-mcp-aaif-donation", + "path": "ai-agents", + "title": "Anthropic donating MCP to the Agentic AI Foundation", + "url": "https://www.anthropic.com/news/donating-the-model-context-protocol-and-establishing-of-the-agentic-ai-foundation", + "source_type": "official-docs", + "level": "intermediate", + "language": "en", + "why_en": "Anthropic's announcement establishing neutral Linux Foundation governance for MCP under the Agentic AI Foundation.", + "why_zh": "Anthropic 官方公告,宣布将 MCP 交由 Linux 基金会旗下的 Agentic AI Foundation 中立治理。", + "reviewed_on": "2026-09-06", + "status": "active", + "requires_key": false, + "risk": "low", + "featured": false, + "order": 10 + }, + { + "id": "aaif-model-context-protocol", + "path": "ai-agents", + "title": "MCP under the Agentic AI Foundation", + "url": "https://aaif.io/projects/model-context-protocol", + "source_type": "official-project", + "level": "intermediate", + "language": "en", + "why_en": "The Agentic AI Foundation project page for MCP, the neutral governance home for specification development and roadmaps.", + "why_zh": "Agentic AI Foundation 的 MCP 项目主页,是规范开发与路线图的中立治理入口。", + "reviewed_on": "2026-09-06", + "status": "active", + "requires_key": false, + "risk": "low", + "featured": false, + "order": 11 + } + ] +} diff --git a/catalog/catalog.yml b/catalog/catalog.yml new file mode 100644 index 0000000..d802a1b --- /dev/null +++ b/catalog/catalog.yml @@ -0,0 +1,2 @@ +reviewed_on: '2026-09-06' +status: active diff --git a/catalog/paths.yml b/catalog/paths.yml new file mode 100644 index 0000000..3ab0048 --- /dev/null +++ b/catalog/paths.yml @@ -0,0 +1,28 @@ +- id: foundations + order: 1 + title_en: Python foundations + title_zh: Python 基础 + summary_en: Learn the language, environments, dependencies, typing, and tests that + reliable Python work depends on. + summary_zh: 掌握语言基础、环境、依赖、类型与测试,为可靠的 Python 项目打好根基。 +- id: web-apis + order: 2 + title_en: Web and APIs + title_zh: Web 与 API + summary_en: Build typed services and applications that connect Python logic to users + and other systems. + summary_zh: 构建有类型约束的服务和应用,把 Python 逻辑连接到用户与其他系统。 +- id: automation + order: 3 + title_en: Automation + title_zh: 自动化 + summary_en: Turn repeatable work into maintainable scripts, browser workflows, and + data pipelines. + summary_zh: 把重复工作变成可维护的脚本、浏览器工作流和数据管道。 +- id: ai-agents + order: 4 + title_en: AI agents + title_zh: AI Agent + summary_en: Learn tools, structured output, state, evaluation, and the safety boundaries + of agent systems. + summary_zh: 理解工具调用、结构化输出、状态、评测以及 Agent 系统的安全边界。 diff --git a/catalog/projects/README.md b/catalog/projects/README.md new file mode 100644 index 0000000..e36a8e4 --- /dev/null +++ b/catalog/projects/README.md @@ -0,0 +1,45 @@ +# Python Project Radar + +FlyPython recommends current Python projects only after a maintainer reviews +the project's source, maintenance state, license, documentation, release +history, and practical user value. This directory is intentionally not seeded +with unverified or AI-generated recommendations. + +Each reviewed project is one YAML file in this directory (`.yml`). The +table below is generated from those files by `tools/render_readmes.py`; edit +the YAML, never the table. `radar.json` (repository root) is the +machine-readable export for consumers who pin a repository commit. + +`ai_familiarity` records whether mainstream model training data covers the +project and its current API — see `docs/CURATION_POLICY.md` for the grading +rules. + +## Reviewed Projects Radar + + + +| Project | Category | Status | AI familiarity | Why it matters | When not to use / Risk | Reviewed | +| --- | --- | --- | --- | --- | --- | --- | +| [fastapi](https://github.com/fastapi/fastapi)
fastapi/fastapi · MIT | Web & APIs | `stable` | AI: high | Production-standard ASGI framework with automatic OpenAPI docs, Pydantic validation, and dependency injection. | For non-HTTP services, or teams standardized on Django's bundled ORM/admin stack.<br>**Risk:** Ensure background tasks handle errors properly and use async endpoints responsibly; blocking calls inside async routes degrade the whole service. | 2026-09-02 | +| [instructor](https://github.com/567-labs/instructor)
567-labs/instructor · MIT | AI Tools | `stable` | AI: medium | Production standard for extracting structured JSON from LLMs using Pydantic models with retry validation. | When your provider already enforces structured outputs natively and you need nothing beyond it.<br>**Risk:** Requires API keys for the target LLM providers; retry loops add latency and token cost. | 2026-09-02 | +| [marimo](https://github.com/marimo-team/marimo)
marimo-team/marimo · Apache-2.0 | Interactive Notebooks | `rising` | AI: low | Reactive, pure-Python notebook stored as standard executable .py files with deterministic state execution. | When your workflow depends on Jupyter-only extensions, or kernel-state debugging is central to your process.<br>**Risk:** Requires a modern browser environment and replaces the Jupyter workflow rather than extending it. | 2026-09-02 | +| [polars](https://github.com/pola-rs/polars)
pola-rs/polars · MIT | Data & Pipelines | `stable` | AI: medium | High-performance DataFrame library built in Rust on Apache Arrow with lazy query optimization. | When your pipeline leans on the pandas ecosystem (accessors, narrow libraries) or you need index-heavy semantics.<br>**Risk:** API differs from pandas and memory layout is columnar; budget migration time rather than assuming drop-in parity. | 2026-09-02 | +| [pydantic-ai](https://github.com/pydantic/pydantic-ai)
pydantic/pydantic-ai · MIT | AI Agents | `rising` | AI: low | Model-agnostic agent framework prioritizing type-safe structured outputs, dependency injection, and testability. | When you need a stable long-lived API surface today, or heavy multi-agent orchestration features.<br>**Risk:** Rapidly evolving API surface; pin minor versions and re-run your evals on every upgrade. | 2026-09-02 | +| [ruff](https://github.com/astral-sh/ruff)
astral-sh/ruff · MIT | Code Quality | `stable` | AI: high | 10-100x faster linter and formatter that unifies Flake8, Black, isort, and pyupgrade rules in a single configuration. | When a project depends on plugin ecosystems (e.g. Flake8 plugins) that have no Ruff equivalent yet.<br>**Risk:** Drop-in Black compatibility; rare syntax-parsing differences surface on unusual code bases. | 2026-09-02 | +| [uv](https://github.com/astral-sh/uv)
astral-sh/uv · MIT OR Apache-2.0 | Tooling & Packaging | `stable` | AI: medium | Extremely fast Rust-based package and project manager that replaces pip, pip-tools, venv, and pyenv with lockfile determinism. | When you must pin an existing pip-tools or Poetry workflow, or in air-gapped environments without wheel mirrors.<br>**Risk:** Actively maintained by Astral; relies on prebuilt binary wheels, so supply-chain review applies on upgrade. | 2026-09-02 | + + +## Machine-readable data + +- `radar.json` — deterministic export of every reviewed project (pinned by + website consumers together with `catalog.json`). +- `candidates.json` — output of `tools/radar_scan.py`; raw discovery + candidates with **no descriptions and no status** until a human reviews + them. + +## Submission & Lifecycle States + +Use the [project proposal form](../../.github/ISSUE_TEMPLATE/project-proposal.yml) +to suggest a project. An accepted record will use one of these lifecycle states: +`new`, `rising`, `stable`, `major-update`, `experimental`, or `archived`. "New" +describes a recent reviewed discovery, not an unverified quality claim. diff --git a/catalog/projects/README_cn.md b/catalog/projects/README_cn.md new file mode 100644 index 0000000..942eb71 --- /dev/null +++ b/catalog/projects/README_cn.md @@ -0,0 +1,41 @@ +# Python 项目雷达 + +FlyPython 只在维护者审查过项目源码、维护状态、许可证、文档、发布历史与 +实际使用价值之后,才会推荐当前流行的 Python 项目。本目录有意不收录任何 +未经核实或 AI 生成的推荐。 + +每个已审查项目对应本目录下的一个 YAML 文件(`.yml`)。下表由 +`tools/render_readmes.py` 从这些文件生成;请修改 YAML,不要直接改表。 +仓库根目录的 `radar.json` 是面向"固定仓库提交"消费者的机器可读导出。 + +`ai_familiarity` 记录主流模型训练数据对该项目及其当前 API 的覆盖程度—— +评级规则见 `docs/CURATION_POLICY.md`。 + +## 已审查项目雷达 + + + +| 项目 | 类别 | 状态 | AI 熟悉度 | 推荐理由 | 何时不用 / 风险 | 审核日期 | +| --- | --- | --- | --- | --- | --- | --- | +| [fastapi](https://github.com/fastapi/fastapi)
fastapi/fastapi · MIT | Web 与 API | `stable` | AI 熟悉度:高 | 生产级 ASGI 框架,自带 OpenAPI 文档、Pydantic 校验与依赖注入。 | 非 HTTP 服务,或已围绕 Django 自带 ORM 与 admin 体系标准化的 团队。<br>**风险:**需正确处理后台任务异常、审慎使用 async 端点;在 async 路由中执行阻塞 调用会拖垮整个服务。 | 2026-09-02 | +| [instructor](https://github.com/567-labs/instructor)
567-labs/instructor · MIT | AI 工具 | `stable` | AI 熟悉度:中 | 从 LLM 提取结构化 JSON 的事实标准,基于 Pydantic 模型并带重试 校验。 | 所用提供商已原生强制结构化输出、且不需要更多能力时。<br>**风险:**需要目标 LLM 提供商的 API Key;重试循环会增加延迟与 token 成本。 | 2026-09-02 | +| [marimo](https://github.com/marimo-team/marimo)
marimo-team/marimo · Apache-2.0 | 交互式笔记本 | `rising` | AI 熟悉度:低 | 响应式纯 Python 笔记本,以标准可执行 .py 文件保存,状态执行具有 确定性。 | 工作流依赖 Jupyter 专属扩展,或内核状态调试是核心流程时。<br>**风险:**需要现代浏览器环境;它是 Jupyter 工作流的替代而非扩展。 | 2026-09-02 | +| [polars](https://github.com/pola-rs/polars)
pola-rs/polars · MIT | 数据与管线 | `stable` | AI 熟悉度:中 | 高性能 DataFrame 库,基于 Rust 与 Apache Arrow,支持惰性查询 优化。 | 管线依赖 pandas 生态(accessor、小众库)或需要重度索引语义时。<br>**风险:**API 与 pandas 不同、内存为列式布局;迁移需要预留时间,不能当作 直接替换。 | 2026-09-02 | +| [pydantic-ai](https://github.com/pydantic/pydantic-ai)
pydantic/pydantic-ai · MIT | AI Agent | `rising` | AI 熟悉度:低 | 模型无关的 Agent 框架,优先保证类型安全的结构化输出、依赖注入与 可测试性。 | 需要当下就稳定的长周期 API,或重度多 Agent 编排能力时。<br>**风险:**API 演进迅速;固定小版本,每次升级后重跑评测。 | 2026-09-02 | +| [ruff](https://github.com/astral-sh/ruff)
astral-sh/ruff · MIT | 代码质量 | `stable` | AI 熟悉度:高 | 快 10–100 倍的 linter 与格式化工具,把 Flake8、Black、isort、 pyupgrade 规则统一到一份配置里。 | 项目依赖尚无 Ruff 等价实现的插件生态(如 Flake8 插件)时。<br>**风险:**与 Black 基本兼容;在非常规代码上偶见语法解析差异。 | 2026-09-02 | +| [uv](https://github.com/astral-sh/uv)
astral-sh/uv · MIT OR Apache-2.0 | 工具与打包 | `stable` | AI 熟悉度:中 | 极快的 Rust 实现的包与项目管理器,以锁文件确定性取代 pip、 pip-tools、venv 与 pyenv。 | 必须沿用既有 pip-tools 或 Poetry 工作流时,或缺少 wheel 镜像的离线环境中。<br>**风险:**由 Astral 积极维护;依赖预编译二进制 wheel,升级时需做供应链审查。 | 2026-09-02 | + + +## 机器可读数据 + +- `radar.json` — 所有已审查项目的确定性导出(网站消费者与 + `catalog.json` 一起按提交固定)。 +- `candidates.json` — `tools/radar_scan.py` 的输出;原始发现候选, + **不含描述、不含状态**,直到有人完成人工审查。 + +## 提交与生命周期状态 + +请使用[项目提案表单](../../.github/ISSUE_TEMPLATE/project-proposal.yml) +推荐项目。被接受的记录会使用以下生命周期状态之一:`new`、`rising`、 +`stable`、`major-update`、`experimental` 或 `archived`。"new" 描述的是 +近期完成的已审查发现,而不是未经证实的质量背书。 diff --git a/catalog/projects/fastapi.yml b/catalog/projects/fastapi.yml new file mode 100644 index 0000000..d37352f --- /dev/null +++ b/catalog/projects/fastapi.yml @@ -0,0 +1,25 @@ +id: fastapi +repo: fastapi/fastapi +url: https://github.com/fastapi/fastapi +category: web-apis +status: stable +first_seen: 2026-09-02 +reviewed_on: 2026-09-02 +license: MIT +evidence: + last_release: "Active release train; verified against the repository releases page at review time." + release_cadence: "Regular minor releases; security fixes ship promptly." + maintenance: "Large maintainer team and contributor base; de facto standard for typed ASGI services." +ai_familiarity: high +alternatives: [flask, django-ninja, litestar] +rationale_en: Production-standard ASGI framework with automatic OpenAPI docs, Pydantic + validation, and dependency injection. +rationale_zh: 生产级 ASGI 框架,自带 OpenAPI 文档、Pydantic 校验与依赖注入。 +when_not_to_use_en: For non-HTTP services, or teams standardized on Django's bundled + ORM/admin stack. +when_not_to_use_zh: 非 HTTP 服务,或已围绕 Django 自带 ORM 与 admin 体系标准化的 + 团队。 +risk_en: Ensure background tasks handle errors properly and use async endpoints + responsibly; blocking calls inside async routes degrade the whole service. +risk_zh: 需正确处理后台任务异常、审慎使用 async 端点;在 async 路由中执行阻塞 + 调用会拖垮整个服务。 diff --git a/catalog/projects/instructor.yml b/catalog/projects/instructor.yml new file mode 100644 index 0000000..8a642bd --- /dev/null +++ b/catalog/projects/instructor.yml @@ -0,0 +1,24 @@ +id: instructor +repo: 567-labs/instructor # repository moved from jxnl/instructor; same project +url: https://github.com/567-labs/instructor +category: ai-tools +status: stable +first_seen: 2026-09-02 +reviewed_on: 2026-09-02 +license: MIT +evidence: + last_release: "Active release train; verified against the repository releases page at review time." + release_cadence: "Regular releases tracking provider API changes." + maintenance: "Actively maintained with broad provider integration coverage." +ai_familiarity: medium +alternatives: [openai structured outputs, outlines] +rationale_en: Production standard for extracting structured JSON from LLMs using + Pydantic models with retry validation. +rationale_zh: 从 LLM 提取结构化 JSON 的事实标准,基于 Pydantic 模型并带重试 + 校验。 +when_not_to_use_en: When your provider already enforces structured outputs natively + and you need nothing beyond it. +when_not_to_use_zh: 所用提供商已原生强制结构化输出、且不需要更多能力时。 +risk_en: Requires API keys for the target LLM providers; retry loops add latency and + token cost. +risk_zh: 需要目标 LLM 提供商的 API Key;重试循环会增加延迟与 token 成本。 diff --git a/catalog/projects/marimo.yml b/catalog/projects/marimo.yml new file mode 100644 index 0000000..9f929bb --- /dev/null +++ b/catalog/projects/marimo.yml @@ -0,0 +1,24 @@ +id: marimo +repo: marimo-team/marimo +url: https://github.com/marimo-team/marimo +category: notebooks +status: rising +first_seen: 2026-09-02 +reviewed_on: 2026-09-02 +license: Apache-2.0 +evidence: + last_release: "Active release train; verified against the repository releases page at review time." + release_cadence: "Frequent releases while the product matures." + maintenance: "Actively maintained by the marimo team with responsive issue triage." +ai_familiarity: low +alternatives: [jupyter, quarto] +rationale_en: Reactive, pure-Python notebook stored as standard executable .py files + with deterministic state execution. +rationale_zh: 响应式纯 Python 笔记本,以标准可执行 .py 文件保存,状态执行具有 + 确定性。 +when_not_to_use_en: When your workflow depends on Jupyter-only extensions, or + kernel-state debugging is central to your process. +when_not_to_use_zh: 工作流依赖 Jupyter 专属扩展,或内核状态调试是核心流程时。 +risk_en: Requires a modern browser environment and replaces the Jupyter workflow + rather than extending it. +risk_zh: 需要现代浏览器环境;它是 Jupyter 工作流的替代而非扩展。 diff --git a/catalog/projects/polars.yml b/catalog/projects/polars.yml new file mode 100644 index 0000000..20a9bbc --- /dev/null +++ b/catalog/projects/polars.yml @@ -0,0 +1,25 @@ +id: polars +repo: pola-rs/polars +url: https://github.com/pola-rs/polars +category: data-pipelines +status: stable +first_seen: 2026-09-02 +reviewed_on: 2026-09-02 +license: MIT +evidence: + last_release: "Active release train; verified against the repository releases page at review time." + release_cadence: "Frequent releases; performance-focused changelogs." + maintenance: "Actively maintained by the Polars team and a growing contributor base." +ai_familiarity: medium +alternatives: [pandas, duckdb, dask] +rationale_en: High-performance DataFrame library built in Rust on Apache Arrow with + lazy query optimization. +rationale_zh: 高性能 DataFrame 库,基于 Rust 与 Apache Arrow,支持惰性查询 + 优化。 +when_not_to_use_en: When your pipeline leans on the pandas ecosystem (accessors, + narrow libraries) or you need index-heavy semantics. +when_not_to_use_zh: 管线依赖 pandas 生态(accessor、小众库)或需要重度索引语义时。 +risk_en: API differs from pandas and memory layout is columnar; budget migration + time rather than assuming drop-in parity. +risk_zh: API 与 pandas 不同、内存为列式布局;迁移需要预留时间,不能当作 + 直接替换。 diff --git a/catalog/projects/pydantic-ai.yml b/catalog/projects/pydantic-ai.yml new file mode 100644 index 0000000..c3e7d1a --- /dev/null +++ b/catalog/projects/pydantic-ai.yml @@ -0,0 +1,24 @@ +id: pydantic-ai +repo: pydantic/pydantic-ai +url: https://github.com/pydantic/pydantic-ai +category: ai-agents +status: rising +first_seen: 2026-09-02 +reviewed_on: 2026-09-02 +license: MIT +evidence: + last_release: "Active pre-1.0 release train; verified against the repository releases page at review time." + release_cadence: "Frequent releases while the API surface evolves." + maintenance: "Maintained by the Pydantic team; expect breaking changes before 1.0." +ai_familiarity: low +alternatives: [openai-agents-sdk, langgraph] +rationale_en: Model-agnostic agent framework prioritizing type-safe structured outputs, + dependency injection, and testability. +rationale_zh: 模型无关的 Agent 框架,优先保证类型安全的结构化输出、依赖注入与 + 可测试性。 +when_not_to_use_en: When you need a stable long-lived API surface today, or heavy + multi-agent orchestration features. +when_not_to_use_zh: 需要当下就稳定的长周期 API,或重度多 Agent 编排能力时。 +risk_en: Rapidly evolving API surface; pin minor versions and re-run your evals on + every upgrade. +risk_zh: API 演进迅速;固定小版本,每次升级后重跑评测。 diff --git a/catalog/projects/ruff.yml b/catalog/projects/ruff.yml new file mode 100644 index 0000000..6597fb8 --- /dev/null +++ b/catalog/projects/ruff.yml @@ -0,0 +1,24 @@ +id: ruff +repo: astral-sh/ruff +url: https://github.com/astral-sh/ruff +category: code-quality +status: stable +first_seen: 2026-09-02 +reviewed_on: 2026-09-02 +license: MIT +evidence: + last_release: "Active release train; verified against the repository releases page at review time." + release_cadence: "Frequent releases with annotated changelogs." + maintenance: "Actively maintained by Astral; broad adoption across major projects." +ai_familiarity: high +alternatives: [flake8, black, isort, pylint] +rationale_en: 10-100x faster linter and formatter that unifies Flake8, Black, isort, + and pyupgrade rules in a single configuration. +rationale_zh: 快 10–100 倍的 linter 与格式化工具,把 Flake8、Black、isort、 + pyupgrade 规则统一到一份配置里。 +when_not_to_use_en: When a project depends on plugin ecosystems (e.g. Flake8 plugins) + that have no Ruff equivalent yet. +when_not_to_use_zh: 项目依赖尚无 Ruff 等价实现的插件生态(如 Flake8 插件)时。 +risk_en: Drop-in Black compatibility; rare syntax-parsing differences surface on + unusual code bases. +risk_zh: 与 Black 基本兼容;在非常规代码上偶见语法解析差异。 diff --git a/catalog/projects/uv.yml b/catalog/projects/uv.yml new file mode 100644 index 0000000..6b8441f --- /dev/null +++ b/catalog/projects/uv.yml @@ -0,0 +1,25 @@ +id: uv +repo: astral-sh/uv +url: https://github.com/astral-sh/uv +category: tooling-packaging +status: stable +first_seen: 2026-09-02 +reviewed_on: 2026-09-02 +license: MIT OR Apache-2.0 +evidence: + last_release: "Active release train; verified against the repository releases page at review time." + release_cadence: "Frequent point releases, often multiple per month." + maintenance: "Actively maintained by Astral with a public roadmap and rapid issue triage." +ai_familiarity: medium +alternatives: [pip, pip-tools, pyenv, poetry] +rationale_en: Extremely fast Rust-based package and project manager that replaces pip, + pip-tools, venv, and pyenv with lockfile determinism. +rationale_zh: 极快的 Rust 实现的包与项目管理器,以锁文件确定性取代 pip、 + pip-tools、venv 与 pyenv。 +when_not_to_use_en: When you must pin an existing pip-tools or Poetry workflow, or in + air-gapped environments without wheel mirrors. +when_not_to_use_zh: 必须沿用既有 pip-tools 或 Poetry 工作流时,或缺少 wheel + 镜像的离线环境中。 +risk_en: Actively maintained by Astral; relies on prebuilt binary wheels, so supply-chain + review applies on upgrade. +risk_zh: 由 Astral 积极维护;依赖预编译二进制 wheel,升级时需做供应链审查。 diff --git a/catalog/resources/aaif-model-context-protocol.yml b/catalog/resources/aaif-model-context-protocol.yml new file mode 100644 index 0000000..9b61410 --- /dev/null +++ b/catalog/resources/aaif-model-context-protocol.yml @@ -0,0 +1,15 @@ +id: aaif-model-context-protocol +path: ai-agents +title: MCP under the Agentic AI Foundation +url: https://aaif.io/projects/model-context-protocol +source_type: official-project +level: intermediate +language: en +why_en: The Agentic AI Foundation project page for MCP, the neutral governance home for specification development and roadmaps. +why_zh: Agentic AI Foundation 的 MCP 项目主页,是规范开发与路线图的中立治理入口。 +reviewed_on: '2026-09-06' +status: active +requires_key: false +risk: low +featured: false +order: 11 diff --git a/catalog/resources/alembic-docs.yml b/catalog/resources/alembic-docs.yml new file mode 100644 index 0000000..adec7b3 --- /dev/null +++ b/catalog/resources/alembic-docs.yml @@ -0,0 +1,15 @@ +id: alembic-docs +path: web-apis +title: Alembic documentation +url: https://alembic.sqlalchemy.org/en/latest/ +source_type: official-project +level: intermediate +language: en +why_en: Database migration tool for SQLAlchemy, providing transactional schema changes and rollback support. +why_zh: 基于 SQLAlchemy 的数据库迁移事实标准工具,提供事务性架构演进与版本回滚支持。 +reviewed_on: '2026-09-02' +status: active +requires_key: false +risk: medium +featured: false +order: 7 diff --git a/catalog/resources/anthropic-mcp-aaif-donation.yml b/catalog/resources/anthropic-mcp-aaif-donation.yml new file mode 100644 index 0000000..1969958 --- /dev/null +++ b/catalog/resources/anthropic-mcp-aaif-donation.yml @@ -0,0 +1,15 @@ +id: anthropic-mcp-aaif-donation +path: ai-agents +title: Anthropic donating MCP to the Agentic AI Foundation +url: https://www.anthropic.com/news/donating-the-model-context-protocol-and-establishing-of-the-agentic-ai-foundation +source_type: official-docs +level: intermediate +language: en +why_en: Anthropic's announcement establishing neutral Linux Foundation governance for MCP under the Agentic AI Foundation. +why_zh: Anthropic 官方公告,宣布将 MCP 交由 Linux 基金会旗下的 Agentic AI Foundation 中立治理。 +reviewed_on: '2026-09-06' +status: active +requires_key: false +risk: low +featured: false +order: 10 diff --git a/catalog/resources/anthropic-mcp-docs.yml b/catalog/resources/anthropic-mcp-docs.yml new file mode 100644 index 0000000..111ef3a --- /dev/null +++ b/catalog/resources/anthropic-mcp-docs.yml @@ -0,0 +1,15 @@ +id: anthropic-mcp-docs +path: ai-agents +title: Model Context Protocol Python SDK +url: https://github.com/modelcontextprotocol/python-sdk +source_type: official-project +level: intermediate +language: en +why_en: Official Python implementation of the Model Context Protocol for building MCP servers and clients. +why_zh: Model Context Protocol 官方 Python SDK,用于快速构建和集成标准的 MCP 工具与服务。 +reviewed_on: '2026-09-02' +status: active +requires_key: false +risk: medium +featured: false +order: 7 diff --git a/catalog/resources/django-tutorial.yml b/catalog/resources/django-tutorial.yml new file mode 100644 index 0000000..8647eab --- /dev/null +++ b/catalog/resources/django-tutorial.yml @@ -0,0 +1,16 @@ +id: django-tutorial +path: web-apis +title: 'Django documentation: First steps' +url: https://docs.djangoproject.com/en/stable/intro/tutorial01/ +source_type: official-docs +level: beginner +language: en +why_en: The official path through Django projects, models, views, templates, forms, + tests, and reusable apps. +why_zh: Django 官方入门路径,覆盖项目、模型、视图、模板、表单、测试和可复用应用。 +reviewed_on: '2026-08-31' +status: active +requires_key: false +risk: low +featured: false +order: 2 diff --git a/catalog/resources/duckdb-docs.yml b/catalog/resources/duckdb-docs.yml new file mode 100644 index 0000000..ea3b8c7 --- /dev/null +++ b/catalog/resources/duckdb-docs.yml @@ -0,0 +1,15 @@ +id: duckdb-docs +path: automation +title: DuckDB Python API documentation +url: https://duckdb.org/docs/api/python/overview +source_type: official-project +level: intermediate +language: en +why_en: Fast in-process analytical SQL engine for automated data transformation, Parquet querying, and embedded analytics. +why_zh: 进程内高性能分析型 SQL 引擎,适用于自动化数据转换、Parquet 文件快速查询与嵌入式分析。 +reviewed_on: '2026-09-02' +status: active +requires_key: false +risk: low +featured: false +order: 7 diff --git a/catalog/resources/fastapi-docs.yml b/catalog/resources/fastapi-docs.yml new file mode 100644 index 0000000..6fc7e65 --- /dev/null +++ b/catalog/resources/fastapi-docs.yml @@ -0,0 +1,16 @@ +id: fastapi-docs +path: web-apis +title: FastAPI documentation +url: https://fastapi.tiangolo.com/ +source_type: official-docs +level: intermediate +language: en +why_en: A direct route from typed Python functions to tested APIs with validation + and generated documentation. +why_zh: 从带类型的 Python 函数出发,构建具备验证、测试和自动文档的 API。 +reviewed_on: '2026-08-31' +status: active +requires_key: false +risk: low +featured: true +order: 1 diff --git a/catalog/resources/flask-docs.yml b/catalog/resources/flask-docs.yml new file mode 100644 index 0000000..e207785 --- /dev/null +++ b/catalog/resources/flask-docs.yml @@ -0,0 +1,16 @@ +id: flask-docs +path: web-apis +title: Flask documentation +url: https://flask.palletsprojects.com/en/stable/ +source_type: official-docs +level: beginner +language: en +why_en: The maintained reference and tutorial for Flask applications, requests, templates, + testing, and deployment. +why_zh: Flask 官方参考与教程,覆盖应用、请求、模板、测试和部署。 +reviewed_on: '2026-08-31' +status: active +requires_key: false +risk: low +featured: false +order: 3 diff --git a/catalog/resources/google-adk.yml b/catalog/resources/google-adk.yml new file mode 100644 index 0000000..96e77a3 --- /dev/null +++ b/catalog/resources/google-adk.yml @@ -0,0 +1,16 @@ +id: google-adk +path: ai-agents +title: Google Agent Development Kit +url: https://adk.dev/ +source_type: official-docs +level: advanced +language: en +why_en: Google's official documentation for developing, evaluating, and deploying + modular agent systems. +why_zh: Google 官方模块化 Agent 系统开发、评测与部署文档。 +reviewed_on: '2026-08-31' +status: active +requires_key: true +risk: medium +featured: false +order: 5 diff --git a/catalog/resources/httpx-docs.yml b/catalog/resources/httpx-docs.yml new file mode 100644 index 0000000..b478ce9 --- /dev/null +++ b/catalog/resources/httpx-docs.yml @@ -0,0 +1,16 @@ +id: httpx-docs +path: web-apis +title: HTTPX documentation +url: https://www.python-httpx.org/ +source_type: official-project +level: intermediate +language: en +why_en: The official reference for synchronous and asynchronous HTTP clients, timeouts, + streaming, and transport control. +why_zh: 同步与异步 HTTP 客户端、超时、流式响应和传输控制的官方参考。 +reviewed_on: '2026-08-31' +status: active +requires_key: false +risk: low +featured: false +order: 5 diff --git a/catalog/resources/instructor-docs.yml b/catalog/resources/instructor-docs.yml new file mode 100644 index 0000000..f89363c --- /dev/null +++ b/catalog/resources/instructor-docs.yml @@ -0,0 +1,15 @@ +id: instructor-docs +path: ai-agents +title: Instructor documentation +url: https://python.useinstructor.com/ +source_type: official-project +level: intermediate +language: en +why_en: Structured outputs and validation for LLMs powered by Pydantic, enabling predictable tool calling and schema enforcement. +why_zh: 基于 Pydantic 的大模型结构化输出与严格验证框架,确保工具调用与数据提取的确定性。 +reviewed_on: '2026-09-02' +status: active +requires_key: true +risk: medium +featured: false +order: 6 diff --git a/catalog/resources/langgraph-python.yml b/catalog/resources/langgraph-python.yml new file mode 100644 index 0000000..fff672e --- /dev/null +++ b/catalog/resources/langgraph-python.yml @@ -0,0 +1,16 @@ +id: langgraph-python +path: ai-agents +title: LangGraph overview +url: https://docs.langchain.com/oss/python/langgraph/overview +source_type: official-docs +level: advanced +language: en +why_en: The official Python guide to stateful graph workflows, durable execution, + memory, interrupts, and human review. +why_zh: 有状态图工作流、持久执行、记忆、中断与人工审核的 Python 官方指南。 +reviewed_on: '2026-08-31' +status: active +requires_key: true +risk: medium +featured: false +order: 4 diff --git a/catalog/resources/mcp-2026-07-28-release-candidate.yml b/catalog/resources/mcp-2026-07-28-release-candidate.yml new file mode 100644 index 0000000..4a7c9c2 --- /dev/null +++ b/catalog/resources/mcp-2026-07-28-release-candidate.yml @@ -0,0 +1,15 @@ +id: mcp-2026-07-28-release-candidate +path: ai-agents +title: MCP 2026-07-28 release candidate overview +url: https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/ +source_type: official-docs +level: intermediate +language: en +why_en: The official release-candidate post that enumerates the 2026-07-28 breaking changes and the migration window for SDK maintainers. +why_zh: 官方 RC 发布说明,逐项列出 2026-07-28 的破坏性变更以及留给 SDK 维护者的迁移窗口。 +reviewed_on: '2026-09-06' +status: active +requires_key: false +risk: low +featured: false +order: 9 diff --git a/catalog/resources/mcp-2026-07-28-spec-release.yml b/catalog/resources/mcp-2026-07-28-spec-release.yml new file mode 100644 index 0000000..6970c2a --- /dev/null +++ b/catalog/resources/mcp-2026-07-28-spec-release.yml @@ -0,0 +1,15 @@ +id: mcp-2026-07-28-spec-release +path: ai-agents +title: MCP 2026-07-28 specification release +url: https://blog.modelcontextprotocol.io/posts/2026-07-28/ +source_type: official-docs +level: intermediate +language: en +why_en: The official release announcement for the 2026-07-28 Model Context Protocol specification, covering the stateless redesign and breaking changes. +why_zh: Model Context Protocol 2026-07-28 规范的官方发布公告,说明无状态重构与破坏性变更。 +reviewed_on: '2026-09-06' +status: active +requires_key: false +risk: low +featured: false +order: 8 diff --git a/catalog/resources/model-context-protocol.yml b/catalog/resources/model-context-protocol.yml new file mode 100644 index 0000000..99a83c6 --- /dev/null +++ b/catalog/resources/model-context-protocol.yml @@ -0,0 +1,16 @@ +id: model-context-protocol +path: ai-agents +title: Model Context Protocol +url: https://modelcontextprotocol.io/docs/getting-started/intro +source_type: official-standard +level: intermediate +language: en +why_en: The open protocol and reference documentation for connecting AI applications + to tools, data, and reusable context. +why_zh: 连接 AI 应用、工具、数据与可复用上下文的开放协议和权威参考。 +reviewed_on: '2026-08-31' +status: active +requires_key: false +risk: medium +featured: true +order: 3 diff --git a/catalog/resources/mypy-docs.yml b/catalog/resources/mypy-docs.yml new file mode 100644 index 0000000..546ccaf --- /dev/null +++ b/catalog/resources/mypy-docs.yml @@ -0,0 +1,15 @@ +id: mypy-docs +path: foundations +title: Mypy documentation +url: https://mypy.readthedocs.io/en/stable/ +source_type: official-project +level: intermediate +language: en +why_en: The standard static type checker for Python to catch bugs and verify type contracts before running code. +why_zh: Python 官方与社区主流的静态类型检查器,在运行前拦截类型错误并验证代码契约。 +reviewed_on: '2026-09-02' +status: active +requires_key: false +risk: low +featured: false +order: 8 diff --git a/catalog/resources/openai-agents-sdk.yml b/catalog/resources/openai-agents-sdk.yml new file mode 100644 index 0000000..b9c2d88 --- /dev/null +++ b/catalog/resources/openai-agents-sdk.yml @@ -0,0 +1,16 @@ +id: openai-agents-sdk +path: ai-agents +title: OpenAI Agents SDK +url: https://openai.github.io/openai-agents-python/ +source_type: official-docs +level: intermediate +language: en +why_en: The official Python toolkit for agents, tools, handoffs, guardrails, sessions, + tracing, and orchestration. +why_zh: 面向 Agent、工具、handoff、guardrail、session、追踪与编排的官方 Python 工具包。 +reviewed_on: '2026-08-31' +status: active +requires_key: true +risk: medium +featured: true +order: 1 diff --git a/catalog/resources/pandas-getting-started.yml b/catalog/resources/pandas-getting-started.yml new file mode 100644 index 0000000..a9984a3 --- /dev/null +++ b/catalog/resources/pandas-getting-started.yml @@ -0,0 +1,16 @@ +id: pandas-getting-started +path: automation +title: pandas getting started guides +url: https://pandas.pydata.org/docs/getting_started/index.html +source_type: official-docs +level: beginner +language: en +why_en: The primary entry point for tabular data loading, cleaning, transformation, + analysis, and export. +why_zh: 表格数据读取、清洗、转换、分析和导出的官方入门入口。 +reviewed_on: '2026-08-31' +status: active +requires_key: false +risk: low +featured: false +order: 5 diff --git a/catalog/resources/pathlib-docs.yml b/catalog/resources/pathlib-docs.yml new file mode 100644 index 0000000..4275c59 --- /dev/null +++ b/catalog/resources/pathlib-docs.yml @@ -0,0 +1,16 @@ +id: pathlib-docs +path: automation +title: pathlib — Object-oriented filesystem paths +url: https://docs.python.org/3/library/pathlib.html +source_type: official-docs +level: beginner +language: en +why_en: The standard-library reference for readable, cross-platform file and directory + automation. +why_zh: 使用可读、跨平台的对象式路径完成文件与目录自动化的标准库参考。 +reviewed_on: '2026-08-31' +status: active +requires_key: false +risk: low +featured: false +order: 1 diff --git a/catalog/resources/playwright-python.yml b/catalog/resources/playwright-python.yml new file mode 100644 index 0000000..27b47bb --- /dev/null +++ b/catalog/resources/playwright-python.yml @@ -0,0 +1,16 @@ +id: playwright-python +path: automation +title: Playwright for Python +url: https://playwright.dev/python/docs/intro +source_type: official-docs +level: intermediate +language: en +why_en: The official Python guide to reliable browser automation, locators, assertions, + traces, and isolated contexts. +why_zh: 可靠浏览器自动化、定位器、断言、追踪和隔离上下文的 Python 官方指南。 +reviewed_on: '2026-08-31' +status: active +requires_key: false +risk: medium +featured: false +order: 3 diff --git a/catalog/resources/polars-docs.yml b/catalog/resources/polars-docs.yml new file mode 100644 index 0000000..1ae3ba5 --- /dev/null +++ b/catalog/resources/polars-docs.yml @@ -0,0 +1,15 @@ +id: polars-docs +path: automation +title: Polars documentation +url: https://docs.pola.rs/ +source_type: official-project +level: intermediate +language: en +why_en: Lightning-fast DataFrame library built on Apache Arrow and Rust, optimized for efficient data pipelines and parallel processing. +why_zh: 基于 Apache Arrow 和 Rust 构建的极速 DataFrame 库,专为并行计算与现代数据管道优化。 +reviewed_on: '2026-09-02' +status: active +requires_key: false +risk: low +featured: false +order: 6 diff --git a/catalog/resources/pydantic-ai.yml b/catalog/resources/pydantic-ai.yml new file mode 100644 index 0000000..96da9b5 --- /dev/null +++ b/catalog/resources/pydantic-ai.yml @@ -0,0 +1,16 @@ +id: pydantic-ai +path: ai-agents +title: Pydantic AI +url: https://pydantic.dev/docs/ai/overview/ +source_type: official-docs +level: intermediate +language: en +why_en: Typed agent development built around validated inputs, structured output, + tools, testing, and model portability. +why_zh: 以输入验证、结构化输出、工具、测试和模型可移植性为核心的类型安全 Agent 开发。 +reviewed_on: '2026-08-31' +status: active +requires_key: true +risk: medium +featured: true +order: 2 diff --git a/catalog/resources/pydantic-docs.yml b/catalog/resources/pydantic-docs.yml new file mode 100644 index 0000000..cb5fa14 --- /dev/null +++ b/catalog/resources/pydantic-docs.yml @@ -0,0 +1,16 @@ +id: pydantic-docs +path: web-apis +title: Pydantic documentation +url: https://pydantic.dev/docs/validation/latest/get-started/ +source_type: official-docs +level: intermediate +language: en +why_en: The primary guide to validating untrusted data and expressing typed contracts + at Python system boundaries. +why_zh: 在 Python 系统边界验证不可信数据、定义类型契约的官方指南。 +reviewed_on: '2026-08-31' +status: active +requires_key: false +risk: low +featured: false +order: 4 diff --git a/catalog/resources/pytest-docs.yml b/catalog/resources/pytest-docs.yml new file mode 100644 index 0000000..66e0c76 --- /dev/null +++ b/catalog/resources/pytest-docs.yml @@ -0,0 +1,16 @@ +id: pytest-docs +path: foundations +title: pytest documentation +url: https://docs.pytest.org/en/stable/ +source_type: official-project +level: intermediate +language: en +why_en: The official guide to test discovery, assertions, fixtures, parametrization, + and maintainable test suites. +why_zh: pytest 官方指南,覆盖测试发现、断言、fixture、参数化和可维护测试套件。 +reviewed_on: '2026-08-31' +status: active +requires_key: false +risk: low +featured: false +order: 5 diff --git a/catalog/resources/python-packaging-guide.yml b/catalog/resources/python-packaging-guide.yml new file mode 100644 index 0000000..3847de2 --- /dev/null +++ b/catalog/resources/python-packaging-guide.yml @@ -0,0 +1,16 @@ +id: python-packaging-guide +path: foundations +title: Python Packaging User Guide +url: https://packaging.python.org/en/latest/tutorials/packaging-projects/ +source_type: official-docs +level: intermediate +language: en +why_en: The PyPA tutorial for project layout, build metadata, distributions, and publishing + Python packages. +why_zh: PyPA 官方教程,覆盖项目结构、构建元数据、分发包与 Python 包发布。 +reviewed_on: '2026-08-31' +status: active +requires_key: false +risk: low +featured: false +order: 3 diff --git a/catalog/resources/python-tutorial.yml b/catalog/resources/python-tutorial.yml new file mode 100644 index 0000000..74d072d --- /dev/null +++ b/catalog/resources/python-tutorial.yml @@ -0,0 +1,16 @@ +id: python-tutorial +path: foundations +title: The Python Tutorial +url: https://docs.python.org/3/tutorial/ +source_type: official-docs +level: beginner +language: en +why_en: The primary introduction to Python syntax, data structures, modules, errors, + classes, and the standard library. +why_zh: Python 官方入门教程,覆盖语法、数据结构、模块、异常、类与标准库。 +reviewed_on: '2026-08-31' +status: active +requires_key: false +risk: low +featured: true +order: 1 diff --git a/catalog/resources/python-venv.yml b/catalog/resources/python-venv.yml new file mode 100644 index 0000000..ad58654 --- /dev/null +++ b/catalog/resources/python-venv.yml @@ -0,0 +1,16 @@ +id: python-venv +path: foundations +title: venv — Creation of virtual environments +url: https://docs.python.org/3/library/venv.html +source_type: official-docs +level: beginner +language: en +why_en: The standard-library reference for creating isolated Python environments and + understanding their behavior. +why_zh: Python 标准库的虚拟环境参考,说明如何创建隔离环境及其运行方式。 +reviewed_on: '2026-08-31' +status: active +requires_key: false +risk: low +featured: false +order: 2 diff --git a/catalog/resources/ruff-docs.yml b/catalog/resources/ruff-docs.yml new file mode 100644 index 0000000..d2e8861 --- /dev/null +++ b/catalog/resources/ruff-docs.yml @@ -0,0 +1,15 @@ +id: ruff-docs +path: foundations +title: Ruff documentation +url: https://docs.astral.sh/ruff/ +source_type: official-project +level: all-levels +language: en +why_en: An extremely fast Python linter and code formatter written in Rust, replacing Flake8, Black, isort, and more. +why_zh: 极速的 Rust 驱动 Python 代码检查与格式化工具,统一替代 Flake8、Black 与 isort。 +reviewed_on: '2026-09-02' +status: active +requires_key: false +risk: low +featured: true +order: 7 diff --git a/catalog/resources/scrapy-tutorial.yml b/catalog/resources/scrapy-tutorial.yml new file mode 100644 index 0000000..433380e --- /dev/null +++ b/catalog/resources/scrapy-tutorial.yml @@ -0,0 +1,16 @@ +id: scrapy-tutorial +path: automation +title: Scrapy tutorial +url: https://docs.scrapy.org/en/latest/intro/tutorial.html +source_type: official-docs +level: intermediate +language: en +why_en: The official introduction to crawl structure, extraction, item pipelines, + persistence, and responsible operation. +why_zh: 爬虫结构、数据提取、item pipeline、持久化与负责任运行的官方入门。 +reviewed_on: '2026-08-31' +status: active +requires_key: false +risk: medium +featured: false +order: 4 diff --git a/catalog/resources/sqlalchemy-docs.yml b/catalog/resources/sqlalchemy-docs.yml new file mode 100644 index 0000000..ee186ce --- /dev/null +++ b/catalog/resources/sqlalchemy-docs.yml @@ -0,0 +1,15 @@ +id: sqlalchemy-docs +path: web-apis +title: SQLAlchemy documentation +url: https://docs.sqlalchemy.org/en/20/ +source_type: official-project +level: intermediate +language: en +why_en: The definitive Python SQL toolkit and Object Relational Mapper, providing type-safe database access and migrations. +why_zh: Python 权威的 SQL 工具包与对象关系映射器(ORM),提供类型安全的数据持久化与访问。 +reviewed_on: '2026-09-02' +status: active +requires_key: false +risk: low +featured: false +order: 6 diff --git a/catalog/resources/subprocess-docs.yml b/catalog/resources/subprocess-docs.yml new file mode 100644 index 0000000..7390451 --- /dev/null +++ b/catalog/resources/subprocess-docs.yml @@ -0,0 +1,16 @@ +id: subprocess-docs +path: automation +title: subprocess — Subprocess management +url: https://docs.python.org/3/library/subprocess.html +source_type: official-docs +level: intermediate +language: en +why_en: The standard-library contract for launching processes, capturing output, handling + failures, and avoiding unsafe shell usage. +why_zh: 启动进程、捕获输出、处理失败并避免不安全 shell 用法的标准库契约。 +reviewed_on: '2026-08-31' +status: active +requires_key: false +risk: medium +featured: false +order: 2 diff --git a/catalog/resources/typing-docs.yml b/catalog/resources/typing-docs.yml new file mode 100644 index 0000000..8c73775 --- /dev/null +++ b/catalog/resources/typing-docs.yml @@ -0,0 +1,16 @@ +id: typing-docs +path: foundations +title: Python typing documentation +url: https://typing.python.org/en/latest/ +source_type: official-docs +level: intermediate +language: en +why_en: The canonical home for Python's type-system specification, guides, and typing + best practices. +why_zh: Python 类型系统规范、指南与类型标注实践的权威入口。 +reviewed_on: '2026-08-31' +status: active +requires_key: false +risk: low +featured: false +order: 6 diff --git a/catalog/resources/uv-docs.yml b/catalog/resources/uv-docs.yml new file mode 100644 index 0000000..2c3940c --- /dev/null +++ b/catalog/resources/uv-docs.yml @@ -0,0 +1,16 @@ +id: uv-docs +path: foundations +title: uv documentation +url: https://docs.astral.sh/uv/ +source_type: official-project +level: all-levels +language: en +why_en: A modern workflow for Python versions, virtual environments, dependencies, + lockfiles, and project commands. +why_zh: 现代 Python 工作流,统一管理版本、虚拟环境、依赖、锁文件和项目命令。 +reviewed_on: '2026-08-31' +status: active +requires_key: false +risk: low +featured: true +order: 4 diff --git a/content-manifest.json b/content-manifest.json new file mode 100644 index 0000000..4b85980 --- /dev/null +++ b/content-manifest.json @@ -0,0 +1,2167 @@ +{ + "schema_version": 1, + "documents": [ + { + "id": "add-a-python-api", + "type": "playbook", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-02", + "locales": [ + { + "lang": "en-US", + "path": "playbooks/add-an-api/README.md", + "title": "Add or Change a Python API", + "summary": "Define the public contract first, then implement and verify success, error, and compatibility behavior.", + "sha256": "75d12e8f14e5d5450fb7eeff10c11d86477758ae46a55fa8954f9105eb26d730" + }, + { + "lang": "zh-CN", + "path": "playbooks/add-an-api/README_cn.md", + "title": "增加或修改 Python API", + "summary": "先定义公共契约,再实现并验证成功、错误和兼容性行为。", + "sha256": "a1d7367965614d51c538162fbabdfd5759bf697510994d53842df428a27f941a" + } + ] + }, + { + "id": "ai-coding-context-budget", + "type": "guide", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-02", + "locales": [ + { + "lang": "en-US", + "path": "guides/ai-coding/context-budget.md", + "title": "Context Budgeting and Bounded Tasks for Coding Agents", + "summary": "Maximize coding agent accuracy by controlling context size, writing explicit task contracts, and enforcing automated verification loops.", + "sha256": "61f2cb3e8629978ddf69d0e9800b4bafde26467b1e44f8ac26384b9ad10a80cc" + }, + { + "lang": "zh-CN", + "path": "guides/ai-coding/context-budget_cn.md", + "title": "Coding Agent 上下文预算与精准任务设计", + "summary": "通过控制上下文体积、编写明确任务契约与建立自动化验证闭环,最大化 Agent 编码准确率。", + "sha256": "302168b4422f89d79fa38b63c1a49c5691ad170f684f6523df490c2bf2670be6" + } + ] + }, + { + "id": "course-agent-rules", + "type": "course", + "content_version": 3, + "status": "reviewed", + "reviewed_on": "2026-09-12", + "locales": [ + { + "lang": "en-US", + "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": "b75cb686586e3ae0ecb92d9b68c220212e9364b752808b5ba63c273da339fe87" + }, + { + "lang": "zh-CN", + "path": "courses/agent-rules-single-source/COURSE_cn.md", + "title": "Agent 规则的单一真源", + "summary": "不再同时维护彼此打架的 AGENTS.md、CLAUDE.md 与 .cursorrules——亲手构建一个能证明仓库只有一处规则真源的检查器,由你的编码 Agent 授课。", + "sha256": "9d5efe0d5ee5615be9d90001fe8a53c209b285a9e7ca6364222f71779d83eedf" + } + ] + }, + { + "id": "course-agent-rules-l01", + "type": "course", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-12", + "locales": [ + { + "lang": "en-US", + "path": "courses/agent-rules-single-source/lessons/L01.md", + "title": "Measure the drift you already have", + "summary": "Run the checker against the three scenario repositories and read drift as a test failure, not a vibe.", + "sha256": "3e8ccfdeaeca11b1b57c53cbc5a5c1a9fbe93ae41eda9e683c1a8d3abe857e47" + }, + { + "lang": "zh-CN", + "path": "courses/agent-rules-single-source/lessons/L01_cn.md", + "title": "量化你已经存在的漂移", + "summary": "对三个场景仓库运行检查器,把漂移读成测试失败,而不是一种感觉。", + "sha256": "f90fd475497139dfa3ca98d50a5d6abb604d384f283da5f36c65ee120452a8a6" + } + ] + }, + { + "id": "course-agent-rules-l02", + "type": "course", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-12", + "locales": [ + { + "lang": "en-US", + "path": "courses/agent-rules-single-source/lessons/L02.md", + "title": "Decide the source of truth", + "summary": "AGENTS.md becomes the one file agents read; everything else is a pointer or a copy — decide, then write it down.", + "sha256": "9144cf869703c4d7b94d464143dc026c6a1031158a76d4dcafd47e28b9646b02" + }, + { + "lang": "zh-CN", + "path": "courses/agent-rules-single-source/lessons/L02_cn.md", + "title": "确定唯一真源", + "summary": "AGENTS.md 成为 Agent 唯一阅读的文件;其余都只能是指针或副本——先决定,再写下来。", + "sha256": "e275ea26dd9d4fd044f788a875533d0c47c7eff9eddc6e986eefecb70276b3df" + } + ] + }, + { + "id": "course-agent-rules-l03", + "type": "course", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-12", + "locales": [ + { + "lang": "en-US", + "path": "courses/agent-rules-single-source/lessons/L03.md", + "title": "Drive a bounded change, test by test", + "summary": "Make the starter detect source, pointer, copy, and drift — one failing test group at a time.", + "sha256": "6c6d008258ce483f52b4f8c826a2c221ada0b1567faede6c41ee87c361aab77d" + }, + { + "lang": "zh-CN", + "path": "courses/agent-rules-single-source/lessons/L03_cn.md", + "title": "按测试驱动一次有边界的变更", + "summary": "让 starter 依次识别真源、指针、副本与漂移——一次一组失败测试。", + "sha256": "d5c62d126ee58d2b381cbe3ee0a5cbe5d8298cb1f256aae6162f7fbde6c1f39a" + } + ] + }, + { + "id": "course-agent-rules-l04", + "type": "course", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-12", + "locales": [ + { + "lang": "en-US", + "path": "courses/agent-rules-single-source/lessons/L04.md", + "title": "Verify and wire it in", + "summary": "Prove both verifier directions, run all three skins, then add the checker to your repository's review loop.", + "sha256": "6cafad36be88ca25059da2b3ddee675278a06b2881067f8292506ccfc1ee6fcd" + }, + { + "lang": "zh-CN", + "path": "courses/agent-rules-single-source/lessons/L04_cn.md", + "title": "验证并接入流程", + "summary": "证明验证器双向通过、跑完三个皮肤,然后把检查器接入你仓库的审查环。", + "sha256": "67eb91c39cb750aaa8e6f147f2977ba727aed20de2c33d8056da2fc62d3bcb02" + } + ] + }, + { + "id": "course-agent-rules-l05", + "type": "course", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-12", + "locales": [ + { + "lang": "en-US", + "path": "courses/agent-rules-single-source/lessons/L05.md", + "title": "Apply this to your repository", + "summary": "One AGENTS.md, thin pointers everywhere else, and a failing check when it breaks.", + "sha256": "65125d7478161c64152fe4ba65c0d5922b98db0e65054ca3688880042c1707bc" + }, + { + "lang": "zh-CN", + "path": "courses/agent-rules-single-source/lessons/L05_cn.md", + "title": "把这套方法带回你的仓库", + "summary": "一个 AGENTS.md,其余全是瘦指针,一旦破坏检查就失败。", + "sha256": "f62e618183d27ced9c5c3de84b4ab7eaab5afccc3a002f1747fb004da7a0ede4" + } + ] + }, + { + "id": "course-claude-code", + "type": "course", + "content_version": 3, + "status": "reviewed", + "reviewed_on": "2026-09-12", + "locales": [ + { + "lang": "en-US", + "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": "3076ed936b16ecae0891ecfa881b6bb553080175639e270b1bc0031a01753006" + }, + { + "lang": "zh-CN", + "path": "courses/hands-on-python-with-claude-code/COURSE_cn.md", + "title": "用 Claude Code 实战 Python", + "summary": "一门由 Agent 授课的课程:从下载课程文件夹到交付一个经过测试与验证的 Python 报表工具——包括任务契约、最小变更和客观的通过/失败证据。", + "sha256": "a7cb245d0247f6057f11e07e58963513358e698dde1eaf63e52633a4eea2b829" + } + ] + }, + { + "id": "course-claude-code-l01", + "type": "course", + "content_version": 2, + "status": "reviewed", + "reviewed_on": "2026-09-12", + "locales": [ + { + "lang": "en-US", + "path": "courses/hands-on-python-with-claude-code/lessons/L01.md", + "title": "Lesson 1: Set up Claude Code, install the FlyPython Skill, fetch the course", + "summary": "Get Claude Code running, install the FlyPython Skill with network access approved, and let the agent fetch this course's files — you download nothing. Then make the starter's failure objective.", + "sha256": "82369069fc4bd61c79235d87d0fad6cfb154c9fd7cef72a35588bd93d07406ae" + }, + { + "lang": "zh-CN", + "path": "courses/hands-on-python-with-claude-code/lessons/L01_cn.md", + "title": "第 1 课:装好 Claude Code、装上 FlyPython Skill、让 Agent 取回课程", + "summary": "先把 Claude Code 跑起来,装好 FlyPython Skill 并打开联网权限,让 Agent 取回本课文件——你不下载任何东西,然后让 starter 的失败变得客观。", + "sha256": "92ecf820ba6e85711fe75f3949ece009e7f32e1f3ffdcc41e6d39bd8ad8795f5" + } + ] + }, + { + "id": "course-claude-code-l02", + "type": "course", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-12", + "locales": [ + { + "lang": "en-US", + "path": "courses/hands-on-python-with-claude-code/lessons/L02.md", + "title": "Lesson 2: Write the task contract before the change", + "summary": "Turn \"make it better\" into a bounded, testable contract — the single highest-leverage skill for working with coding agents.", + "sha256": "61e96ecb739482e545f4480a6dd4c6ed6017e2d41cdb268156ec91131de2e320" + }, + { + "lang": "zh-CN", + "path": "courses/hands-on-python-with-claude-code/lessons/L02_cn.md", + "title": "第 2 课:动手之前先写任务契约", + "summary": "把\"优化一下\"变成有边界、可测试的契约——这是与编码 Agent 协作杠杆最大的一项技能。", + "sha256": "83944c2772438743f920ad177cbce098f8777a3c12d267c74081be0c2280493b" + } + ] + }, + { + "id": "course-claude-code-l03", + "type": "course", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-12", + "locales": [ + { + "lang": "en-US", + "path": "courses/hands-on-python-with-claude-code/lessons/L03.md", + "title": "Lesson 3: Drive a bounded change, test by test", + "summary": "Let the agent edit the starter under contract — smallest change, no new dependencies, red to green one group at a time.", + "sha256": "8fe1f81ea3aeaa03d63bd2d016248df0178846c6100644f067241bea4bddf792" + }, + { + "lang": "zh-CN", + "path": "courses/hands-on-python-with-claude-code/lessons/L03_cn.md", + "title": "第 3 课:按测试驱动一次有边界的变更", + "summary": "让 Agent 在契约约束下修改 starter——最小变更、不新增依赖、一次一组测试从红到绿。", + "sha256": "136f483cfaefbdf7fea0328973a301faad639fb2edc0b633fbe037524afb9aef" + } + ] + }, + { + "id": "course-claude-code-l04", + "type": "course", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-12", + "locales": [ + { + "lang": "en-US", + "path": "courses/hands-on-python-with-claude-code/lessons/L04.md", + "title": "Lesson 4: Verify like an engineer, review like a skeptic", + "summary": "Run the full verification loop, review the agent's diff for scope and side effects, and record what you did — and did not — prove.", + "sha256": "ec724dbdb8172c284a6c970c499056d084b5e36bb3d3e6b4a32f5c14bb39494c" + }, + { + "lang": "zh-CN", + "path": "courses/hands-on-python-with-claude-code/lessons/L04_cn.md", + "title": "第 4 课:像工程师一样验证,像怀疑者一样审查", + "summary": "跑完整验证闭环,审查 Agent 的 diff 是否越界、有无副作用,并如实记录证明了什么、没证明什么。", + "sha256": "0c1f0532498a4f78a5c1255ad4309138890deed62039912677e6e44e5410ba23" + } + ] + }, + { + "id": "course-claude-code-l05", + "type": "course", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-12", + "locales": [ + { + "lang": "en-US", + "path": "courses/hands-on-python-with-claude-code/lessons/L05.md", + "title": "Lesson 5: Apply this to your own project", + "summary": "Port the workflow — not the code — to one script you own: contract, isolation, rounding, atomic writes, and an honest verification record.", + "sha256": "740a67fa1ce064eeca4b870578f036d4d241a96e730a9898fbc5d720e9bdcf79" + }, + { + "lang": "zh-CN", + "path": "courses/hands-on-python-with-claude-code/lessons/L05_cn.md", + "title": "第 5 课:把这套方法带回你自己的项目", + "summary": "迁移工作流——而不是代码——到你拥有的一个脚本上:契约、错误隔离、舍入、原子写入,以及一份诚实的验证记录。", + "sha256": "62bf3fe25f4b27f6aff3b1bddded715f2fdd2ac674d2d2c69144b34d4648b41a" + } + ] + }, + { + "id": "course-codex-cli", + "type": "course", + "content_version": 4, + "status": "reviewed", + "reviewed_on": "2026-09-13", + "locales": [ + { + "lang": "en-US", + "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": "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": "60bdd00ee11995fbefe8f46aded4878e936efbf437586fcdcd543fa50b7d0956" + } + ] + }, + { + "id": "course-codex-cli-l01", + "type": "course", + "content_version": 3, + "status": "reviewed", + "reviewed_on": "2026-09-13", + "locales": [ + { + "lang": "en-US", + "path": "courses/hands-on-with-openai-codex/lessons/L01.md", + "title": "Lesson 1: Set up the Codex app, install the FlyPython Skill, fetch the course", + "summary": "Get the Codex app running, install the FlyPython Skill with network access approved, and let the agent fetch this course's files — you download nothing. Then make the starter's failure objective.", + "sha256": "ee24b9e1e542eb8c740867f6e47467fcf9fa97f16a02458a15586c34b80927a5" + }, + { + "lang": "zh-CN", + "path": "courses/hands-on-with-openai-codex/lessons/L01_cn.md", + "title": "第 1 课:装好 the Codex app、装上 FlyPython Skill、让 Agent 取回课程", + "summary": "先把 the Codex app 跑起来,装好 FlyPython Skill 并打开联网权限,让 Agent 取回本课文件——你不下载任何东西,然后让 starter 的失败变得客观。", + "sha256": "01aaf0281525e670a7707e2860e778a72c2497c86a6ea5070ae4bc5e5ae68cdb" + } + ] + }, + { + "id": "course-codex-cli-l02", + "type": "course", + "content_version": 2, + "status": "reviewed", + "reviewed_on": "2026-09-13", + "locales": [ + { + "lang": "en-US", + "path": "courses/hands-on-with-openai-codex/lessons/L02.md", + "title": "Lesson 2: Write the task contract, let AGENTS.md carry the rules", + "summary": "Turn \"make it better\" into a bounded, testable contract — then make it durable so every Codex thread starts with the same rules.", + "sha256": "e8a8c74a0ae01e129101db976e812f12e4f129820d3aabca648272d9d95e0e85" + }, + { + "lang": "zh-CN", + "path": "courses/hands-on-with-openai-codex/lessons/L02_cn.md", + "title": "第 2 课:写任务契约,让 AGENTS.md 承载规则", + "summary": "把「改好一点」变成有边界、可测试的契约——再让它持久化,让每个 Codex 线程都从同一套规则开始。", + "sha256": "3fc7dfa8bff4401c45f36fb45f0062184eccf57b4730b1cc8ce6e9013f80d965" + } + ] + }, + { + "id": "course-codex-cli-l03", + "type": "course", + "content_version": 2, + "status": "reviewed", + "reviewed_on": "2026-09-13", + "locales": [ + { + "lang": "en-US", + "path": "courses/hands-on-with-openai-codex/lessons/L03.md", + "title": "Lesson 3: The bounded change, reviewed hunk by hunk", + "summary": "Drive the starter to green through bounded turns — and use the app's diff review and approvals to keep every change yours.", + "sha256": "278defc13c2bfa1fdd2328c34854a2ec4389a0b0d591c02ee3556e6b0812f521" + }, + { + "lang": "zh-CN", + "path": "courses/hands-on-with-openai-codex/lessons/L03_cn.md", + "title": "第 3 课:有界改动,逐块审 diff", + "summary": "通过有界的轮次把 starter 推到全绿——并用应用内的 diff 审查与审批机制守住每一处改动。", + "sha256": "a3c4191eb8e5aa672c4360caaf658d02936522b5a35c8cc44b6714f56542eb61" + } + ] + }, + { + "id": "course-codex-cli-l04", + "type": "course", + "content_version": 2, + "status": "reviewed", + "reviewed_on": "2026-09-13", + "locales": [ + { + "lang": "en-US", + "path": "courses/hands-on-with-openai-codex/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 the scenario skins, and review the diff the way you would review a colleague's.", + "sha256": "751c405fefd78f70b46967958f78d51e0c9175725741a7cdf45d81720c0701bd" + }, + { + "lang": "zh-CN", + "path": "courses/hands-on-with-openai-codex/lessons/L04_cn.md", + "title": "第 4 课:像评审者一样验证,别当观众", + "summary": "测试全绿是地板不是天花板——两个套件都跑、三个皮肤都走一遍、按评审同事的标准审这份 diff。", + "sha256": "7c3d2ff74ea861133208b96060e31d7dc62bfad121df5c74f944edd1f4ed0529" + } + ] + }, + { + "id": "course-codex-cli-l05", + "type": "course", + "content_version": 2, + "status": "reviewed", + "reviewed_on": "2026-09-13", + "locales": [ + { + "lang": "en-US", + "path": "courses/hands-on-with-openai-codex/lessons/L05.md", + "title": "Lesson 5: Take the workflow to your own project", + "summary": "The tool generalizes: task contract, bounded thread, approvals, diff review — run the same loop on a script you actually own.", + "sha256": "8964d7d543353de4cebaba40229e945a45aca208b6bbb4881443a0f2603e690a" + }, + { + "lang": "zh-CN", + "path": "courses/hands-on-with-openai-codex/lessons/L05_cn.md", + "title": "第 5 课:把工作流带回自己的项目", + "summary": "工具会泛化:任务契约、有界线程、审批、diff 评审——在你真正拥有的脚本上跑同一套闭环。", + "sha256": "8517bc536fedc8c79e0ff68b7209a91456d4f745107e2793f41b8978841d373c" + } + ] + }, + { + "id": "course-cursor", + "type": "course", + "content_version": 2, + "status": "reviewed", + "reviewed_on": "2026-09-13", + "locales": [ + { + "lang": "en-US", + "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": "ae57993829cf1c271f1b1ad5d723103d484acb7dcb2dd4a03c318f2b5b012bc7" + }, + { + "lang": "zh-CN", + "path": "courses/hands-on-with-cursor/COURSE_cn.md", + "title": "上手 Cursor", + "summary": "经过验证的 Python 工作流——任务契约、有界改动、客观 verify.py 证据——在 Cursor 里手把手教:由 Agent 模式跑闭环,你监督 diff 与项目规则。", + "sha256": "1e6536d0e6ef22d9152615d1c6a3af6d9a17dba5253a4383748abf6c03786886" + } + ] + }, + { + "id": "course-cursor-l01", + "type": "course", + "content_version": 2, + "status": "reviewed", + "reviewed_on": "2026-09-13", + "locales": [ + { + "lang": "en-US", + "path": "courses/hands-on-with-cursor/lessons/L01.md", + "title": "Lesson 1: Set up Cursor, install the FlyPython Skill, fetch the course", + "summary": "Get Cursor running, install the FlyPython Skill with network access approved, and let the agent fetch this course's files — you download nothing. Then make the starter's failure objective.", + "sha256": "de071fa887d8199000873a79feab7bb1ea0e4627f848cd87ba161a4fb0f0a5f7" + }, + { + "lang": "zh-CN", + "path": "courses/hands-on-with-cursor/lessons/L01_cn.md", + "title": "第 1 课:装好 Cursor、装上 FlyPython Skill、让 Agent 取回课程", + "summary": "先把 Cursor 跑起来,装好 FlyPython Skill 并打开联网权限,让 Agent 取回本课文件——你不下载任何东西,然后让 starter 的失败变得客观。", + "sha256": "54202b2349b7f846e71d5a18f876922c69a5c84b717e5350f1683537397ed386" + } + ] + }, + { + "id": "course-cursor-l02", + "type": "course", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-13", + "locales": [ + { + "lang": "en-US", + "path": "courses/hands-on-with-cursor/lessons/L02.md", + "title": "Lesson 2: Write the task contract, let .cursor/rules carry the rules", + "summary": "Turn \"make it better\" into a bounded, testable contract — then make it durable so every Cursor session starts with the same rules.", + "sha256": "f2fb837f9239298608358381309bdaeceec9b1190b0332736ecdaf108b90e40c" + }, + { + "lang": "zh-CN", + "path": "courses/hands-on-with-cursor/lessons/L02_cn.md", + "title": "第 2 课:写任务契约,让 .cursor/rules 承载规则", + "summary": "把「改好一点」变成有边界、可测试的契约——再让它持久化,让每个 Cursor 会话都从同一套规则开始。", + "sha256": "0c9f2e3745a2b84910793bf4717ed483442cc0d88afae5b1c1eecd1fd93cc45f" + } + ] + }, + { + "id": "course-cursor-l03", + "type": "course", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-13", + "locales": [ + { + "lang": "en-US", + "path": "courses/hands-on-with-cursor/lessons/L03.md", + "title": "Lesson 3: The bounded change, reviewed hunk by hunk", + "summary": "Drive the starter to green through bounded turns — and use Cursor's diff review to keep every change yours.", + "sha256": "e99ab81a589813b738d23dcebb1e173966045636ede917ca1ce2c656705269f4" + }, + { + "lang": "zh-CN", + "path": "courses/hands-on-with-cursor/lessons/L03_cn.md", + "title": "第 3 课:有界改动,逐块审 diff", + "summary": "通过有界的轮次把 starter 推到全绿——并用 Cursor 的 diff 审查守住每一处改动。", + "sha256": "035d38ac6d769dc34cb67adea20e92a335176f4de21f3c3f186af09fd37df62a" + } + ] + }, + { + "id": "course-cursor-l04", + "type": "course", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-13", + "locales": [ + { + "lang": "en-US", + "path": "courses/hands-on-with-cursor/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 the scenario skins, and review the accumulated diff the way you would review a colleague's.", + "sha256": "7ef2e4d42c31593047cd3985b5ad45b7afa096153dfd7cb1ab51b8be715453ce" + }, + { + "lang": "zh-CN", + "path": "courses/hands-on-with-cursor/lessons/L04_cn.md", + "title": "第 4 课:像评审者一样验证,别当观众", + "summary": "测试全绿是地板不是天花板——两个套件都跑、三个皮肤都走一遍、按评审同事的标准审这份累积 diff。", + "sha256": "93b63e90fc05fadf71db3371c5a9ca63ef7f643624328c2b78f0b714a111e799" + } + ] + }, + { + "id": "course-cursor-l05", + "type": "course", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-13", + "locales": [ + { + "lang": "en-US", + "path": "courses/hands-on-with-cursor/lessons/L05.md", + "title": "Lesson 5: Take the workflow to your own project", + "summary": "The tool generalizes: task contract, bounded agent work, diff review — run the same loop on a script you actually own.", + "sha256": "b9e9dbad4f6de5b72497220eeaa0866170fa64b5cc1d1e2b548878d9d008d94d" + }, + { + "lang": "zh-CN", + "path": "courses/hands-on-with-cursor/lessons/L05_cn.md", + "title": "第 5 课:把工作流带回自己的项目", + "summary": "工具会泛化:任务契约、有界 Agent 工作、diff 评审——在你真正拥有的脚本上跑同一套闭环。", + "sha256": "263f61fe5e52141fc8e8a7e8ad8d72b187e6d203c6f47a4ae76ac8c938dd4e3c" + } + ] + }, + { + "id": "course-da-eda", + "type": "course", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-12", + "locales": [ + { + "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": "e50b0ca1ec8d3dcccfeb8a9597a31bf3a9b351a869be95c72495a9861ee0b224" + }, + { + "lang": "zh-CN", + "path": "courses/da-eda/COURSE_cn.md", + "title": "用 Agent 做探索性数据分析", + "summary": "挑战式课程——让你的 Agent 处理一份脏的商店导出数据,产出一份经过真值校验的 EDA 摘要(results.json)。目录中第一门非标准库课程;pandas 由 uv 管理。", + "sha256": "83b2a03bbb124d95b81e3d537b8a6a89b0b7be4600e43e3585dbbc5b07af18c1" + } + ] + }, + { + "id": "course-da-eda-l01", + "type": "course", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-12", + "locales": [ + { + "lang": "en-US", + "path": "courses/da-eda/lessons/L01.md", + "title": "Challenge 01: What messy data looks like", + "summary": "Profile the shop export before touching pandas: exact duplicates, unparseable amounts, one bad date — count the dirt first.", + "sha256": "5160d16d0a8d8a66a72d871c869756959a4c2150986036b0c948f7231338abd0" + }, + { + "lang": "zh-CN", + "path": "courses/da-eda/lessons/L01_cn.md", + "title": "挑战 01:脏数据长什么样", + "summary": "动手写 pandas 之前先给商店导出数据画像:完全重复、无法解析的金额、一条坏日期——先把脏数清楚。", + "sha256": "44ca5cd6ae144929a1f1143ed2cd3880b0d2119a7de3d86c3bacaf5954caca65" + } + ] + }, + { + "id": "course-da-eda-l02", + "type": "course", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-12", + "locales": [ + { + "lang": "en-US", + "path": "courses/da-eda/lessons/L02.md", + "title": "Challenge 02: Define the answer first", + "summary": "Sketch results.json — keys, types, rounding — before letting the agent write eda.py.", + "sha256": "2fd93c0dfee425c9aa0280e59492da6011f0871d71a516198db07e8ba56a5a45" + }, + { + "lang": "zh-CN", + "path": "courses/da-eda/lessons/L02_cn.md", + "title": "挑战 02:先定答案", + "summary": "先勾出 results.json 的键、类型与舍入规则,再让 Agent 写 eda.py。", + "sha256": "ba8914add01f3042caaff485e107b6fb6445b4c0810729100a7c1a39df931237" + } + ] + }, + { + "id": "course-da-eda-l03", + "type": "course", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-12", + "locales": [ + { + "lang": "en-US", + "path": "courses/da-eda/lessons/L03.md", + "title": "Challenge 03: Clean and summarize", + "summary": "Implement load_transactions, clean_transactions, summarize, write_results, main until all twelve tests pass.", + "sha256": "d69ed99baf5a80b7a91cd2336f1ca37e5b5f3ad164edf3c9326918fa5d5b833b" + }, + { + "lang": "zh-CN", + "path": "courses/da-eda/lessons/L03_cn.md", + "title": "挑战 03:清洗与统计", + "summary": "实现 load_transactions、clean_transactions、summarize、write_results、main,十二个测试全绿。", + "sha256": "8a08b92ce945a433a20ecf2dc2a5923ec3c27031eb0aed765160bafef21eff45" + } + ] + }, + { + "id": "course-da-eda-l04", + "type": "course", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-12", + "locales": [ + { + "lang": "en-US", + "path": "courses/da-eda/lessons/L04.md", + "title": "Challenge 04: Check against ground truth", + "summary": "The suite asserts exact numbers — 142 in, 137 clean, revenue 33347.89, top category Books; plausible-but-different fails.", + "sha256": "da1727dea278986ba6c718f123a747a48318f69553a8398505328df7e9bda7c2" + }, + { + "lang": "zh-CN", + "path": "courses/da-eda/lessons/L04_cn.md", + "title": "挑战 04:核对真值", + "summary": "套件断言精确数字——142 进 137 净、营收 33347.89、品类第一 Books;看着合理但不同即失败。", + "sha256": "79cfeb70ff534f352174cef72bc12ebd4ab1ae34dddbfa5253af6d6b163f2fe5" + } + ] + }, + { + "id": "course-da-eda-l05", + "type": "course", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-12", + "locales": [ + { + "lang": "en-US", + "path": "courses/da-eda/lessons/L05.md", + "title": "Challenge 05: Run it on your own data", + "summary": "Port the pipeline to a real export you own and record which assumptions break.", + "sha256": "7aea36acb4baeea5abf3f6f4eb853025f0a359e73ccb5154549548da85c9f246" + }, + { + "lang": "zh-CN", + "path": "courses/da-eda/lessons/L05_cn.md", + "title": "挑战 05:换成你自己的数据", + "summary": "把流水线搬到你自己一份真实导出上,记下哪些假设崩了。", + "sha256": "1a75fa8195e74eb054281f6f3976bcd10b4252ff56eaac231df9b974736d0d03" + } + ] + }, + { + "id": "course-da-report", + "type": "course", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-12", + "locales": [ + { + "lang": "en-US", + "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": "3c80de32162187285964142406f1b482dc6c422608de63825783e0444a0f582a" + }, + { + "lang": "zh-CN", + "path": "courses/da-report/COURSE_cn.md", + "title": "用 Agent 从分析到报告", + "summary": "挑战课程——把经过验证的 EDA 摘要变成结构化报告(report.json + report.md),其中每个数字都可回溯到输入。验证检查结构与数字一致性,不评价文笔。", + "sha256": "40e0e079eaa4c9419b0ff72756a6ee28936bfe5c31cbcc530c48082574b2bd5a" + } + ] + }, + { + "id": "course-da-report-l01", + "type": "course", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-12", + "locales": [ + { + "lang": "en-US", + "path": "courses/da-report/lessons/L01.md", + "title": "Challenge 01: A report is a contract with the reader", + "summary": "List which numbers the report must carry and where each comes from in results.json.", + "sha256": "edca71e61fa83e86da5d4e7188a713741bbae666e132fd5102eebf6d9618c612" + }, + { + "lang": "zh-CN", + "path": "courses/da-report/lessons/L01_cn.md", + "title": "挑战 01:报告是与读者的契约", + "summary": "列出报告必须携带哪些数字,以及各自来自 results.json 的哪个字段。", + "sha256": "80b854d5871e09629d8231b0bda5401a203510b39739f5e2264b0544562358cb" + } + ] + }, + { + "id": "course-da-report-l02", + "type": "course", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-12", + "locales": [ + { + "lang": "en-US", + "path": "courses/da-report/lessons/L02.md", + "title": "Challenge 02: Structure before prose", + "summary": "Title, exact section order Data quality → Findings → Appendix, and the metrics block — before any sentence.", + "sha256": "ec7b1721d60834402918d00d749cf1a3a87c6a081de83326fbf7334c929613b9" + }, + { + "lang": "zh-CN", + "path": "courses/da-report/lessons/L02_cn.md", + "title": "挑战 02:先结构,后文字", + "summary": "标题、Data quality → Findings → Appendix 的确切顺序、metrics 块——先于任何句子。", + "sha256": "9b1202017349afdbacc3bb81bb59c9f282dd5ff6e6abaf9bab664f2d4b2d38c3" + } + ] + }, + { + "id": "course-da-report-l03", + "type": "course", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-12", + "locales": [ + { + "lang": "en-US", + "path": "courses/da-report/lessons/L03.md", + "title": "Challenge 03: Build and render the report", + "summary": "Carry metrics through unchanged, emit # title then ## sections in order, money at two decimals.", + "sha256": "860c859e433c56fbe24282839bbbc20d9f2455a950cf21459f0e130be958df55" + }, + { + "lang": "zh-CN", + "path": "courses/da-report/lessons/L03_cn.md", + "title": "挑战 03:构建并渲染报告", + "summary": "metrics 原样透传,先 # 标题再按序 ## 小节,金额两位小数。", + "sha256": "dddbe7184a8d48de81b07acded2631b0136e05917887dd4a6e8785fb26f1b3e6" + } + ] + }, + { + "id": "course-da-report-l04", + "type": "course", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-12", + "locales": [ + { + "lang": "en-US", + "path": "courses/da-report/lessons/L04.md", + "title": "Challenge 04: Numbers traceable to inputs", + "summary": "Every metric in report.json equals its source field — the audit answer is a field name, not an explanation.", + "sha256": "002b778f1a17aab30f5717adef0860ebef3ccd686d729e3ce093b26583842a61" + }, + { + "lang": "zh-CN", + "path": "courses/da-report/lessons/L04_cn.md", + "title": "挑战 04:数字可回溯到输入", + "summary": "report.json 每个指标等于源字段——审计答案是字段名,不是解释。", + "sha256": "039df9f5b59c1e8e4126fcd15e272a503f05b66e5480922b501bb63502009ae1" + } + ] + }, + { + "id": "course-da-report-l05", + "type": "course", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-12", + "locales": [ + { + "lang": "en-US", + "path": "courses/da-report/lessons/L05.md", + "title": "Challenge 05: What report verification cannot see", + "summary": "Structure and number consistency are checked; insight and honest conclusions are not — write the boundary sentence.", + "sha256": "c33d694858ff58eb599ba5ace3fd5947c0a8a1b3b2b7c8888b8cad2177976878" + }, + { + "lang": "zh-CN", + "path": "courses/da-report/lessons/L05_cn.md", + "title": "挑战 05:报告验证看不到的东西", + "summary": "结构与数字一致性被查;洞察与结论诚实不在其中——写下边界句。", + "sha256": "61f78115e8942a8bba28b399e02de2caeb67783494ab7cf17040831459326094" + } + ] + }, + { + "id": "course-da-visualization", + "type": "course", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-12", + "locales": [ + { + "lang": "en-US", + "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": "fe7a7d00197b676cff35f62b1f098b39501c529b8dff93f034f716e623cd467b" + }, + { + "lang": "zh-CN", + "path": "courses/da-visualization/COURSE_cn.md", + "title": "用 Agent 做数据可视化", + "summary": "挑战课程——把干净的销售数据变成三张有规格的图和一份 summary.json,其中数字会与数据集校验。图必须由数据生成,不许硬编码。", + "sha256": "d4e4447b9f31c9c31936b94cc0985e9f37e36090da3c562dd6450dbf1147c7f8" + } + ] + }, + { + "id": "course-da-visualization-l01", + "type": "course", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-12", + "locales": [ + { + "lang": "en-US", + "path": "courses/da-visualization/lessons/L01.md", + "title": "Challenge 01: A chart is packaged evidence", + "summary": "Say what claim each required PNG makes before writing code — the JSON summary is the auditable layer.", + "sha256": "6ea61d76247157f99af1385fd6227d48ad41b22528ac1cb1fe6c0ea352a81b9c" + }, + { + "lang": "zh-CN", + "path": "courses/da-visualization/lessons/L01_cn.md", + "title": "挑战 01:图是证据的包装", + "summary": "写码前先说出每张 PNG 声明什么——JSON 摘要才是可审计层。", + "sha256": "1b490bbad3b7727eb29fc341c8f99b91f995d21d93a0905d9d390d3f22022b21" + } + ] + }, + { + "id": "course-da-visualization-l02", + "type": "course", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-12", + "locales": [ + { + "lang": "en-US", + "path": "courses/da-visualization/lessons/L02.md", + "title": "Challenge 02: Spec first, chart second", + "summary": "Write which aggregation feeds each chart and what summary.json must contain before matplotlib opens.", + "sha256": "e2f7b94b7e7431acc11895a4341b003b3bec35ef34147d6998e41e4388915c30" + }, + { + "lang": "zh-CN", + "path": "courses/da-visualization/lessons/L02_cn.md", + "title": "挑战 02:先写规格,再画图", + "summary": "在打开 matplotlib 之前写清每张图吃哪个聚合、summary.json 必须装什么。", + "sha256": "5268e3dcd77086d848a2c40065ca73bd325a665d3c63e7ec2c51b53035b6cf8a" + } + ] + }, + { + "id": "course-da-visualization-l03", + "type": "course", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-12", + "locales": [ + { + "lang": "en-US", + "path": "courses/da-visualization/lessons/L03.md", + "title": "Challenge 03: Three charts and a summary", + "summary": "Implement the contract in starter/charts.py — Agg before pyplot, parsed dates, real PNG bytes.", + "sha256": "fb07259f034ca60d4d1eca4c12e44b33224efe3481d5a589cff775bea2fde944" + }, + { + "lang": "zh-CN", + "path": "courses/da-visualization/lessons/L03_cn.md", + "title": "挑战 03:三张图与摘要", + "summary": "在 starter/charts.py 实现契约——Agg 先于 pyplot、日期先解析、真 PNG 字节。", + "sha256": "0dd5926407a92d207a91785a3bc74022e11b0a74cc06068f6460e0b78902251d" + } + ] + }, + { + "id": "course-da-visualization-l04", + "type": "course", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-12", + "locales": [ + { + "lang": "en-US", + "path": "courses/da-visualization/lessons/L04.md", + "title": "Challenge 04: Numbers match the charts", + "summary": "summary.json is checked against ground truth; a chart disagreeing with the JSON is a failed chart.", + "sha256": "4c4dd1cf0f2bf15f5d7ee11cff3e4d915cbd787bece2be4fc51d57309fd30955" + }, + { + "lang": "zh-CN", + "path": "courses/da-visualization/lessons/L04_cn.md", + "title": "挑战 04:数字与图一致", + "summary": "summary.json 对照真值校验;图与 JSON 不一致就是失败的图。", + "sha256": "2dad0ea079ae52782273d0570546399cebed2b294a86c41eefa56779ce6d1a8a" + } + ] + }, + { + "id": "course-da-visualization-l05", + "type": "course", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-12", + "locales": [ + { + "lang": "en-US", + "path": "courses/da-visualization/lessons/L05.md", + "title": "Challenge 05: Honest boundary", + "summary": "The suite checks files and numbers — not readability or honest scales; state what was not verified.", + "sha256": "087690387fe9646a9bbb2edae83a19f6a1726a598c7ee6899e1ec8a8a6df3977" + }, + { + "lang": "zh-CN", + "path": "courses/da-visualization/lessons/L05_cn.md", + "title": "挑战 05:诚实边界", + "summary": "套件查文件与数字——不查可读性与刻度诚实;写清没验证什么。", + "sha256": "9047f23b6c3b53d2ecf27841bca16c91466a1e79fbceaadcee35fb26d1f0ab8b" + } + ] + }, + { + "id": "course-deepseek-harness", + "type": "course", + "content_version": 2, + "status": "reviewed", + "reviewed_on": "2026-09-13", + "locales": [ + { + "lang": "en-US", + "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": "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": "3bef27e9425c37709924781f53d12f42b883cd5174eb94cc32675c01f5d1bfac" + } + ] + }, + { + "id": "course-deepseek-harness-l01", + "type": "course", + "content_version": 2, + "status": "reviewed", + "reviewed_on": "2026-09-13", + "locales": [ + { + "lang": "en-US", + "path": "courses/hands-on-with-deepseek-harness/lessons/L01.md", + "title": "Lesson 1: Set up DeepSeek Harness, install the FlyPython Skill, fetch the course", + "summary": "Get DeepSeek Harness running, install the FlyPython Skill with network access approved, and let the agent fetch this course's files — you download nothing. Then make the starter's failure objective.", + "sha256": "966ec591705e7351461ec85a7d3f615dcdc001dc559767b32b9a362efde7aaf7" + }, + { + "lang": "zh-CN", + "path": "courses/hands-on-with-deepseek-harness/lessons/L01_cn.md", + "title": "第 1 课:装好 DeepSeek Harness、装上 FlyPython Skill、让 Agent 取回课程", + "summary": "先把 DeepSeek Harness 跑起来,装好 FlyPython Skill 并打开联网权限,让 Agent 取回本课文件——你不下载任何东西,然后让 starter 的失败变得客观。", + "sha256": "0f83e1dc5c7d1ec13b6cc04b4eb2b5050d957ec39da99faaa05adb4f5dc51d4b" + } + ] + }, + { + "id": "course-deepseek-harness-l02", + "type": "course", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-13", + "locales": [ + { + "lang": "en-US", + "path": "courses/hands-on-with-deepseek-harness/lessons/L02.md", + "title": "Lesson 2: Write the task contract, let cordis.yml carry the setup", + "summary": "Turn \"make it better\" into a bounded, testable contract — then make the working rules part of the composed agent, not a forgotten prompt.", + "sha256": "64d4f1e4f8dc54c6d151ddcbbb9c530d866f232ac9673423c90a777d7fbd6280" + }, + { + "lang": "zh-CN", + "path": "courses/hands-on-with-deepseek-harness/lessons/L02_cn.md", + "title": "第 2 课:写任务契约,让 cordis.yml 承载配置", + "summary": "把「改好一点」变成有边界、可测试的契约——再把工作规则放进组装出来的 Agent 里,而不是会被遗忘的提示词。", + "sha256": "6a540ca0dc99c2d91c44ab1c195993c5157299fcac57f3803b6b4c0c02bff26f" + } + ] + }, + { + "id": "course-deepseek-harness-l03", + "type": "course", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-13", + "locales": [ + { + "lang": "en-US", + "path": "courses/hands-on-with-deepseek-harness/lessons/L03.md", + "title": "Lesson 3: The bounded change, audited from the trajectory", + "summary": "Drive the starter to green through bounded steps — then prove the agent stayed in bounds by inspecting the append-only trajectory.", + "sha256": "f9b2867d86bb4257086e250f6585b9604e5299430893e47d2eefc457decc6d0d" + }, + { + "lang": "zh-CN", + "path": "courses/hands-on-with-deepseek-harness/lessons/L03_cn.md", + "title": "第 3 课:有界改动,用轨迹做审计", + "summary": "通过有界的步骤把 starter 推到全绿——再检查只增轨迹,证明 Agent 没有越界。", + "sha256": "dd24dcb13eeff9144f5fbb7c64219bc0a36fc88aed125e08e92c374adbcfd358" + } + ] + }, + { + "id": "course-deepseek-harness-l04", + "type": "course", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-13", + "locales": [ + { + "lang": "en-US", + "path": "courses/hands-on-with-deepseek-harness/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 the scenario skins, and review the trajectory the way you would review a colleague's work.", + "sha256": "f0eeca44e39b33504977d8f2acf0d4e3639f0e184bdccd4e9dd622f5144a3150" + }, + { + "lang": "zh-CN", + "path": "courses/hands-on-with-deepseek-harness/lessons/L04_cn.md", + "title": "第 4 课:像评审者一样验证,别当观众", + "summary": "测试全绿是地板不是天花板——两个套件都跑、三个皮肤都走一遍、按评审同事工作的标准审这条轨迹。", + "sha256": "f7e24bccba2a86153b115e9b9a5b456f564601f6d7594f242ff78c832f9c63bd" + } + ] + }, + { + "id": "course-deepseek-harness-l05", + "type": "course", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-13", + "locales": [ + { + "lang": "en-US", + "path": "courses/hands-on-with-deepseek-harness/lessons/L05.md", + "title": "Lesson 5: Take the workflow to your own project", + "summary": "The pattern generalizes: task contract, composed agent, bounded steps, trajectory audit — run the same loop on a script you actually own.", + "sha256": "0e76a3df730783e55b3a0faa07aa8bd974f905c262f9f52bbc81db634d931cc6" + }, + { + "lang": "zh-CN", + "path": "courses/hands-on-with-deepseek-harness/lessons/L05_cn.md", + "title": "第 5 课:把工作流带回自己的项目", + "summary": "模式会泛化:任务契约、组装 Agent、有界步骤、轨迹审计——在你真正拥有的脚本上跑同一套闭环。", + "sha256": "428b9c925dcf232c238e3a7e8a44934e8d74f960b8126be38941b43aecc87dc8" + } + ] + }, + { + "id": "course-kimi-code", + "type": "course", + "content_version": 2, + "status": "reviewed", + "reviewed_on": "2026-09-13", + "locales": [ + { + "lang": "en-US", + "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": "d574ba24d74c189cbc4cfa599f14d64ed327a65299272564300c2e85e6d6a272" + }, + { + "lang": "zh-CN", + "path": "courses/hands-on-with-kimi-code/COURSE_cn.md", + "title": "上手 Kimi Code", + "summary": "经过验证的 Python 工作流——任务契约、有界改动、客观 verify.py 证据——在 Kimi Code 里手把手教:内置子代理把只读勘察、规划和经你批准的编辑分在三条道上。", + "sha256": "a7c09dafda26d905430d182258485d6792b43a55703822a3438ce552518c5815" + } + ] + }, + { + "id": "course-kimi-code-l01", + "type": "course", + "content_version": 2, + "status": "reviewed", + "reviewed_on": "2026-09-13", + "locales": [ + { + "lang": "en-US", + "path": "courses/hands-on-with-kimi-code/lessons/L01.md", + "title": "Lesson 1: Set up Kimi Code, install the FlyPython Skill, fetch the course", + "summary": "Get Kimi Code running, install the FlyPython Skill with network access approved, and let the agent fetch this course's files — you download nothing. Then make the starter's failure objective.", + "sha256": "40c376c81b7a887e17a986908a8e0fce271c494f9e169bea1dd3b9c19e35bd3b" + }, + { + "lang": "zh-CN", + "path": "courses/hands-on-with-kimi-code/lessons/L01_cn.md", + "title": "第 1 课:装好 Kimi Code、装上 FlyPython Skill、让 Agent 取回课程", + "summary": "先把 Kimi Code 跑起来,装好 FlyPython Skill 并打开联网权限,让 Agent 取回本课文件——你不下载任何东西,然后让 starter 的失败变得客观。", + "sha256": "5bb681bb60e03cbb2145b76bb671ddfd831f3680e1e617efcf637dc93592f6b6" + } + ] + }, + { + "id": "course-kimi-code-l02", + "type": "course", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-13", + "locales": [ + { + "lang": "en-US", + "path": "courses/hands-on-with-kimi-code/lessons/L02.md", + "title": "Lesson 2: Write the task contract, plan it in the planning lane", + "summary": "Turn \"make it better\" into a bounded, testable contract — then let the plan subagent (no write, no shell) turn it into steps before anything edits.", + "sha256": "10b65aecb5e665973ecbfa1808c7418c9b9689da3e6195d400182e8d86bbe8b9" + }, + { + "lang": "zh-CN", + "path": "courses/hands-on-with-kimi-code/lessons/L02_cn.md", + "title": "第 2 课:写任务契约,在规划道里拆步", + "summary": "把「改好一点」变成有边界、可测试的契约——再让 plan 子代理(无写无 shell)先把它拆成步骤,然后才许动文件。", + "sha256": "5021e765800ea1dd28baacf709720610cee1ad49873e5956111dbea6e7de6231" + } + ] + }, + { + "id": "course-kimi-code-l03", + "type": "course", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-13", + "locales": [ + { + "lang": "en-US", + "path": "courses/hands-on-with-kimi-code/lessons/L03.md", + "title": "Lesson 3: The bounded change, one contract line at a time", + "summary": "Drive the starter to green through the coder lane in bounded steps — and review every edit before the next one lands.", + "sha256": "a4152a672227b999c7b7b9da4a282786925035a6abf5b7d675f574db5ff52508" + }, + { + "lang": "zh-CN", + "path": "courses/hands-on-with-kimi-code/lessons/L03_cn.md", + "title": "第 3 课:有界改动,一次一条契约行", + "summary": "通过 coder 道的有界步骤把 starter 推到全绿——下一处编辑落地前,先审完这一处。", + "sha256": "0be0b2a66186028eae871cc8ed46d1fcf6152dc2dd98319c1e0caa4e9dce17e3" + } + ] + }, + { + "id": "course-kimi-code-l04", + "type": "course", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-13", + "locales": [ + { + "lang": "en-US", + "path": "courses/hands-on-with-kimi-code/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 the scenario skins, and review the accumulated diff the way you would review a colleague's.", + "sha256": "279887be163fd98ab3a478c698a7ad1500593f2127750f1929c77b77da51033b" + }, + { + "lang": "zh-CN", + "path": "courses/hands-on-with-kimi-code/lessons/L04_cn.md", + "title": "第 4 课:像评审者一样验证,别当观众", + "summary": "测试全绿是地板不是天花板——两个套件都跑、三个皮肤都走一遍、按评审同事的标准审这份累积 diff。", + "sha256": "7f6c9b4a4f75c1f4f469224384a4921ea690844849be40b0c5bf0d11907c0357" + } + ] + }, + { + "id": "course-kimi-code-l05", + "type": "course", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-13", + "locales": [ + { + "lang": "en-US", + "path": "courses/hands-on-with-kimi-code/lessons/L05.md", + "title": "Lesson 5: Take the workflow to your own project", + "summary": "The pattern generalizes: contract first, read-only survey, planning lane, bounded edits, written verification — run the same loop on a script you actually own.", + "sha256": "e3b502add27090e1fd6730d0e4ca0c70b55c18d66bcb3ade452beb2513f95c24" + }, + { + "lang": "zh-CN", + "path": "courses/hands-on-with-kimi-code/lessons/L05_cn.md", + "title": "第 5 课:把工作流带回自己的项目", + "summary": "模式会泛化:契约先行、只读勘察、规划道拆步、有界编辑、书面验证——在你真正拥有的脚本上跑同一套闭环。", + "sha256": "e1403c1fc31c8304f82219c214afe574ff5dc69f41b3cab7a0c5002c8b8774f2" + } + ] + }, + { + "id": "course-mcp-tools", + "type": "course", + "content_version": 3, + "status": "reviewed", + "reviewed_on": "2026-09-12", + "locales": [ + { + "lang": "en-US", + "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": "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": "197797063a2f01da277d2c51a3cb3816077feca8eae9dfa00b76706bc0ef4887" + } + ] + }, + { + "id": "course-mcp-tools-l01", + "type": "course", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-12", + "locales": [ + { + "lang": "en-US", + "path": "courses/mcp-server-in-python/lessons/L01.md", + "title": "What MCP actually is (2026-07-28)", + "summary": "A stateless JSON-RPC 2.0 surface for tools — no handshake, no sessions, versions in _meta.", + "sha256": "96de808002e5441672c198682dc7ae2b25ecca60fbe6735c1fd7448add92ca2f" + }, + { + "lang": "zh-CN", + "path": "courses/mcp-server-in-python/lessons/L01_cn.md", + "title": "MCP 到底是什么(2026-07-28)", + "summary": "一个面向工具的无状态 JSON-RPC 2.0 接口——无握手、无会话、版本放在 _meta 里。", + "sha256": "d598f5b026942aa917f90d08650de30839e4858a0580427580b9b5fb4733d7de" + } + ] + }, + { + "id": "course-mcp-tools-l02", + "type": "course", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-12", + "locales": [ + { + "lang": "en-US", + "path": "courses/mcp-server-in-python/lessons/L02.md", + "title": "The JSON-RPC dispatch contract", + "summary": "Valid requests echo their id; invalid ones get -32600; unknown or removed methods get -32601.", + "sha256": "bfe4fe19e51256a5f387f83caf62dda1f02ff51888f1603e6842339cb3cc0ff6" + }, + { + "lang": "zh-CN", + "path": "courses/mcp-server-in-python/lessons/L02_cn.md", + "title": "JSON-RPC 分发契约", + "summary": "合法请求回显 id;非法请求得 -32600;未知或已移除的方法得 -32601。", + "sha256": "0fb8429550ce58383938809824aff13a91df741eca2263d1800dc29def99337f" + } + ] + }, + { + "id": "course-mcp-tools-l03", + "type": "course", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-12", + "locales": [ + { + "lang": "en-US", + "path": "courses/mcp-server-in-python/lessons/L03.md", + "title": "Validation and error isolation", + "summary": "Arguments are untrusted input: validate before execution, and never let a handler crash the transport.", + "sha256": "6b31bd6ea95bd294590be3bbc3a326d20333cee67ed3d9c1131a47f659a135e1" + }, + { + "lang": "zh-CN", + "path": "courses/mcp-server-in-python/lessons/L03_cn.md", + "title": "校验与错误隔离", + "summary": "参数是不可信输入:先校验再执行,绝不让处理函数打断传输。", + "sha256": "cc570c73e4269dcfba2c09f9cdb24b197773b7339f1dd029404dade02a8851bc" + } + ] + }, + { + "id": "course-mcp-tools-l04", + "type": "course", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-12", + "locales": [ + { + "lang": "en-US", + "path": "courses/mcp-server-in-python/lessons/L04.md", + "title": "The input_required round-trip", + "summary": "A tool can ask the client a question mid-call — return the requests, finish on the retry carrying answers.", + "sha256": "4d8b445585b3e139badba149c7037dab34fb83760eefae9c7f8c11bf47d870cf" + }, + { + "lang": "zh-CN", + "path": "courses/mcp-server-in-python/lessons/L04_cn.md", + "title": "input_required 多轮交互", + "summary": "工具可以在调用中途向客户端提问——先返回问题,客户端带答案重试后完成。", + "sha256": "89b23a1cc7ec4a88efdf9e469ada40c8a39e91f0a1713083de4b355030f60577" + } + ] + }, + { + "id": "course-mcp-tools-l05", + "type": "course", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-12", + "locales": [ + { + "lang": "en-US", + "path": "courses/mcp-server-in-python/lessons/L05.md", + "title": "Expose one real tool to your agent", + "summary": "Wrap one function you actually own behind the validated, isolated boundary you just built.", + "sha256": "ef5f5b3588419591faadb1d5e63d701e95e54e4589ab079e59f0d60917588722" + }, + { + "lang": "zh-CN", + "path": "courses/mcp-server-in-python/lessons/L05_cn.md", + "title": "给你的 Agent 暴露一个真实工具", + "summary": "把你真正拥有的一个函数包进刚建好的“校验 + 隔离”边界。", + "sha256": "f5a906e707393326ece9a984916dd3624cf2573dd6cefc0371ca362e0a02b7c7" + } + ] + }, + { + "id": "course-verify-ship", + "type": "course", + "content_version": 3, + "status": "reviewed", + "reviewed_on": "2026-09-12", + "locales": [ + { + "lang": "en-US", + "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": "f21f75bd6a716245b614ee2d9b8d782354e33d890694683a66fbd7a1fe53f46b" + }, + { + "lang": "zh-CN", + "path": "courses/verifying-ai-generated-code/COURSE_cn.md", + "title": "从「能跑」到「能上线」:验证 AI 写的代码", + "summary": "为你的 AI 项目补上缺失的发布证据机器——一个 ship check:运行测试、解析实际跑了什么、拒绝零测试项目,并写出诚实的交付记录。", + "sha256": "ade471c2a4f88526bdb5630b67791b4b5c0757e2a6daf59d2a56566b72f34da9" + } + ] + }, + { + "id": "course-verify-ship-l01", + "type": "course", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-12", + "locales": [ + { + "lang": "en-US", + "path": "courses/verifying-ai-generated-code/lessons/L01.md", + "title": "\"It runs\" is not evidence", + "summary": "Watch the starter bless a project with no tests and a project with a failing one — then want better.", + "sha256": "02703933706d1557470a3f6305d331daa07983c3ede05fe5d8c462a3835e0273" + }, + { + "lang": "zh-CN", + "path": "courses/verifying-ai-generated-code/lessons/L01_cn.md", + "title": "“能跑”不是证据", + "summary": "看着 starter 给零测试的项目和有失败的项目都盖章放行——然后想要更好的东西。", + "sha256": "d4561bad447524145ac0618c8940ed856766f38b458bec68c4ab8d91af5664c3" + } + ] + }, + { + "id": "course-verify-ship-l02", + "type": "course", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-12", + "locales": [ + { + "lang": "en-US", + "path": "courses/verifying-ai-generated-code/lessons/L02.md", + "title": "Define what a release record must prove", + "summary": "Command, exit code, parsed count and outcome, an unverified list, and a verdict that refuses zero-test projects.", + "sha256": "3910f5128e43027769f3b0b03f4f483ff916a39af1abdfe8d6efc3984d014a71" + }, + { + "lang": "zh-CN", + "path": "courses/verifying-ai-generated-code/lessons/L02_cn.md", + "title": "定义发布记录必须证明什么", + "summary": "命令、退出码、解析出的数量与结果、一份未验证清单,以及一个拒绝零测试项目的判定。", + "sha256": "babb9e3127e75928c76d0f706faab3c0dfc1844d05d182007237a485880fd500" + } + ] + }, + { + "id": "course-verify-ship-l03", + "type": "course", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-12", + "locales": [ + { + "lang": "en-US", + "path": "courses/verifying-ai-generated-code/lessons/L03.md", + "title": "Build the ship check, test by test", + "summary": "Parsing, verdicts, the unverified list, atomic writes, exit codes — one failing group at a time.", + "sha256": "512e4562c2279e0a064d79e2417c2a42aaceae60b6b6cfae3e653f514150d09a" + }, + { + "lang": "zh-CN", + "path": "courses/verifying-ai-generated-code/lessons/L03_cn.md", + "title": "按测试逐组构建 ship check", + "summary": "解析、判定、未验证清单、原子写入、退出码——一次一组失败测试。", + "sha256": "bd910efe67f4d17a30b129f65f3d1adb8b5f13af22d33dafbc809f3ff35e7a67" + } + ] + }, + { + "id": "course-verify-ship-l04", + "type": "course", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-12", + "locales": [ + { + "lang": "en-US", + "path": "courses/verifying-ai-generated-code/lessons/L04.md", + "title": "Read the record like a skeptic", + "summary": "Green, red, and no-tests mean different things — prove you can tell them apart and say what the record does not prove.", + "sha256": "d8017ac4bfdfa42be12a80f1325921ce033c6d5b5b0e1e80d59e2a596b8a0231" + }, + { + "lang": "zh-CN", + "path": "courses/verifying-ai-generated-code/lessons/L04_cn.md", + "title": "像怀疑者一样读记录", + "summary": "绿、红、零测试是三件不同的事——证明你能分辨,并说清记录不能证明什么。", + "sha256": "f59ef01d2c7f6476e3e0755da892738b816b13412ea8adb94d7ea41a464e94fa" + } + ] + }, + { + "id": "course-verify-ship-l05", + "type": "course", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-12", + "locales": [ + { + "lang": "en-US", + "path": "courses/verifying-ai-generated-code/lessons/L05.md", + "title": "Gate your next release", + "summary": "Take the ship check to a project you own and make the record part of shipping.", + "sha256": "e3700ed08ce5a0fd2d154da5275b7edeead88ff70275552f67f23da3b11af1c8" + }, + { + "lang": "zh-CN", + "path": "courses/verifying-ai-generated-code/lessons/L05_cn.md", + "title": "给你的下一次发布装上门禁", + "summary": "把 ship check 带到你自己的项目,让记录成为交付的一部分。", + "sha256": "dda3c53b3a73121efd5eb602f5893dddd0151ba9f0f6ef97980f7ca482c94ae1" + } + ] + }, + { + "id": "course-zcode", + "type": "course", + "content_version": 2, + "status": "reviewed", + "reviewed_on": "2026-09-13", + "locales": [ + { + "lang": "en-US", + "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": "051fee38652ea32840258ec50e304190124bdef5965309c0e24578ecbf5c5481" + }, + { + "lang": "zh-CN", + "path": "courses/hands-on-with-zcode/COURSE_cn.md", + "title": "上手 ZCode", + "summary": "经过验证的 Python 工作流——任务契约、有界改动、客观 verify.py 证据——在 ZCode 桌面工作区里手把手教:Goal 模式拆解任务,Git 状态展示每一处改动。", + "sha256": "a3c9916af904b3c054bab2ae84c486d8ee629c699ec024ddbb8c7e993c8fe3a5" + } + ] + }, + { + "id": "course-zcode-l01", + "type": "course", + "content_version": 2, + "status": "reviewed", + "reviewed_on": "2026-09-13", + "locales": [ + { + "lang": "en-US", + "path": "courses/hands-on-with-zcode/lessons/L01.md", + "title": "Lesson 1: Set up ZCode, install the FlyPython Skill, fetch the course", + "summary": "Get ZCode running, install the FlyPython Skill with network access approved, and let the agent fetch this course's files — you download nothing. Then make the starter's failure objective.", + "sha256": "2fbfe9fff71fc209d2b89558f34c47c44985711d02cdb0f7a674db606271e665" + }, + { + "lang": "zh-CN", + "path": "courses/hands-on-with-zcode/lessons/L01_cn.md", + "title": "第 1 课:装好 ZCode、装上 FlyPython Skill、让 Agent 取回课程", + "summary": "先把 ZCode 跑起来,装好 FlyPython Skill 并打开联网权限,让 Agent 取回本课文件——你不下载任何东西,然后让 starter 的失败变得客观。", + "sha256": "1e13ba6f4e3562d25b84de6610cdac2a8fdab4915496181143b179762b6e95d9" + } + ] + }, + { + "id": "course-zcode-l02", + "type": "course", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-13", + "locales": [ + { + "lang": "en-US", + "path": "courses/hands-on-with-zcode/lessons/L02.md", + "title": "Lesson 2: Write the task contract, let Goal Mode hold the plan", + "summary": "Turn \"make it better\" into a bounded, testable contract — then let Goal Mode decompose it into tasks you can watch land one by one.", + "sha256": "8d82172aa73a5350511ab0e07b88c0725417228ab88766149836c892fb5931a6" + }, + { + "lang": "zh-CN", + "path": "courses/hands-on-with-zcode/lessons/L02_cn.md", + "title": "第 2 课:写任务契约,让 Goal 模式承载计划", + "summary": "把「改好一点」变成有边界、可测试的契约——再让 Goal 模式把它拆成你能看着逐一落地的任务。", + "sha256": "8ccfc361351b45a791ae70bd4ee0faeb59e350898f746b9916f85f79ed564eb5" + } + ] + }, + { + "id": "course-zcode-l03", + "type": "course", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-13", + "locales": [ + { + "lang": "en-US", + "path": "courses/hands-on-with-zcode/lessons/L03.md", + "title": "Lesson 3: The bounded change, watched task by task", + "summary": "Drive the starter to green through the goal's task list — and use the workspace Git state to review every change that lands.", + "sha256": "0bd215e38f23a1bb54f13b8ab8e93280694332cefafa5d5ba31c516bd7609840" + }, + { + "lang": "zh-CN", + "path": "courses/hands-on-with-zcode/lessons/L03_cn.md", + "title": "第 3 课:有界改动,逐任务盯守", + "summary": "沿着目标的任务列表把 starter 推到全绿——用工作区 Git 状态审每一处落地的改动。", + "sha256": "91c25c0373c7b132d1d8ddb57f6aeca107b542caa7aa02db307edb97d593ddaa" + } + ] + }, + { + "id": "course-zcode-l04", + "type": "course", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-13", + "locales": [ + { + "lang": "en-US", + "path": "courses/hands-on-with-zcode/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 the scenario skins, and review the accumulated Git diff the way you would review a colleague's.", + "sha256": "c488567de7ff1f2f1ca1e0616a3eae14da324cf294dfe632ea7bb3ac23c3c556" + }, + { + "lang": "zh-CN", + "path": "courses/hands-on-with-zcode/lessons/L04_cn.md", + "title": "第 4 课:像评审者一样验证,别当观众", + "summary": "测试全绿是地板不是天花板——两个套件都跑、三个皮肤都走一遍、按评审同事的标准审这份累积 Git diff。", + "sha256": "e9d723ddff1c5586556c1535fadccd8b5ded06f70157b4f7b66919f045b1dae3" + } + ] + }, + { + "id": "course-zcode-l05", + "type": "course", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-13", + "locales": [ + { + "lang": "en-US", + "path": "courses/hands-on-with-zcode/lessons/L05.md", + "title": "Lesson 5: Take the workflow to your own project", + "summary": "The pattern generalizes: contract as goal, task list, bounded execution, Git-state review — run the same loop on a project you actually own.", + "sha256": "60e68f537ddfd30ff271e995d9be15e962ad641ca02cdababf1d1cf363773ca3" + }, + { + "lang": "zh-CN", + "path": "courses/hands-on-with-zcode/lessons/L05_cn.md", + "title": "第 5 课:把工作流带回自己的项目", + "summary": "模式会泛化:契约变目标、任务列表、有界执行、Git 状态审查——在你真正拥有的项目上跑同一套闭环。", + "sha256": "1adee0b75e4d418005cc9aafdba92a0cd70d6d4f9175ef0972cd874258670602" + } + ] + }, + { + "id": "example-async-fetcher", + "type": "example", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-02", + "locales": [ + { + "lang": "en-US", + "path": "examples/async-fetcher/README.md", + "title": "Async Batch Fetcher with Bounded Concurrency and Retries", + "summary": "Implement a resilient async task batcher with Semaphore concurrency control, exponential backoff, and error isolation.", + "sha256": "1a309f791eb6b17b5323b59ddd8f5f27c6039751de6cb7c51df607cdd8e0ffd9" + }, + { + "lang": "zh-CN", + "path": "examples/async-fetcher/README_cn.md", + "title": "带受控并发与重试的异步抓取器", + "summary": "使用 Semaphore 信号量控制并发、指数退避重试与错误隔离,实现韧性异步任务批处理。", + "sha256": "ee9a13cb4756abe75a19e28ba3b2ce837764f24dfdce1064e9bff765a139fcce" + } + ] + }, + { + "id": "example-mcp-server", + "type": "example", + "content_version": 2, + "status": "reviewed", + "reviewed_on": "2026-09-06", + "locales": [ + { + "lang": "en-US", + "path": "examples/mcp-server/README.md", + "title": "Model Context Protocol (MCP) Tool Server", + "summary": "Build a stateless 2026-07-28 MCP tool server with JSON-RPC 2.0 dispatch, no initialize handshake, schema validation, error isolation, and the input_required round-trip.", + "sha256": "bab7ca867883f2380acb131351634462ceacfcb397e2b6758f0ee460d3f977ab" + }, + { + "lang": "zh-CN", + "path": "examples/mcp-server/README_cn.md", + "title": "Model Context Protocol (MCP) 工具服务", + "summary": "构建符合 2026-07-28 无状态规范的 MCP 工具服务,实现无需 initialize 握手的 JSON-RPC 2.0 分发、参数校验、错误隔离与 input_required 多轮交互。", + "sha256": "03a6ec203daa98ee6af75a6bda99e9297e278424b6b813480b1bcfb42cc1c347" + } + ] + }, + { + "id": "example-pydantic-validation", + "type": "example", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-02", + "locales": [ + { + "lang": "en-US", + "path": "examples/pydantic-validation/README.md", + "title": "Validate Untrusted Boundary Payloads", + "summary": "Parse, sanitize, and validate messy external payloads with field normalization and structured error contracts.", + "sha256": "aa79d051487995dde6a76018aabc0b7dc5e59c0584319ea84ca42c2fc61b0aeb" + }, + { + "lang": "zh-CN", + "path": "examples/pydantic-validation/README_cn.md", + "title": "验证不可信边界请求体", + "summary": "使用字段规范化与结构化错误契约,清洗并验证混乱的外部数据载荷。", + "sha256": "96a1c746603e9648fe4be5320721fc13445f325daecacf7e94546bf4e8ea03c3" + } + ] + }, + { + "id": "fix-a-python-bug", + "type": "playbook", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-02", + "locales": [ + { + "lang": "en-US", + "path": "playbooks/fix-a-bug/README.md", + "title": "Fix a Python Bug with a Regression Test", + "summary": "Reproduce the behavior, constrain the cause, make the smallest fix, and prove the regression stays fixed.", + "sha256": "1b97bcb5a4fd2fe8dc5863dce80555459a1e359732a710990107e66c492028b2" + }, + { + "lang": "zh-CN", + "path": "playbooks/fix-a-bug/README_cn.md", + "title": "用回归测试修复 Python Bug", + "summary": "复现行为、缩小原因、完成最小修复,并证明回归问题不会再次出现。", + "sha256": "61e3745570569cf81760d2919ee618602ecf9e11bd8b675672f27eac19b91d0c" + } + ] + }, + { + "id": "integrate-an-external-api", + "type": "playbook", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-02", + "locales": [ + { + "lang": "en-US", + "path": "playbooks/integrate-an-external-api/README.md", + "title": "Integrate an External API", + "summary": "Wrap a third-party API behind a typed boundary with safe credentials, timeouts, retries, and deterministic tests.", + "sha256": "412f1185e8e456b921ba284e898419bb35ae3bfb520cdc3663897506244f6cef" + }, + { + "lang": "zh-CN", + "path": "playbooks/integrate-an-external-api/README_cn.md", + "title": "接入外部 API", + "summary": "用类型边界封装第三方 API,并安全处理凭据、超时、重试和确定性测试。", + "sha256": "a17f56ac6bf6bc18d2b5a628f413554ae49cbe11d2c44596dcb66b9fa4e80336" + } + ] + }, + { + "id": "manage-database-migrations", + "type": "playbook", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-02", + "locales": [ + { + "lang": "en-US", + "path": "playbooks/manage-database-migrations/README.md", + "title": "Manage Database Schema Migrations", + "summary": "Plan, execute, and verify reversible database migrations with zero-downtime compatibility and rollback safety.", + "sha256": "82a62d3172e28bf98a32a2a3df8f1ba2e054df83264479258b789e13d95428ba" + }, + { + "lang": "zh-CN", + "path": "playbooks/manage-database-migrations/README_cn.md", + "title": "管理数据库架构迁移", + "summary": "规划、执行并验证具备零停机兼容性与回滚安全性的可逆数据库迁移。", + "sha256": "9f2b53002fc2e2f7b1d2322809035b363436dcecb417231046ae7b6d8cd83b4d" + } + ] + }, + { + "id": "mcp-2026-07-28-migration", + "type": "guide", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-06", + "locales": [ + { + "lang": "en-US", + "path": "guides/mcp/migrate-2026-07-28.md", + "title": "Migrate a Python MCP server to the 2026-07-28 specification", + "summary": "Update a Python MCP server for the stateless 2026-07-28 specification by removing the initialize handshake, adopting the input_required round-trip, dropping deprecated features, and hardening authorization.", + "sha256": "063e285acdebc4854245cbdc2951060a1b38f0fdb814692c6bdc3cd0229d2316" + }, + { + "lang": "zh-CN", + "path": "guides/mcp/migrate-2026-07-28_cn.md", + "title": "将 Python MCP 服务端迁移到 2026-07-28 规范", + "summary": "面向 2026-07-28 无状态规范更新 Python MCP 服务端,移除 initialize 握手、采用 input_required 多轮交互、放弃废弃特性并强化授权。", + "sha256": "cc5360996f1d45cd8155dd1a0cd7a6c418d988247c54ba31ca0d5efaa1e81c8f" + } + ] + }, + { + "id": "path-da-capstone", + "type": "path", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-12", + "locales": [ + { + "lang": "en-US", + "path": "paths/data-analysis/modules/capstone.md", + "title": "Capstone: verified analysis of a real dataset", + "summary": "Data Analysis path capstone — end-to-end clean, explore, and report on orders.csv with verify.py checking the numbers.", + "sha256": "814507300283d9c7c27409138e35ebc57ab85dcc04a1f468e11b486783b580f1" + }, + { + "lang": "zh-CN", + "path": "paths/data-analysis/modules/capstone_cn.md", + "title": "综合项目:真实数据集的验证分析", + "summary": "数据分析路线综合项目——在 orders.csv 上端到端完成清洗、探索与报告,verify.py 校验数字。", + "sha256": "5f2af0b8b6c384e5fd430e603df573bee3c8cdfb564c500ad7d733d813b4778e" + } + ] + }, + { + "id": "path-foundation-challenge", + "type": "path", + "content_version": 2, + "status": "reviewed", + "reviewed_on": "2026-09-13", + "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" + }, + { + "lang": "zh-CN", + "path": "paths/foundation/modules/route-challenge_cn.md", + "title": "路线挑战:双 Agent、双重验证", + "summary": "基础路线收官——把同一个任务分别交给六个路线 Agent 中任选的两个,并亲手验证两份产出。", + "sha256": "eafb3dc130a96a8ba6b8128c4353c689f2590abbfe2ecd8c06fd00940f7e0ea5" + } + ] + }, + { + "id": "path-foundation-m0", + "type": "path", + "content_version": 2, + "status": "reviewed", + "reviewed_on": "2026-09-13", + "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" + }, + { + "lang": "zh-CN", + "path": "paths/foundation/modules/m0-orientation_cn.md", + "title": "Agent 工具生态总览", + "summary": "基础路线模块 0——六个 Agentic 编程工具生态导览;按你的工作环境选一个,并观察它的证据习惯。", + "sha256": "9d416d3f68766ee6d241dff0e00edb21a654ba27c8013ff4de8e23ff90852a1e" + } + ] + }, + { + "id": "product-slug-example", + "type": "example", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-02", + "locales": [ + { + "lang": "en-US", + "path": "examples/product-slug/README.md", + "title": "Three-minute AI-coding change", + "summary": "Reproduce a Python text-boundary bug, make a bounded fix, and verify it with the standard library.", + "sha256": "8f240817e26384924c624ce4cc2e563fe17909d60e179d647f33b97a61f8b2b5" + }, + { + "lang": "zh-CN", + "path": "examples/product-slug/README_cn.md", + "title": "3 分钟完成一次 AI Coding 修改", + "summary": "复现 Python 文本边界 Bug,完成范围明确的修复,并只用标准库验证结果。", + "sha256": "698ae339a14f4b56b59accd1a03eb29028b663495613b2bfb6fff2ae8663ed0b" + } + ] + }, + { + "id": "python-ai-coding-workflow", + "type": "guide", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-02", + "locales": [ + { + "lang": "en-US", + "path": "guides/ai-coding/workflow.md", + "title": "Use Python Well with AI Coding", + "summary": "A contract-first workflow for using coding agents to make small, testable, and safe Python changes.", + "sha256": "aa29ed9752b83991f93a5a4c7d5282a514c67cf4108d56c147ea2d6a970c0fd9" + }, + { + "lang": "zh-CN", + "path": "guides/ai-coding/workflow_cn.md", + "title": "用 AI Coding 写好 Python", + "summary": "一套契约优先的工作方法,用 coding agent 完成小而可测、能够安全交付的 Python 修改。", + "sha256": "5e3e4534f557a37321ab9e1eb795bfff1a36d2577e544ccd1c23f12ed1c38860" + } + ] + }, + { + "id": "python-async-patterns", + "type": "guide", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-02", + "locales": [ + { + "lang": "en-US", + "path": "guides/python-engineering/async-patterns.md", + "title": "Reliable Async Python Patterns and Pitfalls", + "summary": "Build resilient concurrent Python applications by avoiding common async antipatterns and managing task lifecycles.", + "sha256": "b883d9dcc72ac355812a6880592c1913eff34cd0d1b48617b004c3bf22248000" + }, + { + "lang": "zh-CN", + "path": "guides/python-engineering/async-patterns_cn.md", + "title": "异步 Python 可靠模式与避坑指南", + "summary": "通过避免常见异步反模式与规范管理任务生命周期,构建高韧性的并发 Python 应用。", + "sha256": "08e5c4f7ca489b5f62e7da13de505ee9d8ad3bb637cce2966d4d6d217c13f9a7" + } + ] + }, + { + "id": "python-modern-typing", + "type": "guide", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-02", + "locales": [ + { + "lang": "en-US", + "path": "guides/python-engineering/modern-typing.md", + "title": "Modern Python Typing in Practice", + "summary": "Use explicit Python types as machine-checkable contracts for reliable AI-assisted engineering.", + "sha256": "fa69b55d3b122c5086e1836d4404c3b34181608fabe9a4345751040a2b03a54f" + }, + { + "lang": "zh-CN", + "path": "guides/python-engineering/modern-typing_cn.md", + "title": "现代 Python 类型系统实战", + "summary": "将明确的 Python 类型作为机器可读的契约,指导可靠的 AI 辅助编程与工程交付。", + "sha256": "22afdb56b35819c3fb7adccd9e1d5016aa41baef821dbe639f41dc984a8e1784" + } + ] + }, + { + "id": "python-product-quality", + "type": "guide", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-02", + "locales": [ + { + "lang": "en-US", + "path": "guides/python-engineering/product-quality.md", + "title": "Build a Python Product That Can Be Changed Safely", + "summary": "A practical quality model for moving from a useful script to an operable Python product.", + "sha256": "6ab42b0a271d22c4c250e33d4d76d2a441f2b3c258ec253c36bfc2ddb308f0fd" + }, + { + "lang": "zh-CN", + "path": "guides/python-engineering/product-quality_cn.md", + "title": "构建能够持续修改的 Python 产品", + "summary": "从有用脚本走向可运行、可维护 Python 产品的一套实用质量模型。", + "sha256": "94fce6390a97e84e65628bf77b4ae6c560ef726879464e4a68adf4326f0c5891" + } + ] + }, + { + "id": "setup-structured-logging", + "type": "playbook", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-02", + "locales": [ + { + "lang": "en-US", + "path": "playbooks/setup-structured-logging/README.md", + "title": "Set Up Production Structured Logging", + "summary": "Configure structured JSON logging with context propagation, sensitive data redaction, and environment isolation.", + "sha256": "9ef0a7edc6a4d08c2c216706a29ada10f677bc08c516f5c5dca7161b4204167e" + }, + { + "lang": "zh-CN", + "path": "playbooks/setup-structured-logging/README_cn.md", + "title": "搭建生产级结构化日志", + "summary": "配置具备上下文贯穿、敏感数据脱敏与环境隔离的 JSON 结构化日志体系。", + "sha256": "2024d5386b1cf4c3e397a1034dc884e6ea244a967ed0eb4612ce5bd36b885950" + } + ] + }, + { + "id": "ship-a-python-release", + "type": "playbook", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-02", + "locales": [ + { + "lang": "en-US", + "path": "playbooks/ship-a-release/README.md", + "title": "Ship a Python Release", + "summary": "Produce a traceable package or service release and verify the real deployed behavior and rollback path.", + "sha256": "8c9b410dbfc088ac8abba7f44cd87463d3d829a81801717f61b80069bbeb54b7" + }, + { + "lang": "zh-CN", + "path": "playbooks/ship-a-release/README_cn.md", + "title": "发布 Python 版本", + "summary": "交付可追踪的包或服务版本,并验证真实线上行为与回退路径。", + "sha256": "41697b3bdd720d29516138af2712dc5d241e8a8fc0ef66e87bf4bcd7f90f7d58" + } + ] + }, + { + "id": "structured-pipeline-example", + "type": "example", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-02", + "locales": [ + { + "lang": "en-US", + "path": "examples/structured-pipeline/README.md", + "title": "Resilient batch data pipeline", + "summary": "Build an isolated, bounded data batch processing pipeline with error recovery using the standard library.", + "sha256": "cbfe3b304da301b503229a4043092eea39e5ca531049fb074b672c64e2ea7cb6" + }, + { + "lang": "zh-CN", + "path": "examples/structured-pipeline/README_cn.md", + "title": "鲁棒批处理数据流水线", + "summary": "使用标准库实现具备错误隔离、类型校验与批处理统计的鲁棒数据流水线。", + "sha256": "be0baacb411a0d0661da5b24136343cb26b862d3aaeb39ab8b603cc7598f8cbc" + } + ] + }, + { + "id": "upgrade-python-dependencies", + "type": "playbook", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-02", + "locales": [ + { + "lang": "en-US", + "path": "playbooks/upgrade-dependencies/README.md", + "title": "Upgrade Python Dependencies", + "summary": "Upgrade dependencies in a bounded change with lockfile, compatibility, security, and runtime evidence.", + "sha256": "d4a7f7e5972e3a6cc953a1d07475a8064ae2d6df0c47b24296584246b67584b1" + }, + { + "lang": "zh-CN", + "path": "playbooks/upgrade-dependencies/README_cn.md", + "title": "升级 Python 依赖", + "summary": "在边界明确的修改中,用锁文件、兼容性、安全和运行证据完成依赖升级。", + "sha256": "84aab5fed41cddd07f4c466a4f0eac675afa7af2cacc05fbdd6a8ed4cde3d514" + } + ] + }, + { + "id": "write-llm-evals", + "type": "playbook", + "content_version": 1, + "status": "reviewed", + "reviewed_on": "2026-09-02", + "locales": [ + { + "lang": "en-US", + "path": "playbooks/write-llm-evals/README.md", + "title": "Write Deterministic Evals for LLMs and Agents", + "summary": "Build regression test suites for LLM prompt changes and tool-calling agents with golden datasets and schema assertions.", + "sha256": "d96648fa2e4a3a5c73bf6a83ea853f9b4757416823b05c088d03354c9ed4d5b1" + }, + { + "lang": "zh-CN", + "path": "playbooks/write-llm-evals/README_cn.md", + "title": "为大模型与 Agent 编写确定性评测", + "summary": "结合黄金测试集与 Schema 断言,为 Prompt 调整与工具调用 Agent 构建防劣化回归测试套件。", + "sha256": "5bc19f11f82be78197cdc8ab7b1aab01b201ffa57edf21d5e21b75b27e0f40e3" + } + ] + } + ] +} diff --git a/course-solutions.json b/course-solutions.json new file mode 100644 index 0000000..c753681 --- /dev/null +++ b/course-solutions.json @@ -0,0 +1,65 @@ +{ + "schema_version": 1, + "courses": [ + { + "slug": "agent-rules-single-source", + "course_id": "course-agent-rules", + "solution_sha256": "92757cc9943502021864bab3d508a168a4239b369b8a8ce55d6d3df5d1ca1fb8" + }, + { + "slug": "da-eda", + "course_id": "course-da-eda", + "solution_sha256": "9eb0075ac148b788d9756ed4bf45242cb1c085601669cc7f0fe84f3af015397d" + }, + { + "slug": "da-report", + "course_id": "course-da-report", + "solution_sha256": "f825baf3a1d4aac1292b5a24f3f2b3465cb02b5e9f79ebda6843c5e560f38629" + }, + { + "slug": "da-visualization", + "course_id": "course-da-visualization", + "solution_sha256": "04d39d6f0c09e8c46d83835cf8d838b59a6ea3933828233f59b1b6ea2d935f69" + }, + { + "slug": "hands-on-python-with-claude-code", + "course_id": "course-claude-code", + "solution_sha256": "d0aa4b9a5486f9d50abf2fe34f9166815174591e7fc0c2fb23ae3111a5a16699" + }, + { + "slug": "hands-on-with-cursor", + "course_id": "course-cursor", + "solution_sha256": "d0aa4b9a5486f9d50abf2fe34f9166815174591e7fc0c2fb23ae3111a5a16699" + }, + { + "slug": "hands-on-with-deepseek-harness", + "course_id": "course-deepseek-harness", + "solution_sha256": "d0aa4b9a5486f9d50abf2fe34f9166815174591e7fc0c2fb23ae3111a5a16699" + }, + { + "slug": "hands-on-with-kimi-code", + "course_id": "course-kimi-code", + "solution_sha256": "d0aa4b9a5486f9d50abf2fe34f9166815174591e7fc0c2fb23ae3111a5a16699" + }, + { + "slug": "hands-on-with-openai-codex", + "course_id": "course-codex-cli", + "solution_sha256": "d0aa4b9a5486f9d50abf2fe34f9166815174591e7fc0c2fb23ae3111a5a16699" + }, + { + "slug": "hands-on-with-zcode", + "course_id": "course-zcode", + "solution_sha256": "d0aa4b9a5486f9d50abf2fe34f9166815174591e7fc0c2fb23ae3111a5a16699" + }, + { + "slug": "mcp-server-in-python", + "course_id": "course-mcp-tools", + "solution_sha256": "e9a0c348e12bd1f04d83783689f299e3ece8884f3f8a5be2f8a40655e211b790" + }, + { + "slug": "verifying-ai-generated-code", + "course_id": "course-verify-ship", + "solution_sha256": "3426cef9b1a5ee074e3839bb152b0ddff186e3c9a18928d99099795654073e16" + } + ] +} diff --git a/courses/agent-rules-single-source/COURSE.md b/courses/agent-rules-single-source/COURSE.md new file mode 100644 index 0000000..79cca49 --- /dev/null +++ b/courses/agent-rules-single-source/COURSE.md @@ -0,0 +1,87 @@ +--- +id: course-agent-rules +type: course +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. +lang: en-US +content_version: 3 +status: reviewed +reviewed_on: 2026-09-12 +badge: + id: course-agent-rules + name_en: Measure the drift + name_zh: 规则漂移检测 + requires: All five checkpoints claimed (L01–L05) +course_id: course-agent-rules +--- + +# One source of truth for agent rules + +> 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 a working rule-consistency checker +> (`rules_check.py`) that fails CI the moment a rule file drifts from +> AGENTS.md — plus the single-source setup applied to your own repository. +> Tool-agnostic by design: Claude Code, Codex CLI, and Cursor all read these +> files. + +## What you build + +A standard-library-only checker that enforces the contract used by this very +repository: `AGENTS.md` must exist and be non-empty, and every other +recognized rule file (`CLAUDE.md`, `.cursorrules`) must either be a thin +pointer that defers to it or an exact copy of it. Anything else is drift, +reported with a reason and a failing exit code. Three scenario repositories +ship with the course: + +| Skin | State | Data | +| --- | --- | --- | +| `scenario/thin-pointer/` | healthy: AGENTS.md + two pointer files | pointer-style CLAUDE.md, .cursorrules | +| `scenario/single-copy/` | healthy: AGENTS.md + exact copy | duplicated rule text | +| `scenario/drifted/` | broken: .cursorrules restates stale rules | a real drift to catch | + +## Teaching contract (read this first, agent) + +- **Audience:** anyone maintaining rule files for more than one agent tool — + you have felt the pain of three files disagreeing about one behavior. +- **Prerequisites:** Python 3.11+ on PATH and any coding agent (taught and + reviewed with Claude Code 2.x and Codex CLI 0.x; reviewed 2026-09-12 — + tool-agnostic by design). Standard library only. +- **Lesson order:** L01 → L05; never skip the checkpoint. +- **Teaching style:** work from the files in this folder; quote the contract + line you satisfy; smallest change per failing test; no new dependencies; + never edit `solution/`; ask before touching unnamed files. +- **When to stop:** a lesson is done when its checkpoint command runs and the + learner can explain what failed and why. +- **`verify.py`:** `python verify.py starter --expect-failure` reproduces the + six listed failures; `python verify.py solution` passes 11/11. +- **Honesty rules:** say what you did not verify; no guarantees about agent + obedience — the checker reports file state, not agent behavior. + +## What this course does NOT cover + +Which rules to write (see the repository's `templates/AGENT_RULES.example.md` +and the AGENTS.md guide on flypython.com), multi-repo setups, or +machine-policy enforcement. The checker is deliberately narrow: one +directory, three file names, one truth. + + +## Badge contract + +- 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 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`, bilingual `lessons/`, `scenario/` repositories, +`TASK.md`/`TASK_cn.md` (the code contract), `starter/`, `solution/`, `tests/` +(11 tests), `verify.py`, `REVIEW.md`. + +## Evidence and licensing + +`REVIEW.md` records the run-through state. Code is MIT; prose is CC BY 4.0 +(see repository `LICENSE`). Teaching drift goes to the `course-feedback` +issue form. diff --git a/courses/agent-rules-single-source/COURSE_cn.md b/courses/agent-rules-single-source/COURSE_cn.md new file mode 100644 index 0000000..8384ea4 --- /dev/null +++ b/courses/agent-rules-single-source/COURSE_cn.md @@ -0,0 +1,81 @@ +--- +id: course-agent-rules +type: course +title: Agent 规则的单一真源 +summary: 不再同时维护彼此打架的 AGENTS.md、CLAUDE.md 与 .cursorrules——亲手构建一个能证明仓库只有一处规则真源的检查器,由你的编码 Agent 授课。 +lang: zh-CN +content_version: 3 +status: reviewed +reviewed_on: 2026-09-12 +badge: + id: course-agent-rules + name_en: Measure the drift + name_zh: 规则漂移检测 + requires: 全部五个检查点认领通过(L01–L05) +course_id: course-agent-rules +--- + +# Agent 规则的单一真源 + +> 摘要:在你的编码 Agent 里装上 FlyPython Skill,让它取回本课文件,再 +> 说一句 **“开始第 1 课”**(课程文件由 Skill 取回——你不用手动下载)。 +> 课程结束时你拥有一个可运行的规则一致性检查器(`rules_check.py`)—— +> 任何规则文件偏离 AGENTS.md 的那一刻,CI 就会失败——外加应用到你自己 +> 仓库的单一真源配置。天然工具无关:Claude Code、Codex CLI、Cursor +> 都读这些文件。 + +## 你将做出什么 + +一个仅用标准库的检查器,强制执行本仓库自己使用的契约:`AGENTS.md` +必须存在且非空,其他被识别的规则文件(`CLAUDE.md`、`.cursorrules`) +要么是指向它的瘦指针,要么是它的精确副本。其余情况都算漂移——带 +原因报告并以非零退出码失败。课程附带三个场景仓库: + +| 皮肤 | 状态 | 数据 | +| --- | --- | --- | +| `scenario/thin-pointer/` | 健康:AGENTS.md + 两个指针文件 | 指针式 CLAUDE.md、.cursorrules | +| `scenario/single-copy/` | 健康:AGENTS.md + 精确副本 | 完全重复的规则文本 | +| `scenario/drifted/` | 故障:.cursorrules 复述过期规则 | 一个待捕获的真实漂移 | + +## 教学契约(Agent 请先阅读本节) + +- **受众:** 为多个 Agent 工具维护规则文件的任何人——你体会过三个 + 文件对同一行为各执一词的痛苦。 +- **前置条件:** PATH 中有 Python 3.11+,任一编码 Agent(以 Claude + Code 2.x 与 Codex CLI 0.x 完成教学与审核,审核日期 2026-09-12—— + 设计上工具无关)。只用标准库。 +- **课程顺序:** L01 → L05;绝不跳过检查点。 +- **教学风格:** 从本文件夹的文件出发;引用你满足的契约原文;每个 + 失败测试做最小变更;不新增依赖;不修改 `solution/`;改动未授权文件 + 前先询问。 +- **何时停止:** 检查点命令通过、且学习者能说清什么失败了、为什么。 +- **`verify.py`:** `python verify.py starter --expect-failure` 复现六个 + 具名失败;`python verify.py solution` 通过 11/11。 +- **诚实规则:** 说明哪些没验证过;不保证 Agent 服从——检查器报告的是 + 文件状态,不是 Agent 行为。 + +## 本课程不涉及的内容 + +该写什么规则(见仓库 `templates/AGENT_RULES.example.md` 与 +flypython.com 上的 AGENTS.md 指南)、多仓库配置、机器策略强制。检查器 +刻意收窄:一个目录、三个文件名、一个真源。 + + +## 徽章契约 + +- 徽章:**量化漂移徽章**(徽章 id `course-agent-rules`)——认领全部五个检查点后获得。 +- 挑战: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`、双语 `lessons/`、`scenario/` 仓库、 +`TASK.md`/`TASK_cn.md`(代码契约)、`starter/`、`solution/`、`tests/` +(11 个测试)、`verify.py`、`REVIEW.md`。 + +## 证据与许可 + +`REVIEW.md` 记录试跑状态。代码 MIT;文字 CC BY 4.0(见仓库 `LICENSE`)。 +教学偏差请走 `course-feedback` issue 表单。 diff --git a/courses/agent-rules-single-source/REVIEW.md b/courses/agent-rules-single-source/REVIEW.md new file mode 100644 index 0000000..7b72f3f --- /dev/null +++ b/courses/agent-rules-single-source/REVIEW.md @@ -0,0 +1,39 @@ +# Maintainer run-through record + +## 2026-09-12 — contract verification (mechanical) + +- Environment: macOS (arm64), Python 3.13; repository clean checkout on + branch `feat/0.0.3-courses-and-radar`. + +- Commands and results: + - `python verify.py starter --expect-failure` — exit 0; all expected + failure names reproduced. + - `python verify.py solution` — exit 0; full suite passes. + - Every scenario repository (single-copy, thin-pointer, drifted) exercised end to end by the suite. +- Not verified in this pass: teaching quality with a live agent session. + +## Pending before the public course drop + +- One full agent-taught run-through ("start lesson 1" through the Lesson 5 + checkpoint), recording observed deviations from COURSE.md here; the + recording doubles as demo-video source material. + +## Deviation log + +### 2026-09-13 — agent solvability run (challenge mode, not taught mode) + +- Agent: Devin (SWE-2 Max), CLI session on macOS arm64, system Python + 3.14 (stdlib only — this course needs no third-party deps). +- Method: implemented `starter/rules_check.py` from `TASK.md` and the + test expectations only; did not read or copy `solution/`. +- Result: `python verify.py starter` exits 0 (all eleven tests); + `verify.py progress` printed all five claim codes (l03/l04 + `[passed]`, l01/l02/l05 `[attest]`). +- Observed deviation: one iteration needed, not one pass — my first + `diverged` issue text said "differs" where the suite asserts the + substring "differ from AGENTS.md". The contract pins observable + strings, so the fix was wording, not logic. +- Limitations: challenge-mode run (TASK.md → tests), not the taught + COURSE.md walkthrough — teaching quality remains separately pending. +- Starter restored to the deliberately-unfinished state after the run + (`--expect-failure` verified again). diff --git a/courses/agent-rules-single-source/TASK.md b/courses/agent-rules-single-source/TASK.md new file mode 100644 index 0000000..fee44e1 --- /dev/null +++ b/courses/agent-rules-single-source/TASK.md @@ -0,0 +1,25 @@ +# Task contract: agent rule-file consistency checker + +Change only `starter/rules_check.py`. Standard library only. + +- `load_rule_files(directory) -> dict[str, str]`: read the text of every + present rule file among `AGENTS.md`, `CLAUDE.md`, `.cursorrules`. +- `is_pointer(text) -> bool`: true when the file is a thin pointer — at most + 10 non-empty lines and at least one line referencing `AGENTS.md`. +- `check_directory(directory) -> dict` returning + `{"source": "AGENTS.md", "ok": bool, "files": [...]}`: + - `AGENTS.md` missing or empty produces a `missing-source` entry and + `ok=False`. + - A present, non-empty `AGENTS.md` produces a `source` entry. + - Every other rule file is classified: `pointer` (thin pointer), + `copy` (exact match of `AGENTS.md` content), or `diverged` (anything + else), each with an `issues` list; `diverged` entries carry a reason + naming the difference from `AGENTS.md`. + - `ok=True` only when every entry is `source`, `pointer`, or `copy`. +- `main(argv=None) -> int`: + - Exactly one argument (the directory). Otherwise print usage to stderr + and return 2. + - Print `ok=... files=... diverged=...` to stdout; return 0 when `ok`, + otherwise 1. + +Done means `python verify.py starter` exits 0 with all eleven tests passing. diff --git a/courses/agent-rules-single-source/TASK_cn.md b/courses/agent-rules-single-source/TASK_cn.md new file mode 100644 index 0000000..266ad4f --- /dev/null +++ b/courses/agent-rules-single-source/TASK_cn.md @@ -0,0 +1,22 @@ +# 任务契约:Agent 规则文件一致性检查器 + +只修改 `starter/rules_check.py`。仅用标准库。 + +- `load_rule_files(directory) -> dict[str, str]`:读取 `AGENTS.md`、 + `CLAUDE.md`、`.cursorrules` 中每个存在的规则文件文本。 +- `is_pointer(text) -> bool`:当文件是瘦指针时为真——非空行不超过 + 10 行,且至少一行引用 `AGENTS.md`。 +- `check_directory(directory) -> dict` 返回 + `{"source": "AGENTS.md", "ok": bool, "files": [...]}`: + - `AGENTS.md` 缺失或为空产生 `missing-source` 条目且 `ok=False`。 + - 存在且非空的 `AGENTS.md` 产生 `source` 条目。 + - 其余规则文件逐一分类:`pointer`(瘦指针)、`copy`(与 `AGENTS.md` + 内容完全一致)或 `diverged`(其余情况),各带 `issues` 列表; + `diverged` 条目必须写明与 `AGENTS.md` 的差异原因。 + - 仅当所有条目都是 `source`、`pointer` 或 `copy` 时 `ok=True`。 +- `main(argv=None) -> int`: + - 恰好一个参数(目录)。否则向 stderr 打印用法并返回 2。 + - 向 stdout 打印 `ok=... files=... diverged=...`;`ok` 时返回 0, + 否则 1。 + +完成的标准是 `python verify.py starter` 以 0 退出且全部十一个测试通过。 diff --git a/courses/agent-rules-single-source/lessons/L01.md b/courses/agent-rules-single-source/lessons/L01.md new file mode 100644 index 0000000..f2b5b19 --- /dev/null +++ b/courses/agent-rules-single-source/lessons/L01.md @@ -0,0 +1,49 @@ +--- +id: course-agent-rules-l01 +type: course +title: "Measure the drift you already have" +summary: "Run the checker against the three scenario repositories and read drift as a test failure, not a vibe." +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "What this checkpoint tests" + body: "That you can measure drift instead of feeling it: run the checker on the three scenario repositories and read each result as a test failure, not a vibe." + - title: "Find the three repo states" + body: "The scenario folders show the three shapes the contract classifies — a clean single-source repo, one with a thin pointer, and one where a copy has drifted. Name which is which before moving on." + - title: "When you are done" + body: "You can explain what `ok=false` means in terms of files, not feelings: which file diverged from `AGENTS.md` and how the checker knew." +--- + +# Measure the drift you already have + +## Objective + +Run the checker against the three scenario repositories and read drift as a test failure, not a vibe. + +## The lesson + +Run `python starter/rules_check.py scenario/drifted` — it says everything is fine. That is the bug: three files disagree about one behavior and nothing notices. Then run the verifier: + +```bash +python verify.py starter --expect-failure +``` + +## Exercise + +- Map each `verify.py --expect-failure` name to a real drift class: no source, no pointer detection, no drift reporting +- Open `scenario/drifted/.cursorrules` and find the rules that contradict AGENTS.md + +## Checkpoint + +The lesson's commands run, and you can answer these in your own words +(the agent asks; you answer — that is the gate): + +1. Which command did this lesson run, and what did it decide? +2. What failed at the start, and why — in your own words? +3. Before trusting a similar change next time, what would you check first? + +## Expected evidence + +The command transcript and your answers. diff --git a/courses/agent-rules-single-source/lessons/L01_cn.md b/courses/agent-rules-single-source/lessons/L01_cn.md new file mode 100644 index 0000000..42d233e --- /dev/null +++ b/courses/agent-rules-single-source/lessons/L01_cn.md @@ -0,0 +1,49 @@ +--- +id: course-agent-rules-l01 +type: course +title: "量化你已经存在的漂移" +summary: "对三个场景仓库运行检查器,把漂移读成测试失败,而不是一种感觉。" +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "这个检查点考什么" + body: "考你能不能把漂移量化而不是凭感觉:对三个 scenario 仓库跑检查器,把每个结果当测试失败读,不当气氛。" + - title: "认出三种仓库状态" + body: "scenario 文件夹展示了契约要分类的三种形态——干净的单一真源仓库、带薄指针的仓库、副本已漂移的仓库。先说出谁是谁再往下走。" + - title: "完成标准" + body: "你能用文件而不是感觉解释 `ok=false`:哪个文件偏离了 `AGENTS.md`,检查器是怎么发现的。" +--- + +# 量化你已经存在的漂移 + +## 目标 + +对三个场景仓库运行检查器,把漂移读成测试失败,而不是一种感觉。 + +## 课程内容 + +运行 `python starter/rules_check.py scenario/drifted`——它说一切正常。这正是 bug:三个文件对同一行为各执一词,却没有任何东西察觉。然后运行验证器: + +```bash +python verify.py starter --expect-failure +``` + +## 练习 + +- 把 `verify.py --expect-failure` 里的每个具名失败对应到一类真实漂移:无真源、无指针识别、无漂移报告 +- 打开 `scenario/drifted/.cursorrules`,找出与 AGENTS.md 矛盾的规则 + +## 检查点 + +本课的命令运行通过,并且你能用自己的话回答下面的问题(由 Agent +提问、你作答——这就是关口): + +1. 本课跑了哪条命令,它判定了什么? +2. 一开始什么失败了、为什么——用你自己的话说? +3. 下次再信任类似的改动之前,你会先检查什么? + +## 预期证据 + +命令输出记录与你的回答。 diff --git a/courses/agent-rules-single-source/lessons/L02.md b/courses/agent-rules-single-source/lessons/L02.md new file mode 100644 index 0000000..979064a --- /dev/null +++ b/courses/agent-rules-single-source/lessons/L02.md @@ -0,0 +1,45 @@ +--- +id: course-agent-rules-l02 +type: course +title: "Decide the source of truth" +summary: "AGENTS.md becomes the one file agents read; everything else is a pointer or a copy — decide, then write it down." +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "What this checkpoint tests" + body: "Deciding the single source of truth: `AGENTS.md` is the file agents read; every other rule file becomes a thin pointer or an exact copy." + - title: "Pointer vs copy" + body: "A pointer is at most 10 non-empty lines and references `AGENTS.md`; a copy is byte-identical. Anything else is `diverged`. Write down which of your own repos' files would classify as which." + - title: "When you are done" + body: "Your decision is written down: which file is the source, and what every other file may contain." +--- + +# Decide the source of truth + +## Objective + +AGENTS.md becomes the one file agents read; everything else is a pointer or a copy — decide, then write it down. + +## The lesson + +Read the repository's own AGENTS.md and `templates/AGENT_RULES.example.md`. The decision this course enforces: AGENTS.md is the source (the AAIF-governed, agent-native standard); CLAUDE.md and .cursorrules may only point at it. Write your repository's decision as one sentence — that sentence is your contract. + +## Exercise + +- List which of your rule files restate rules instead of pointing +- Ask the agent to critique your sentence for ambiguity + +## Checkpoint + +The lesson's commands run, and you can answer these in your own words +(the agent asks; you answer — that is the gate): + +1. Which command did this lesson run, and what did it decide? +2. What failed at the start, and why — in your own words? +3. Before trusting a similar change next time, what would you check first? + +## Expected evidence + +The command transcript and your answers. diff --git a/courses/agent-rules-single-source/lessons/L02_cn.md b/courses/agent-rules-single-source/lessons/L02_cn.md new file mode 100644 index 0000000..62bd7d5 --- /dev/null +++ b/courses/agent-rules-single-source/lessons/L02_cn.md @@ -0,0 +1,45 @@ +--- +id: course-agent-rules-l02 +type: course +title: "确定唯一真源" +summary: "AGENTS.md 成为 Agent 唯一阅读的文件;其余都只能是指针或副本——先决定,再写下来。" +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "这个检查点考什么" + body: "定出唯一真源:`AGENTS.md` 是 Agent 真正读的文件,其余规则文件只能是薄指针或逐字节副本。" + - title: "指针还是副本" + body: "指针最多 10 个非空行且引用 `AGENTS.md`;副本与真源逐字节一致。其余都是 `diverged`。写下你自己仓库里每个文件会被判成哪类。" + - title: "完成标准" + body: "你的决定落了字:谁是真源,其余文件允许装什么。" +--- + +# 确定唯一真源 + +## 目标 + +AGENTS.md 成为 Agent 唯一阅读的文件;其余都只能是指针或副本——先决定,再写下来。 + +## 课程内容 + +读仓库自己的 AGENTS.md 与 `templates/AGENT_RULES.example.md`。本课程强制的决定是:AGENTS.md 是真源(AAIF 治理、Agent 原生的标准);CLAUDE.md 与 .cursorrules 只能指向它。把你仓库的决定写成一句话——那句话就是你的契约。 + +## 练习 + +- 列出你的哪些规则文件在复述规则而不是指向真源 +- 让 Agent 点评这句话里的歧义 + +## 检查点 + +本课的命令运行通过,并且你能用自己的话回答下面的问题(由 Agent +提问、你作答——这就是关口): + +1. 本课跑了哪条命令,它判定了什么? +2. 一开始什么失败了、为什么——用你自己的话说? +3. 下次再信任类似的改动之前,你会先检查什么? + +## 预期证据 + +命令输出记录与你的回答。 diff --git a/courses/agent-rules-single-source/lessons/L03.md b/courses/agent-rules-single-source/lessons/L03.md new file mode 100644 index 0000000..3f12084 --- /dev/null +++ b/courses/agent-rules-single-source/lessons/L03.md @@ -0,0 +1,45 @@ +--- +id: course-agent-rules-l03 +type: course +title: "Drive a bounded change, test by test" +summary: "Make the starter detect source, pointer, copy, and drift — one failing test group at a time." +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "What this checkpoint tests" + body: "The gate is `python verify.py starter` — implement `load_rule_files`, `is_pointer`, `check_directory`, and `main` until all eleven tests pass." + - title: "Classification order matters" + body: "`AGENTS.md` missing or empty yields `missing-source` and `ok=false`. Every other file classifies as `pointer`, `copy`, or `diverged` — check pointer and copy before diverged, and `ok` only when no entry is diverged or missing." + - title: "Output shape" + body: "`main` prints `ok=... files=... diverged=...` and exits 0 only when ok — argument errors print usage to stderr and return 2." +--- + +# Drive a bounded change, test by test + +## Objective + +Make the starter detect source, pointer, copy, and drift — one failing test group at a time. + +## The lesson + +Tell the agent: "Work through TASK.md against starter/rules_check.py, one failing test group at a time: pointer detection, then source requirement, then copy/drift classification, then CLI exit codes. Show me the diff after each group." + +## Exercise + +- Run PYTHONPATH=starter python -m unittest discover -s tests -v after each group +- Reject any change that touches tests/, solution/, or scenario/ + +## Checkpoint + +The lesson's commands run, and you can answer these in your own words +(the agent asks; you answer — that is the gate): + +1. Which command did this lesson run, and what did it decide? +2. What failed at the start, and why — in your own words? +3. Before trusting a similar change next time, what would you check first? + +## Expected evidence + +The command transcript and your answers. diff --git a/courses/agent-rules-single-source/lessons/L03_cn.md b/courses/agent-rules-single-source/lessons/L03_cn.md new file mode 100644 index 0000000..4fb07a7 --- /dev/null +++ b/courses/agent-rules-single-source/lessons/L03_cn.md @@ -0,0 +1,45 @@ +--- +id: course-agent-rules-l03 +type: course +title: "按测试驱动一次有边界的变更" +summary: "让 starter 依次识别真源、指针、副本与漂移——一次一组失败测试。" +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "这个检查点考什么" + body: "门控是 `python verify.py starter`——实现 `load_rule_files`、`is_pointer`、`check_directory`、`main`,十一个测试全绿。" + - title: "分类顺序很关键" + body: "`AGENTS.md` 缺失或为空产出 `missing-source` 且 `ok=false`。其余文件分 `pointer`、`copy`、`diverged`——先判指针和副本再判漂移,没有任何漂移或缺失项时 `ok` 才为真。" + - title: "输出形态" + body: "`main` 打印 `ok=... files=... diverged=...`,仅当 ok 时退出 0——参数错误打印用法到 stderr 并返回 2。" +--- + +# 按测试驱动一次有边界的变更 + +## 目标 + +让 starter 依次识别真源、指针、副本与漂移——一次一组失败测试。 + +## 课程内容 + +对 Agent 说:“按 TASK.md 修改 starter/rules_check.py,一次只处理一组失败测试:先指针识别,再真源要求,再副本/漂移分类,最后 CLI 退出码。每组之后给我看 diff。” + +## 练习 + +- 每组之后运行 PYTHONPATH=starter python -m unittest discover -s tests -v +- 拒绝任何触碰 tests/、solution/ 或 scenario/ 的改动 + +## 检查点 + +本课的命令运行通过,并且你能用自己的话回答下面的问题(由 Agent +提问、你作答——这就是关口): + +1. 本课跑了哪条命令,它判定了什么? +2. 一开始什么失败了、为什么——用你自己的话说? +3. 下次再信任类似的改动之前,你会先检查什么? + +## 预期证据 + +命令输出记录与你的回答。 diff --git a/courses/agent-rules-single-source/lessons/L04.md b/courses/agent-rules-single-source/lessons/L04.md new file mode 100644 index 0000000..fba1399 --- /dev/null +++ b/courses/agent-rules-single-source/lessons/L04.md @@ -0,0 +1,45 @@ +--- +id: course-agent-rules-l04 +type: course +title: "Verify and wire it in" +summary: "Prove both verifier directions, run all three skins, then add the checker to your repository's review loop." +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "What this checkpoint tests" + body: "Both suites plus wiring: prove both verifier directions, run all three skins, then add the checker where your review loop actually runs." + - title: "Both directions" + body: "The starter must fail where the scenario is broken and pass where it is clean — `--expect-failure` and the plain run tell you which side you are on." + - title: "Wire it where it bites" + body: "A checker nobody runs is a rule file nobody reads: add it to a pre-commit hook or CI step in one repo you own, and record what it caught first." +--- + +# Verify and wire it in + +## Objective + +Prove both verifier directions, run all three skins, then add the checker to your repository's review loop. + +## The lesson + +Run `python verify.py starter` (11/11 after your change) and `python verify.py solution`. Then apply it for real: point the checker at your own repository root and read the report. Wire it into review — a pre-commit hook, a CI step, or a checklist line — so drift fails loudly. + +## Exercise + +- Hand-review the diff: every change must be forced by a test +- Record what the checker does NOT prove (it reads files; it cannot see agent behavior) + +## Checkpoint + +The lesson's commands run, and you can answer these in your own words +(the agent asks; you answer — that is the gate): + +1. Which command did this lesson run, and what did it decide? +2. What failed at the start, and why — in your own words? +3. Before trusting a similar change next time, what would you check first? + +## Expected evidence + +The command transcript and your answers. diff --git a/courses/agent-rules-single-source/lessons/L04_cn.md b/courses/agent-rules-single-source/lessons/L04_cn.md new file mode 100644 index 0000000..c8a0a4d --- /dev/null +++ b/courses/agent-rules-single-source/lessons/L04_cn.md @@ -0,0 +1,45 @@ +--- +id: course-agent-rules-l04 +type: course +title: "验证并接入流程" +summary: "证明验证器双向通过、跑完三个皮肤,然后把检查器接入你仓库的审查环。" +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "这个检查点考什么" + body: "双套件加接线:两个方向都验证、三个皮肤都跑,然后把检查器接进你真正的评审环。" + - title: "两个方向" + body: "starter 在坏 scenario 上必须失败、在干净 scenario 上必须通过——`--expect-failure` 和普通运行告诉你自己在哪一侧。" + - title: "接到咬得动的地方" + body: "没人运行的检查器等于没人读的规则文件:把它加进你某个仓库的 pre-commit 或 CI,记下它第一次抓到了什么。" +--- + +# 验证并接入流程 + +## 目标 + +证明验证器双向通过、跑完三个皮肤,然后把检查器接入你仓库的审查环。 + +## 课程内容 + +运行 `python verify.py starter`(你改完后 11/11)与 `python verify.py solution`。然后真正用起来:把检查器指向你自己的仓库根目录并读报告。接入审查——pre-commit 钩子、CI 步骤或清单条目——让漂移大声失败。 + +## 练习 + +- 人工审查 diff:每处改动都必须有测试逼着 +- 记录检查器不能证明什么(它读文件;看不见 Agent 行为) + +## 检查点 + +本课的命令运行通过,并且你能用自己的话回答下面的问题(由 Agent +提问、你作答——这就是关口): + +1. 本课跑了哪条命令,它判定了什么? +2. 一开始什么失败了、为什么——用你自己的话说? +3. 下次再信任类似的改动之前,你会先检查什么? + +## 预期证据 + +命令输出记录与你的回答。 diff --git a/courses/agent-rules-single-source/lessons/L05.md b/courses/agent-rules-single-source/lessons/L05.md new file mode 100644 index 0000000..6357a86 --- /dev/null +++ b/courses/agent-rules-single-source/lessons/L05.md @@ -0,0 +1,44 @@ +--- +id: course-agent-rules-l05 +type: course +title: "Apply this to your repository" +summary: "One AGENTS.md, thin pointers everywhere else, and a failing check when it breaks." +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "What this checkpoint tests" + body: "One `AGENTS.md`, thin pointers everywhere else, and a failing check when it breaks — applied to a repository you own." + - title: "Keep pointers thin" + body: "Each pointer file: at most 10 non-empty lines and a reference to `AGENTS.md`. Rewrite, do not copy — copies drift." + - title: "The honest record" + body: "Note which files you deleted or slimmed and what the checker cannot see (like rule *quality* — it checks consistency, not wisdom)." +--- + +# Apply this to your repository + +## Objective + +One AGENTS.md, thin pointers everywhere else, and a failing check when it breaks. + +## The lesson + +In your own repository: write (or prune) AGENTS.md as the single rule source, replace every other rule file with a thin pointer, add rules_check.py and its test line to CI. The workflow is the product; the checker is just the guardrail. + +## Exercise + +- Run the checker from a clean checkout to prove it does not depend on your machine + +## Checkpoint + +The lesson's commands run, and you can answer these in your own words +(the agent asks; you answer — that is the gate): + +1. Which command did this lesson run, and what did it decide? +2. What failed at the start, and why — in your own words? +3. Before trusting a similar change next time, what would you check first? + +## Expected evidence + +The command transcript and your answers. diff --git a/courses/agent-rules-single-source/lessons/L05_cn.md b/courses/agent-rules-single-source/lessons/L05_cn.md new file mode 100644 index 0000000..86e3c8e --- /dev/null +++ b/courses/agent-rules-single-source/lessons/L05_cn.md @@ -0,0 +1,44 @@ +--- +id: course-agent-rules-l05 +type: course +title: "把这套方法带回你的仓库" +summary: "一个 AGENTS.md,其余全是瘦指针,一旦破坏检查就失败。" +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "这个检查点考什么" + body: "一个 `AGENTS.md`、其余都是薄指针、坏了就有检查报警——落地到你自己一个仓库。" + - title: "指针要薄" + body: "每个指针文件:最多 10 个非空行并引用 `AGENTS.md`。要重写不要复制——副本会漂移。" + - title: "诚实的记录" + body: "记下你删掉或削薄了哪些文件,以及检查器看不到什么(比如规则质量——它查一致性,不查智慧)。" +--- + +# 把这套方法带回你的仓库 + +## 目标 + +一个 AGENTS.md,其余全是瘦指针,一旦破坏检查就失败。 + +## 课程内容 + +在你自己的仓库里:把 AGENTS.md 写成(或修剪成)唯一规则真源,把其他规则文件替换为瘦指针,把 rules_check.py 和它的测试命令加进 CI。工作流才是产品;检查器只是护栏。 + +## 练习 + +- 在干净检出上运行检查器,证明它不依赖你的机器 + +## 检查点 + +本课的命令运行通过,并且你能用自己的话回答下面的问题(由 Agent +提问、你作答——这就是关口): + +1. 本课跑了哪条命令,它判定了什么? +2. 一开始什么失败了、为什么——用你自己的话说? +3. 下次再信任类似的改动之前,你会先检查什么? + +## 预期证据 + +命令输出记录与你的回答。 diff --git a/courses/agent-rules-single-source/scenario/drifted/.cursorrules b/courses/agent-rules-single-source/scenario/drifted/.cursorrules new file mode 100644 index 0000000..9712fe4 --- /dev/null +++ b/courses/agent-rules-single-source/scenario/drifted/.cursorrules @@ -0,0 +1,5 @@ +# Cursor rules + +- Always rewrite tests to match new behavior. +- Prefer large refactors over small changes. +- Skip verification when the demo works. diff --git a/courses/agent-rules-single-source/scenario/drifted/AGENTS.md b/courses/agent-rules-single-source/scenario/drifted/AGENTS.md new file mode 100644 index 0000000..b993c50 --- /dev/null +++ b/courses/agent-rules-single-source/scenario/drifted/AGENTS.md @@ -0,0 +1,6 @@ +# Agent rules + +- Make the smallest change that satisfies the task contract. +- Run the test suite before declaring work finished. +- Never edit files outside the ones the contract names. +- Record what was not verified. diff --git a/courses/agent-rules-single-source/scenario/drifted/CLAUDE.md b/courses/agent-rules-single-source/scenario/drifted/CLAUDE.md new file mode 100644 index 0000000..4352874 --- /dev/null +++ b/courses/agent-rules-single-source/scenario/drifted/CLAUDE.md @@ -0,0 +1 @@ +This project keeps its agent rules in AGENTS.md. diff --git a/courses/agent-rules-single-source/scenario/single-copy/AGENTS.md b/courses/agent-rules-single-source/scenario/single-copy/AGENTS.md new file mode 100644 index 0000000..b993c50 --- /dev/null +++ b/courses/agent-rules-single-source/scenario/single-copy/AGENTS.md @@ -0,0 +1,6 @@ +# Agent rules + +- Make the smallest change that satisfies the task contract. +- Run the test suite before declaring work finished. +- Never edit files outside the ones the contract names. +- Record what was not verified. diff --git a/courses/agent-rules-single-source/scenario/single-copy/CLAUDE.md b/courses/agent-rules-single-source/scenario/single-copy/CLAUDE.md new file mode 100644 index 0000000..b993c50 --- /dev/null +++ b/courses/agent-rules-single-source/scenario/single-copy/CLAUDE.md @@ -0,0 +1,6 @@ +# Agent rules + +- Make the smallest change that satisfies the task contract. +- Run the test suite before declaring work finished. +- Never edit files outside the ones the contract names. +- Record what was not verified. diff --git a/courses/agent-rules-single-source/scenario/thin-pointer/.cursorrules b/courses/agent-rules-single-source/scenario/thin-pointer/.cursorrules new file mode 100644 index 0000000..5fcd999 --- /dev/null +++ b/courses/agent-rules-single-source/scenario/thin-pointer/.cursorrules @@ -0,0 +1 @@ +Agent rules are defined in AGENTS.md. diff --git a/courses/agent-rules-single-source/scenario/thin-pointer/AGENTS.md b/courses/agent-rules-single-source/scenario/thin-pointer/AGENTS.md new file mode 100644 index 0000000..b993c50 --- /dev/null +++ b/courses/agent-rules-single-source/scenario/thin-pointer/AGENTS.md @@ -0,0 +1,6 @@ +# Agent rules + +- Make the smallest change that satisfies the task contract. +- Run the test suite before declaring work finished. +- Never edit files outside the ones the contract names. +- Record what was not verified. diff --git a/courses/agent-rules-single-source/scenario/thin-pointer/CLAUDE.md b/courses/agent-rules-single-source/scenario/thin-pointer/CLAUDE.md new file mode 100644 index 0000000..6e91466 --- /dev/null +++ b/courses/agent-rules-single-source/scenario/thin-pointer/CLAUDE.md @@ -0,0 +1 @@ +All agent rules live in AGENTS.md. Read it first. diff --git a/courses/agent-rules-single-source/solution/rules_check.py b/courses/agent-rules-single-source/solution/rules_check.py new file mode 100644 index 0000000..3912b74 --- /dev/null +++ b/courses/agent-rules-single-source/solution/rules_check.py @@ -0,0 +1,94 @@ +"""Agent rule-file consistency checker (reviewed solution). + +Enforces the single-source-of-truth contract: AGENTS.md must exist and be +non-empty, and every other recognized rule file (CLAUDE.md, .cursorrules) +must either be a thin pointer that defers to AGENTS.md or an exact copy of +it. Anything else is reported as drifted, with a reason. Standard library +only. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +RULE_FILE_NAMES = ("AGENTS.md", "CLAUDE.md", ".cursorrules") +SOURCE_NAME = "AGENTS.md" +POINTER_MAX_LINES = 10 + + +def load_rule_files(directory: str | Path) -> dict[str, str]: + """Return the text of every present rule file in *directory*.""" + directory = Path(directory) + files: dict[str, str] = {} + for name in RULE_FILE_NAMES: + path = directory / name + if path.is_file(): + files[name] = path.read_text(encoding="utf-8") + return files + + +def is_pointer(text: str) -> bool: + """A thin pointer file defers to AGENTS.md instead of restating rules.""" + non_empty = [line for line in text.splitlines() if line.strip()] + references_source = any(SOURCE_NAME in line for line in non_empty) + return len(non_empty) <= POINTER_MAX_LINES and references_source + + +def check_directory(directory: str | Path) -> dict: + """Check the single-source contract for one repository directory.""" + files = load_rule_files(directory) + entries: list[dict] = [] + + source = files.get(SOURCE_NAME) + if source is None or not source.strip(): + entries.append( + { + "file": SOURCE_NAME, + "status": "missing-source", + "issues": [f"{SOURCE_NAME} is missing or empty"], + } + ) + else: + entries.append({"file": SOURCE_NAME, "status": "source", "issues": []}) + + for name, text in files.items(): + if name == SOURCE_NAME: + continue + if is_pointer(text): + entries.append({"file": name, "status": "pointer", "issues": []}) + elif source is not None and source.strip() and text.strip() == source.strip(): + entries.append({"file": name, "status": "copy", "issues": []}) + else: + entries.append( + { + "file": name, + "status": "diverged", + "issues": [ + f"contains rules that differ from {SOURCE_NAME} " + "and is not a thin pointer" + ], + } + ) + + allowed = {"source", "pointer", "copy"} + ok = bool(entries) and all(entry["status"] in allowed for entry in entries) + return {"source": SOURCE_NAME, "ok": ok, "files": entries} + + +def main(argv: list[str] | None = None) -> int: + """Check one directory and print a one-line summary.""" + arguments = sys.argv[1:] if argv is None else argv + if len(arguments) != 1: + print("usage: python rules_check.py ", file=sys.stderr) + return 2 + report = check_directory(arguments[0]) + diverged = sum(1 for entry in report["files"] if entry["status"] == "diverged") + print( + f"ok={report['ok']} files={len(report['files'])} diverged={diverged}" + ) + return 0 if report["ok"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/courses/agent-rules-single-source/starter/rules_check.py b/courses/agent-rules-single-source/starter/rules_check.py new file mode 100644 index 0000000..efcf2d1 --- /dev/null +++ b/courses/agent-rules-single-source/starter/rules_check.py @@ -0,0 +1,55 @@ +"""Agent rule-file consistency checker (starter, deliberately incomplete). + +Checks one repository directory for the "single source of truth" contract: +AGENTS.md exists and every other rule file (CLAUDE.md, .cursorrules) either +is a thin pointer to it or an exact copy. This starter reproduces the naive +state: it assumes everything is fine, never requires AGENTS.md, and cannot +detect pointer, copy, or drifted files. ../TASK.md defines the contract. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +RULE_FILE_NAMES = ("AGENTS.md", "CLAUDE.md", ".cursorrules") + + +def load_rule_files(directory: str | Path) -> dict[str, str]: + """Return the text of every present rule file in *directory*.""" + directory = Path(directory) + files: dict[str, str] = {} + for name in RULE_FILE_NAMES: + path = directory / name + if path.is_file(): + files[name] = path.read_text(encoding="utf-8") + return files + + +def is_pointer(text: str) -> bool: + """A thin pointer file defers to AGENTS.md instead of restating rules.""" + return False + + +def check_directory(directory: str | Path) -> dict: + """Check the single-source contract for one repository directory.""" + files = load_rule_files(directory) + entries = [] + for name, _text in files.items(): + entries.append({"file": name, "status": "ok", "issues": []}) + return {"source": "AGENTS.md", "ok": True, "files": entries} + + +def main(argv: list[str] | None = None) -> int: + """Check one directory and print a one-line summary.""" + arguments = sys.argv[1:] if argv is None else argv + if len(arguments) != 1: + print("usage: python rules_check.py ", file=sys.stderr) + return 2 + report = check_directory(arguments[0]) + print(f"ok={report['ok']} files={len(report['files'])} diverged=0") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/courses/agent-rules-single-source/tests/test_rules_check.py b/courses/agent-rules-single-source/tests/test_rules_check.py new file mode 100644 index 0000000..efc1177 --- /dev/null +++ b/courses/agent-rules-single-source/tests/test_rules_check.py @@ -0,0 +1,108 @@ +"""Contract tests for the agent rule-file consistency checker.""" + +from __future__ import annotations + +import shutil +import tempfile +import unittest +from pathlib import Path + +import rules_check + +COURSE_ROOT = Path(__file__).resolve().parent.parent +SCENARIO_ROOT = COURSE_ROOT / "scenario" + +RICH_AGENTS = """# Agent rules + +- Make the smallest change that satisfies the task contract. +- Run the test suite before declaring work finished. +- Never edit files outside the ones the contract names. +- Record what was not verified. +""" + + +class IsPointerTest(unittest.TestCase): + def test_thin_pointer_that_references_agents_md_is_a_pointer(self) -> None: + text = "This project keeps all agent rules in AGENTS.md.\nRead it first.\n" + self.assertTrue(rules_check.is_pointer(text)) + + def test_full_ruleset_is_not_a_pointer(self) -> None: + lines = [f"- rule {i} restated here for agents" for i in range(12)] + lines.append("See AGENTS.md for more.\n") + self.assertFalse(rules_check.is_pointer("\n".join(lines))) + + def test_short_file_without_reference_is_not_a_pointer(self) -> None: + self.assertFalse(rules_check.is_pointer("Be excellent to each other.\n")) + + +class CheckDirectoryTest(unittest.TestCase): + def _tmp(self, files: dict[str, str]) -> Path: + directory = Path(tempfile.mkdtemp(prefix="rules-")) + self.addCleanup(shutil.rmtree, directory, ignore_errors=True) + for name, text in files.items(): + (directory / name).write_text(text, encoding="utf-8") + return directory + + def test_missing_agents_md_is_reported(self) -> None: + report = rules_check.check_directory( + self._tmp({"CLAUDE.md": "Some rules that live only here.\n" * 12}) + ) + self.assertFalse(report["ok"]) + self.assertTrue( + any(e["status"] == "missing-source" for e in report["files"]) + ) + + def test_thin_pointer_is_accepted(self) -> None: + report = rules_check.check_directory( + self._tmp( + { + "AGENTS.md": RICH_AGENTS, + "CLAUDE.md": "All agent rules live in AGENTS.md.\n", + } + ) + ) + self.assertTrue(report["ok"]) + self.assertTrue(any(e["status"] == "pointer" for e in report["files"])) + + def test_exact_copy_is_accepted(self) -> None: + report = rules_check.check_directory( + self._tmp({"AGENTS.md": RICH_AGENTS, "CLAUDE.md": RICH_AGENTS}) + ) + self.assertTrue(report["ok"]) + self.assertTrue(any(e["status"] == "copy" for e in report["files"])) + + def test_diverged_file_is_flagged_with_reason(self) -> None: + drifted = RICH_AGENTS + "- Always rewrite tests to match new behavior.\n" + report = rules_check.check_directory( + self._tmp({"AGENTS.md": RICH_AGENTS, ".cursorrules": drifted}) + ) + self.assertFalse(report["ok"]) + diverged = [e for e in report["files"] if e["status"] == "diverged"] + self.assertEqual(len(diverged), 1) + self.assertIn("differ from AGENTS.md", diverged[0]["issues"][0]) + + def test_only_agents_md_is_ok(self) -> None: + report = rules_check.check_directory(self._tmp({"AGENTS.md": RICH_AGENTS})) + self.assertTrue(report["ok"]) + self.assertEqual(len(report["files"]), 1) + + +class MainTest(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_scenario_drifted_exits_nonzero(self) -> None: + self.assertEqual(rules_check.main([str(self._copy("drifted"))]), 1) + + def test_scenario_single_copy_exits_zero(self) -> None: + self.assertEqual(rules_check.main([str(self._copy("single-copy"))]), 0) + + def test_missing_argument_prints_usage(self) -> None: + self.assertEqual(rules_check.main([]), 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/courses/agent-rules-single-source/verify.py b/courses/agent-rules-single-source/verify.py new file mode 100644 index 0000000..0f3c3ba --- /dev/null +++ b/courses/agent-rules-single-source/verify.py @@ -0,0 +1,284 @@ +#!/usr/bin/env python3 +"""Run the course contract against starter or solution. + +Objective completion evidence for "One source of truth for agent rules". +""" + +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_thin_pointer_that_references_agents_md_is_a_pointer", + "test_missing_agents_md_is_reported", + "test_thin_pointer_is_accepted", + "test_exact_copy_is_accepted", + "test_diverged_file_is_flagged_with_reason", + "test_scenario_drifted_exits_nonzero", +) + + + +COURSE_ID = 'course-agent-rules' +# 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 = '3af23393e1911a4a' + +CHECKPOINTS = [ + {"id": "l01", "gate": "attest", "title": '量化漂移 / Measure the drift'}, + {"id": "l02", "gate": "attest", "title": '确定唯一真源 / Decide the source of truth'}, + {"id": "l03", "gate": "starter-suite", "title": '有边界的变更 / Bounded change'}, + {"id": "l04", "gate": "both-suites", "title": '验证并接入流程 / Verify and wire it in'}, + {"id": "l05", "gate": "attest", "title": '应用到你的仓库 / Apply to your repository'}, +] + +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 _suite_stats(run): + """(tests_run, duration_ms) for a completed suite subprocess.""" + tests = claim_receipt.count_tests(run.stderr) if claim_receipt else 0 + return tests, int(getattr(run, "_elapsed_ms", 0)) + + +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 "failed" + solution_state = "passed" if solution_ok else "failed" + 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 it 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: no pointer detection, no source " + "requirement, no drift reporting, and CLI exit codes never signal failure." + ) + 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/da-eda/COURSE.md b/courses/da-eda/COURSE.md new file mode 100644 index 0000000..6572334 --- /dev/null +++ b/courses/da-eda/COURSE.md @@ -0,0 +1,91 @@ +--- +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. +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +badge: + id: course-da-eda + name_en: EDA Challenger + name_zh: 探索分析挑战者 + requires: All five checkpoints claimed (L01–L05) +course_id: course-da-eda +--- + +# Exploratory Data Analysis with an Agent + +> TL;DR: this is a **challenge course** — you solve it, your agent is the +> tool. Point it at `scenario/shop-export/transactions.csv` (142 messy +> rows), implement the contract in `starter/eda.py`, and finish with a +> `results.json` whose numbers are objectively checked against ground +> truth. Optional guided mode: `COURSE.md` + `lessons/` still work as an +> agent-taught path if you want a tour instead of a challenge. + +## What you build + +A pandas script that loads a real-world-messy transactions export, cleans +it (duplicates, unparseable amounts, bad dates), and writes a verified +summary: clean row counts, total revenue, revenue by region, top category, +date range. + +## Setup — lesson zero is the environment + +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 +``` + +`verify.py` checks for pandas and prints this hint if it is missing. + +## Challenges (checkpoints) + +| # | Challenge | Gate | +| --- | --- | --- | +| L01 | What messy data looks like — profile the CSV before touching code | self-attested | +| L02 | Define the answer first — write down the expected results.json shape | self-attested | +| L03 | Clean and summarize — implement `clean_transactions` + `summarize` | objective (starter suite) | +| L04 | Check against ground truth — both suites green | objective (both suites) | +| L05 | Run it on your own data — swap in an export you actually have | self-attested | + +## Badge contract + +- Badge: **EDA Challenger** (badge id `da-eda`) — earned by claiming all + five checkpoints. +- Challenges: L01–L05, 10 points each; +50 course-badge bonus when all + five are claimed on flypython.com. +- Evidence: `python verify.py` — L03 and L04 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. + +## What this course does NOT cover + +Visualization (see `da-visualization`), reporting (see `da-report`), +statistics theory, SQL, or notebooks. The dataset is synthetic but the +quality issues are the ones you will meet in real exports. + +## Folder map + +``` +COURSE.md / COURSE_cn.md this file (EN / 中文) +lessons/L01.md … L05.md challenge notes (each has an _cn.md pair) +scenario/shop-export/ transactions.csv (messy input data) +TASK.md / TASK_cn.md the contract your code must satisfy +starter/eda.py the deliberately unfinished implementation +solution/eda.py the reviewed solution +tests/test_eda.py the contract suite (read-only) +verify.py objective pass/fail + claim codes +requirements.txt pinned pandas dependency +REVIEW.md maintainer run-through record +``` + +## Evidence and licensing + +Reviewed content: `REVIEW.md` records the run-through. Code is +MIT-licensed; lesson prose is CC BY 4.0 (see repository `LICENSE`). diff --git a/courses/da-eda/COURSE_cn.md b/courses/da-eda/COURSE_cn.md new file mode 100644 index 0000000..06759bf --- /dev/null +++ b/courses/da-eda/COURSE_cn.md @@ -0,0 +1,88 @@ +--- +id: course-da-eda +type: course +title: 用 Agent 做探索性数据分析 +summary: 挑战式课程——让你的 Agent 处理一份脏的商店导出数据,产出一份经过真值校验的 EDA 摘要(results.json)。目录中第一门非标准库课程;pandas 由 uv 管理。 +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +badge: + id: course-da-eda + name_en: EDA Challenger + name_zh: 探索分析挑战者 + requires: 全部五个检查点认领通过(L01–L05) +course_id: course-da-eda +--- + +# 用 Agent 做探索性数据分析 + +> 速览:这是一门**挑战课程**——解题的是你,Agent 是手里的工具。让它 +> 处理 `scenario/shop-export/transactions.csv`(142 行脏数据),实现 +> `starter/eda.py` 里的契约,最终产出一份 `results.json`,其中每个 +> 数字都会被客观真值校验。可选引导模式:`COURSE.md` + `lessons/` 仍 +> 可作为 Agent 授课路径,想要引导而不是挑战时用它。 + +## 你要做出的东西 + +一个 pandas 脚本:读入一份真实风格的脏交易导出,清洗(重复行、不可 +解析金额、坏日期),写出经过验证的摘要:清洗行数、总收入、分地区 +收入、最高收入品类、日期范围。 + +## 环境——第 0 课就是装环境 + +这是目录里第一门离开标准库的课程: + +```bash +cd courses/da-eda +uv sync # 或:pip install -r requirements.txt +``` + +`verify.py` 会检查 pandas,缺失时打印这条提示。 + +## 挑战(检查点) + +| # | 挑战 | 判卷方式 | +| --- | --- | --- | +| L01 | 脏数据长什么样——写代码前先给 CSV 做画像 | 自我声明 | +| L02 | 先定答案——写下期望的 results.json 结构 | 自我声明 | +| L03 | 清洗与统计——实现 `clean_transactions` + `summarize` | 客观(starter 套件) | +| L04 | 核对真值——两套件全绿 | 客观(双套件) | +| L05 | 换成你自己的数据——拿一份真实导出来跑 | 自我声明 | + +## 徽章契约 + +- 徽章:**探索分析挑战者**(badge id `da-eda`)——五个检查点全部 + 认领后获得。 +- 挑战:L01–L05 各 10 分;五个全部在 flypython.com 认领后 +50 课程 + 徽章奖励分。 +- 证据:`python verify.py`——L03/L04 由套件客观判卷, + L01/L02/L05 为学习者自报。 +- 提交:测试通过的检查点打印确定性认领码;自报检查点要先回答课后问题,再运行 `python verify.py --attest ID` 才打印码,在 flypython.com 上 + 记入你的账号。自我报告的证据,绝非证书。 + +## 本课程不覆盖的内容 + +可视化(见 `da-visualization`)、报告(见 `da-report`)、统计理论、 +SQL、notebook。数据集是合成的,但数据质量问题和你会在真实导出中 +遇到的一样。 + +## 文件夹地图 + +``` +COURSE.md / COURSE_cn.md 本文件(EN / 中文) +lessons/L01.md … L05.md 挑战说明(每课有 _cn.md 配对) +scenario/shop-export/ transactions.csv(脏输入数据) +TASK.md / TASK_cn.md 代码必须满足的契约 +starter/eda.py 故意未完成的实现 +solution/eda.py 审核过的解答 +tests/test_eda.py 契约测试套件(只读) +verify.py 客观通过/失败 + 认领码 +requirements.txt 锁定的 pandas 依赖 +REVIEW.md 维护者走查记录 +``` + +## 证据与许可 + +审核过的内容:`REVIEW.md` 记录走查。代码 MIT 许可;课程文字 +CC BY 4.0(见仓库 `LICENSE`)。 diff --git a/courses/da-eda/REVIEW.md b/courses/da-eda/REVIEW.md new file mode 100644 index 0000000..a689f84 --- /dev/null +++ b/courses/da-eda/REVIEW.md @@ -0,0 +1,23 @@ +# Maintainer run-through — da-eda + +- Date: 2026-09-12 +- Reviewed with: mechanical verification (this course ships no teaching + contract beyond guided mode) +- `python verify.py starter --expect-failure`: reproduces the intended + unfinished state — all twelve contract tests fail on NotImplementedError. +- `python verify.py solution`: all twelve tests pass. +- `python verify.py progress`: deterministic claim codes; L03/L04 + objective, L01/L02/L05 attested. +- Agent solvability run (challenge-model gate): **done 2026-09-13**. + Agent: Devin (SWE-2 Max), CLI session on macOS arm64, repo venv with + pandas. Method: implemented `starter/eda.py` from `TASK.md` only — + drop_duplicates, `to_numeric`/`to_datetime` with `errors="coerce"`, + groupby sums, merged stats into results.json — did not read or copy + `solution/`. Result: `python verify.py starter` exits 0 (all twelve + tests); `verify.py progress` printed all five claim codes. Deviations: + none — solvable in one pass; cleaning order (dedupe before counting + bad parses) matters and the contract pins the expected counts, which + disambiguates it. Limitations: single run, single agent; results.json + was written into `scenario/shop-export/` per the contract (already + git-ignored/shipped — verify by `git status` staying clean). + Starter restored to the deliberately-unfinished state after the run. diff --git a/courses/da-eda/TASK.md b/courses/da-eda/TASK.md new file mode 100644 index 0000000..fcf6d7d --- /dev/null +++ b/courses/da-eda/TASK.md @@ -0,0 +1,28 @@ +# Task contract: exploratory data analysis (da-eda) + +Change only `starter/eda.py`. The dataset is +`scenario/shop-export/transactions.csv` — a shop export with exact +duplicates, missing/unparseable amounts, and one bad date. + +- `load_transactions(path) -> pandas.DataFrame`: reads the CSV. +- `clean_transactions(df) -> (clean_df, stats)`: + - Drop exact duplicate rows, count them as `duplicates_removed`. + - Parse `amount` to numeric; unparseable rows are removed and counted as + `missing_amount`. + - Parse `date`; unparseable rows are removed and counted as `bad_dates`. + - `stats = {"duplicates_removed": int, "missing_amount": int, + "bad_dates": int}`; clean rows keep parsed `amount` and `date`. +- `summarize(clean_df) -> dict` returns: + - `rows_clean`, `total_revenue` (rounded to 2 decimals), + `revenue_by_region` (each value rounded to 2 decimals), + `top_category` (category with the highest total revenue), + `first_date` / `last_date` (ISO `YYYY-MM-DD`). +- `write_results(results, dest)`: JSON, UTF-8, indent 2, trailing newline. +- `main(argv)`: runs on the shipped scenario CSV, merges `rows_total`, + `duplicates_removed`, `missing_amount`, `bad_dates` into the results, + writes `scenario/shop-export/results.json`, prints + `rows_total=... rows_clean=...`, returns 0. + +Done means `python verify.py starter` exits 0 with all twelve tests +passing, and `python verify.py starter --expect-failure` exits nonzero +because the starter no longer reproduces the unfinished state. diff --git a/courses/da-eda/TASK_cn.md b/courses/da-eda/TASK_cn.md new file mode 100644 index 0000000..32ca989 --- /dev/null +++ b/courses/da-eda/TASK_cn.md @@ -0,0 +1,27 @@ +# 任务契约:探索性数据分析(da-eda) + +只允许修改 `starter/eda.py`。数据集是 +`scenario/shop-export/transactions.csv`——一份商店导出,含完全重复行、 +缺失/不可解析的金额、以及一条坏日期。 + +- `load_transactions(path) -> pandas.DataFrame`:读取 CSV。 +- `clean_transactions(df) -> (clean_df, stats)`: + - 删除完全重复的行,数量记入 `duplicates_removed`。 + - 把 `amount` 解析为数值;解析失败的行移除并记入 `missing_amount`。 + - 把 `date` 解析为日期;解析失败的行移除并记入 `bad_dates`。 + - `stats = {"duplicates_removed": int, "missing_amount": int, + "bad_dates": int}`;清洗后的行保留解析好的 `amount` 与 `date`。 +- `summarize(clean_df) -> dict` 返回: + - `rows_clean`、`total_revenue`(保留两位小数)、 + `revenue_by_region`(每个值保留两位小数)、 + `top_category`(总收入最高的品类)、 + `first_date` / `last_date`(ISO `YYYY-MM-DD`)。 +- `write_results(results, dest)`:JSON,UTF-8,缩进 2,末尾换行。 +- `main(argv)`:处理随课附带的场景 CSV,把 `rows_total`、 + `duplicates_removed`、`missing_amount`、`bad_dates` 合并进结果, + 写出 `scenario/shop-export/results.json`,打印 + `rows_total=... rows_clean=...`,返回 0。 + +完成的标准:`python verify.py starter` 退出 0 且全部 12 个测试通过; +`python verify.py starter --expect-failure` 退出非零——因为 starter +已不再处于未完成状态。 diff --git a/courses/da-eda/lessons/L01.md b/courses/da-eda/lessons/L01.md new file mode 100644 index 0000000..eb87b53 --- /dev/null +++ b/courses/da-eda/lessons/L01.md @@ -0,0 +1,33 @@ +--- +id: course-da-eda-l01 +type: course +title: "Challenge 01: What messy data looks like" +summary: "Profile the shop export before touching pandas: exact duplicates, unparseable amounts, one bad date — count the dirt first." +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "What this checkpoint tests" + body: "Profiling before coding: open `scenario/shop-export/transactions.csv` and find the dirt yourself — exact duplicates, unparseable amounts, one bad date." + - title: "Count it first" + body: "Note how many rows exist and how many of each defect you spot — those numbers are what the suite will compare against, so knowing them early is the advantage." + - title: "When you are done" + body: "You can name the three defect classes and roughly how dirty each is." +--- + +# Challenge 01: What messy data looks like + +**Gate:** self-attested · **Points:** 10 + +Before writing any code, profile the input. Open +`scenario/shop-export/transactions.csv` (or let your agent do it) and find +the dirt yourself: + +- How many rows? How many exact duplicates? +- Which `amount` values are not numbers? +- Which `date` values are not dates? + +**Checkpoint:** you can state the three numbers without running the test +suite — total rows, duplicates, unparsed values. Record your claim with +`python verify.py` (L01 is self-attested). diff --git a/courses/da-eda/lessons/L01_cn.md b/courses/da-eda/lessons/L01_cn.md new file mode 100644 index 0000000..00523ff --- /dev/null +++ b/courses/da-eda/lessons/L01_cn.md @@ -0,0 +1,32 @@ +--- +id: course-da-eda-l01 +type: course +title: "挑战 01:脏数据长什么样" +summary: "动手写 pandas 之前先给商店导出数据画像:完全重复、无法解析的金额、一条坏日期——先把脏数清楚。" +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "这个检查点考什么" + body: "先画像再写码:打开 `scenario/shop-export/transactions.csv`,亲手找出脏数据——完全重复行、解析不了的金额、一条坏日期。" + - title: "先数一遍" + body: "记下总行数和每类缺陷大约多少——套件比对的就是这些数,提前知道就是优势。" + - title: "完成标准" + body: "你能说出三类缺陷,以及各自大概有多脏。" +--- + +# 挑战 01:脏数据长什么样 + +**判卷:** 自我声明 · **积分:** 10 + +写代码之前先给输入做画像。打开 +`scenario/shop-export/transactions.csv`(或让你的 Agent 来做),亲自 +找出这些脏东西: + +- 一共多少行?有多少完全重复行? +- 哪些 `amount` 值不是数字? +- 哪些 `date` 值不是日期? + +**检查点:** 不跑测试套件也能说出三个数——总行数、重复数、不可解析 +值数。用 `python verify.py` 记录你的认领(L01 为自我声明)。 diff --git a/courses/da-eda/lessons/L02.md b/courses/da-eda/lessons/L02.md new file mode 100644 index 0000000..a69799d --- /dev/null +++ b/courses/da-eda/lessons/L02.md @@ -0,0 +1,34 @@ +--- +id: course-da-eda-l02 +type: course +title: "Challenge 02: Define the answer first" +summary: "Sketch results.json — keys, types, rounding — before letting the agent write eda.py." +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "What this checkpoint tests" + body: "Defining the answer before generating it: sketch `results.json` — keys, types, rounding — before letting the agent write `eda.py`." + - title: "Contract first" + body: "Decide what `total_revenue` rounding means, what `revenue_by_region` keys look like, and that `rows_total` equals input rows — the suite compares exact numbers." + - title: "When you are done" + body: "Your sketch names every key in `results.json` and its source column." +--- + +# Challenge 02: Define the answer first + +**Gate:** self-attested · **Points:** 10 + +Write down the shape of `results.json` before implementing anything: +which keys, which types, which rounding rules. The contract in `TASK.md` +defines the *form* — you decide what "correct" means for each value +before you let the agent generate code. + +This is the anti-pattern this course exists to kill: "the agent wrote +code, it printed something, ship it." EDA only counts if the numbers are +*checkable*. + +**Checkpoint:** you have a written list of the expected keys and you can +explain what each one means. Self-attested; record via +`python verify.py`. diff --git a/courses/da-eda/lessons/L02_cn.md b/courses/da-eda/lessons/L02_cn.md new file mode 100644 index 0000000..43e77ed --- /dev/null +++ b/courses/da-eda/lessons/L02_cn.md @@ -0,0 +1,31 @@ +--- +id: course-da-eda-l02 +type: course +title: "挑战 02:先定答案" +summary: "先勾出 results.json 的键、类型与舍入规则,再让 Agent 写 eda.py。" +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "这个检查点考什么" + body: "先定义答案再让 Agent 生成:动手写 `eda.py` 之前先勾出 `results.json` 的键、类型和舍入规则。" + - title: "契约先行" + body: "定清楚 `total_revenue` 怎么舍入、`revenue_by_region` 键长什么样、`rows_total` 等于输入行数——套件比对的是精确数字。" + - title: "完成标准" + body: "你的草图列出 `results.json` 每个键和它的来源列。" +--- + +# 挑战 02:先定答案 + +**判卷:** 自我声明 · **积分:** 10 + +动手实现之前,先写下 `results.json` 的结构:有哪些键、什么类型、 +什么舍入规则。`TASK.md` 定义的是*形式*——每个值"什么叫对"由你在 +让 Agent 生成代码之前先想清楚。 + +这门课要消灭的反模式就是:"Agent 写了代码、打印了点东西、上线吧。" +EDA 只有数字可核查才算数。 + +**检查点:** 你已写下期望键清单,并能解释每个键的含义。自我声明; +用 `python verify.py` 记录。 diff --git a/courses/da-eda/lessons/L03.md b/courses/da-eda/lessons/L03.md new file mode 100644 index 0000000..75a7174 --- /dev/null +++ b/courses/da-eda/lessons/L03.md @@ -0,0 +1,35 @@ +--- +id: course-da-eda-l03 +type: course +title: "Challenge 03: Clean and summarize" +summary: "Implement load_transactions, clean_transactions, summarize, write_results, main until all twelve tests pass." +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "What this checkpoint tests" + body: "`python verify.py starter` — implement `load_transactions`, `clean_transactions`, `summarize`, `write_results`, `main` until all twelve tests pass." + - title: "Classic EDA traps" + body: "`to_numeric` without `errors='coerce'` crashes on 'n/a' instead of counting it; dedup must happen on raw rows or exact duplicates of dirty rows slip through; `groupby().sum()` silently drops NaN — count defects before cleaning." + - title: "Exact contract bits" + body: "stats keys are exactly `duplicates_removed`, `missing_amount`, `bad_dates`; `total_revenue` and region values round to 2 decimals; dates output ISO `YYYY-MM-DD`." +--- + +# Challenge 03: Clean and summarize + +**Gate:** objective — starter suite · **Points:** 10 + +Implement `load_transactions`, `clean_transactions`, `summarize`, +`write_results`, and `main` in `starter/eda.py`. Drive your agent with +`TASK.md` as the contract; review every line it writes. + +Watch for the classic EDA traps: + +- `to_numeric` without `errors="coerce"` crashes on `n/a` instead of + counting it. +- Dedup after filtering misses exact duplicates of dirty rows. +- `groupby().sum()` silently drops NaN — count them *before* cleaning. + +**Checkpoint:** `python verify.py starter` exits 0 — all twelve tests +pass. Claim code prints via `python verify.py`. diff --git a/courses/da-eda/lessons/L03_cn.md b/courses/da-eda/lessons/L03_cn.md new file mode 100644 index 0000000..70b7152 --- /dev/null +++ b/courses/da-eda/lessons/L03_cn.md @@ -0,0 +1,34 @@ +--- +id: course-da-eda-l03 +type: course +title: "挑战 03:清洗与统计" +summary: "实现 load_transactions、clean_transactions、summarize、write_results、main,十二个测试全绿。" +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "这个检查点考什么" + body: "`python verify.py starter`——实现 `load_transactions`、`clean_transactions`、`summarize`、`write_results`、`main`,十二个测试全绿。" + - title: "经典 EDA 陷阱" + body: "`to_numeric` 不加 `errors='coerce'` 遇到 'n/a' 直接崩而不是计数;去重必须在原始行上做,否则脏行的完全重复会漏掉;`groupby().sum()` 会静默丢 NaN——清洗前先数缺陷。" + - title: "契约细节" + body: "stats 的键必须正好是 `duplicates_removed`、`missing_amount`、`bad_dates`;`total_revenue` 和地区值两位小数;日期输出 ISO `YYYY-MM-DD`。" +--- + +# 挑战 03:清洗与统计 + +**判卷:** 客观——starter 套件 · **积分:** 10 + +在 `starter/eda.py` 里实现 `load_transactions`、`clean_transactions`、 +`summarize`、`write_results`、`main`。把 `TASK.md` 当契约驱动你的 +Agent;它写的每一行都要过目。 + +注意这几个经典 EDA 陷阱: + +- `to_numeric` 不带 `errors="coerce"` 会在 `n/a` 上崩溃而不是计数。 +- 先过滤再去重会漏掉脏行的完全重复。 +- `groupby().sum()` 会悄悄丢掉 NaN——清洗*之前*就要数清楚。 + +**检查点:** `python verify.py starter` 退出 0——12 个测试全过。 +用 `python verify.py` 打印认领码。 diff --git a/courses/da-eda/lessons/L04.md b/courses/da-eda/lessons/L04.md new file mode 100644 index 0000000..b6ad917 --- /dev/null +++ b/courses/da-eda/lessons/L04.md @@ -0,0 +1,33 @@ +--- +id: course-da-eda-l04 +type: course +title: "Challenge 04: Check against ground truth" +summary: "The suite asserts exact numbers — 142 in, 137 clean, revenue 33347.89, top category Books; plausible-but-different fails." +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "What this checkpoint tests" + body: "Both suites against ground truth: 142 rows in, 137 clean, 2 duplicates, 2 bad amounts, 1 bad date, revenue 33347.89, top category Books." + - title: "Plausible is not correct" + body: "An implementation producing different-but-reasonable numbers fails — this dataset has exactly one right answer. If yours differ, find which cleaning step drifted." + - title: "When you are done" + body: "Your `results.json` matches the ground truth byte-for-number, and you can point each figure at the cleaning step that produced it." +--- + +# Challenge 04: Check against ground truth + +**Gate:** objective — both suites · **Points:** 10 + +The suite asserts exact numbers: 142 rows in, 137 clean, 2 duplicates, +2 bad amounts, 1 bad date, total revenue 33347.89, top category Books. +If your implementation "passes" by producing different-but-plausible +numbers, it failed — the dataset has one right answer. + +Run `python verify.py starter --expect-failure`: it must exit nonzero +now (the starter is no longer unfinished). Then +`python verify.py solution` — green. + +**Checkpoint:** both gates pass. Objective claim code via +`python verify.py`. diff --git a/courses/da-eda/lessons/L04_cn.md b/courses/da-eda/lessons/L04_cn.md new file mode 100644 index 0000000..df0147c --- /dev/null +++ b/courses/da-eda/lessons/L04_cn.md @@ -0,0 +1,33 @@ +--- +id: course-da-eda-l04 +type: course +title: "挑战 04:核对真值" +summary: "套件断言精确数字——142 进 137 净、营收 33347.89、品类第一 Books;看着合理但不同即失败。" +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "这个检查点考什么" + body: "双套件对真值:142 行进、137 行净、2 条重复、2 条坏金额、1 条坏日期、营收 33347.89、品类第一 Books。" + - title: "合理不等于正确" + body: "产出『看着合理但不同』的数字就是失败——这份数据只有一个正确答案。对不上就找哪一步清洗跑偏了。" + - title: "完成标准" + body: "你的 `results.json` 与真值逐数字一致,且每个数字都能指回产生它的清洗步骤。" +--- + +# 挑战 04:核对真值 + +**判卷:** 客观——双套件 · **积分:** 10 + +测试套件断言的是精确数字:输入 142 行、清洗后 137 行、2 个重复、 +2 个坏金额、1 个坏日期、总收入 33347.89、最高品类 Books。如果你的 +实现"通过"的方式是产出不同但看似合理的数字,那就是没通过——这份 +数据集只有一个正确答案。 + +跑 `python verify.py starter --expect-failure`:现在必须退出非零 +(starter 已不处于未完成状态)。再跑 `python verify.py solution`—— +全绿。 + +**检查点:** 两道门禁都过。用 `python verify.py` 取客观 +认领码。 diff --git a/courses/da-eda/lessons/L05.md b/courses/da-eda/lessons/L05.md new file mode 100644 index 0000000..5a9b1f7 --- /dev/null +++ b/courses/da-eda/lessons/L05.md @@ -0,0 +1,30 @@ +--- +id: course-da-eda-l05 +type: course +title: "Challenge 05: Run it on your own data" +summary: "Port the pipeline to a real export you own and record which assumptions break." +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "What this checkpoint tests" + body: "Running the same pipeline on data you own — the point is discovering which assumptions break: column names, encodings, date formats, currencies." + - title: "Expect it not to port" + body: "Your export will not match the scenario's shape. The lesson is seeing *where* the contract needs editing, not forcing the code to fit." + - title: "The honest record" + body: "Write down which assumptions broke — that list is the checkpoint, not a clean port." +--- + +# Challenge 05: Run it on your own data + +**Gate:** self-attested · **Points:** 10 + +Take a real export you have — an order sheet, a usage log, a bank CSV — +and run the same load → clean → summarize pipeline on it. The point is +not the code porting cleanly (it won't); it is discovering which +assumptions break: column names, encodings, date formats, currencies. + +**Checkpoint:** you can name one assumption from this course's dataset +that did *not* hold on your real data. Self-attested; record via +`python verify.py`. diff --git a/courses/da-eda/lessons/L05_cn.md b/courses/da-eda/lessons/L05_cn.md new file mode 100644 index 0000000..a1b55a6 --- /dev/null +++ b/courses/da-eda/lessons/L05_cn.md @@ -0,0 +1,28 @@ +--- +id: course-da-eda-l05 +type: course +title: "挑战 05:换成你自己的数据" +summary: "把流水线搬到你自己一份真实导出上,记下哪些假设崩了。" +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "这个检查点考什么" + body: "把同一条流水线跑在你自己的数据上——重点是发现哪些假设会崩:列名、编码、日期格式、币种。" + - title: "别指望直接搬" + body: "你的导出和 scenario 长不一样。本课是看契约哪里需要改,不是硬把代码塞进去。" + - title: "诚实的记录" + body: "写下哪些假设崩了——那张清单就是这个检查点,不是一个干净的移植。" +--- + +# 挑战 05:换成你自己的数据 + +**判卷:** 自我声明 · **积分:** 10 + +拿一份你真正有的导出——订单表、使用日志、银行 CSV——把同一套 +加载 → 清洗 → 统计流程跑上去。重点不在于代码能原样移植(通常不能), +而在于发现哪些假设会崩:列名、编码、日期格式、币种。 + +**检查点:** 你能说出本课程数据集的哪一条假设在你的真实数据上 +*不成立*。自我声明;用 `python verify.py` 记录。 diff --git a/courses/da-eda/requirements.txt b/courses/da-eda/requirements.txt new file mode 100644 index 0000000..5728f51 --- /dev/null +++ b/courses/da-eda/requirements.txt @@ -0,0 +1 @@ +pandas==2.3.3 diff --git a/courses/da-eda/scenario/shop-export/transactions.csv b/courses/da-eda/scenario/shop-export/transactions.csv new file mode 100644 index 0000000..3e0c754 --- /dev/null +++ b/courses/da-eda/scenario/shop-export/transactions.csv @@ -0,0 +1,143 @@ +order_id,date,region,category,amount,quantity +O0001,2026-01-22,West,Electronics,64.14,5 +O0002,2026-03-19,West,Books,103.7,1 +O0003,2026-02-15,North,Books,169.9,5 +O0004,2026-02-26,North,Home,495.12,5 +O0005,2026-01-16,West,Books,79.08,2 +O0006,2026-03-04,South,Electronics,168.39,3 +O0007,2026-03-05,North,Electronics,346.65,5 +O0008,2026-02-04,East,Clothing,31.48,3 +O0009,2026-03-24,South,Home,233.69,2 +O0010,2026-01-06,North,Electronics,339.17,4 +O0011,2026-01-03,South,Electronics,,1 +O0012,2026-01-14,North,Books,490.65,5 +O0013,2026-03-20,South,Clothing,56.22,1 +O0014,2026-03-11,East,Electronics,326.93,5 +O0015,2026-01-12,South,Home,23.99,3 +O0016,2026-03-16,West,Electronics,170.79,4 +O0017,2026-01-25,North,Home,288.54,1 +O0018,2026-03-13,West,Electronics,268.98,3 +O0019,2026-02-16,East,Books,402.86,2 +O0020,2026-03-02,South,Books,315.14,4 +O0021,2026-01-07,South,Books,44.92,1 +O0022,2026-03-08,East,Clothing,335.37,2 +O0023,2026-03-12,North,Home,152.35,4 +O0024,2026-03-28,South,Electronics,151.78,2 +O0025,2026-02-20,East,Electronics,147.67,2 +O0026,2026-03-22,East,Electronics,409.97,5 +O0027,2026-02-03,West,Clothing,184.99,5 +O0028,2026-01-25,West,Books,222.46,3 +O0029,2026-01-12,East,Clothing,72.64,4 +O0030,2026-01-24,North,Home,196.29,1 +O0031,2026-03-16,East,Books,215.26,3 +O0032,2026-03-01,West,Electronics,204.95,5 +O0033,2026-03-18,West,Books,138.33,3 +O0034,2026-03-28,East,Electronics,20.06,5 +O0035,2026-03-27,South,Clothing,314.52,4 +O0036,2026-02-27,West,Clothing,436.55,4 +O0037,2026-03-13,North,Clothing,300.2,2 +O0038,2026-01-06,North,Books,45.29,3 +O0039,2026-03-26,East,Clothing,24.89,5 +O0040,2026-01-26,West,Clothing,396.27,4 +O0041,2026-01-22,South,Home,29.63,1 +O0042,2026-02-10,North,Clothing,86.92,4 +O0043,2026-01-21,East,Clothing,249.01,4 +O0044,2026-03-24,West,Electronics,426.91,3 +O0045,2026-01-22,South,Books,125.65,1 +O0046,2026-01-28,West,Home,309.12,2 +O0047,2026-01-04,East,Electronics,239.87,1 +O0048,2026-02-07,South,Books,140.23,3 +O0049,2026-01-16,East,Clothing,204.63,1 +O0050,2026-02-21,South,Clothing,481.04,2 +O0051,2026-02-13,North,Clothing,286.23,4 +O0052,2026-01-28,South,Electronics,372.08,1 +O0053,2026-03-05,North,Books,234.92,4 +O0054,2026-03-14,South,Clothing,61.98,2 +O0055,2026-02-19,South,Home,91.92,3 +O0056,2026-02-20,East,Electronics,n/a,3 +O0057,2026-03-16,South,Books,137.31,1 +O0058,2026-03-24,South,Books,143.95,1 +O0059,2026-03-23,South,Clothing,112.98,1 +O0060,2026-03-05,East,Books,284.05,5 +O0061,2026-01-18,West,Electronics,215.75,5 +O0062,2026-01-12,West,Clothing,374.9,2 +O0063,2026-01-06,East,Clothing,12.22,2 +O0064,2026-02-23,West,Electronics,484.71,2 +O0065,2026-03-10,South,Clothing,325.81,3 +O0066,2026-01-12,South,Books,488.75,5 +O0067,2026-02-08,North,Clothing,437.96,4 +O0068,2026-01-11,North,Home,76.38,1 +O0069,2026-01-13,East,Books,346.34,5 +O0070,2026-02-12,East,Electronics,237.79,2 +O0071,2026-02-26,North,Home,334.04,3 +O0072,2026-02-04,East,Books,59.42,2 +O0073,2026-01-27,South,Books,186.47,2 +O0074,2026-01-19,North,Books,447.45,4 +O0075,2026-01-03,East,Electronics,174.96,3 +O0076,2026-02-25,South,Electronics,94.77,2 +O0077,2026-02-11,North,Clothing,23.84,2 +O0078,2026-02-20,South,Books,479.25,5 +O0079,2026-01-26,North,Clothing,441.95,1 +O0080,2026-03-14,South,Clothing,145.0,5 +O0081,not-a-date,West,Home,155.65,2 +O0082,2026-02-08,East,Clothing,382.3,1 +O0083,2026-01-28,East,Clothing,271.92,4 +O0084,2026-02-12,West,Clothing,216.66,2 +O0085,2026-02-03,South,Clothing,489.78,1 +O0086,2026-01-03,North,Clothing,273.53,1 +O0087,2026-03-01,South,Books,198.04,1 +O0088,2026-03-18,West,Home,360.94,5 +O0089,2026-03-03,South,Electronics,72.91,3 +O0090,2026-03-06,South,Clothing,208.1,4 +O0091,2026-03-07,North,Electronics,439.89,3 +O0092,2026-02-04,North,Books,357.45,4 +O0093,2026-01-23,South,Books,197.14,2 +O0094,2026-01-25,North,Electronics,261.3,1 +O0095,2026-03-18,West,Books,289.57,4 +O0096,2026-03-15,North,Books,147.86,4 +O0097,2026-03-22,East,Books,115.13,3 +O0098,2026-01-27,South,Books,163.86,2 +O0099,2026-02-01,West,Electronics,475.34,1 +O0100,2026-01-20,North,Books,251.9,2 +O0101,2026-01-23,South,Books,84.24,1 +O0102,2026-03-21,East,Books,208.14,1 +O0103,2026-01-24,South,Clothing,9.16,4 +O0104,2026-01-25,North,Books,303.64,2 +O0105,2026-01-20,North,Electronics,307.34,4 +O0106,2026-01-18,South,Clothing,256.75,5 +O0107,2026-03-02,North,Electronics,171.69,1 +O0108,2026-03-19,South,Electronics,474.35,4 +O0109,2026-01-09,North,Books,136.84,5 +O0110,2026-01-24,West,Clothing,271.86,1 +O0111,2026-01-07,East,Books,336.69,5 +O0112,2026-03-17,West,Electronics,110.94,3 +O0113,2026-03-01,North,Books,491.02,1 +O0114,2026-02-01,East,Home,330.7,3 +O0115,2026-01-16,East,Books,326.78,1 +O0116,2026-03-09,South,Books,431.06,1 +O0117,2026-01-16,North,Clothing,96.65,3 +O0118,2026-02-19,West,Electronics,482.54,1 +O0119,2026-01-07,West,Clothing,176.35,5 +O0120,2026-02-27,North,Clothing,66.95,3 +O0121,2026-01-21,West,Home,237.34,2 +O0122,2026-03-04,South,Home,169.58,4 +O0123,2026-03-18,North,Electronics,68.56,1 +O0124,2026-02-01,East,Clothing,277.75,3 +O0125,2026-01-07,South,Books,390.17,4 +O0126,2026-01-11,South,Clothing,345.09,2 +O0127,2026-03-19,South,Books,249.03,1 +O0128,2026-03-09,South,Clothing,219.26,3 +O0129,2026-03-16,West,Home,48.22,5 +O0130,2026-01-28,West,Clothing,122.36,5 +O0131,2026-03-20,West,Books,455.19,2 +O0132,2026-03-13,East,Electronics,499.52,3 +O0133,2026-01-12,East,Home,439.65,3 +O0134,2026-03-20,East,Home,464.04,5 +O0135,2026-02-18,North,Books,359.57,3 +O0136,2026-01-13,North,Electronics,434.14,1 +O0137,2026-03-10,South,Electronics,456.1,1 +O0138,2026-03-04,East,Clothing,6.35,5 +O0139,2026-03-12,North,Clothing,39.46,2 +O0140,2026-01-15,East,Home,22.83,2 +O0021,2026-01-07,South,Books,44.92,1 +O0021,2026-01-07,South,Books,44.92,1 diff --git a/courses/da-eda/solution/eda.py b/courses/da-eda/solution/eda.py new file mode 100644 index 0000000..8d1ae6b --- /dev/null +++ b/courses/da-eda/solution/eda.py @@ -0,0 +1,67 @@ +"""Exploratory data analysis — reviewed solution.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pandas as pd + +SCENARIO_DIR = Path(__file__).resolve().parent.parent / "scenario" / "shop-export" + + +def load_transactions(path): + return pd.read_csv(path) + + +def clean_transactions(df): + stats = {"duplicates_removed": int(df.duplicated().sum()), + "missing_amount": 0, "bad_dates": 0} + df = df.drop_duplicates().copy() + df["amount"] = pd.to_numeric(df["amount"], errors="coerce") + df["date"] = pd.to_datetime(df["date"], errors="coerce") + stats["missing_amount"] = int(df["amount"].isna().sum()) + clean = df.dropna(subset=["amount"]) + stats["bad_dates"] = int(clean["date"].isna().sum()) + return clean.dropna(subset=["date"]), stats + + +def summarize(clean_df): + revenue_by_region = ( + clean_df.groupby("region")["amount"].sum().round(2).to_dict() + ) + top_category = clean_df.groupby("category")["amount"].sum().idxmax() + return { + "rows_total": None, # filled by main from the raw frame + "rows_clean": int(len(clean_df)), + "total_revenue": round(float(clean_df["amount"].sum()), 2), + "revenue_by_region": {k: float(v) for k, v in revenue_by_region.items()}, + "top_category": str(top_category), + "first_date": clean_df["date"].min().date().isoformat(), + "last_date": clean_df["date"].max().date().isoformat(), + } + + +def write_results(results, dest): + dest = Path(dest) + dest.write_text(json.dumps(results, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8") + + +def main(argv=None): + raw = load_transactions(SCENARIO_DIR / "transactions.csv") + clean, stats = clean_transactions(raw) + results = summarize(clean) + results.update({ + "rows_total": int(len(raw)), + "duplicates_removed": stats["duplicates_removed"], + "missing_amount": stats["missing_amount"], + "bad_dates": stats["bad_dates"], + }) + write_results(results, SCENARIO_DIR / "results.json") + print(f"rows_total={results['rows_total']} rows_clean={results['rows_clean']}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/courses/da-eda/starter/eda.py b/courses/da-eda/starter/eda.py new file mode 100644 index 0000000..c516a32 --- /dev/null +++ b/courses/da-eda/starter/eda.py @@ -0,0 +1,51 @@ +"""Exploratory data analysis — starter (deliberately unfinished). + +Contract: see TASK.md. Implement the functions below so the test suite +passes and results.json matches the reviewed summary of the dataset. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pandas as pd + +SCENARIO_DIR = Path(__file__).resolve().parent.parent / "scenario" / "shop-export" + + +def load_transactions(path): + """Read the transactions CSV into a DataFrame.""" + raise NotImplementedError + + +def clean_transactions(df): + """Return (clean_df, stats). + + - Drop exact duplicate rows. + - Parse ``amount`` to numeric; rows that do not parse are removed and + counted as ``missing_amount``. + - Parse ``date``; rows that do not parse are removed and counted as + ``bad_dates``. + stats = {"duplicates_removed": int, "missing_amount": int, "bad_dates": int} + clean_df keeps original columns plus parsed ``amount`` and ``date``. + """ + raise NotImplementedError + + +def summarize(clean_df): + """Build the results dict described in TASK.md.""" + raise NotImplementedError + + +def write_results(results, dest): + """Write results as JSON (UTF-8, indent 2, trailing newline).""" + raise NotImplementedError + + +def main(argv=None): + raise NotImplementedError + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/courses/da-eda/tests/test_eda.py b/courses/da-eda/tests/test_eda.py new file mode 100644 index 0000000..e8c733a --- /dev/null +++ b/courses/da-eda/tests/test_eda.py @@ -0,0 +1,78 @@ +"""Contract tests for the EDA challenge (run against starter or solution).""" + +import unittest + +import eda + + +class TestLoad(unittest.TestCase): + def test_load_returns_dataframe(self): + df = eda.load_transactions(eda.SCENARIO_DIR / "transactions.csv") + self.assertEqual(len(df), 142) + + def test_load_has_expected_columns(self): + df = eda.load_transactions(eda.SCENARIO_DIR / "transactions.csv") + for column in ("order_id", "date", "region", "category", "amount", "quantity"): + self.assertIn(column, df.columns) + + +class TestClean(unittest.TestCase): + def setUp(self): + self.df = eda.load_transactions(eda.SCENARIO_DIR / "transactions.csv") + self.clean, self.stats = eda.clean_transactions(self.df) + + def test_removes_exact_duplicates(self): + self.assertEqual(self.stats["duplicates_removed"], 2) + + def test_counts_bad_amounts(self): + self.assertEqual(self.stats["missing_amount"], 2) + + def test_counts_bad_dates(self): + self.assertEqual(self.stats["bad_dates"], 1) + + def test_clean_row_count(self): + self.assertEqual(len(self.clean), 137) + + def test_clean_has_no_unparsed_amounts(self): + self.assertFalse(self.clean["amount"].isna().any()) + self.assertFalse(self.clean["date"].isna().any()) + + +class TestSummarize(unittest.TestCase): + def setUp(self): + df = eda.load_transactions(eda.SCENARIO_DIR / "transactions.csv") + clean, _ = eda.clean_transactions(df) + self.results = eda.summarize(clean) + + def test_total_revenue(self): + self.assertAlmostEqual(self.results["total_revenue"], 33347.89, places=2) + + def test_revenue_by_region(self): + expected = {"East": 7477.22, "North": 9401.64, + "South": 9140.09, "West": 7328.94} + for region, value in expected.items(): + self.assertAlmostEqual(self.results["revenue_by_region"][region], + value, places=2) + + def test_top_category(self): + self.assertEqual(self.results["top_category"], "Books") + + def test_date_range(self): + self.assertEqual(self.results["first_date"], "2026-01-03") + self.assertEqual(self.results["last_date"], "2026-03-28") + + +class TestMain(unittest.TestCase): + def test_main_writes_results_json(self): + import json + self.assertEqual(eda.main([]), 0) + results = json.loads( + (eda.SCENARIO_DIR / "results.json").read_text(encoding="utf-8")) + self.assertEqual(results["rows_total"], 142) + self.assertEqual(results["rows_clean"], 137) + self.assertEqual(results["duplicates_removed"], 2) + self.assertAlmostEqual(results["total_revenue"], 33347.89, places=2) + + +if __name__ == "__main__": + unittest.main() diff --git a/courses/da-eda/verify.py b/courses/da-eda/verify.py new file mode 100644 index 0000000..875c5f5 --- /dev/null +++ b/courses/da-eda/verify.py @@ -0,0 +1,318 @@ +#!/usr/bin/env python3 +"""Run the course contract against starter or solution. + +Objective completion evidence for "Exploratory data analysis" (da-eda). +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. ``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. +""" + +from __future__ import annotations + +import argparse +import base64 +import hashlib +import importlib.util +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_returns_dataframe", + "test_load_has_expected_columns", + "test_removes_exact_duplicates", + "test_counts_bad_amounts", + "test_counts_bad_dates", + "test_clean_row_count", + "test_clean_has_no_unparsed_amounts", + "test_total_revenue", + "test_revenue_by_region", + "test_top_category", + "test_date_range", + "test_main_writes_results_json", +) + +COURSE_ID = 'course-da-eda' +# 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 = 'e2a7c019f4d3b5aa' + +CHECKPOINTS = [ + {"id": "l01", "gate": "attest", "title": 'What messy data looks like'}, + {"id": "l02", "gate": "attest", "title": 'Define the answer first'}, + {"id": "l03", "gate": "starter-suite", "title": 'Clean and summarize'}, + {"id": "l04", "gate": "both-suites", "title": 'Check against ground truth'}, + {"id": "l05", "gate": "attest", "title": 'Run it on your own data'}, +] + + +def _deps_available(): + return importlib.util.find_spec("pandas") is not None + + +def _deps_hint(): + print( + "pandas is not installed. Run `uv sync` (or " + "`pip install -r requirements.txt`) inside this course folder first.", + file=sys.stderr, + ) + + +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): + starter = solution = None + starter_ms = solution_ms = 0 + if not _deps_available(): + _deps_hint() + starter_ok = solution_ok = False + else: + 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) if starter else 0 + solution_tests = claim_receipt.count_tests(solution.stderr) if solution else 0 + 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 it 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) + + if not _deps_available(): + _deps_hint() + return 1 + + environment = os.environ.copy() + environment["PYTHONPATH"] = str(ROOT / args.implementation) + result = subprocess.run( + [sys.executable, "-m", "unittest", "discover", "-s", str(ROOT / "tests")], + 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: all contract tests fail " + "because the analysis functions are not implemented." + ) + 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/da-report/COURSE.md b/courses/da-report/COURSE.md new file mode 100644 index 0000000..e4ed25b --- /dev/null +++ b/courses/da-report/COURSE.md @@ -0,0 +1,76 @@ +--- +id: course-da-report +type: course +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. +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +badge: + id: course-da-report + name_en: Report Challenger + name_zh: 分析报告挑战者 + requires: All five checkpoints claimed (L01–L05) +course_id: course-da-report +--- + +# From Analysis to Report with an Agent + +> TL;DR: challenge course — turn `scenario/analysis-pack/results.json` +> into `out/report.json` + `out/report.md`. The contract: every number in +> the report traces back to its input unchanged. Verification checks +> structure and numeric consistency; it does not grade prose. + +## What you build + +A report generator: load verified inputs → build a structured report +dict → render Markdown → write both artifacts. The structured JSON is +the auditable layer; the Markdown is for humans. + +## Setup + +```bash +cd courses/da-report +# Standard library only — nothing to install. +``` + +## Challenges (checkpoints) + +| # | Challenge | Gate | +| --- | --- | --- | +| L01 | A report is a contract with the reader | self-attested | +| L02 | Structure before prose | self-attested | +| L03 | Build and render the report | objective (starter suite) | +| L04 | Numbers traceable to inputs | objective (both suites) | +| L05 | What report verification cannot see | self-attested | + +## Badge contract + +- Badge: **Report Challenger** (badge id `da-report`) — all five + checkpoints claimed. +- Challenges: L01–L05, 10 points each; +50 course-badge bonus on + flypython.com. +- Evidence: `python verify.py` — L03/L04 objective, L01/L02/L05 + attested. Self-reported evidence, never a certificate. + +## What this course does NOT cover + +Templates engines (Jinja), PDF export, styling, or editorial quality. +The suite verifies structure and numbers — good writing stays your job. + +## Folder map + +``` +COURSE.md / COURSE_cn.md this file (EN / 中文) +lessons/L01.md … L05.md challenge notes (each has an _cn.md pair) +scenario/analysis-pack/ results.json (verified input) +TASK.md / TASK_cn.md the contract +starter/report.py unfinished implementation +solution/report.py reviewed solution +tests/test_report.py contract suite (read-only) +verify.py objective pass/fail + claim codes +REVIEW.md maintainer run-through record +``` + +Code is MIT-licensed; prose is CC BY 4.0 (see repository `LICENSE`). diff --git a/courses/da-report/COURSE_cn.md b/courses/da-report/COURSE_cn.md new file mode 100644 index 0000000..b2b0bd4 --- /dev/null +++ b/courses/da-report/COURSE_cn.md @@ -0,0 +1,73 @@ +--- +id: course-da-report +type: course +title: 用 Agent 从分析到报告 +summary: 挑战课程——把经过验证的 EDA 摘要变成结构化报告(report.json + report.md),其中每个数字都可回溯到输入。验证检查结构与数字一致性,不评价文笔。 +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +badge: + id: course-da-report + name_en: Report Challenger + name_zh: 分析报告挑战者 + requires: 全部五个检查点认领通过(L01–L05) +course_id: course-da-report +--- + +# 用 Agent 从分析到报告 + +> 速览:挑战课程——把 `scenario/analysis-pack/results.json` 变成 +> `out/report.json` + `out/report.md`。契约:报告里每个数字都必须 +> 原样回溯到输入。验证检查结构与数字一致性,不评价文笔。 + +## 你要做出的东西 + +一个报告生成器:载入已验证的输入 → 构建结构化报告 dict → 渲染 +Markdown → 写出两份产物。结构化 JSON 是可审计层,Markdown 给人看。 + +## 环境 + +```bash +cd courses/da-report +# 仅使用标准库——无需安装任何依赖。 +``` + +## 挑战(检查点) + +| # | 挑战 | 判卷方式 | +| --- | --- | --- | +| L01 | 报告是与读者的契约 | 自我声明 | +| L02 | 先结构,后文字 | 自我声明 | +| L03 | 构建并渲染报告 | 客观(starter 套件) | +| L04 | 数字可回溯到输入 | 客观(双套件) | +| L05 | 报告验证看不到的东西 | 自我声明 | + +## 徽章契约 + +- 徽章:**分析报告挑战者**(badge id `da-report`)——五个检查点 + 全部认领。 +- 挑战:L01–L05 各 10 分;flypython.com 上 +50 课程徽章奖励分。 +- 证据:`python verify.py`——L03/L04 客观判卷, + L01/L02/L05 自报。自我报告的证据,绝非证书。 + +## 本课程不覆盖的内容 + +模板引擎(Jinja)、PDF 导出、排版样式、文字质量。套件验证的是结构 +与数字——写得好不好仍是你的事。 + +## 文件夹地图 + +``` +COURSE.md / COURSE_cn.md 本文件(EN / 中文) +lessons/L01.md … L05.md 挑战说明(每课有 _cn.md 配对) +scenario/analysis-pack/ results.json(已验证输入) +TASK.md / TASK_cn.md 契约 +starter/report.py 未完成实现 +solution/report.py 审核过的解答 +tests/test_report.py 契约套件(只读) +verify.py 客观通过/失败 + 认领码 +REVIEW.md 维护者走查记录 +``` + +代码 MIT 许可;文字 CC BY 4.0(见仓库 `LICENSE`)。 diff --git a/courses/da-report/REVIEW.md b/courses/da-report/REVIEW.md new file mode 100644 index 0000000..be50e68 --- /dev/null +++ b/courses/da-report/REVIEW.md @@ -0,0 +1,23 @@ +# Maintainer run-through — da-report + +- Date: 2026-09-12 +- `python verify.py starter --expect-failure`: reproduces the intended + unfinished state — all six contract tests fail. +- `python verify.py solution`: all six tests pass; report.md and + report.json written to out/. +- `python verify.py progress`: deterministic claim codes; L03/L04 + objective, L01/L02/L05 attested. +- Agent solvability run (challenge-model gate): **done 2026-09-13**. + Agent: Devin (SWE-2 Max), CLI session on macOS arm64, repo venv + Python 3.x with pandas installed. Method: implemented + `starter/report.py` from `TASK.md` only (load → build → render → + write → main), did not read or copy `solution/`. Result: + `python verify.py starter` exits 0; `verify.py progress` printed all + five claim codes (l03/l04 `[passed]`, l01/l02/l05 `[attest]`). + Observed deviations: none — the contract was implementable in one + pass; the metrics-carry-unchanged rule is the main trap and the + TASK.md wording makes it explicit. Limitations: single run, single + agent; the course does not teach *how* to write the report, so this + verifies solvability, not pedagogy. Starter restored to the + deliberately-unfinished state after the run (required by + `--expect-failure`). diff --git a/courses/da-report/TASK.md b/courses/da-report/TASK.md new file mode 100644 index 0000000..1429dd6 --- /dev/null +++ b/courses/da-report/TASK.md @@ -0,0 +1,22 @@ +# Task contract: from analysis to report (da-report) + +Change only `starter/report.py`. Input: +`scenario/analysis-pack/results.json` — a verified EDA summary. + +- `load_inputs(directory) -> dict`: read `results.json`. +- `build_report(inputs) -> dict` returns `{"title", "metrics", "sections"}`: + - `metrics` carries `rows_total`, `rows_clean`, `total_revenue`, + `top_category`, `revenue_by_region` **unchanged** from the inputs — + a report that recomputes differently is a bug, not a feature. + - `sections` is an ordered list of `{"heading", "body"}` containing + `Data quality`, `Findings`, `Appendix` in that order. +- `render_markdown(report) -> str`: emits `# `, then + `## <heading>` per section in order; key numbers appear formatted with + two decimals (e.g. `33347.89`). +- `write_report(report, markdown, out_dir)`: writes `report.json` + (UTF-8, indent 2, trailing newline) and `report.md`. +- `main()`: runs the whole pipeline on the shipped scenario pack, writes + to `out/`, prints a one-line confirmation, returns 0. + +Done means `python verify.py starter` exits 0 and +`python verify.py starter --expect-failure` exits nonzero. diff --git a/courses/da-report/TASK_cn.md b/courses/da-report/TASK_cn.md new file mode 100644 index 0000000..86e0c55 --- /dev/null +++ b/courses/da-report/TASK_cn.md @@ -0,0 +1,21 @@ +# 任务契约:从分析到报告(da-report) + +只允许修改 `starter/report.py`。输入: +`scenario/analysis-pack/results.json`——一份经过验证的 EDA 摘要。 + +- `load_inputs(directory) -> dict`:读取 `results.json`。 +- `build_report(inputs) -> dict` 返回 `{"title", "metrics", "sections"}`: + - `metrics` 原样携带输入的 `rows_total`、`rows_clean`、 + `total_revenue`、`top_category`、`revenue_by_region`——报告里重新 + 算出不同数字是缺陷,不是功能。 + - `sections` 是 `{"heading", "body"}` 的有序列表,按序包含 + `Data quality`、`Findings`、`Appendix`。 +- `render_markdown(report) -> str`:输出 `# <title>`,然后按序输出 + 各节的 `## <heading>`;关键数字以两位小数呈现(如 `33347.89`)。 +- `write_report(report, markdown, out_dir)`:写出 `report.json` + (UTF-8、缩进 2、末尾换行)和 `report.md`。 +- `main()`:对随课数据包跑完整流程,写入 `out/`,打印一行确认, + 返回 0。 + +完成标准:`python verify.py starter` 退出 0,且 +`python verify.py starter --expect-failure` 退出非零。 diff --git a/courses/da-report/lessons/L01.md b/courses/da-report/lessons/L01.md new file mode 100644 index 0000000..a895ceb --- /dev/null +++ b/courses/da-report/lessons/L01.md @@ -0,0 +1,29 @@ +--- +id: course-da-report-l01 +type: course +title: "Challenge 01: A report is a contract with the reader" +summary: "List which numbers the report must carry and where each comes from in results.json." +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "What this checkpoint tests" + body: "A report as a contract with the reader: list which numbers it must carry — `total_revenue`, `rows_clean`, `top_category`, `revenue_by_region` — and where each comes from in `results.json`." + - title: "Trace before writing" + body: "Every number in the report maps to a field in the input pack; a number you cannot trace does not belong in the report." + - title: "When you are done" + body: "Your list pairs each report number with its source field." +--- + +# Challenge 01: A report is a contract with the reader + +**Gate:** self-attested · **Points:** 10 + +A report promises the reader: "these numbers come from this data." +Before writing code, list which numbers your report must carry — +`total_revenue`, `rows_clean`, `top_category`, `revenue_by_region` — +and where each one comes from in `results.json`. + +**Checkpoint:** you can trace every required number to its source field. +Self-attested; record via `python verify.py`. diff --git a/courses/da-report/lessons/L01_cn.md b/courses/da-report/lessons/L01_cn.md new file mode 100644 index 0000000..22b62c3 --- /dev/null +++ b/courses/da-report/lessons/L01_cn.md @@ -0,0 +1,28 @@ +--- +id: course-da-report-l01 +type: course +title: "挑战 01:报告是与读者的契约" +summary: "列出报告必须携带哪些数字,以及各自来自 results.json 的哪个字段。" +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "这个检查点考什么" + body: "报告是与读者的契约:列出它必须携带哪些数字——`total_revenue`、`rows_clean`、`top_category`、`revenue_by_region`——以及各自来自 `results.json` 的哪个字段。" + - title: "先追溯再动笔" + body: "报告里每个数字都映射到输入包的某个字段;追溯不了的数字不该进报告。" + - title: "完成标准" + body: "你的清单把报告里每个数字和来源字段配了对。" +--- + +# 挑战 01:报告是与读者的契约 + +**判卷:** 自我声明 · **积分:** 10 + +报告向读者承诺:“这些数字来自这份数据。”写代码之前,列出你的报告 +必须携带的数字——`total_revenue`、`rows_clean`、`top_category`、 +`revenue_by_region`——以及它们在 `results.json` 里的出处。 + +**检查点:** 每个必需数字你都能回溯到来源字段。自我声明;用 +`python verify.py` 记录。 diff --git a/courses/da-report/lessons/L02.md b/courses/da-report/lessons/L02.md new file mode 100644 index 0000000..727be0b --- /dev/null +++ b/courses/da-report/lessons/L02.md @@ -0,0 +1,28 @@ +--- +id: course-da-report-l02 +type: course +title: "Challenge 02: Structure before prose" +summary: "Title, exact section order Data quality → Findings → Appendix, and the metrics block — before any sentence." +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "What this checkpoint tests" + body: "Structure before prose: title, the exact section order `Data quality` → `Findings` → `Appendix`, and what lives in the machine-readable `metrics` block." + - title: "Sections are ordered" + body: "`sections` is an ordered list — the suite checks sequence, so get the skeleton right before any sentence is written." + - title: "When you are done" + body: "Your skeleton names the title, the three section headings in order, and the metrics keys." +--- + +# Challenge 02: Structure before prose + +**Gate:** self-attested · **Points:** 10 + +Decide the skeleton first: title, section order (`Data quality` → +`Findings` → `Appendix`), and what belongs in the machine-readable +`metrics` block. Prose fills structure — it never substitutes for it. + +**Checkpoint:** your section plan exists on paper before you touch +`starter/report.py`. Self-attested; record via `python verify.py`. diff --git a/courses/da-report/lessons/L02_cn.md b/courses/da-report/lessons/L02_cn.md new file mode 100644 index 0000000..5b0ca16 --- /dev/null +++ b/courses/da-report/lessons/L02_cn.md @@ -0,0 +1,28 @@ +--- +id: course-da-report-l02 +type: course +title: "挑战 02:先结构,后文字" +summary: "标题、Data quality → Findings → Appendix 的确切顺序、metrics 块——先于任何句子。" +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "这个检查点考什么" + body: "先结构后文字:标题、`Data quality` → `Findings` → `Appendix` 的确切顺序,以及机器可读的 `metrics` 块装什么。" + - title: "小节是有序的" + body: "`sections` 是有序列表——套件查顺序,先把骨架弄对再写句子。" + - title: "完成标准" + body: "你的骨架点明标题、三个小节标题按序,以及 metrics 的键。" +--- + +# 挑战 02:先结构,后文字 + +**判卷:** 自我声明 · **积分:** 10 + +先定骨架:标题、章节顺序(`Data quality` → `Findings` → +`Appendix`)、以及哪些内容进机器可读的 `metrics` 块。文字填充结构, +永远替代不了结构。 + +**检查点:** 动 `starter/report.py` 之前,章节计划已经写在纸上。 +自我声明;用 `python verify.py` 记录。 diff --git a/courses/da-report/lessons/L03.md b/courses/da-report/lessons/L03.md new file mode 100644 index 0000000..1000417 --- /dev/null +++ b/courses/da-report/lessons/L03.md @@ -0,0 +1,32 @@ +--- +id: course-da-report-l03 +type: course +title: "Challenge 03: Build and render the report" +summary: "Carry metrics through unchanged, emit # title then ## sections in order, money at two decimals." +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "What this checkpoint tests" + body: "`python verify.py starter` — `load_inputs`, `build_report`, `render_markdown`, `write_report`, `main` all green." + - title: "Carry, do not recompute" + body: "`metrics` passes inputs through unchanged — the suite compares numbers exactly, and a report that recomputes differently is a bug, not a feature." + - title: "Exact output shape" + body: "`render_markdown` emits `# <title>` then `## <heading>` in order; money formats to two decimals like `33347.89`; `report.json` is UTF-8, indent 2, trailing newline." +--- + +# Challenge 03: Build and render the report + +**Gate:** objective — starter suite · **Points:** 10 + +Implement the contract in `starter/report.py`. Watch for: + +- Recomputing metrics instead of carrying inputs through — the suite + compares numbers exactly. +- Headings drifting from the spec — `## Data quality`, `## Findings`, + `## Appendix`, in that order. +- Number formatting: `33347.89` must appear with two decimals. + +**Checkpoint:** `python verify.py starter` exits 0. Claim code via +`python verify.py`. diff --git a/courses/da-report/lessons/L03_cn.md b/courses/da-report/lessons/L03_cn.md new file mode 100644 index 0000000..0ae333b --- /dev/null +++ b/courses/da-report/lessons/L03_cn.md @@ -0,0 +1,31 @@ +--- +id: course-da-report-l03 +type: course +title: "挑战 03:构建并渲染报告" +summary: "metrics 原样透传,先 # 标题再按序 ## 小节,金额两位小数。" +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "这个检查点考什么" + body: "`python verify.py starter`——`load_inputs`、`build_report`、`render_markdown`、`write_report`、`main` 全绿。" + - title: "透传不重算" + body: "`metrics` 原样携带输入——套件精确比对数字,重算出不同结果的报告是 bug 不是特色。" + - title: "输出形态要精确" + body: "`render_markdown` 先输出 `# <标题>` 再按序输出 `## <小节>`;金额两位小数字形如 `33347.89`;`report.json` 用 UTF-8、缩进 2、结尾换行。" +--- + +# 挑战 03:构建并渲染报告 + +**判卷:** 客观——starter 套件 · **积分:** 10 + +在 `starter/report.py` 里实现契约。注意: + +- 重新计算指标而不是原样传递输入——套件对数字是精确比对。 +- 标题偏离规格——`## Data quality`、`## Findings`、`## Appendix`, + 顺序固定。 +- 数字格式:`33347.89` 必须以两位小数出现。 + +**检查点:** `python verify.py starter` 退出 0。用 +`python verify.py` 取认领码。 diff --git a/courses/da-report/lessons/L04.md b/courses/da-report/lessons/L04.md new file mode 100644 index 0000000..7890e70 --- /dev/null +++ b/courses/da-report/lessons/L04.md @@ -0,0 +1,32 @@ +--- +id: course-da-report-l04 +type: course +title: "Challenge 04: Numbers traceable to inputs" +summary: "Every metric in report.json equals its source field — the audit answer is a field name, not an explanation." +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "What this checkpoint tests" + body: "Both suites: every metric in `report.json` equals its source in `results.json` — the audit answer is a field name, not an explanation." + - title: "The trace question" + body: "For each number in your report, answer 'where did this come from' with an input field. If any answer is a sentence instead of a field name, that number is suspect." + - title: "When you are done" + body: "All metrics trace byte-for-number and you can say which section each lives in." +--- + +# Challenge 04: Numbers traceable to inputs + +**Gate:** objective — both suites · **Points:** 10 + +Every metric in `report.json` must equal its source in `results.json` +byte-for-number. The audit question this challenge answers: "show me +where this number came from" — and the answer is a field name, not an +explanation. + +`python verify.py starter --expect-failure` must now exit nonzero; +`python verify.py solution` must be green. + +**Checkpoint:** both gates pass. Objective claim code via +`python verify.py`. diff --git a/courses/da-report/lessons/L04_cn.md b/courses/da-report/lessons/L04_cn.md new file mode 100644 index 0000000..9724f51 --- /dev/null +++ b/courses/da-report/lessons/L04_cn.md @@ -0,0 +1,31 @@ +--- +id: course-da-report-l04 +type: course +title: "挑战 04:数字可回溯到输入" +summary: "report.json 每个指标等于源字段——审计答案是字段名,不是解释。" +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "这个检查点考什么" + body: "双套件:`report.json` 里每个指标等于 `results.json` 里的源值——审计答案是一个字段名,不是一段解释。" + - title: "追溯之问" + body: "对报告里每个数字回答『它从哪来』,答案必须是输入字段。哪个答案变成了一句话,那个数字就可疑。" + - title: "完成标准" + body: "所有指标逐数字可追溯,且你能说出各自在哪个小节。" +--- + +# 挑战 04:数字可回溯到输入 + +**判卷:** 客观——双套件 · **积分:** 10 + +`report.json` 里的每个指标都必须与 `results.json` 中的来源逐数相等。 +本挑战回答的审计问题是:“这个数字从哪来的?”——答案是字段名, +不是一段解释。 + +`python verify.py starter --expect-failure` 现在必须退出非零; +`python verify.py solution` 必须全绿。 + +**检查点:** 两道门禁都过。用 `python verify.py` 取客观 +认领码。 diff --git a/courses/da-report/lessons/L05.md b/courses/da-report/lessons/L05.md new file mode 100644 index 0000000..4b655e6 --- /dev/null +++ b/courses/da-report/lessons/L05.md @@ -0,0 +1,29 @@ +--- +id: course-da-report-l05 +type: course +title: "Challenge 05: What report verification cannot see" +summary: "Structure and number consistency are checked; insight and honest conclusions are not — write the boundary sentence." +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "What this checkpoint tests" + body: "What report verification cannot see: structure, order, and number consistency are checked — insight, prose quality, and honest conclusions are not." + - title: "One boundary sentence" + body: "State what your report's verification covered and what it did not — that sentence, applied to a report you own, is the checkpoint." + - title: "Same rule as always" + body: "Every verifier has a blind spot; naming yours is the engineering habit this course exists to build." +--- + +# Challenge 05: What report verification cannot see + +**Gate:** self-attested · **Points:** 10 + +The suite checks structure, section order, and number consistency. It +does **not** check that your findings are insightful, your prose clear, +or your conclusions honest. Write one sentence stating what was verified +in your report and what was not. + +**Checkpoint:** that sentence exists. Self-attested; record via +`python verify.py`. diff --git a/courses/da-report/lessons/L05_cn.md b/courses/da-report/lessons/L05_cn.md new file mode 100644 index 0000000..17d2029 --- /dev/null +++ b/courses/da-report/lessons/L05_cn.md @@ -0,0 +1,28 @@ +--- +id: course-da-report-l05 +type: course +title: "挑战 05:报告验证看不到的东西" +summary: "结构与数字一致性被查;洞察与结论诚实不在其中——写下边界句。" +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "这个检查点考什么" + body: "报告验证看不到什么:结构、顺序、数字一致性被查——洞察、文笔、结论诚实不在其中。" + - title: "一句边界话" + body: "说清你的报告验证覆盖了什么、没覆盖什么——把这句话用到你自己的一份报告上,就完成了本检查点。" + - title: "老规矩" + body: "每个验证器都有盲区;说出自己的盲区正是这门课要建的工程习惯。" +--- + +# 挑战 05:报告验证看不到的东西 + +**判卷:** 自我声明 · **积分:** 10 + +套件检查结构、章节顺序、数字一致性。它**不**检查你的发现是否有洞见、 +文字是否清楚、结论是否诚实。写一句话说明你的报告里哪些被验证过、 +哪些没有。 + +**检查点:** 这句话已写下。自我声明;用 +`python verify.py` 记录。 diff --git a/courses/da-report/scenario/analysis-pack/results.json b/courses/da-report/scenario/analysis-pack/results.json new file mode 100644 index 0000000..3f089b8 --- /dev/null +++ b/courses/da-report/scenario/analysis-pack/results.json @@ -0,0 +1,15 @@ +{ + "dataset": "transactions.csv", + "rows_total": 142, + "rows_clean": 137, + "total_revenue": 33347.89, + "revenue_by_region": { + "East": 7477.22, + "North": 9401.64, + "South": 9140.09, + "West": 7328.94 + }, + "top_category": "Books", + "first_date": "2026-01-03", + "last_date": "2026-03-28" +} \ No newline at end of file diff --git a/courses/da-report/solution/report.py b/courses/da-report/solution/report.py new file mode 100644 index 0000000..ef48e69 --- /dev/null +++ b/courses/da-report/solution/report.py @@ -0,0 +1,72 @@ +"""From analysis to report — reviewed solution.""" + +from __future__ import annotations + +import json +from pathlib import Path + +SCENARIO_DIR = Path(__file__).resolve().parent.parent / "scenario" / "analysis-pack" +OUT_DIR = Path(__file__).resolve().parent.parent / "out" + + +def load_inputs(directory): + return json.loads((Path(directory) / "results.json").read_text(encoding="utf-8")) + + +def build_report(inputs): + metrics = { + "rows_total": inputs["rows_total"], + "rows_clean": inputs["rows_clean"], + "total_revenue": inputs["total_revenue"], + "top_category": inputs["top_category"], + "revenue_by_region": inputs["revenue_by_region"], + } + return { + "title": f"Analysis report: {inputs['dataset']}", + "metrics": metrics, + "sections": [ + {"heading": "Data quality", + "body": f"{inputs['rows_clean']} of {inputs['rows_total']} rows " + f"survived cleaning."}, + {"heading": "Findings", + "body": f"Total revenue {inputs['total_revenue']:.2f}; top " + f"category {inputs['top_category']}; strongest region " + f"{max(inputs['revenue_by_region'], key=inputs['revenue_by_region'].get)}."}, + {"heading": "Appendix", + "body": f"Period {inputs['first_date']} to {inputs['last_date']}. " + "Numbers verified against results.json; prose is not verified."}, + ], + } + + +def render_markdown(report): + lines = [f"# {report['title']}", ""] + for section in report["sections"]: + lines += [f"## {section['heading']}", "", section["body"], ""] + lines += [ + "```json", + json.dumps(report["metrics"], indent=2), + "```", + "", + ] + return "\n".join(lines) + + +def write_report(report, markdown, out_dir): + out_dir = Path(out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + (out_dir / "report.json").write_text( + json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + (out_dir / "report.md").write_text(markdown, encoding="utf-8") + + +def main(argv=None): + inputs = load_inputs(SCENARIO_DIR) + report = build_report(inputs) + write_report(report, render_markdown(report), OUT_DIR) + print(f"wrote report.md + report.json to {OUT_DIR}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/courses/da-report/starter/report.py b/courses/da-report/starter/report.py new file mode 100644 index 0000000..1fd8761 --- /dev/null +++ b/courses/da-report/starter/report.py @@ -0,0 +1,43 @@ +"""From analysis to report — starter (deliberately unfinished). + +Contract: see TASK.md. Read scenario/analysis-pack/results.json and +produce a structured report.json plus a rendered report.md in out/. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +SCENARIO_DIR = Path(__file__).resolve().parent.parent / "scenario" / "analysis-pack" +OUT_DIR = Path(__file__).resolve().parent.parent / "out" + + +def load_inputs(directory): + """Load results.json into a dict.""" + raise NotImplementedError + + +def build_report(inputs): + """Return {"title": str, "sections": [{"heading", "body"}, ...], + "metrics": {...}} — metrics must carry the input numbers unchanged.""" + raise NotImplementedError + + +def render_markdown(report): + """Render the report dict to Markdown. Required headings in order: + '# <title>', '## Data quality', '## Findings', '## Appendix'.""" + raise NotImplementedError + + +def write_report(report, markdown, out_dir): + """Write report.json (indent 2, trailing newline) and report.md.""" + raise NotImplementedError + + +def main(argv=None): + raise NotImplementedError + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/courses/da-report/tests/test_report.py b/courses/da-report/tests/test_report.py new file mode 100644 index 0000000..9b051ac --- /dev/null +++ b/courses/da-report/tests/test_report.py @@ -0,0 +1,57 @@ +"""Contract tests for the report challenge.""" + +import json +import unittest + +import report + + +class TestLoad(unittest.TestCase): + def test_load_returns_dict(self): + inputs = report.load_inputs(report.SCENARIO_DIR) + self.assertEqual(inputs["total_revenue"], 33347.89) + + +class TestBuild(unittest.TestCase): + def setUp(self): + self.inputs = report.load_inputs(report.SCENARIO_DIR) + self.report = report.build_report(self.inputs) + + def test_metrics_match_inputs(self): + metrics = self.report["metrics"] + self.assertEqual(metrics["total_revenue"], self.inputs["total_revenue"]) + self.assertEqual(metrics["rows_clean"], self.inputs["rows_clean"]) + self.assertEqual(metrics["top_category"], self.inputs["top_category"]) + + def test_required_sections_in_order(self): + headings = [s["heading"] for s in self.report["sections"]] + for required in ("Data quality", "Findings", "Appendix"): + self.assertIn(required, headings) + self.assertLess(headings.index("Data quality"), headings.index("Findings")) + self.assertLess(headings.index("Findings"), headings.index("Appendix")) + + +class TestRender(unittest.TestCase): + def setUp(self): + inputs = report.load_inputs(report.SCENARIO_DIR) + self.markdown = report.render_markdown(report.build_report(inputs)) + + def test_headings_present(self): + for heading in ("# ", "## Data quality", "## Findings", "## Appendix"): + self.assertIn(heading, self.markdown) + + def test_key_numbers_appear(self): + self.assertIn("33347.89", self.markdown) + self.assertIn("Books", self.markdown) + + +class TestMain(unittest.TestCase): + def test_main_writes_report_files(self): + self.assertEqual(report.main([]), 0) + data = json.loads((report.OUT_DIR / "report.json").read_text()) + self.assertEqual(data["metrics"]["total_revenue"], 33347.89) + self.assertIn("## Findings", (report.OUT_DIR / "report.md").read_text()) + + +if __name__ == "__main__": + unittest.main() diff --git a/courses/da-report/verify.py b/courses/da-report/verify.py new file mode 100644 index 0000000..62fbe35 --- /dev/null +++ b/courses/da-report/verify.py @@ -0,0 +1,275 @@ +#!/usr/bin/env python3 +"""Run the course contract against starter or solution. + +Objective completion evidence for "From analysis to report" (da-report). +``progress`` prints checkpoint claim codes for recording on flypython.com. +Standard library only — no dependencies to install. +""" + +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_returns_dict", + "test_metrics_match_inputs", + "test_required_sections_in_order", + "test_headings_present", + "test_key_numbers_appear", + "test_main_writes_report_files", +) + +COURSE_ID = 'course-da-report' +# Documented constant: claim codes derive deterministically from +# (COURSE_ID, checkpoint_id, COURSE_SALT). Spot-checkable self-reported +# evidence, not tamper-proof secrets — see docs/repo-plan-0.0.4.md FP-411. +COURSE_SALT = 'b9d2e8f14c6a7053' + +CHECKPOINTS = [ + {"id": "l01", "gate": "attest", "title": 'A report is a contract with the reader'}, + {"id": "l02", "gate": "attest", "title": 'Structure before prose'}, + {"id": "l03", "gate": "starter-suite", "title": 'Build and render the report'}, + {"id": "l04", "gate": "both-suites", "title": 'Numbers traceable to inputs'}, + {"id": "l05", "gate": "attest", "title": 'What report verification cannot see'}, +] + + +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) if starter else 0 + solution_tests = claim_receipt.count_tests(solution.stderr) if solution else 0 + 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 it 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) + + environment = os.environ.copy() + environment["PYTHONPATH"] = str(ROOT / args.implementation) + result = subprocess.run( + [sys.executable, "-m", "unittest", "discover", "-s", str(ROOT / "tests")], + 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 = [n for n in EXPECTED_STARTER_FAILURES if n not in output] + if missing: + print("Starter failed for unexpected reasons:", file=sys.stderr) + print("\n".join(missing), file=sys.stderr) + print(output, file=sys.stderr) + return 1 + print("Expected starter state reproduced: report functions unimplemented.") + 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/da-visualization/COURSE.md b/courses/da-visualization/COURSE.md new file mode 100644 index 0000000..c6cf8aa --- /dev/null +++ b/courses/da-visualization/COURSE.md @@ -0,0 +1,78 @@ +--- +id: course-da-visualization +type: course +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. +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +badge: + id: course-da-visualization + name_en: Visualization Challenger + name_zh: 可视化挑战者 + requires: All five checkpoints claimed (L01–L05) +course_id: course-da-visualization +--- + +# Data Visualization with an Agent + +> TL;DR: challenge course — you solve, the agent is your tool. Turn +> `scenario/sales-clean/sales_daily.csv` into three spec'd PNG charts and +> a `summary.json`. Verification checks the files exist, are valid PNGs, +> and that the numbers match the dataset. It does **not** judge +> aesthetics — say so honestly anywhere you show the output. + +## What you build + +A matplotlib script that produces `out/revenue_by_region.png`, +`out/daily_revenue.png`, `out/revenue_histogram.png`, and +`out/summary.json` — the machine-checkable half of "here are my charts". + +## Setup + +```bash +cd courses/da-visualization +uv sync # pandas + matplotlib (or: pip install -r requirements.txt) +``` + +## Challenges (checkpoints) + +| # | Challenge | Gate | +| --- | --- | --- | +| L01 | A chart is packaged evidence — what does each chart claim? | self-attested | +| L02 | Spec first — write down which numbers each chart must show | self-attested | +| L03 | Three charts and a summary — implement the contract | objective (starter suite) | +| L04 | Numbers match the charts — both suites green | objective (both suites) | +| L05 | Honest boundary — what verification cannot see | self-attested | + +## Badge contract + +- Badge: **Visualization Challenger** (badge id `da-visualization`) — + all five checkpoints claimed. +- Challenges: L01–L05, 10 points each; +50 course-badge bonus on + flypython.com. +- Evidence: `python verify.py` — L03/L04 objective, L01/L02/L05 + attested. Self-reported evidence, never a certificate. + +## What this course does NOT cover + +Chart design, dashboards, seaborn/plotly, interactive charts. The Agg +backend is deliberate: charts are evidence artifacts, not a UI. + +## Folder map + +``` +COURSE.md / COURSE_cn.md this file (EN / 中文) +lessons/L01.md … L05.md challenge notes (each has an _cn.md pair) +scenario/sales-clean/ sales_daily.csv (clean input) +TASK.md / TASK_cn.md the contract +starter/charts.py unfinished implementation +solution/charts.py reviewed solution +tests/test_charts.py contract suite (read-only) +verify.py objective pass/fail + claim codes +requirements.txt pinned pandas + matplotlib +REVIEW.md maintainer run-through record +``` + +Code is MIT-licensed; prose is CC BY 4.0 (see repository `LICENSE`). diff --git a/courses/da-visualization/COURSE_cn.md b/courses/da-visualization/COURSE_cn.md new file mode 100644 index 0000000..17eb7ab --- /dev/null +++ b/courses/da-visualization/COURSE_cn.md @@ -0,0 +1,76 @@ +--- +id: course-da-visualization +type: course +title: 用 Agent 做数据可视化 +summary: 挑战课程——把干净的销售数据变成三张有规格的图和一份 summary.json,其中数字会与数据集校验。图必须由数据生成,不许硬编码。 +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +badge: + id: course-da-visualization + name_en: Visualization Challenger + name_zh: 可视化挑战者 + requires: 全部五个检查点认领通过(L01–L05) +course_id: course-da-visualization +--- + +# 用 Agent 做数据可视化 + +> 速览:挑战课程——你解题,Agent 是工具。把 +> `scenario/sales-clean/sales_daily.csv` 变成三张符合规格的 PNG 图和 +> 一份 `summary.json`。验证检查文件存在、是合法 PNG、数字与数据集 +> 一致——它**不**评价美观,展示产出时请如实说明这一点。 + +## 你要做出的东西 + +一个 matplotlib 脚本,产出 `out/revenue_by_region.png`、 +`out/daily_revenue.png`、`out/revenue_histogram.png` 和 +`out/summary.json`——“这是我的图”这句话中可机器核查的那一半。 + +## 环境 + +```bash +cd courses/da-visualization +uv sync # pandas + matplotlib(或 pip install -r requirements.txt) +``` + +## 挑战(检查点) + +| # | 挑战 | 判卷方式 | +| --- | --- | --- | +| L01 | 图是证据的包装——每张图在声称什么? | 自我声明 | +| L02 | 先写规格——写下每张图必须呈现的数字 | 自我声明 | +| L03 | 三张图与摘要——实现契约 | 客观(starter 套件) | +| L04 | 数字与图一致——两套件全绿 | 客观(双套件) | +| L05 | 诚实边界——验证看不到什么 | 自我声明 | + +## 徽章契约 + +- 徽章:**可视化挑战者**(badge id `da-visualization`)——五个检查点 + 全部认领。 +- 挑战:L01–L05 各 10 分;flypython.com 上 +50 课程徽章奖励分。 +- 证据:`python verify.py`——L03/L04 客观判卷,L01/L02/L05 + 自报。自我报告的证据,绝非证书。 + +## 本课程不覆盖的内容 + +图表设计、仪表盘、seaborn/plotly、交互图。只用 Agg 后端是刻意的: +图是证据产物,不是 UI。 + +## 文件夹地图 + +``` +COURSE.md / COURSE_cn.md 本文件(EN / 中文) +lessons/L01.md … L05.md 挑战说明(每课有 _cn.md 配对) +scenario/sales-clean/ sales_daily.csv(干净输入) +TASK.md / TASK_cn.md 契约 +starter/charts.py 未完成实现 +solution/charts.py 审核过的解答 +tests/test_charts.py 契约套件(只读) +verify.py 客观通过/失败 + 认领码 +requirements.txt 锁定的 pandas + matplotlib +REVIEW.md 维护者走查记录 +``` + +代码 MIT 许可;文字 CC BY 4.0(见仓库 `LICENSE`)。 diff --git a/courses/da-visualization/REVIEW.md b/courses/da-visualization/REVIEW.md new file mode 100644 index 0000000..829edce --- /dev/null +++ b/courses/da-visualization/REVIEW.md @@ -0,0 +1,21 @@ +# Maintainer run-through — da-visualization + +- Date: 2026-09-12 +- `python verify.py starter --expect-failure`: reproduces the intended + unfinished state — all five contract tests fail. +- `python verify.py solution`: all five tests pass; three valid PNGs and + summary.json written to out/. +- `python verify.py progress`: deterministic claim codes; L03/L04 + objective, L01/L02/L05 attested. +- Agent solvability run (challenge-model gate): **done 2026-09-13**. + Agent: Devin (SWE-2 Max), CLI session on macOS arm64, repo venv with + pandas + matplotlib. Method: implemented `starter/charts.py` from + `TASK.md` only (Agg backend, groupby sums, three fig.savefig + + summary.json), did not read or copy `solution/`. Result: + `python verify.py starter` exits 0; `verify.py progress` printed all + five claim codes (l03/l04 `[passed]`). Deviations: none — solvable in + one pass; the only trap is forgetting `matplotlib.use("Agg")` before + pyplot import, which TASK.md states plainly. Limitations: single run; + the suite checks PNG validity and JSON numbers, not chart quality — + consistent with the L05 "what verification cannot see" lesson. + Starter restored to the deliberately-unfinished state after the run. diff --git a/courses/da-visualization/TASK.md b/courses/da-visualization/TASK.md new file mode 100644 index 0000000..7afc859 --- /dev/null +++ b/courses/da-visualization/TASK.md @@ -0,0 +1,25 @@ +# Task contract: data visualization (da-visualization) + +Change only `starter/charts.py`. Input: +`scenario/sales-clean/sales_daily.csv` — clean long-format data +(date, region, revenue; 135 rows, 45 days, 3 regions). + +- `load_sales(path) -> DataFrame` with a parsed `date` column. +- `summary(df) -> dict`: + - `revenue_by_region` — total per region, 2 decimals. + - `daily_revenue` — total per ISO date, 2 decimals. + - `grand_total` — 2 decimals. +- `revenue_by_region(df, dest)` — bar chart PNG at `dest`. +- `daily_revenue(df, dest)` — line chart PNG at `dest`. +- `revenue_histogram(df, dest)` — histogram PNG at `dest`. +- `main()` — writes `out/revenue_by_region.png`, `out/daily_revenue.png`, + `out/revenue_histogram.png`, and `out/summary.json` (UTF-8, indent 2, + trailing newline); prints a one-line confirmation; returns 0. + +Constraints: matplotlib Agg backend only (no display); charts must be +generated *from the data*, never hardcoded. Verification checks that the +PNG files exist and are valid, and that summary.json's numbers match the +dataset — it does not judge how the charts look. + +Done means `python verify.py starter` exits 0 and +`python verify.py starter --expect-failure` exits nonzero. diff --git a/courses/da-visualization/TASK_cn.md b/courses/da-visualization/TASK_cn.md new file mode 100644 index 0000000..afc57bb --- /dev/null +++ b/courses/da-visualization/TASK_cn.md @@ -0,0 +1,24 @@ +# 任务契约:数据可视化(da-visualization) + +只允许修改 `starter/charts.py`。输入: +`scenario/sales-clean/sales_daily.csv`——干净的长表数据 +(date、region、revenue;135 行,45 天,3 个地区)。 + +- `load_sales(path) -> DataFrame`,`date` 列已解析为日期。 +- `summary(df) -> dict`: + - `revenue_by_region`——各地区总额,两位小数。 + - `daily_revenue`——每日总额(ISO 日期键),两位小数。 + - `grand_total`——两位小数。 +- `revenue_by_region(df, dest)`——柱状图 PNG 写到 `dest`。 +- `daily_revenue(df, dest)`——折线图 PNG 写到 `dest`。 +- `revenue_histogram(df, dest)`——直方图 PNG 写到 `dest`。 +- `main()`——写出 `out/revenue_by_region.png`、`out/daily_revenue.png`、 + `out/revenue_histogram.png` 与 `out/summary.json`(UTF-8、缩进 2、 + 末尾换行);打印一行确认;返回 0。 + +约束:只用 matplotlib Agg 后端(不需要显示器);图必须*由数据生成*, +绝不许硬编码。验证检查 PNG 文件存在且有效、summary.json 的数字与 +数据集一致——它不评价图好不好看。 + +完成标准:`python verify.py starter` 退出 0,且 +`python verify.py starter --expect-failure` 退出非零。 diff --git a/courses/da-visualization/lessons/L01.md b/courses/da-visualization/lessons/L01.md new file mode 100644 index 0000000..a59b9a8 --- /dev/null +++ b/courses/da-visualization/lessons/L01.md @@ -0,0 +1,34 @@ +--- +id: course-da-visualization-l01 +type: course +title: "Challenge 01: A chart is packaged evidence" +summary: "Say what claim each required PNG makes before writing code — the JSON summary is the auditable layer." +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "What this checkpoint tests" + body: "Charts as packaged evidence: before coding, say what claim each required PNG makes — `revenue_by_region` claims region totals, `daily_revenue` claims the trend, `revenue_histogram` claims the distribution." + - title: "Claim before pixels" + body: "A chart whose numbers are wrong is a lie with good typography — the JSON summary is the auditable layer the PNG must match." + - title: "When you are done" + body: "You can state the three claims and which aggregation feeds each." +--- + +# Challenge 01: A chart is packaged evidence + +**Gate:** self-attested · **Points:** 10 + +Every chart is a claim about numbers. Before writing code, say out loud +what each of the three required charts *claims*: + +- `revenue_by_region.png` claims: "region totals are X, Y, Z." +- `daily_revenue.png` claims: "the daily series looks like this." +- `revenue_histogram.png` claims: "row-level revenue is distributed + like this." + +If the claim is wrong, a beautiful chart is worse than no chart. + +**Checkpoint:** you can state the claim behind each chart. Self-attested; +record via `python verify.py`. diff --git a/courses/da-visualization/lessons/L01_cn.md b/courses/da-visualization/lessons/L01_cn.md new file mode 100644 index 0000000..d91c71c --- /dev/null +++ b/courses/da-visualization/lessons/L01_cn.md @@ -0,0 +1,33 @@ +--- +id: course-da-visualization-l01 +type: course +title: "挑战 01:图是证据的包装" +summary: "写码前先说出每张 PNG 声明什么——JSON 摘要才是可审计层。" +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "这个检查点考什么" + body: "图表是打包好的证据:写码前先说出每张 PNG 声明什么——`revenue_by_region` 声明地区总计、`daily_revenue` 声明趋势、`revenue_histogram` 声明分布。" + - title: "先声明后像素" + body: "数字错了的图是排版精良的谎言——JSON 汇总才是 PNG 必须对得上的可审计层。" + - title: "完成标准" + body: "你能说清三条声明,以及各自吃哪个聚合。" +--- + +# 挑战 01:图是证据的包装 + +**判卷:** 自我声明 · **积分:** 10 + +每张图都是关于数字的一种声称。写代码之前,先说出三张规定的图各自 +在*声称*什么: + +- `revenue_by_region.png` 声称:“各地区总额是 X、Y、Z。” +- `daily_revenue.png` 声称:“每日序列长这样。” +- `revenue_histogram.png` 声称:“行级收入的分布长这样。” + +声称要是错了,图越漂亮越糟糕。 + +**检查点:** 你能说出每张图背后的声称。自我声明;用 +`python verify.py` 记录。 diff --git a/courses/da-visualization/lessons/L02.md b/courses/da-visualization/lessons/L02.md new file mode 100644 index 0000000..c3d8a64 --- /dev/null +++ b/courses/da-visualization/lessons/L02.md @@ -0,0 +1,29 @@ +--- +id: course-da-visualization-l02 +type: course +title: "Challenge 02: Spec first, chart second" +summary: "Write which aggregation feeds each chart and what summary.json must contain before matplotlib opens." +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "What this checkpoint tests" + body: "Spec before chart: which aggregation feeds each PNG, how many rows the input has, and what `summary.json` must contain — before matplotlib opens." + - title: "Write the numbers down" + body: "135 rows, 45 days, 3 regions — the suite checks `summary.json` against ground truth, so your spec is the answer key you grade the agent against." + - title: "When you are done" + body: "Your spec lists each output file and the exact numbers it must carry." +--- + +# Challenge 02: Spec first, chart second + +**Gate:** self-attested · **Points:** 10 + +Write the spec before the chart: which aggregation feeds each chart, how +many rows the input has, what `summary.json` must contain. Your agent +will happily render *something* — your job is to define the numbers it +must render. + +**Checkpoint:** you have a written spec mapping each output file to its +source aggregation. Self-attested; record via `python verify.py`. diff --git a/courses/da-visualization/lessons/L02_cn.md b/courses/da-visualization/lessons/L02_cn.md new file mode 100644 index 0000000..1302d93 --- /dev/null +++ b/courses/da-visualization/lessons/L02_cn.md @@ -0,0 +1,28 @@ +--- +id: course-da-visualization-l02 +type: course +title: "挑战 02:先写规格,再画图" +summary: "在打开 matplotlib 之前写清每张图吃哪个聚合、summary.json 必须装什么。" +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "这个检查点考什么" + body: "先规格后图表:每张 PNG 吃哪个聚合、输入多少行、`summary.json` 必须有什么——都在打开 matplotlib 之前。" + - title: "把数字写下来" + body: "135 行、45 天、3 个地区——套件拿 `summary.json` 对真值,你的规格就是你批改 Agent 的参考答案。" + - title: "完成标准" + body: "你的规格列出每个输出文件和它必须携带的精确数字。" +--- + +# 挑战 02:先写规格,再画图 + +**判卷:** 自我声明 · **积分:** 10 + +画图之前先写规格:每张图吃哪个聚合结果、输入有多少行、 +`summary.json` 必须含什么。你的 Agent 会很乐意渲染出*某种东西*—— +你要做的是定义它必须渲染的数字。 + +**检查点:** 你已写下规格,把每个输出文件映射到它的聚合来源。 +自我声明;用 `python verify.py` 记录。 diff --git a/courses/da-visualization/lessons/L03.md b/courses/da-visualization/lessons/L03.md new file mode 100644 index 0000000..33c5972 --- /dev/null +++ b/courses/da-visualization/lessons/L03.md @@ -0,0 +1,33 @@ +--- +id: course-da-visualization-l03 +type: course +title: "Challenge 03: Three charts and a summary" +summary: "Implement the contract in starter/charts.py — Agg before pyplot, parsed dates, real PNG bytes." +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "What this checkpoint tests" + body: "`python verify.py starter` — `load_sales`, `summary`, the three chart functions, and `main`, all green." + - title: "Agg before pyplot" + body: "`matplotlib.use('Agg')` must run before importing pyplot or the script dies on display-less machines. And parse `date` first — `groupby` on unparsed strings sorts lexically." + - title: "Files must be real PNGs" + body: "The suite checks magic bytes, not that a file exists — write with `savefig` to the `out/` paths the contract names, and emit `summary.json` UTF-8 indent 2 with a trailing newline." +--- + +# Challenge 03: Three charts and a summary + +**Gate:** objective — starter suite · **Points:** 10 + +Implement the contract in `starter/charts.py`. Notes for driving the +agent: + +- `matplotlib.use("Agg")` must run *before* importing pyplot, or the + script dies on machines with no display. +- `groupby("date")` on unparsed strings sorts lexically — parse dates + first. +- Tests check PNG magic bytes: a file that is not a real PNG fails. + +**Checkpoint:** `python verify.py starter` exits 0. Claim code via +`python verify.py`. diff --git a/courses/da-visualization/lessons/L03_cn.md b/courses/da-visualization/lessons/L03_cn.md new file mode 100644 index 0000000..4f004d8 --- /dev/null +++ b/courses/da-visualization/lessons/L03_cn.md @@ -0,0 +1,31 @@ +--- +id: course-da-visualization-l03 +type: course +title: "挑战 03:三张图与摘要" +summary: "在 starter/charts.py 实现契约——Agg 先于 pyplot、日期先解析、真 PNG 字节。" +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "这个检查点考什么" + body: "`python verify.py starter`——`load_sales`、`summary`、三个画图函数和 `main` 全绿。" + - title: "Agg 要在 pyplot 之前" + body: "`matplotlib.use('Agg')` 必须先于导入 pyplot 执行,否则在没有显示器的机器上直接死。`date` 也要先解析——对未解析字符串 `groupby` 是按字典序排的。" + - title: "必须是真 PNG" + body: "套件查的是 magic bytes 不是文件存不存在——用 `savefig` 写到契约点名的 `out/` 路径,`summary.json` 用 UTF-8、缩进 2、结尾换行。" +--- + +# 挑战 03:三张图与摘要 + +**判卷:** 客观——starter 套件 · **积分:** 10 + +在 `starter/charts.py` 里实现契约。驱动 Agent 时的要点: + +- `matplotlib.use("Agg")` 必须在导入 pyplot *之前*执行,否则在没有 + 显示器的机器上直接崩。 +- 在未解析的字符串列上 `groupby("date")` 会按字典序排——先解析日期。 +- 测试检查 PNG 魔数:不是真 PNG 的文件会失败。 + +**检查点:** `python verify.py starter` 退出 0。用 +`python verify.py` 取认领码。 diff --git a/courses/da-visualization/lessons/L04.md b/courses/da-visualization/lessons/L04.md new file mode 100644 index 0000000..c4d1828 --- /dev/null +++ b/courses/da-visualization/lessons/L04.md @@ -0,0 +1,32 @@ +--- +id: course-da-visualization-l04 +type: course +title: "Challenge 04: Numbers match the charts" +summary: "summary.json is checked against ground truth; a chart disagreeing with the JSON is a failed chart." +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "What this checkpoint tests" + body: "Both suites: `summary.json` against ground truth — East 12334.94, North 14294.7, South 11200.48, grand 37830.12, 45 days." + - title: "JSON is the audit layer" + body: "A chart showing numbers that differ from the JSON is a failed chart — if the PNG and JSON disagree, the JSON loses the argument only when the PNG is right, which it almost never is." + - title: "When you are done" + body: "Every figure in `summary.json` matches the dataset's truth and you can trace each to its aggregation." +--- + +# Challenge 04: Numbers match the charts + +**Gate:** objective — both suites · **Points:** 10 + +The suite verifies `summary.json` against the dataset's ground truth +(East 12334.94, North 14294.7, South 11200.48, grand total 37830.12, +45 days). A chart that shows different numbers than the JSON is a failed +chart — the JSON is the auditable layer. + +`python verify.py starter --expect-failure` must now exit nonzero; +`python verify.py solution` must be green. + +**Checkpoint:** both gates pass. Objective claim code via +`python verify.py`. diff --git a/courses/da-visualization/lessons/L04_cn.md b/courses/da-visualization/lessons/L04_cn.md new file mode 100644 index 0000000..c1b26e5 --- /dev/null +++ b/courses/da-visualization/lessons/L04_cn.md @@ -0,0 +1,31 @@ +--- +id: course-da-visualization-l04 +type: course +title: "挑战 04:数字与图一致" +summary: "summary.json 对照真值校验;图与 JSON 不一致就是失败的图。" +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "这个检查点考什么" + body: "双套件:`summary.json` 对真值——East 12334.94、North 14294.7、South 11200.48、总计 37830.12、45 天。" + - title: "JSON 是审计层" + body: "图上的数和 JSON 不一致就是失败的图——两者对不上时几乎总是 JSON 对。" + - title: "完成标准" + body: "`summary.json` 每个数字都对得上数据集真值,且都能追溯到它的聚合。" +--- + +# 挑战 04:数字与图一致 + +**判卷:** 客观——双套件 · **积分:** 10 + +套件用数据集真值校验 `summary.json`(East 12334.94、North 14294.7、 +South 11200.48、总计 37830.12、45 天)。图里呈现的数字和 JSON 不一致, +就是失败的图——JSON 才是可审计的那一层。 + +`python verify.py starter --expect-failure` 现在必须退出非零; +`python verify.py solution` 必须全绿。 + +**检查点:** 两道门禁都过。用 `python verify.py` 取客观 +认领码。 diff --git a/courses/da-visualization/lessons/L05.md b/courses/da-visualization/lessons/L05.md new file mode 100644 index 0000000..b384c9b --- /dev/null +++ b/courses/da-visualization/lessons/L05.md @@ -0,0 +1,31 @@ +--- +id: course-da-visualization-l05 +type: course +title: "Challenge 05: Honest boundary" +summary: "The suite checks files and numbers — not readability or honest scales; state what was not verified." +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "What this checkpoint tests" + body: "Naming what verification cannot see: the suite checks files exist, are real PNGs, and numbers match — it does not judge readability, scale honesty, or design." + - title: "Write the boundary sentence" + body: "One sentence stating what was verified (files, PNG validity, numbers) and what was not (whether the chart tells the truth visually) completes this checkpoint." + - title: "Carry it forward" + body: "The same boundary applies to every automated check you will ever write: state what it proves and what it does not." +--- + +# Challenge 05: Honest boundary + +**Gate:** self-attested · **Points:** 10 + +The verifier checks files exist, are real PNGs, and that summary numbers +are right. It does **not** check that the chart is readable, honest in +scale, or well-designed — a misleading y-axis passes the suite. + +Write one sentence you would attach to these charts stating exactly what +was and was not verified. + +**Checkpoint:** you have that sentence written down. Self-attested; +record via `python verify.py`. diff --git a/courses/da-visualization/lessons/L05_cn.md b/courses/da-visualization/lessons/L05_cn.md new file mode 100644 index 0000000..fefea89 --- /dev/null +++ b/courses/da-visualization/lessons/L05_cn.md @@ -0,0 +1,30 @@ +--- +id: course-da-visualization-l05 +type: course +title: "挑战 05:诚实边界" +summary: "套件查文件与数字——不查可读性与刻度诚实;写清没验证什么。" +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "这个检查点考什么" + body: "说出验证看不到什么:套件查文件存在、是真 PNG、数字对——不管可读性、刻度诚实、设计好坏。" + - title: "写下边界句" + body: "一句话说清验证了什么(文件、PNG 有效性、数字)和没验证什么(图在视觉上是否诚实),本检查点就完成了。" + - title: "带到以后" + body: "同一条边界适用于你今后写的每个自动化检查:说清它证明什么、不证明什么。" +--- + +# 挑战 05:诚实边界 + +**判卷:** 自我声明 · **积分:** 10 + +验证器检查的是:文件存在、是真 PNG、摘要数字正确。它**不**检查图 +是否可读、坐标轴是否诚实、设计是否合理——一个误导性的 y 轴照样 +能过套件。 + +写一句话附在这些图旁边,准确说明哪些东西验证过、哪些没有。 + +**检查点:** 这句话你已写下来。自我声明;用 +`python verify.py` 记录。 diff --git a/courses/da-visualization/requirements.txt b/courses/da-visualization/requirements.txt new file mode 100644 index 0000000..a77eee1 --- /dev/null +++ b/courses/da-visualization/requirements.txt @@ -0,0 +1,2 @@ +pandas==2.3.3 +matplotlib==3.10.9 diff --git a/courses/da-visualization/scenario/sales-clean/sales_daily.csv b/courses/da-visualization/scenario/sales-clean/sales_daily.csv new file mode 100644 index 0000000..3cab462 --- /dev/null +++ b/courses/da-visualization/scenario/sales-clean/sales_daily.csv @@ -0,0 +1,136 @@ +date,region,revenue +2026-04-01,North,239.1 +2026-04-01,South,320.72 +2026-04-01,East,217.96 +2026-04-02,North,266.55 +2026-04-02,South,318.79 +2026-04-02,East,337.9 +2026-04-03,North,294.45 +2026-04-03,South,224.09 +2026-04-03,East,190.16 +2026-04-04,North,299.18 +2026-04-04,South,181.79 +2026-04-04,East,288.44 +2026-04-05,North,246.83 +2026-04-05,South,325.53 +2026-04-05,East,318.6 +2026-04-06,North,318.66 +2026-04-06,South,168.22 +2026-04-06,East,261.04 +2026-04-07,North,305.9 +2026-04-07,South,281.78 +2026-04-07,East,324.13 +2026-04-08,North,236.5 +2026-04-08,South,202.47 +2026-04-08,East,285.26 +2026-04-09,North,262.62 +2026-04-09,South,313.8 +2026-04-09,East,304.2 +2026-04-10,North,322.72 +2026-04-10,South,148.16 +2026-04-10,East,238.77 +2026-04-11,North,289.79 +2026-04-11,South,236.31 +2026-04-11,East,366.25 +2026-04-12,North,393.0 +2026-04-12,South,206.2 +2026-04-12,East,349.67 +2026-04-13,North,326.02 +2026-04-13,South,321.17 +2026-04-13,East,231.15 +2026-04-14,North,397.54 +2026-04-14,South,285.63 +2026-04-14,East,371.94 +2026-04-15,North,307.98 +2026-04-15,South,238.62 +2026-04-15,East,247.84 +2026-04-16,North,408.28 +2026-04-16,South,261.51 +2026-04-16,East,250.11 +2026-04-17,North,357.25 +2026-04-17,South,140.61 +2026-04-17,East,205.64 +2026-04-18,North,283.66 +2026-04-18,South,296.84 +2026-04-18,East,323.86 +2026-04-19,North,251.16 +2026-04-19,South,320.11 +2026-04-19,East,192.17 +2026-04-20,North,277.99 +2026-04-20,South,286.67 +2026-04-20,East,344.51 +2026-04-21,North,271.05 +2026-04-21,South,314.61 +2026-04-21,East,194.89 +2026-04-22,North,398.16 +2026-04-22,South,143.23 +2026-04-22,East,222.45 +2026-04-23,North,351.18 +2026-04-23,South,249.87 +2026-04-23,East,262.37 +2026-04-24,North,417.48 +2026-04-24,South,301.88 +2026-04-24,East,301.99 +2026-04-25,North,340.73 +2026-04-25,South,290.9 +2026-04-25,East,306.27 +2026-04-26,North,236.98 +2026-04-26,South,308.95 +2026-04-26,East,268.71 +2026-04-27,North,377.73 +2026-04-27,South,276.87 +2026-04-27,East,184.47 +2026-04-28,North,276.0 +2026-04-28,South,194.0 +2026-04-28,East,200.12 +2026-04-29,North,258.04 +2026-04-29,South,163.19 +2026-04-29,East,284.52 +2026-04-30,North,267.95 +2026-04-30,South,142.09 +2026-04-30,East,302.46 +2026-05-01,North,310.44 +2026-05-01,South,257.97 +2026-05-01,East,376.97 +2026-05-02,North,419.55 +2026-05-02,South,247.76 +2026-05-02,East,192.17 +2026-05-03,North,249.5 +2026-05-03,South,291.79 +2026-05-03,East,270.07 +2026-05-04,North,220.34 +2026-05-04,South,332.65 +2026-05-04,East,361.75 +2026-05-05,North,321.0 +2026-05-05,South,212.3 +2026-05-05,East,360.85 +2026-05-06,North,410.45 +2026-05-06,South,292.78 +2026-05-06,East,291.94 +2026-05-07,North,342.18 +2026-05-07,South,164.4 +2026-05-07,East,359.93 +2026-05-08,North,263.67 +2026-05-08,South,278.72 +2026-05-08,East,207.72 +2026-05-09,North,317.68 +2026-05-09,South,171.51 +2026-05-09,East,328.71 +2026-05-10,North,354.33 +2026-05-10,South,231.15 +2026-05-10,East,269.0 +2026-05-11,North,337.38 +2026-05-11,South,171.41 +2026-05-11,East,245.36 +2026-05-12,North,410.59 +2026-05-12,South,250.11 +2026-05-12,East,288.76 +2026-05-13,North,385.02 +2026-05-13,South,286.77 +2026-05-13,East,183.56 +2026-05-14,North,369.64 +2026-05-14,South,212.97 +2026-05-14,East,207.92 +2026-05-15,North,302.45 +2026-05-15,South,333.58 +2026-05-15,East,212.38 diff --git a/courses/da-visualization/solution/charts.py b/courses/da-visualization/solution/charts.py new file mode 100644 index 0000000..7009119 --- /dev/null +++ b/courses/da-visualization/solution/charts.py @@ -0,0 +1,83 @@ +"""Data visualization — reviewed solution.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import matplotlib + +matplotlib.use("Agg") # headless: never require a display + +import matplotlib.pyplot as plt +import pandas as pd + +SCENARIO_DIR = Path(__file__).resolve().parent.parent / "scenario" / "sales-clean" +OUT_DIR = Path(__file__).resolve().parent.parent / "out" + + +def load_sales(path): + df = pd.read_csv(path) + df["date"] = pd.to_datetime(df["date"]) + return df + + +def summary(df): + by_region = df.groupby("region")["revenue"].sum().round(2) + daily = df.groupby("date")["revenue"].sum().round(2) + return { + "revenue_by_region": {str(k): float(v) for k, v in by_region.items()}, + "daily_revenue": {k.date().isoformat(): float(v) for k, v in daily.items()}, + "grand_total": round(float(df["revenue"].sum()), 2), + } + + +def revenue_by_region(df, dest): + totals = df.groupby("region")["revenue"].sum().sort_index() + fig, ax = plt.subplots() + ax.bar(totals.index.astype(str), totals.values) + ax.set_xlabel("Region") + ax.set_ylabel("Total revenue") + ax.set_title("Revenue by region") + fig.savefig(dest, dpi=100) + plt.close(fig) + + +def daily_revenue(df, dest): + daily = df.groupby("date")["revenue"].sum().sort_index() + fig, ax = plt.subplots() + ax.plot(daily.index, daily.values) + ax.set_xlabel("Date") + ax.set_ylabel("Total revenue") + ax.set_title("Daily revenue") + fig.autofmt_xdate() + fig.savefig(dest, dpi=100) + plt.close(fig) + + +def revenue_histogram(df, dest): + fig, ax = plt.subplots() + ax.hist(df["revenue"], bins=20) + ax.set_xlabel("Revenue") + ax.set_ylabel("Rows") + ax.set_title("Revenue distribution") + fig.savefig(dest, dpi=100) + plt.close(fig) + + +def main(argv=None): + df = load_sales(SCENARIO_DIR / "sales_daily.csv") + OUT_DIR.mkdir(parents=True, exist_ok=True) + revenue_by_region(df, OUT_DIR / "revenue_by_region.png") + daily_revenue(df, OUT_DIR / "daily_revenue.png") + revenue_histogram(df, OUT_DIR / "revenue_histogram.png") + (OUT_DIR / "summary.json").write_text( + json.dumps(summary(df), indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + print(f"wrote 3 charts + summary.json to {OUT_DIR}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/courses/da-visualization/starter/charts.py b/courses/da-visualization/starter/charts.py new file mode 100644 index 0000000..637d178 --- /dev/null +++ b/courses/da-visualization/starter/charts.py @@ -0,0 +1,49 @@ +"""Data visualization — starter (deliberately unfinished). + +Contract: see TASK.md. Produce the three spec'd charts and summary.json +in out/ using matplotlib (Agg backend — no display required). +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pandas as pd + +SCENARIO_DIR = Path(__file__).resolve().parent.parent / "scenario" / "sales-clean" +OUT_DIR = Path(__file__).resolve().parent.parent / "out" + + +def load_sales(path): + """Read the daily-sales CSV into a DataFrame with a parsed date column.""" + raise NotImplementedError + + +def summary(df): + """Return {"revenue_by_region": {region: float}, "daily_revenue": + {date: float}, "grand_total": float} — every value rounded to 2.""" + raise NotImplementedError + + +def revenue_by_region(df, dest): + """Bar chart of total revenue per region -> PNG at dest.""" + raise NotImplementedError + + +def daily_revenue(df, dest): + """Line chart of total revenue per day -> PNG at dest.""" + raise NotImplementedError + + +def revenue_histogram(df, dest): + """Histogram of row-level revenue values -> PNG at dest.""" + raise NotImplementedError + + +def main(argv=None): + raise NotImplementedError + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/courses/da-visualization/tests/test_charts.py b/courses/da-visualization/tests/test_charts.py new file mode 100644 index 0000000..db6310b --- /dev/null +++ b/courses/da-visualization/tests/test_charts.py @@ -0,0 +1,53 @@ +"""Contract tests for the visualization challenge.""" + +import json +import unittest + +import charts + + +class TestLoad(unittest.TestCase): + def test_load_returns_rows(self): + df = charts.load_sales(charts.SCENARIO_DIR / "sales_daily.csv") + self.assertEqual(len(df), 135) + + +class TestSummary(unittest.TestCase): + def setUp(self): + df = charts.load_sales(charts.SCENARIO_DIR / "sales_daily.csv") + self.result = charts.summary(df) + + def test_region_totals(self): + expected = {"East": 12334.94, "North": 14294.7, "South": 11200.48} + for region, value in expected.items(): + self.assertAlmostEqual( + self.result["revenue_by_region"][region], value, places=2) + + def test_daily_count_and_total(self): + self.assertEqual(len(self.result["daily_revenue"]), 45) + self.assertAlmostEqual(self.result["grand_total"], 37830.12, places=2) + + +def _is_png(path): + data = path.read_bytes() + return data[:8] == b"\x89PNG\r\n\x1a\n" + + +class TestCharts(unittest.TestCase): + def setUp(self): + charts.main([]) + + def test_three_pngs_exist_and_are_valid(self): + for name in ("revenue_by_region", "daily_revenue", "revenue_histogram"): + path = charts.OUT_DIR / f"{name}.png" + self.assertTrue(path.exists(), name) + self.assertTrue(_is_png(path), name) + self.assertGreater(path.stat().st_size, 3000, name) + + def test_summary_json_written(self): + data = json.loads((charts.OUT_DIR / "summary.json").read_text()) + self.assertAlmostEqual(data["grand_total"], 37830.12, places=2) + + +if __name__ == "__main__": + unittest.main() diff --git a/courses/da-visualization/verify.py b/courses/da-visualization/verify.py new file mode 100644 index 0000000..3087273 --- /dev/null +++ b/courses/da-visualization/verify.py @@ -0,0 +1,299 @@ +#!/usr/bin/env python3 +"""Run the course contract against starter or solution. + +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. +""" + +from __future__ import annotations + +import argparse +import base64 +import hashlib +import importlib.util +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_returns_rows", + "test_region_totals", + "test_daily_count_and_total", + "test_three_pngs_exist_and_are_valid", + "test_summary_json_written", +) + +COURSE_ID = 'course-da-visualization' +# Documented constant: claim codes derive deterministically from +# (COURSE_ID, checkpoint_id, COURSE_SALT). Spot-checkable self-reported +# evidence, not tamper-proof secrets — see docs/repo-plan-0.0.4.md FP-411. +COURSE_SALT = '7c1f4a2e9b8d6f03' + +CHECKPOINTS = [ + {"id": "l01", "gate": "attest", "title": 'A chart is packaged evidence'}, + {"id": "l02", "gate": "attest", "title": 'Spec first, chart second'}, + {"id": "l03", "gate": "starter-suite", "title": 'Three charts and a summary'}, + {"id": "l04", "gate": "both-suites", "title": 'Numbers match the charts'}, + {"id": "l05", "gate": "attest", "title": 'What verification cannot see'}, +] + + +def _deps_available(): + return (importlib.util.find_spec("pandas") is not None + and importlib.util.find_spec("matplotlib") is not None) + + +def _deps_hint(): + print( + "pandas/matplotlib not installed. Run `uv sync` (or " + "`pip install -r requirements.txt`) inside this course folder first.", + file=sys.stderr, + ) + + +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): + starter = solution = None + starter_ms = solution_ms = 0 + if not _deps_available(): + _deps_hint() + starter_ok = solution_ok = False + else: + 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) if starter else 0 + solution_tests = claim_receipt.count_tests(solution.stderr) if solution else 0 + 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) + + if not _deps_available(): + _deps_hint() + return 1 + + environment = os.environ.copy() + environment["PYTHONPATH"] = str(ROOT / args.implementation) + result = subprocess.run( + [sys.executable, "-m", "unittest", "discover", "-s", str(ROOT / "tests")], + 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 = [n for n in EXPECTED_STARTER_FAILURES if n not in output] + if missing: + print("Starter failed for unexpected reasons:", file=sys.stderr) + print("\n".join(missing), file=sys.stderr) + print(output, file=sys.stderr) + return 1 + print("Expected starter state reproduced: chart functions unimplemented.") + 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-python-with-claude-code/COURSE.md b/courses/hands-on-python-with-claude-code/COURSE.md new file mode 100644 index 0000000..a1a8c37 --- /dev/null +++ b/courses/hands-on-python-with-claude-code/COURSE.md @@ -0,0 +1,110 @@ +--- +id: course-claude-code +type: course +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. +lang: en-US +content_version: 3 +status: reviewed +reviewed_on: 2026-09-12 +badge: + id: course-claude-code + name_en: Reproduce the failure + name_zh: 复现故障 + requires: All five checkpoints claimed (L01–L05) +course_id: course-claude-code +--- + +# Hands-on Python with Claude Code + +> TL;DR: set up Claude Code, install the FlyPython Skill, and let the agent +> fetch this course — lesson 1 walks you through all three steps and you +> download nothing. Then say **"start lesson 1"**, and the agent teaches you a verified workflow for AI-written Python: task +> contract → smallest change → tests → objective verification. You finish with +> a working report tool, a reproducible pass/fail command, and a pattern you +> can apply to your own project. Claude Code is the tool used in the lessons, +> but the workflow works with any capable coding agent. + +## 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 the coding agent teaching this course, follow these rules: + +- **Audience:** a project owner who has working-with-AI experience but is stuck + on reliability — the script runs, then breaks on real data. Not a Python + beginner tutorial; not a prompt-engineering course. +- **Prerequisites:** Python 3.11+ on PATH, this folder open in the tool, + and the ability to run shell commands. No packages to install — the course + is standard library only. +- **Tool:** taught and reviewed with Claude Code 2.x (reviewed 2026-09-12). + A Claude Code major release 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. +- **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 while the gated checkpoints + (l03, l04) are still open. `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 + +Installing Claude Code, prompt tricks, multi-file architecture, pandas or +real Excel (`.xlsx`) files, deployment, or selling anything. For those, the +companion repository has guides and playbooks — link, don't improvise. + + +## Badge contract + +- 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 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/<skin>/ 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-python-with-claude-code/COURSE_cn.md b/courses/hands-on-python-with-claude-code/COURSE_cn.md new file mode 100644 index 0000000..3c8bd04 --- /dev/null +++ b/courses/hands-on-python-with-claude-code/COURSE_cn.md @@ -0,0 +1,100 @@ +--- +id: course-claude-code +type: course +title: 用 Claude Code 实战 Python +summary: 一门由 Agent 授课的课程:从下载课程文件夹到交付一个经过测试与验证的 Python 报表工具——包括任务契约、最小变更和客观的通过/失败证据。 +lang: zh-CN +content_version: 3 +status: reviewed +reviewed_on: 2026-09-12 +badge: + id: course-claude-code + name_en: Reproduce the failure + name_zh: 复现故障 + requires: 全部五个检查点认领通过(L01–L05) +course_id: course-claude-code +--- + +# 用 Claude Code 实战 Python + +> 摘要:装好 Claude Code、装上 FlyPython Skill、让 Agent 取回本课文件(第 1 课 +> 就是这三步,你什么都不用下载),说一句 **"开始第 1 课"**,Agent +> 就会带你走完一套经过验证的 AI 写 Python 工作流:任务契约 → 最小变更 → +> 测试 → 客观验证。课程结束时你会得到一个可运行的报表工具、一条可复现的 +> 通过/失败命令,以及能迁移到自己项目里的模式。课程以 Claude Code 为教学 +> 工具,但工作流适用于任何有能力的编码 Agent。 + +## 你将做出什么 + +一个小型 Python 报表工具:读取混乱的真实数据(CSV 或 JSON),把无效行 +隔离而不是崩溃,汇总有效行,并以原子方式写出报告。课程附带三个场景 +"皮肤",你可以在自己熟悉的领域里练习: + +| 皮肤 | 你的角色 | 数据 | +| --- | --- | --- | +| `scenario/excel-report/` | 把每周订单导出变成区域销售摘要 | `orders.csv` | +| `scenario/data-monitor/` | 检查来自服务器的服务延迟数据 | `metrics.csv` | +| `scenario/api-tool/` | 汇总从内部 API 拉取的工单数据 | `tickets.json` | + +三个皮肤走同一份代码和同一套测试。真正的教学目标是技能——契约、错误 +隔离、舍入、原子写入、验证;皮肤只是让它们具体可感。 + +## 教学契约(Agent 请先阅读本节) + +如果你是授课的编码 Agent,请遵守以下规则: + +- **受众:** 有 AI 协作经验、但卡在可靠性上的项目所有者——脚本能跑, + 一遇真实数据就坏。这不是 Python 入门教程,也不是提示词工程课。 +- **前置条件:** PATH 中有 Python 3.11+,本文件夹已在工具中打开,能运行 + shell 命令。无需安装任何第三方包——课程只用标准库。 +- **工具:** 以 Claude Code 2.x 完成教学与审核(审核日期 2026-09-12)。 + Claude Code 主版本更新会触发课程复审。 +- **课程顺序:** L01 → L02 → L03 → L04 → L05,每次一课。绝不跳过课末的 + 检查点。 +- **教学风格:** 从本文件夹的文件出发,而不是凭通用知识。引用你正在 + 满足的契约原文。做能让一个测试从红变绿的最小变更。不新增依赖,不修改 + `solution/`,改动当前课程未授权的文件前先询问。 +- **何时停止:** 当检查点命令运行通过、且学习者能用自己的话说清"什么 + 失败了、为什么"时,一课才算完成。说不出来就基于失败的测试重新讲, + 而不是直接看答案。 +- **如何使用 `verify.py`:** 学员命令就是 `python verify.py`——它跑套件、 + 打印逐检查点状态与已获认领码,在门控检查点(l03、l04)未全通过时以 + 非零退出。`starter --expect-failure` 与 `solution` 留作维护者检查。 + 「完成」指学习者做完有界改动后,默认命令把所有门显示为 `[passed]`。- **诚实规则:** 说明哪些内容没有验证过。不要宣称代码已达到生产可用。 + 不承诺任何学习或职业结果。 + +## 本课程不涉及的内容 + +Claude Code 安装、提示词技巧、多文件架构、pandas 或真正的 Excel +(`.xlsx`)文件、部署、任何销售内容。这些主题在配套仓库里有指南和 +手册——链接过去,不要现场发挥。 + + +## 徽章契约 + +- 徽章:**复现故障徽章**(徽章 id `course-claude-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 本文件(英文 / 中文) +lessons/L01.md … L05.md 课程(每课都有 _cn.md 中文版) +scenario/<skin>/ 每个皮肤的数据文件与 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-python-with-claude-code/REVIEW.md b/courses/hands-on-python-with-claude-code/REVIEW.md new file mode 100644 index 0000000..d1a63b5 --- /dev/null +++ b/courses/hands-on-python-with-claude-code/REVIEW.md @@ -0,0 +1,57 @@ +# Maintainer run-through record + +## 2026-09-12 — contract verification (mechanical) + +- Environment: macOS (arm64), Python 3.13; repository clean checkout on + branch `feat/0.0.3-courses-and-radar`. +- Commands and results: + - `python verify.py starter --expect-failure` — exit 0; all seven expected + failure names reproduced (JSON loading, unsupported suffix, row + isolation, rounding, parent directories, scenario report, CLI summary). + - `python verify.py solution` — exit 0; 9/9 tests pass. + - All three skins exercised end to end through `run_scenario` in the test + suite (`excel-report`, `data-monitor`, `api-tool`); report totals and + group sums recomputed by hand against the scenario data files. +- Not verified in this pass: teaching quality with a live agent session. + +## Pending before the public course drop + +- One full agent-taught run-through with Claude Code 2.x ("start lesson 1" + through Lesson 5 checkpoint), recording observed deviations from + COURSE.md here. This recording doubles as the demo-video source material + per the 0.0.3 plan §4.5. +- Course status remains `reviewed` for content and code; the agent-teaching + sample is tracked as launch evidence, not a content blocker. + +## 2026-09-13 — 0.0.8 rework: lesson 1 is now tool → Skill → agent fetch (FP-822) + +- L01 (EN+ZH) rewritten around the fixed order: get the tool running → + install the FlyPython Skill (network access included) → let the agent + fetch this course's files via the files endpoint. All "download this + folder" wording removed from `COURSE.md` and lessons. +- The learner's check command across lessons is now the bare + `python verify.py` (FP-820): per-checkpoint status + claim codes, + non-zero exit while gated checkpoints are open. `solution` is no longer + presented as a completion standard. +- Tool-specific Skill install and network steps are authored from the + same official documentation as the course; **not yet exercised inside + the tool** — the pending live run-through below still stands. + +## Deviation log + +### 2026-09-13 — agent solvability run (challenge mode, not taught mode) + +- Agent: Devin (SWE-2 Max), CLI session on macOS arm64, system Python + 3.14 (stdlib only — this course needs no third-party deps). +- Method: implemented `starter/report_tool.py` from `TASK.md` and the + test expectations only; did not read or copy `solution/`. +- Result: `python verify.py starter` exits 0 (all nine tests); + `verify.py progress` printed all five claim codes (l03/l04 + `[passed]`, l01/l02/l05 `[attest]`). +- Observed deviations: none — contract landed on the first pass. All + three scenario skins (csv/json inputs, invalid-row isolation, + rounding, atomic write into a fresh directory) were exercised. +- Limitations: challenge-mode run (TASK.md → tests), not the taught + COURSE.md walkthrough — teaching quality remains separately pending. +- Starter restored to the deliberately-unfinished state after the run + (`--expect-failure` verified again). diff --git a/courses/hands-on-python-with-claude-code/TASK.md b/courses/hands-on-python-with-claude-code/TASK.md new file mode 100644 index 0000000..e923183 --- /dev/null +++ b/courses/hands-on-python-with-claude-code/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": <row position>, "reason": <short string>}`. 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-python-with-claude-code/TASK_cn.md b/courses/hands-on-python-with-claude-code/TASK_cn.md new file mode 100644 index 0000000..7af2609 --- /dev/null +++ b/courses/hands-on-python-with-claude-code/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-python-with-claude-code/core-group b/courses/hands-on-python-with-claude-code/core-group new file mode 100644 index 0000000..b56b0eb --- /dev/null +++ b/courses/hands-on-python-with-claude-code/core-group @@ -0,0 +1 @@ +report-tool diff --git a/courses/hands-on-python-with-claude-code/lessons/L01.md b/courses/hands-on-python-with-claude-code/lessons/L01.md new file mode 100644 index 0000000..1357d80 --- /dev/null +++ b/courses/hands-on-python-with-claude-code/lessons/L01.md @@ -0,0 +1,152 @@ +--- +id: course-claude-code-l01 +type: course +title: "Lesson 1: Set up Claude Code, install the FlyPython Skill, fetch the course" +summary: "Get Claude Code running, install the FlyPython Skill with network access approved, and let the agent fetch this course's files — you download nothing. Then make the starter's failure objective." +lang: en-US +content_version: 2 +status: reviewed +reviewed_on: 2026-09-12 +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). The checkpoint is seeing the failure objectively, not repairing it." + - title: "Read the failure names as a contract" + body: "Each failing test name maps to one line in `TASK.md` — e.g. `test_non_numeric_amount_is_invalid` is the contract's 'a non-numeric numeric_field value'. If a name maps to no contract line, that drift is worth noticing." + - title: "When you are done" + body: "You can say out loud which nine behaviors the suite proves and why the starter cannot fake them. `python verify.py` then shows this checkpoint's claim code." +--- + +# Lesson 1: Set up Claude Code, install the FlyPython Skill, fetch the course + +## Objective + +By the end of this lesson Claude Code is running, the FlyPython Skill is +installed in it (with network access approved), 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 Claude Code running + +Install the Claude Code CLI +(`npm install -g @anthropic-ai/claude-code`), create an empty working +folder, and start `claude` in it. + +## 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 tools that keep skills between sessions. + +Save the Skill as a project skill — run this in the working folder: + +```bash +mkdir -p .claude/skills/flypython +curl -s https://flypython.com/skills/flypython/SKILL.md -o .claude/skills/flypython/SKILL.md +``` + +(A personal copy for all projects: the same command under `~/.claude/skills/`.) +Claude Code reaches the web through permission prompts — when it asks to +fetch or curl, approve. If you run it inside a network-blocking sandbox, +run the curl yourself and paste the file instead. + +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 session/chat/thread in the working folder and paste this one +sentence: + +> Read https://flypython.com/skills/flypython/SKILL.md and start the FlyPython course `hands-on-python-with-claude-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 takes a token once and writes this +course's files to `courses/hands-on-python-with-claude-code/`. + +**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. + +## Warm-up (2 minutes) + +Open `starter/report_tool.py` 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-python-with-claude-code/` 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."** + +While it reads, run the learner's command — 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.) +That pair is your completion evidence for the whole course. + +## 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. + +## 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? + +This checkpoint's claim code is already in the default command's output — +submit it only once you can answer the questions. + +## 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-python-with-claude-code/lessons/L01_cn.md b/courses/hands-on-python-with-claude-code/lessons/L01_cn.md new file mode 100644 index 0000000..c07228e --- /dev/null +++ b/courses/hands-on-python-with-claude-code/lessons/L01_cn.md @@ -0,0 +1,136 @@ +--- +id: course-claude-code-l01 +type: course +title: "第 1 课:装好 Claude Code、装上 FlyPython Skill、让 Agent 取回课程" +summary: "先把 Claude Code 跑起来,装好 FlyPython Skill 并打开联网权限,让 Agent 取回本课文件——你不下载任何东西,然后让 starter 的失败变得客观。" +lang: zh-CN +content_version: 2 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "这个检查点考什么" + body: "考你能不能先复现故障再谈修复:`python verify.py starter --expect-failure` 必须打印出那组具名失败测试。重点是让失败变成客观证据,而不是修好它。" + - title: "把失败名当成契约读" + body: "每个失败的测试名都对应 `TASK.md` 里的一行——比如 `test_non_numeric_amount_is_invalid` 对应契约的『非数值的 numeric_field 值』。如果有名字对不上契约,那处漂移值得注意。" + - title: "完成标准" + body: "你能说清楚这九个测试各自证明了什么、为什么 starter 装不出来。然后 `python verify.py` 就会显示本检查点的认领码。" +--- + +# 第 1 课:装好 Claude Code、装上 FlyPython Skill、让 Agent 取回课程 + +## 目标 + +本课结束时,Claude Code 已经跑起来、FlyPython Skill 已装好(联网权限已 +打开),Agent 已把本课文件取回你的工作目录——你没有手动下载任何 +东西。文件夹就位后,你运行课程的客观检查命令,并解释「starter +失败」在测试名里的含义。本课不改任何代码。 + +## 第 1 步——把 Claude Code 跑起来 + +安装 Claude Code CLI +(`npm install -g @anthropic-ai/claude-code`),新建一个空工作目录, +在里面运行 `claude`。 + +## 第 2 步——(可选)预装 FlyPython Skill + +这一步可选:第 3 步的开课句子会直接从 URL 读取 Skill,不装也能上课——预装只是省掉一次权限往返。 + +把 Skill 存为项目级技能——在工作目录里运行: + +```bash +mkdir -p .claude/skills/flypython +curl -s https://flypython.com/skills/flypython/SKILL.md -o .claude/skills/flypython/SKILL.md +``` + +(全项目通用的个人副本:在 `~/.claude/skills/` 下执行同样命令。) +Claude Code 通过权限提示访问网络——它请求 fetch 或 curl 时批准即可。 +如果你在禁网的沙箱里运行,就自己在终端跑 curl,把文件粘贴过去。 + +Skill 只是一个文件,在每个工具里都一样:它告诉 Agent 如何为你 +授权、取课程文件、验证、提交认领码。 + +## 第 3 步——让 Agent 取回本课文件(你不下载) + +在工作目录里新开一个会话/对话/线程,粘贴这一句话: + +> Read https://flypython.com/skills/flypython/SKILL.md and start the FlyPython course `hands-on-python-with-claude-code`. + +Agent 会给你一个授权链接和一组短码。打开链接、登录、核对码与 +Agent 显示的一致后点「允许」,然后对 Agent 说「好了」。它取一次 +token,把本课文件写到 `courses/hands-on-python-with-claude-code/`。 + +**前提**:这些课程需要一个能执行命令**并且**能联网的编码 Agent。 +只能聊天的网页 AI 做不了。 + +## 为什么有这一课 + +大多数 AI 写的 Python 死法相同:演示输入没问题,真实输入一到,脚本 +在第 3 行崩溃——或者更糟,悄悄算出一个错误的数字。修复不是从更好的 +提示词开始,而是从把"它坏了"变成一条任何人运行都能得到同样结果的 +命令开始。 + +## 热身(2 分钟) + +打开 `starter/report_tool.py` 从头读一遍。它看起来很像样:带 docstring +的函数、类型标注、命令行入口。这正是 AI 生成代码的典型样子——表面 +干净,行为缺失。然后打开 `TASK.md` 放在手边。 + +## 本课内容 + +本课文件现在已经位于 `courses/hands-on-python-with-claude-code/`——你这边没有发生任何下载。 +让 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`),打开它的数据文件,找出会被拒绝的行。在运行任何命令 +之前,写下每个坏行会触发哪条测试,然后对照上面的表格检查。 + +## 检查点 + +在课程文件夹里运行 `python verify.py`。不看材料能回答下面三个 +问题即算通过: + +1. 哪五个行为把 starter 和 solution 区分开?其中哪两个失败测试是它们的端到端后果? +2. `python verify.py` 为什么故意以非零退出——它在报告什么状态? + 为什么这是成功条件而不是报错? +3. 你在练习里找到的那一行会让 starter 崩溃、被隔离,还是悄悄 + 通过? + +本检查点的认领码已经在默认命令的输出里——能回答这些问题之后再 +提交。 + +## 预期证据 + +两条命令的输出记录,加上你对三个问题的回答。保存好,第 4 课会再次 +用到。 diff --git a/courses/hands-on-python-with-claude-code/lessons/L02.md b/courses/hands-on-python-with-claude-code/lessons/L02.md new file mode 100644 index 0000000..3b9f61b --- /dev/null +++ b/courses/hands-on-python-with-claude-code/lessons/L02.md @@ -0,0 +1,83 @@ +--- +id: course-claude-code-l02 +type: course +title: "Lesson 2: Write the task contract before the change" +summary: 'Turn "make it better" into a bounded, testable contract — the single highest-leverage skill for working with coding agents.' +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +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: "Trace one line to one test" + body: "Pick a contract line — say `total = valid + invalid must hold` — and find the test that asserts it in `tests/test_report_tool.py`. Then write one contract line of your own for a script you own: inputs, outputs, error cases, and 'done means <command> exits 0'." + - 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 before the change + +## 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 one contract line for your +own project using the repository's task-contract template. + +## 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. The contract is where you decide what "done" means — before the +agent burns your trust with a plausible-looking rewrite. + +## 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 statement | Pinned by | +| --- | --- | +| JSON data files load as lists of objects | `test_load_json_records_returns_list_of_dicts` | +| Unsupported extensions raise `ValueError` | `test_unsupported_suffix_raises_value_error` | +| Invalid rows are isolated with `{"index", "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` | + +Also notice the boundary clauses — the lines that say what the change may NOT +do: no new dependencies, change only `starter/report_tool.py`, standard +library only. Boundary clauses are what keep an agent's "helpful" rewrite from +swallowing your whole file. + +The full template lives in the companion repository +(`templates/TASK_CONTRACT.md`). Its fields: user outcome, current behavior, +expected behavior, inputs and outputs, allowed files, out of scope, failure +and recovery, acceptance commands, and authority (network/commit/push). Every +field you leave blank is a decision you delegated to the machine. + +## Exercise + +Write the next contract yourself, small and real: + +1. Pick one script you actually own (a report, a scraper, a sync job). +2. Write three contract statements for it in the table form above — behavior, + not implementation. +3. For each statement, write the name of the test that would pin it. +4. Add one boundary clause (a file the agent must not touch, or a dependency + it must not add). + +Ask the agent to critique your contract against `TASK.md` — it should find +ambiguities, not add features. + +## Checkpoint + +Show your four lines to the agent and ask: "Which of these could you satisfy +by cheating — passing the test without the behavior?" If neither of you can +find a cheat path, your contract is concrete enough. Fix any line that fails +this test. + +## Expected evidence + +Your contract (4 lines) and the cheat-review result. In Lesson 3 the agent +will code against the course's `TASK.md` only — yours ships in Lesson 5. diff --git a/courses/hands-on-python-with-claude-code/lessons/L02_cn.md b/courses/hands-on-python-with-claude-code/lessons/L02_cn.md new file mode 100644 index 0000000..2ac2b49 --- /dev/null +++ b/courses/hands-on-python-with-claude-code/lessons/L02_cn.md @@ -0,0 +1,75 @@ +--- +id: course-claude-code-l02 +type: course +title: "第 2 课:动手之前先写任务契约" +summary: 把"优化一下"变成有边界、可测试的契约——这是与编码 Agent 协作杠杆最大的一项技能。 +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "这个检查点考什么" + body: "考你能不能把 `TASK.md` 读成可测试的语句而不是散文。每个以函数名开头的句子都是套件能断言的契约行。" + - title: "把一行契约对到一个测试" + body: "挑一行契约——比如 `total = valid + invalid must hold`——在 `tests/test_report_tool.py` 里找到断言它的测试。然后为你自己的一个脚本写一行契约:输入、输出、错误路径,以及『完成 = 某命令退出 0』。" + - title: "可测试性检查" + body: "判断一句话是否可测试:套件能否不读你的心思就断言它?看数字、退出码、磁盘上的文件——不看感觉。" +--- + +# 第 2 课:动手之前先写任务契约 + +## 目标 + +你能把 `TASK.md` 读成一组可测试的陈述,把每条陈述对应到 +`tests/test_report_tool.py` 中的测试,并用仓库的任务契约模板为自己的 +项目写出一条契约。 + +## 为什么要有这一课 + +模糊的需求产出模糊的代码。"把坏数据处理得稳一点"是在允许 Agent 猜; +"无效行收集进 `errors`,带 index 和 reason,有效行照常出报告"既给了 +它目标,也给了你验收方式。契约是你决定"完成"含义的地方——要在 Agent +用一次看似合理的重写消耗掉你的信任之前决定。 + +## 课程内容 + +打开 `TASK.md`。注意每一行的共同点:它描述可观察的行为,而不是实现 +方式。契约中的四条陈述及其锚定测试: + +| 契约陈述 | 锚定测试 | +| --- | --- | +| JSON 数据文件按对象列表加载 | `test_load_json_records_returns_list_of_dicts` | +| 不支持的扩展名抛出 `ValueError` | `test_unsupported_suffix_raises_value_error` | +| 无效行以 `{"index", "reason"}` 隔离;有效行照常汇总 | `test_invalid_records_are_isolated_with_reasons` | +| 分组总计保留两位小数 | `test_group_totals_are_rounded_to_two_decimals` | + +再注意边界条款——说明变更"不可以"做什么的行:不新增依赖、只改 +`starter/report_tool.py`、只用标准库。边界条款的作用,是防止 Agent +出于"帮忙"的重写吞掉你的整个文件。 + +完整模板在配套仓库(`templates/TASK_CONTRACT.md`)。字段包括:用户 +结果、当前行为、期望行为、输入输出、允许触碰的文件、明确不做的事、 +失败与恢复、验收命令、权限(网络/提交/推送)。你留空的每个字段,都是 +你交给机器替你做的决定。 + +## 练习 + +下一个契约由你自己来写,小而真实: + +1. 选一个你真正拥有的脚本(报表、爬虫、同步任务)。 +2. 按上面的表格形式为它写三条契约陈述——写行为,不写实现。 +3. 为每条陈述写出锚定它的测试名。 +4. 加一条边界条款(Agent 不可触碰的文件,或不可新增的依赖)。 + +让 Agent 参照 `TASK.md` 点评你的契约——它应该找出歧义,而不是加功能。 + +## 检查点 + +把你的四行内容给 Agent 看,问:"这几条里哪一条你能靠作弊满足——测试 +过了但行为没做到?"如果你们都找不到作弊路径,契约就足够具体。修掉 +任何没通过这一关的行。 + +## 预期证据 + +你的契约(4 行)和作弊评审结果。第 3 课中 Agent 只按课程的 `TASK.md` +编码——你自己的契约在第 5 课投入使用。 diff --git a/courses/hands-on-python-with-claude-code/lessons/L03.md b/courses/hands-on-python-with-claude-code/lessons/L03.md new file mode 100644 index 0000000..7bd8625 --- /dev/null +++ b/courses/hands-on-python-with-claude-code/lessons/L03.md @@ -0,0 +1,82 @@ +--- +id: course-claude-code-l03 +type: course +title: "Lesson 3: Drive a bounded change, test by test" +summary: "Let the agent edit the starter under contract — smallest change, no new dependencies, red to green one group at a time." +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "What this checkpoint tests" + body: "The gate is `python verify.py starter` — all nine tests green against your starter. Work one failing group at a time: `load_records` (csv/json/ValueError), then `build_report`, then `write_report`, then `main`." + - title: "Traps the suite actually asserts" + body: "Booleans are not numbers — `isinstance(True, int)` is True, so exclude bools explicitly. Group totals round to two decimals. `total = valid + invalid` must hold even when every row is invalid." + - title: "Atomic means atomic" + body: "`write_report` must use a sibling temp file plus `os.replace`, and no `.tmp` may survive a successful write — the suite checks the filesystem, not your intentions. Still red? Run the unittest directly to see the failing name." +--- + +# Lesson 3: Drive a bounded change, test by test + +## 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. +Watching an agent make seven failing tests pass in one giant rewrite teaches +you nothing. Supervising seven small diffs, each pinned to a contract line, +teaches you how to keep AI-written code yours. + +## The lesson + +Tell the agent: **"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 the suite and show me the diff before continuing."** + +Hold it to the contract's boundaries: + +- Only `starter/report_tool.py` changes. If a diff touches `tests/`, + `solution/`, or `scenario/`, stop and 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…"). +- If the agent wants to change a test, the answer is no. Tests are the + contract; the code moves. + +Run the suite after each group: + +```bash +PYTHONPATH=starter python -m unittest discover -s tests -v +``` + +Expect the failing count to drop group by group: 7 → 5 → 4 → 3 → 2 → 0. + +## Exercise + +Do the last group yourself. When only the end-to-end tests remain, write the +`run_scenario`/`main` fix by hand (they are small), then run the full suite. +Reading the agent's diff is learning; writing the last ten lines is +internalizing. + +## Checkpoint + +```bash +python verify.py starter --expect-failure # must now FAIL the expectation: starter passes +python verify.py starter # must pass all nine tests +``` + +The first command failing is good news — it means the starter is no longer +correctly unfinished. Then answer: which change was smallest? Which would you +have over-built? + +## Expected evidence + +Test transcript from 7 failures to 0, plus the diffs. You are allowed to open +`solution/` only after your starter passes — compare approaches, then note one +thing the reviewed solution does that yours does not. diff --git a/courses/hands-on-python-with-claude-code/lessons/L03_cn.md b/courses/hands-on-python-with-claude-code/lessons/L03_cn.md new file mode 100644 index 0000000..fcc9dd9 --- /dev/null +++ b/courses/hands-on-python-with-claude-code/lessons/L03_cn.md @@ -0,0 +1,75 @@ +--- +id: course-claude-code-l03 +type: course +title: "第 3 课:按测试驱动一次有边界的变更" +summary: 让 Agent 在契约约束下修改 starter——最小变更、不新增依赖、一次一组测试从红到绿。 +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "这个检查点考什么" + body: "门控是 `python verify.py starter`——九个测试在你的 starter 上全绿。一次只推一组失败:先 `load_records`(csv/json/ValueError),再 `build_report`,再 `write_report`,最后 `main`。" + - title: "套件真正断言的坑" + body: "布尔值不是数字——`isinstance(True, int)` 为 True,要显式排除。分组总计保留两位小数。`total = valid + invalid` 在全部行都非法时也必须成立。" + - title: "原子写就是原子写" + body: "`write_report` 必须用同级临时文件加 `os.replace`,成功时不能留下 `.tmp`——套件检查的是文件系统,不是你的意图。还红就直接跑 unittest 看失败名。" +--- + +# 第 3 课:按测试驱动一次有边界的变更 + +## 目标 + +starter 通过一系列有边界的变更通过全部九个测试,并且在不先看 +solution 的前提下,你能解释 Agent 产出的每一个 diff。 + +## 为什么要有这一课 + +这是最容易被跳过、也最练真功夫的一课。看 Agent 用一次巨型重写让 +七个失败测试全部通过,你什么都学不到;监督七个小 diff、每个都锚定 +一条契约线,你学到的是如何让 AI 写的代码仍然属于你。 + +## 课程内容 + +对 Agent 说:"**按 TASK.md 修改 starter/report_tool.py。一次只处理一组 +失败测试:先 JSON 加载,再校验隔离,再舍入,再原子写入,最后端到端。 +每完成一组就运行测试套件,给我看 diff,再继续。**" + +守住契约的边界: + +- 只有 `starter/report_tool.py` 可以变。diff 若触碰 `tests/`、 + `solution/` 或 `scenario/`,停下来问为什么。 +- 不引入标准库之外的新依赖——也不引入当前变更用不到的 import。 +- 每次变更只应指向一条契约线。拒绝顺手重构("既然改到这里我把…… + 也重命名了")。 +- Agent 想改测试?答案是不。测试就是契约;动的是代码。 + +每组之后运行套件: + +```bash +PYTHONPATH=starter python -m unittest discover -s tests -v +``` + +失败数应逐组下降:7 → 5 → 4 → 3 → 2 → 0。 + +## 练习 + +最后一组自己做。当只剩端到端测试时,亲手写下 `run_scenario`/`main` +的修复(它们很小),然后跑完整套件。看懂 Agent 的 diff 是学习;亲手 +写最后十行是内化。 + +## 检查点 + +```bash +python verify.py starter --expect-failure # 此时应当报"预期失败但没有失败":starter 已经通过 +python verify.py starter # 必须通过全部九个测试 +``` + +第一条命令失败是好消息——说明 starter 不再处于"正确的未完成"状态。 +然后回答:哪个变更最小?哪个你自己会做过头? + +## 预期证据 + +从 7 个失败降到 0 的测试输出记录,加上各步 diff。只有当你的 starter +通过之后才允许打开 `solution/`——对比两种实现,记下已审核方案做到了 +而你没有做到的一件事。 diff --git a/courses/hands-on-python-with-claude-code/lessons/L04.md b/courses/hands-on-python-with-claude-code/lessons/L04.md new file mode 100644 index 0000000..3fd6d6b --- /dev/null +++ b/courses/hands-on-python-with-claude-code/lessons/L04.md @@ -0,0 +1,89 @@ +--- +id: course-claude-code-l04 +type: course +title: "Lesson 4: Verify like an engineer, review like a skeptic" +summary: "Run the full verification loop, review the agent's diff for scope and side effects, and record what you did — and did not — prove." +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "What this checkpoint tests" + body: "Both suites: your starter passes, and you demonstrate you can review evidence rather than only produce green." + - title: "Exercise all three skins" + body: "Run `run_scenario` over every scenario folder — csv and json inputs plus the error paths. Then review your diff against the contract: any function doing more than its contract line is scope creep." + - title: "The skeptic part" + body: "If the suite is green but you cannot explain a diff, that is the real failure. Re-read the change before claiming — the claim code says you verified, not that the tests did." +--- + +# Lesson 4: Verify like an engineer, review like a skeptic + +## Objective + +You complete the full evidence loop — verify both directions, exercise all +three scenario skins end to end, review the final diff for scope creep and +side effects — and write a three-line verification record that says what is +proven and what is not. + +## Why this lesson exists + +"Tests pass" is the beginning of verification, not the end. The suite pins +nine behaviors. It does not tell you whether the agent edited files it should +not have, whether the tool leaves junk behind on real data, or whether the +report numbers are right for *your* domain. That judgment is the part that +stays human. + +## The lesson + +Run the whole loop, in order: + +```bash +python verify.py starter # your finished implementation: 9/9 +python verify.py solution # reviewed reference: 9/9 +git diff --stat # (or your tool's equivalent) what actually changed? +``` + +Then run the tool like a user, once per skin (each writes `report.json` +inside its scenario folder — check `.gitignore` handles it, then inspect the +output): + +```bash +python starter/report_tool.py scenario/excel-report +python starter/report_tool.py scenario/data-monitor +python starter/report_tool.py scenario/api-tool +``` + +Check three things in each `report.json`: `total = valid + invalid`, every +`errors[i].reason` names a real row you can find in the data file, and one +group total you recompute by hand. + +Now the skeptic's diff review. For every change the agent made beyond the +contract lines, ask: which test forced this? If the answer is "none", it is +scope creep — revert it and rerun the suite. Also check for side effects the +tests cannot see: files created outside `scenario/`, network calls (there +should be zero), and behavior on an empty data file (try it — what happens?). + +## Exercise + +Write the verification record. Three lines, honest: + +``` +Verified: <commands run, date, result> +Not verified: <what the suite does not cover — e.g. encoding of real exports, huge files> +Known limits: <what would make this break — e.g. schema change in the source system> +``` + +Compare it with `REVIEW.md` at the course root — same discipline, maintainer +version. + +## Checkpoint + +You pass when: all three skins run clean, your hand-check of one group total +matches, the diff review found (and reverted) at least one piece of +unforced scope — or you can defend why every change was forced — and your +three-line record exists. + +## Expected evidence + +The verification record, the `git diff --stat` output, and one hand-checked +group total. This is the artifact you would attach to a real change. diff --git a/courses/hands-on-python-with-claude-code/lessons/L04_cn.md b/courses/hands-on-python-with-claude-code/lessons/L04_cn.md new file mode 100644 index 0000000..ec9f321 --- /dev/null +++ b/courses/hands-on-python-with-claude-code/lessons/L04_cn.md @@ -0,0 +1,82 @@ +--- +id: course-claude-code-l04 +type: course +title: "第 4 课:像工程师一样验证,像怀疑者一样审查" +summary: 跑完整验证闭环,审查 Agent 的 diff 是否越界、有无副作用,并如实记录证明了什么、没证明什么。 +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "这个检查点考什么" + body: "双套件门控:你的 starter 全绿,同时你要证明自己会审查证据而不只是产出绿灯。" + - title: "三个皮肤都要端到端跑" + body: "对每个 scenario 文件夹运行 `run_scenario`——csv、json 输入和错误路径都过一遍。然后拿契约审你的 diff:任何函数做得比它的契约行多,就是范围蔓延。" + - title: "怀疑者的部分" + body: "套件绿了但你解释不了某个 diff,那才是真正的失败。认领前先重读改动——认领码声明的是你验证过,不只是测试跑过。" +--- + +# 第 4 课:像工程师一样验证,像怀疑者一样审查 + +## 目标 + +完成完整的证据闭环——双向验证、三个皮肤各跑一次端到端、审查最终 +diff 是否越界与有副作用——并写出三行验证记录,说明证明了什么、没 +证明什么。 + +## 为什么要有这一课 + +"测试通过"是验证的起点而不是终点。测试套件锚定了九个行为,但它不会 +告诉你:Agent 是否改了不该改的文件、工具在真实数据上会不会留下 +垃圾、报表数字在**你的**领域里对不对。这部分判断永远属于人。 + +## 课程内容 + +按顺序跑完整个闭环: + +```bash +python verify.py starter # 你完成的实现:9/9 +python verify.py solution # 已审核参考实现:9/9 +git diff --stat # (或等价命令)到底改了什么? +``` + +然后像真实用户一样,每个皮肤各跑一次(每次会在对应 scenario 文件夹 +里写 `report.json`——确认 `.gitignore` 已处理,然后检查输出): + +```bash +python starter/report_tool.py scenario/excel-report +python starter/report_tool.py scenario/data-monitor +python starter/report_tool.py scenario/api-tool +``` + +对每个 `report.json` 核对三件事:`total = valid + invalid`;每条 +`errors[i].reason` 都能对应到数据文件里真实存在的行;抽一个分组总计 +手动重算。 + +接下来是怀疑者的 diff 审查。对 Agent 超出契约线的每一处改动问:是哪条 +测试逼出来的?答案是"没有",就是范围蔓延——回滚它并重跑套件。再检查 +测试看不见的副作用:`scenario/` 之外有没有新建文件、有没有网络调用 +(应当为零)、空数据文件会发生什么(试试看)。 + +## 练习 + +写下验证记录。三行,诚实: + +``` +已验证:<运行的命令、日期、结果> +未验证:<套件覆盖不到的部分——例如真实导出文件的编码、超大文件> +已知局限:<什么会让它失效——例如源系统改了表结构> +``` + +对照课程根目录的 `REVIEW.md`——同样的纪律,维护者版本。 + +## 检查点 + +以下全部达成即通过:三个皮肤干净运行;手动核算的分组总计一致;diff +审查发现(并回滚)了至少一处无强制理由的改动——或者你能论证每处改动 +都有测试逼着;三行记录已经写下。 + +## 预期证据 + +验证记录、`git diff --stat` 输出、一个手动核算的分组总计。这就是真实 +变更中你会附上的交付物。 diff --git a/courses/hands-on-python-with-claude-code/lessons/L05.md b/courses/hands-on-python-with-claude-code/lessons/L05.md new file mode 100644 index 0000000..4fb813d --- /dev/null +++ b/courses/hands-on-python-with-claude-code/lessons/L05.md @@ -0,0 +1,90 @@ +--- +id: course-claude-code-l05 +type: course +title: "Lesson 5: Apply this to your own project" +summary: "Port the workflow — not the code — to one script you own: contract, isolation, rounding, atomic writes, and an honest verification record." +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "What this checkpoint tests" + body: "That you port the workflow — not the code — to one script you own: a task contract, an isolated run, and an honest record." + - title: "Port the smallest useful slice" + body: "Atomic JSON write, one aggregate with rounding, and `main` exit codes are the portable core. Do not port the grouping machinery if your script does not need it." + - title: "The honest record" + body: "Write down what your new tests do NOT prove — that unverified list is the point of the course, not a footnote." +--- + +# Lesson 5: Apply this to your own project + +## Objective + +You select one real script you own, write its task contract (from Lesson 2), +and apply the smallest useful slice of this course to it — with tests, a +verify command, and a written record of what the tests do not prove. + +## Why this lesson exists + +Course projects are forgiving: the data is small, the tests exist, the +contract is written for you. Your project is where the habits either transfer +or evaporate. The transfer works when you shrink the scope: one script, one +contract, one failing test made to pass — not a rewrite. + +## The lesson + +1. **Pick the script.** One you run repeatedly and have been mildly afraid + of. Not the biggest one — the most annoying one. +2. **Write its contract** (Lesson 2 exercise): three behavior statements, one + boundary clause, one acceptance command. +3. **Add one failing test** for the scariest contract line. Copy the shape + from `tests/test_report_tool.py` — `tempfile` for outputs, inline data, + one assertion per behavior. +4. **Have the agent make it pass** under Lesson 3's rules: smallest change, + show the diff, no drive-by refactors. +5. **Write the verification record** (Lesson 4): verified / not verified / + known limits. + +Which pattern to port depends on your script's actual failure mode: + +| If your script… | Port this course pattern | +| --- | --- | +| crashes on one bad row | error isolation with `{"index", "reason"}` collection | +| prints `0.30000000000000004` | two-decimal rounding at aggregation boundaries | +| leaves half-written output when interrupted | atomic write via temp file + `os.replace` | +| mixes two input formats | one loader, explicit unsupported-type rejection | +| has no way to say "done" | the verify command: failing starter, passing target | + +## What verification does not prove + +Be equally clear about the limits, because overselling tests is how projects +get hurt: + +- Passing tests prove the pinned behaviors on the tested inputs — not + correctness on tomorrow's data, not absence of bugs elsewhere. +- A green suite says nothing about performance on production-sized files, or + about the upstream system changing its schema next month. +- The repo's tests are not your outcome. Your outcome is a user-visible + behavior (a correct report delivered, a crash eliminated), with tests as + evidence. For the full framing, read the companion guide + "Use Python Well with AI Coding" and the product-quality guide + (flypython.com or the repository `guides/` directory). + +## Exercise + +Ship the slice. Run your new acceptance command from a clean checkout of your +project (or a colleague's machine) and confirm it reproduces the same result. +If it does not, your command depends on your machine — fix the command, not +the excuse. + +## Checkpoint + +Your project now has: a written contract, one test that used to fail and now +passes, a single command that demonstrates both, and a three-line verification +record. That is the whole course, compressed into your codebase. + +## Expected evidence + +The contract, the test transcript, and the verification record. If you found +the workflow useful, the course's landing page lists where to go next — +and the `course-feedback` issue form is where teaching drift gets fixed. diff --git a/courses/hands-on-python-with-claude-code/lessons/L05_cn.md b/courses/hands-on-python-with-claude-code/lessons/L05_cn.md new file mode 100644 index 0000000..d8208a7 --- /dev/null +++ b/courses/hands-on-python-with-claude-code/lessons/L05_cn.md @@ -0,0 +1,84 @@ +--- +id: course-claude-code-l05 +type: course +title: "第 5 课:把这套方法带回你自己的项目" +summary: 迁移工作流——而不是代码——到你拥有的一个脚本上:契约、错误隔离、舍入、原子写入,以及一份诚实的验证记录。 +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "这个检查点考什么" + body: "考你能不能把这套工作流——而不是代码——搬到你自己一个脚本上:任务契约、隔离运行、诚实记录。" + - title: "只搬最小可用切片" + body: "可移植的内核是:原子 JSON 写、一个带舍入的聚合、`main` 的退出码。你的脚本用不到分组机制就别搬。" + - title: "诚实的记录" + body: "写下你的新测试没有证明什么——那张未验证清单就是本课的重点,不是附注。" +--- + +# 第 5 课:把这套方法带回你自己的项目 + +## 目标 + +选一个你真实拥有的脚本,写下它的任务契约(第 2 课的练习),并把本 +课程中最小可用的一片迁移过去——附测试、一条验证命令,以及一份说明 +测试没证明什么的记录。 + +## 为什么要有这一课 + +课程项目是宽容的:数据小、测试现成、契约替你写好。回到你的项目, +习惯要么迁移过去,要么蒸发掉。迁移成功的关键是收缩范围:一个脚本、 +一份契约、一个从红变绿的测试——不是一次重写。 + +## 课程内容 + +1. **选脚本。** 选一个你反复运行、而且一直有点心里没底的。不是最大 + 的那个——是最烦人的那个。 +2. **写它的契约**(第 2 课练习):三条行为陈述、一条边界条款、一条 + 验收命令。 +3. **为最吓人的那条契约线加一个失败测试。** 照抄 + `tests/test_report_tool.py` 的形状——输出用 `tempfile`、数据内联、 + 一个行为一个断言。 +4. **让 Agent 按第 3 课的规则让它通过:** 最小变更、展示 diff、拒绝 + 顺手重构。 +5. **写验证记录**(第 4 课):已验证 / 未验证 / 已知局限。 + +迁移哪种模式,取决于你脚本真实的故障方式: + +| 如果你的脚本…… | 迁移本课程的这个模式 | +| --- | --- | +| 一行坏数据就崩溃 | 以 `{"index", "reason"}` 收集的错误隔离 | +| 打印出 `0.30000000000000004` | 在聚合边界做两位小数舍入 | +| 中断时留下写了一半的输出 | 临时文件 + `os.replace` 的原子写入 | +| 混用两种输入格式 | 一个加载器 + 明确拒绝不支持的类型 | +| 没有办法说"完成" | 验证命令:失败的 starter + 可达的目标 | + +## 验证不能证明什么 + +同样要看清局限,因为夸大测试的作用只会让项目受伤: + +- 测试通过证明的是"被锚定的行为在测试输入上成立"——不等于明天的 + 数据也对,更不等于其他地方没有 bug。 +- 全绿的套件不能说明生产规模文件下的性能,也不能阻止上游系统下个月 + 改表结构。 +- 仓库的测试不等于你的结果。你的结果是一个用户可见的行为(一份正确 + 的报告送达、一次崩溃被消除),测试只是证据。完整框架请阅读配套 + 指南"Use Python Well with AI Coding"与产品质量指南(flypython.com + 或仓库 `guides/` 目录)。 + +## 练习 + +交付这一小片。在项目的干净检出(或同事的机器)上运行你新的验收 +命令,确认能复现相同结果。如果不行,说明命令依赖你的机器——修命令, +不要找借口。 + +## 检查点 + +你的项目现在拥有:一份书面契约、一个曾经失败现在通过的测试、一条 +能同时演示两者的命令、一份三行验证记录。这就是整门课程压缩进你 +代码库的样子。 + +## 预期证据 + +契约、测试输出记录、验证记录。如果这套工作流对你有用,课程落地页 +列出了下一步去向——教学偏差请通过 `course-feedback` issue 表单反馈。 diff --git a/courses/hands-on-python-with-claude-code/scenario/api-tool/scenario.json b/courses/hands-on-python-with-claude-code/scenario/api-tool/scenario.json new file mode 100644 index 0000000..e17e7c2 --- /dev/null +++ b/courses/hands-on-python-with-claude-code/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-python-with-claude-code/scenario/api-tool/tickets.json b/courses/hands-on-python-with-claude-code/scenario/api-tool/tickets.json new file mode 100644 index 0000000..03e35b7 --- /dev/null +++ b/courses/hands-on-python-with-claude-code/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-python-with-claude-code/scenario/data-monitor/metrics.csv b/courses/hands-on-python-with-claude-code/scenario/data-monitor/metrics.csv new file mode 100644 index 0000000..15a5a15 --- /dev/null +++ b/courses/hands-on-python-with-claude-code/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-python-with-claude-code/scenario/data-monitor/scenario.json b/courses/hands-on-python-with-claude-code/scenario/data-monitor/scenario.json new file mode 100644 index 0000000..b103477 --- /dev/null +++ b/courses/hands-on-python-with-claude-code/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-python-with-claude-code/scenario/excel-report/orders.csv b/courses/hands-on-python-with-claude-code/scenario/excel-report/orders.csv new file mode 100644 index 0000000..a41fca1 --- /dev/null +++ b/courses/hands-on-python-with-claude-code/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-python-with-claude-code/scenario/excel-report/scenario.json b/courses/hands-on-python-with-claude-code/scenario/excel-report/scenario.json new file mode 100644 index 0000000..66b2bd9 --- /dev/null +++ b/courses/hands-on-python-with-claude-code/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-python-with-claude-code/solution/report_tool.py b/courses/hands-on-python-with-claude-code/solution/report_tool.py new file mode 100644 index 0000000..65ba0bf --- /dev/null +++ b/courses/hands-on-python-with-claude-code/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 <scenario-dir>", 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-python-with-claude-code/starter/report_tool.py b/courses/hands-on-python-with-claude-code/starter/report_tool.py new file mode 100644 index 0000000..019edb8 --- /dev/null +++ b/courses/hands-on-python-with-claude-code/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 <scenario-dir>", 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-python-with-claude-code/tests/test_report_tool.py b/courses/hands-on-python-with-claude-code/tests/test_report_tool.py new file mode 100644 index 0000000..3fbc19f --- /dev/null +++ b/courses/hands-on-python-with-claude-code/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-python-with-claude-code/verify.py b/courses/hands-on-python-with-claude-code/verify.py new file mode 100644 index 0000000..d692cdd --- /dev/null +++ b/courses/hands-on-python-with-claude-code/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-claude-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 = 'a54f3ce504534d68' + +CHECKPOINTS = [ + {"id": "l01", "gate": "attest", "title": '复现故障 / Reproduce the failure'}, + {"id": "l02", "gate": "attest", "title": '任务契约 / Task contract'}, + {"id": "l03", "gate": "starter-suite", "title": '边界修改,按测试推进 / Bounded change, test by test'}, + {"id": "l04", "gate": "both-suites", "title": '验证与审查 / Verify 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-cursor/COURSE.md b/courses/hands-on-with-cursor/COURSE.md new file mode 100644 index 0000000..8864dec --- /dev/null +++ b/courses/hands-on-with-cursor/COURSE.md @@ -0,0 +1,113 @@ +--- +id: course-cursor +type: course +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. +lang: en-US +content_version: 2 +status: reviewed +reviewed_on: 2026-09-13 +badge: + id: course-cursor + name_en: Reproduce with Cursor in the loop + name_zh: Cursor 协同复现 + requires: All five checkpoints claimed (L01–L05) +course_id: course-cursor +--- + +# Hands-on with Cursor + +> TL;DR: set up Cursor, install the FlyPython Skill, and let the agent fetch +> this course — lesson 1 walks you through all three steps and you download +> nothing. Open an Agent chat and say **"start lesson 1"**. You finish with a working report tool, +> a reproducible pass/fail command, and the Cursor workflow: Agent/Ask/Plan +> modes, `.cursor/rules` project rules, and diff review. The exercise core is +> the same one the Claude Code and Codex 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 the Cursor agent teaching this course, follow these rules: + +- **Audience:** a project owner who has working-with-AI experience but is + stuck on reliability — the script runs, then breaks on real data. Not a + Python beginner tutorial; not a prompt-engineering course. +- **Prerequisites:** Python 3.11+ on PATH and the Cursor editor installed + and signed in. No packages to install — the course is standard library + only. Agent usage limits on the learner's plan are unverified at authoring + time — see `REVIEW.md`. +- **Tool:** authored against Cursor as documented at cursor.com/docs + (Agent/Ask/Plan modes, `.cursor/rules/*.mdc` project rules with + `description`/`globs`/`alwaysApply` front matter, diff review). A live + teaching run-through is pending — `REVIEW.md` records what has and has + not been exercised. A Cursor major release 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. Read `AGENTS.md` in the repository root + if one exists — Cursor 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 while the gated checkpoints + (l03, l04) are still open. `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 + +Cursor installation, pricing plans, model selection, MCP setup, team/admin +features, or selling anything. For those, the companion repository has +guides and playbooks — link, don't improvise. + +## Badge contract + +- 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 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/<skin>/ 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-cursor/COURSE_cn.md b/courses/hands-on-with-cursor/COURSE_cn.md new file mode 100644 index 0000000..fa09bca --- /dev/null +++ b/courses/hands-on-with-cursor/COURSE_cn.md @@ -0,0 +1,101 @@ +--- +id: course-cursor +type: course +title: "上手 Cursor" +summary: "经过验证的 Python 工作流——任务契约、有界改动、客观 verify.py 证据——在 Cursor 里手把手教:由 Agent 模式跑闭环,你监督 diff 与项目规则。" +lang: zh-CN +content_version: 2 +status: reviewed +reviewed_on: 2026-09-13 +badge: + id: course-cursor + name_en: Reproduce with Cursor in the loop + name_zh: Cursor 协同复现 + requires: 认领全部五个检查点(L01–L05) +course_id: course-cursor +--- + +# 上手 Cursor + +> 一句话:装好 Cursor、装上 FlyPython Skill、让 Agent 取回本课文件(第 1 课 +> 就是这三步,你什么都不用下载),开一个 Agent 对话,对它说**「开始第 1 课」**。完成时你会得到一个能跑的报表工具、一条 +> 可复现的通过/失败命令,以及 Cursor 工作流:Agent/Ask/Plan 模式、 +> `.cursor/rules` 项目规则与 diff 审查。练习核心与 Claude Code、Codex +> 课程完全相同——变的只是你驱动的工具。 + +## 你要做的东西 + +一个小型 Python 报表工具:读进脏乱的真实数据(CSV 或 JSON)、隔离 +无效行而不是直接崩溃、聚合有效行、原子地写出报告。课程自带三个 +「场景皮肤」,让你在自己熟悉的领域里练习: + +| 皮肤 | 你是…… | 数据 | +| --- | --- | --- | +| `scenario/excel-report/` | 把每周订单导出整理成区域汇总 | `orders.csv` | +| `scenario/data-monitor/` | 检查自家服务器的延迟数据 | `metrics.csv` | +| `scenario/api-tool/` | 汇总从内部 API 拉取的工单数据 | `tickets.json` | + +三个皮肤跑的是同一份代码、同一套测试。真正的产品是这些技能——契约、 +错误隔离、舍入、原子写、验证——皮肤只是让它们变得具体。 + +## 教学契约(先读,Agent) + +如果你是教授本课程的 Cursor Agent,请遵守以下规则: + +- **受众:** 有 AI 协作经验、但困在可靠性上的项目所有者——脚本能跑, + 一遇真实数据就坏。这不是 Python 入门教程,也不是提示词工程课。 +- **前置条件:** PATH 上有 Python 3.11+,装有 Cursor 编辑器并已登录。 + 无需安装任何包——课程只用标准库。学习者套餐的 Agent 额度能否撑完 + 整门课在编写时未经证实——见 `REVIEW.md`。 +- **工具:** 按 cursor.com/docs 上文档描述的 Cursor 编写(Agent/Ask/ + Plan 模式、带 `description`/`globs`/`alwaysApply` front matter 的 + `.cursor/rules/*.mdc` 项目规则、diff 审查)。真实授课实跑尚待 + 完成——`REVIEW.md` 记录已验证与未验证的部分。Cursor 主版本发布 + 会触发课程重审。 +- **课序:** L01 → L02 → L03 → L04 → L05,一次课一个会话。 + 绝不跳过课尾的检查点。 +- **教学风格:** 从本文件夹里的文件出发,不要凭通用知识发挥。引用 + 你正在满足的那条契约原文。做让测试从红变绿的最小改动。绝不加 + 依赖、绝不改 `solution/`、动当前课程未点名的任何文件前先询问。 + 仓库根若有 `AGENTS.md`,读它——Cursor 同样会读。 +- **何时停下:** 一节课的检查点命令跑通、且学习者能用自己的话说清 + 哪里坏了、为什么,这节课才算完。学习者说不出就从失败测试重新教, + 不要从 solution 教。 +- **如何使用 `verify.py`:** 学员命令就是 `python verify.py`——它跑套件、 + 打印逐检查点状态与已获认领码,在门控检查点(l03、l04)未全通过时以 + 非零退出。`starter --expect-failure` 与 `solution` 留作维护者检查。 + 「完成」指学习者做完有界改动后,默认命令把所有门显示为 `[passed]`。- **诚实规则:** 说清你没验证什么。不要声称代码达到生产可用。不要 + 承诺学习或职业结果。 + +## 本课程不覆盖什么 + +Cursor 的安装、套餐定价、模型选择、MCP 配置、团队/管理功能,以及 +任何推销内容。这些由配套仓库的指南与手册承载——给链接,不即兴发挥。 + +## 徽章契约 + +- 徽章:**Cursor 协同复现徽章**(badge id `course-cursor`)——认领全部五个检查点获得。 +- 挑战: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/<skin>/ 每个皮肤的数据文件与 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-cursor/REVIEW.md b/courses/hands-on-with-cursor/REVIEW.md new file mode 100644 index 0000000..256a26c --- /dev/null +++ b/courses/hands-on-with-cursor/REVIEW.md @@ -0,0 +1,44 @@ +# Maintainer run-through record + +## 2026-09-13 — authored from official documentation (mechanical verification only) + +- Course authored against Cursor documentation (cursor.com/docs rules and + agent-mode references) and the shared `report-tool` core. `TASK.md`, + `tests/`, `scenario/`, `starter/`, `solution/` are byte-identical to the + Claude Code / Codex course cores — enforced by the `core-group` check in + `tools/verify_courses.py`. +- Environment for mechanical verification: macOS (arm64), Python 3.13. +- Commands and results: + - `python verify.py starter --expect-failure` — exit 0; expected failures + reproduced. + - `python verify.py solution` — exit 0; 9/9 tests pass. + - `python tools/verify_courses.py` — contract satisfied, core identical. +- **Not done:** any run inside Cursor. No Cursor install or account was + used; every Cursor behavior described in the lessons is sourced from + official documentation, not observed. + +## 2026-09-13 — 0.0.8 rework: lesson 1 is now tool → Skill → agent fetch (FP-822) + +- L01 (EN+ZH) rewritten around the fixed order: get the tool running → + install the FlyPython Skill (network access included) → let the agent + fetch this course's files via the files endpoint. All "download this + folder" wording removed from `COURSE.md` and lessons. +- The learner's check command across lessons is now the bare + `python verify.py` (FP-820): per-checkpoint status + claim codes, + non-zero exit while gated checkpoints are open. `solution` is no longer + presented as a completion standard. +- Tool-specific Skill install and network steps are authored from the + same official documentation as the course; **not yet exercised inside + the tool** — the pending live run-through below still stands. + +## Pending before this course can be called taught-in-tool + +- One full agent-taught run-through inside Cursor ("start lesson 1" through + the Lesson 5 checkpoint) on a current Cursor build, recording observed + deviations from `COURSE.md` here. +- Whether a free Cursor plan's Agent quota lasts the whole course is + unverified. + +## Deviation log + +(none yet — no live run has happened) diff --git a/courses/hands-on-with-cursor/TASK.md b/courses/hands-on-with-cursor/TASK.md new file mode 100644 index 0000000..e923183 --- /dev/null +++ b/courses/hands-on-with-cursor/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": <row position>, "reason": <short string>}`. 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-cursor/TASK_cn.md b/courses/hands-on-with-cursor/TASK_cn.md new file mode 100644 index 0000000..7af2609 --- /dev/null +++ b/courses/hands-on-with-cursor/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-cursor/core-group b/courses/hands-on-with-cursor/core-group new file mode 100644 index 0000000..b56b0eb --- /dev/null +++ b/courses/hands-on-with-cursor/core-group @@ -0,0 +1 @@ +report-tool diff --git a/courses/hands-on-with-cursor/lessons/L01.md b/courses/hands-on-with-cursor/lessons/L01.md new file mode 100644 index 0000000..7ee25e5 --- /dev/null +++ b/courses/hands-on-with-cursor/lessons/L01.md @@ -0,0 +1,152 @@ +--- +id: course-cursor-l01 +type: course +title: "Lesson 1: Set up Cursor, install the FlyPython Skill, fetch the course" +summary: "Get Cursor running, install the FlyPython Skill with network access approved, and let the agent fetch this course's files — you download nothing. Then make the starter's failure objective." +lang: en-US +content_version: 2 +status: reviewed +reviewed_on: 2026-09-13 +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 Cursor, Agent mode is where the work happens — the command is still the evidence." + - title: "Ask vs Agent" + body: "Ask mode answers questions without touching files; Agent mode reads, edits, and runs commands. This lesson only needs the Agent to read — tell it not to change anything." + - 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." +--- + +# Lesson 1: Set up Cursor, install the FlyPython Skill, fetch the course + +## Objective + +By the end of this lesson Cursor is running, the FlyPython Skill is +installed in it (with network access approved), 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 Cursor running + +Install Cursor, then `File → Open Folder` +on an empty working folder. + +## 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 tools that keep skills between sessions. + +Save the Skill as a project rule: + +```bash +mkdir -p .cursor/rules +curl -s https://flypython.com/skills/flypython/SKILL.md -o .cursor/rules/flypython.mdc +``` + +Open a chat in **Agent** mode — that mode reads files and runs commands. +Agent mode fetches URLs with approval; approve when it asks. If your +setup blocks the network, run the curl yourself and paste the file. + +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 session/chat/thread 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-cursor`. + +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-cursor/`. + +**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. In Cursor, Agent mode runs that command right next to the code it +verifies. + +## 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-cursor/` 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."** + +While it reads, run the learner's command — 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? + +This checkpoint's claim code is already in the default command's output — +submit it only once you can answer the questions. + +## 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-cursor/lessons/L01_cn.md b/courses/hands-on-with-cursor/lessons/L01_cn.md new file mode 100644 index 0000000..d8c7c97 --- /dev/null +++ b/courses/hands-on-with-cursor/lessons/L01_cn.md @@ -0,0 +1,138 @@ +--- +id: course-cursor-l01 +type: course +title: "第 1 课:装好 Cursor、装上 FlyPython Skill、让 Agent 取回课程" +summary: "先把 Cursor 跑起来,装好 FlyPython Skill 并打开联网权限,让 Agent 取回本课文件——你不下载任何东西,然后让 starter 的失败变得客观。" +lang: zh-CN +content_version: 2 +status: reviewed +reviewed_on: 2026-09-13 +hints: + - title: "本检查点考察什么" + body: "先复现失败再动手修:`python verify.py` 必须把 l03 和 l04 显示为 `[open]`(`starter --expect-failure` 会打印点名测试)。在 Cursor 里,Agent 模式是干活的地方——但命令仍然是证据。" + - title: "Ask 与 Agent 的区别" + body: "Ask 模式只回答问题、不碰文件;Agent 模式会读文件、改代码、跑命令。本课只需要 Agent 读——明确告诉它不要改任何文件。" + - title: "完成的标志" + body: "你能说出套件证明了哪九种行为、为什么 starter 伪造不出来。`python verify.py` 随即显示本检查点的认领码。" +--- + +# 第 1 课:装好 Cursor、装上 FlyPython Skill、让 Agent 取回课程 + +## 目标 + +本课结束时,Cursor 已经跑起来、FlyPython Skill 已装好(联网权限已 +打开),Agent 已把本课文件取回你的工作目录——你没有手动下载任何 +东西。文件夹就位后,你运行课程的客观检查命令,并解释「starter +失败」在测试名里的含义。本课不改任何代码。 + +## 第 1 步——把 Cursor 跑起来 + +安装 Cursor,然后 `File → Open Folder` +打开一个空工作目录。 + +## 第 2 步——(可选)预装 FlyPython Skill + +这一步可选:第 3 步的开课句子会直接从 URL 读取 Skill,不装也能上课——预装只是省掉一次权限往返。 + +把 Skill 存为项目规则: + +```bash +mkdir -p .cursor/rules +curl -s https://flypython.com/skills/flypython/SKILL.md -o .cursor/rules/flypython.mdc +``` + +开一个对话并确认处于 **Agent** 模式——这个模式才能读文件、跑命令。 +Agent 模式抓取 URL 需要批准——被询问时批准。如果你的环境禁网, +就自己跑 curl,把文件粘贴过去。 + +Skill 只是一个文件,在每个工具里都一样:它告诉 Agent 如何为你 +授权、取课程文件、验证、提交认领码。 + +## 第 3 步——让 Agent 取回本课文件(你不下载) + +在工作目录里新开一个会话/对话/线程,粘贴这一句话: + +> Read https://flypython.com/skills/flypython/SKILL.md and start the FlyPython course `hands-on-with-cursor`. + +Agent 会给你一个授权链接和一组短码。打开链接、登录、核对码与 +Agent 显示的一致后点「允许」,然后对 Agent 说「好了」。它取一次 +token,把本课文件写到 `courses/hands-on-with-cursor/`。 + +**前提**:这些课程需要一个能执行命令**并且**能联网的编码 Agent。 +只能聊天的网页 AI 做不了。 + +## 为什么有这一课 + +AI 写的 Python 大多是同一种死法:演示输入能跑,真实输入一到,脚本 +在第 3 行崩掉——或者更糟,悄悄算出一个错的数。修复不从更好的 +提示词开始,而从把「坏了」变成一条任何人都能跑、且得到相同答案的 +命令开始。在 Cursor 里,Agent 模式就在它要验证的代码旁边跑这条 +命令。 + +## 热身(2 分钟) + +在编辑器里打开 `starter/report_tool.py`,从头读到尾。它看起来很 +合理:带 docstring 的函数、类型标注、CLI。这正是 AI 生成代码的 +样子——表面干净、行为缺失。现在打开 `TASK.md`,放在手边。 + +## 本课内容 + +本课文件现在已经位于 `courses/hands-on-with-cursor/`——你这边没有发生任何下载。 +让 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 崩溃、被隔离,还是悄悄 + 通过? + +本检查点的认领码已经在默认命令的输出里——能回答这些问题之后再 +提交。 + +## 预期证据 + +默认命令的完整输出记录,加你的三个答案。保留好——第 4 课还会 +用到。 diff --git a/courses/hands-on-with-cursor/lessons/L02.md b/courses/hands-on-with-cursor/lessons/L02.md new file mode 100644 index 0000000..4622595 --- /dev/null +++ b/courses/hands-on-with-cursor/lessons/L02.md @@ -0,0 +1,87 @@ +--- +id: course-cursor-l02 +type: course +title: "Lesson 2: Write the task contract, let .cursor/rules carry the rules" +summary: 'Turn "make it better" into a bounded, testable contract — then make it durable so every Cursor session starts with the same rules.' +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-13 +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: "Project rules are the instruction chain" + body: "Cursor reads `.cursor/rules/*.mdc` before doing work. Front matter decides when a rule applies: `description` for agent-chosen rules, `globs` for file-matched rules, `alwaysApply: true` for always-on rules. AGENTS.md is read too." + - 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 .cursor/rules 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 rules +a Cursor session will pick up automatically. + +## 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 Cursor there is a second failure mode: a rule you typed +into one chat does not exist in the next. The contract fixes the task; +project rules 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. Cursor reads project rules from `.cursor/rules/` — +each rule is an `.mdc` file whose front matter controls when it applies: +`alwaysApply: true` attaches it to every request, `globs` attaches it when +matching files are in play, and `description` lets the agent pull it in +when relevant. This course's working rules belong in an always-on rule. +Tell the agent: + +**"Create `.cursor/rules/flypython-course.mdc` with `alwaysApply: true` +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."** + +Then open a *new* chat and ask it to summarize its working rules. If it +quotes yours back, the rule is applying; if not, check the front matter — +a missing or mistyped `alwaysApply` is the usual cause. + +## Exercise + +Write one contract line for a script you actually own, using the same +shape: inputs, outputs, error cases, and "done means `<command>` exits 0". +Then write two rules you would put in that project's `.cursor/rules/` and +decide which front-matter field controls each. + +## Checkpoint + +Run `python verify.py` — this checkpoint's claim code prints when +you can answer: + +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 + `.cursor/rules`? +3. What is the difference between a rule with `globs` and a rule with + `alwaysApply: true`? + +## Expected evidence + +Your drafted contract line, your two rules with their front-matter choice, +and the new chat's summary of its working rules. diff --git a/courses/hands-on-with-cursor/lessons/L02_cn.md b/courses/hands-on-with-cursor/lessons/L02_cn.md new file mode 100644 index 0000000..f32afed --- /dev/null +++ b/courses/hands-on-with-cursor/lessons/L02_cn.md @@ -0,0 +1,82 @@ +--- +id: course-cursor-l02 +type: course +title: "第 2 课:写任务契约,让 .cursor/rules 承载规则" +summary: "把「改好一点」变成有边界、可测试的契约——再让它持久化,让每个 Cursor 会话都从同一套规则开始。" +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-13 +hints: + - title: "本检查点考察什么" + body: "你能把 `TASK.md` 当成可测试的陈述来读,而不是散文。每个以函数名开头的句子都是套件能断言的契约行。" + - title: "项目规则是指令链" + body: "Cursor 干活前先读 `.cursor/rules/*.mdc`。front matter 决定规则何时生效:`description` 让 Agent 按需取用,`globs` 匹配到文件时生效,`alwaysApply: true` 永远生效。AGENTS.md 也会被读取。" + - title: "可测试性检查" + body: "判断一句话能不能测:测试套件不看你的心思能不能断言它?数字、退出码、磁盘上的文件——不看感觉。" +--- + +# 第 2 课:写任务契约,让 .cursor/rules 承载规则 + +## 目标 + +你能把 `TASK.md` 当成一组可测试陈述来读,把每条陈述追查到 +`tests/test_report_tool.py` 里的一个测试,并写出 Cursor 会话会 +自动读取的持久规则。 + +## 为什么有这节课 + +含糊的需求产出含糊的代码。「把坏行处理好一点」是许可 Agent 去猜; +「无效行收进 `errors`、带下标和原因,有效行照常产出报告」是给它 +目标、也给你检查的方法。在 Cursor 里还有第二种失败:你在某个 +对话里敲的规则,下一个对话里根本不存在。契约固定任务本身; +项目规则固定工作规则。 + +## 本课内容 + +打开 `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` | + +再看持久的另一半。Cursor 从 `.cursor/rules/` 读项目规则——每条 +规则是一个 `.mdc` 文件,front matter 控制它何时生效: +`alwaysApply: true` 附加到每次请求,`globs` 在匹配文件出现时 +生效,`description` 让 Agent 在相关时自行取用。本课程的工作规则 +应该放一条常驻规则。告诉 Agent: + +**“创建 `.cursor/rules/flypython-course.mdc`,`alwaysApply: true`, +写入这些工作规则:只许改 starter/report_tool.py;tests/、solution/、 +scenario/ 只读;每轮只修一组失败测试;修完跑 +`python verify.py starter`;只用标准库。”** + +然后开一个*新*对话,让它总结自己的工作规则。它把你的规则原样引 +回来,说明规则生效了;没有就检查 front matter——`alwaysApply` +写错或漏写是最常见的原因。 + +## 练习 + +为你真正拥有的一个脚本写一条契约行,用同样的形状:输入、输出、 +错误情况、以及「完成 = `<命令>` 退出码为 0」。再写你会放进那个 +项目 `.cursor/rules/` 的两条规则,并决定各自用哪个 front matter +字段控制生效时机。 + +## 检查点 + +运行 `python verify.py`——能回答下面三题时,本检查点的 +认领码就会显示: + +1. 用你自己的话说,`test_invalid_records_are_isolated_with_reasons` + 钉住的是 `TASK.md` 哪一行? +2. 跨会话携带规则的是对话提示词还是 `.cursor/rules`? +3. 带 `globs` 的规则和带 `alwaysApply: true` 的规则有什么区别? + +## 预期证据 + +你起草的契约行、两条带 front matter 选择的规则,以及新对话对 +自己工作规则的总结。 diff --git a/courses/hands-on-with-cursor/lessons/L03.md b/courses/hands-on-with-cursor/lessons/L03.md new file mode 100644 index 0000000..2c176da --- /dev/null +++ b/courses/hands-on-with-cursor/lessons/L03.md @@ -0,0 +1,84 @@ +--- +id: course-cursor-l03 +type: course +title: "Lesson 3: The bounded change, reviewed hunk by hunk" +summary: "Drive the starter to green through bounded turns — and use Cursor's diff review to keep every change yours." +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-13 +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: "The diff is the boundary" + body: "Agent mode shows each edit as a diff you accept or reject. Read every hunk — approving blind is how bounded work becomes a rewrite." + - 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 hunk by hunk + +## 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 Agent mode 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. Cursor's +accept/reject diff flow exists for exactly this. + +## The lesson + +Start a fresh Agent chat (a finished task's chat carries stale context) +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` and show me the diff before continuing."** + +Hold it to the contract's boundaries: + +- Only `starter/report_tool.py` changes. If a diff touches `tests/`, + `solution/`, or `scenario/`, reject it and 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…"). +- If the agent wants to change a test, the answer is no. Tests are the + contract; the code moves. + +Review deliberately. When the agent proposes an edit, Cursor shows it as a +diff — read every hunk before accepting. When it proposes a terminal +command, read it: `python verify.py starter` is safe; `pip install` +anything is a stop sign. A diff you cannot explain is a diff you reject. + +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 accepting the next change. + +## 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 reject or narrow, and why? +3. Point at the hunk that implements "isolate invalid rows with reasons" — + where is it? + +## Expected evidence + +A green `python verify.py starter` run, and your hunk-by-hunk explanation +of the change that earned it. diff --git a/courses/hands-on-with-cursor/lessons/L03_cn.md b/courses/hands-on-with-cursor/lessons/L03_cn.md new file mode 100644 index 0000000..74898d9 --- /dev/null +++ b/courses/hands-on-with-cursor/lessons/L03_cn.md @@ -0,0 +1,74 @@ +--- +id: course-cursor-l03 +type: course +title: "第 3 课:有界改动,逐块审 diff" +summary: "通过有界的轮次把 starter 推到全绿——并用 Cursor 的 diff 审查守住每一处改动。" +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-13 +hints: + - title: "本检查点考察什么" + body: "starter 通过你监督的改动让九个测试全绿——`python verify.py` 只在 starter 套件变绿后显示本检查点的码。" + - title: "diff 就是边界" + body: "Agent 模式把每处编辑显示为可接受或拒绝的 diff。逐块读完再放行——闭眼接受正是「有界工作」变成「整文件重写」的方式。" + - title: "完成的标志" + body: "每个 diff 都能对上一条契约行,而且不看 solution/ 你也讲得清每一处改动。" +--- + +# 第 3 课:有界改动,逐块审 diff + +## 目标 + +starter 通过一串有界改动让九个测试全绿,并且不看 solution,你也 +讲得清 Agent 产出的每一个 diff。 + +## 为什么有这节课 + +这节课最容易被跳过——也恰恰是长本事的那节。放任 Agent 模式把 +文件一次重写,什么也学不到,只留下一份你没底气审查的 diff。监督 +一连串小 diff、各自钉在一条契约行上,才能让 AI 写的代码仍然是 +你的。Cursor 的接受/拒绝 diff 流程就是为这个存在的。 + +## 本课内容 + +开一个新的 Agent 对话(已完结任务的对话带着过期上下文),说: + +**“按 TASK.md 改 starter/report_tool.py。一次只修一组失败测试: +先 JSON 加载,再无效行隔离,再舍入,再原子写,最后是端到端测试。 +每修完一组跑 `python verify.py starter`,先给我 diff 再继续。”** + +守住契约边界: + +- 只动 `starter/report_tool.py`。diff 碰到 `tests/`、`solution/`、 + `scenario/` 就拒绝并问为什么。 +- 标准库之外不加 import——连与本改动无关的 import 也不要。 +- 每处改动朝一条契约行推进。顺手重构(「我顺便重命名了……」) + 一律拒绝。 +- Agent 想改测试,答案是不行。测试是契约;动的是代码。 + +认真审查。Agent 提议编辑时,Cursor 把它显示为 diff——逐块读完再 +接受。它提议终端命令时读它:`python verify.py starter` 安全; +`pip install` 任何东西都是停止信号。讲不清的 diff 就拒绝。 + +预期失败数逐组下降: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-cursor/lessons/L04.md b/courses/hands-on-with-cursor/lessons/L04.md new file mode 100644 index 0000000..5e9a247 --- /dev/null +++ b/courses/hands-on-with-cursor/lessons/L04.md @@ -0,0 +1,79 @@ +--- +id: course-cursor-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 the scenario skins, and review the accumulated diff the way you would review a colleague's." +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-13 +hints: + - title: "What this checkpoint tests" + body: "That starter AND solution both pass — `verify.py` prints this code only when both suites are green. The gate is the pair, not a single run." + - title: "Review the delta, not the vibes" + body: "Git's working-tree diff 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: + +```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/<skin>/` folder and run + the tool on it: `python starter/report_tool.py scenario/<skin>`. Read + the produced report against the input file — recompute one group total + by hand. +2. **Review the accumulated diff.** `git diff` (or Cursor's source-control + panel) shows every hunk that landed across the session. 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. +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. + +## 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 only when +both suites pass. 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-cursor/lessons/L04_cn.md b/courses/hands-on-with-cursor/lessons/L04_cn.md new file mode 100644 index 0000000..5cd02b5 --- /dev/null +++ b/courses/hands-on-with-cursor/lessons/L04_cn.md @@ -0,0 +1,75 @@ +--- +id: course-cursor-l04 +type: course +title: "第 4 课:像评审者一样验证,别当观众" +summary: "测试全绿是地板不是天花板——两个套件都跑、三个皮肤都走一遍、按评审同事的标准审这份累积 diff。" +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-13 +hints: + - title: "本检查点考察什么" + body: "starter 和 solution 双双通过——`verify.py` 只在两个套件都绿时打印本检查点的码。门槛是一对结果,不是一次运行。" + - title: "审增量,不凭感觉" + body: "Git 工作区 diff 展示整个会话累积的改动。按评审者的方式读:每个 hunk 对上一条契约行、没有多余挪动、没有测试被放松。" + - title: "完成的标志" + body: "你已写下三行证据——已验证 / 未验证 / 已知局限——并且能在代码评审里为这次改动辩护。" +--- + +# 第 4 课:像评审者一样验证,别当观众 + +## 目标 + +两个套件都通过,你手握一份书面记录:说清绿色运行证明了什么、没 +证明什么——外加一份你敢向同事辩护的 diff。 + +## 为什么有这节课 + +「测试全过」是大多数 AI 辅助工作停下的地方——也是大多数隐性 +bug 开始的地方。套件只证明它断言过的契约行;没人写过测试的行, +它一概不担保。验证是你写的一份报告,不是一种感觉。 + +## 本课内容 + +在集成终端里跑完整的一对命令: + +```bash +python verify.py starter # 你监督出来的实现 +python verify.py solution # 评审过的参考实现 +``` + +双全绿意味着契约*按字面*被满足。现在做套件替不了你的评审: + +1. **走完一个皮肤。** 挑一个 `scenario/<skin>/` 文件夹,用工具跑 + 它:`python starter/report_tool.py scenario/<skin>`。对照输入 + 文件读产出的报告——亲手重算一个分组总计。 +2. **审累积的 diff。** `git diff`(或 Cursor 的源代码管理面板) + 展示整个会话落地的每个 hunk。从头读到尾。每个 hunk 都应能对 + 上你在第 2 课点名的契约行。对不上的就是范围蔓延——回退或让 + 它解释。 +3. **检查测试没钉的边界。** 喂给工具一个空 CSV、一个全是无效行 + 的 CSV、一个不存在的文件。契约没钉这些——你的判断钉。记下 + 它的行为。 + +## 练习 + +为这次改动写三行证据记录: + +- **已验证:** 你跑过的命令和它们证明了什么。 +- **未验证:** 没有测试钉住的输入与行为。 +- **已知局限:** 你看到的、契约从未承诺过的东西。 + +写得诚实——「未验证」是一份事实清单,不是忏悔书。 + +## 检查点 + +运行 `python verify.py`。本检查点的码只在两个套件都过 +时打印。不看材料能回答即算通过: + +1. 你手工重算的分组总计结果如何? +2. 说出一个套件没钉的输入,以及工具对它的实际行为。 +3. 哪个 hunk(如果有)你会请同事来解释? + +## 预期证据 + +两次全绿运行、你的三行证据记录、diff 评审笔记。 diff --git a/courses/hands-on-with-cursor/lessons/L05.md b/courses/hands-on-with-cursor/lessons/L05.md new file mode 100644 index 0000000..3451fdf --- /dev/null +++ b/courses/hands-on-with-cursor/lessons/L05.md @@ -0,0 +1,77 @@ +--- +id: course-cursor-l05 +type: course +title: "Lesson 5: Take the workflow to your own project" +summary: "The tool generalizes: task contract, bounded agent work, diff review — run the same loop on a script you actually own." +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-13 +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 accepted diffs, 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 diff read before accepted, verification written down." + - title: "When you are done" + body: "Your own script 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, project rules, bounded agent work, +diff review, written verification — to a script or project 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 `<command>` exits 0". One short paragraph, every + sentence testable. +2. **Rules that persist.** Open the project in Cursor and put the working + rules in `.cursor/rules/` — the files the agent may touch, the + verification command, the dependency policy. Every new chat inherits + them. +3. **Bounded work.** One Agent chat, one contract line at a time. Read + every diff hunk before accepting; read every command before it runs. +4. **Verify.** Run the verification command, 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 chat. + +## Checkpoint + +This checkpoint is self-attested: claim it when you can answer: + +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 `.cursor/rules` 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-cursor/lessons/L05_cn.md b/courses/hands-on-with-cursor/lessons/L05_cn.md new file mode 100644 index 0000000..9b90321 --- /dev/null +++ b/courses/hands-on-with-cursor/lessons/L05_cn.md @@ -0,0 +1,68 @@ +--- +id: course-cursor-l05 +type: course +title: "第 5 课:把工作流带回自己的项目" +summary: "工具会泛化:任务契约、有界 Agent 工作、diff 评审——在你真正拥有的脚本上跑同一套闭环。" +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-13 +hints: + - title: "本检查点考察什么" + body: "自我报告:你确认把这套闭环用到了自己的项目上。证据是你的契约、你接受的 diff、你的验证记录——不是我们查得了的码。" + - title: "带走形状,不是代码" + body: "能迁移的是闭环:契约先行、一次一处有界改动、每个 diff 审过才接受、验证写下来。" + - title: "完成的标志" + body: "你自己的脚本有了一份契约、一处你讲得清的有界改动、一份已验证/未验证/已知局限记录——或一条写清楚的「闭环不合身」的原因。" +--- + +# 第 5 课:把工作流带回自己的项目 + +## 目标 + +你把整套闭环——契约、项目规则、有界 Agent 工作、diff 评审、 +书面验证——用到自己真正拥有的脚本或项目上,并记录发生了什么。 + +## 为什么有这节课 + +课程文件夹是安全沙箱;你自己的代码库不是。前四课的意义从来不是 +那个报表工具——而是一种可复用的、让你始终掌控产出的 Agent 驱动 +方式。本检查点要证明迁移真的发生了。它是自我报告的:闭环跑没跑 +过只有你知道。 + +## 本课内容 + +挑一个你真正拥有的脚本——一个你拖了很久的 bug,或一个想要但 +还没写的行为。然后走同样的五步: + +1. **契约。** 先写任务契约:输入、输出、错误情况、以及「完成 = + `<命令>` 退出码为 0」。一小段,每句都可测试。 +2. **能留存的规则。** 在 Cursor 里打开项目,把工作规则写进 + `.cursor/rules/`——Agent 可以碰哪些文件、验证命令是什么、 + 依赖政策。每个新对话自动继承。 +3. **有界工作。** 一个 Agent 对话、一次一条契约行。每个 diff 的 + 每块都在接受前读完;每条命令都在运行前读完。 +4. **验证。** 跑验证命令,然后做套件做不了的评审——拿一个真实 + 输入端到端走一遍,手工核对输出。 +5. **记录。** 写三行:已验证 / 未验证 / 已知局限。 + +如果闭环跟你打架——任务小到不需要契约,或模糊到不可测——那也 +是发现。写下形状不合身的地方;这个判断本身就是真本事。 + +## 练习 + +如果你的项目还有不止一处值得钉住的边界,现在就起草下一条契约行 +——开下一个对话时你会用得上。 + +## 检查点 + +本检查点自我报告:能回答下面三题时即可认领: + +1. 你的契约里「完成 =」命令是什么? +2. 你在哪个 diff 或命令上顶回去过? +3. 你的「未验证」清单上有什么是陌生人会以为已覆盖的? + +## 预期证据 + +你的契约段落、你写的 `.cursor/rules` 文件、三行验证记录。它们是 +你自己的——是工作流完成迁移的证明。 diff --git a/courses/hands-on-with-cursor/scenario/api-tool/scenario.json b/courses/hands-on-with-cursor/scenario/api-tool/scenario.json new file mode 100644 index 0000000..e17e7c2 --- /dev/null +++ b/courses/hands-on-with-cursor/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-cursor/scenario/api-tool/tickets.json b/courses/hands-on-with-cursor/scenario/api-tool/tickets.json new file mode 100644 index 0000000..03e35b7 --- /dev/null +++ b/courses/hands-on-with-cursor/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-cursor/scenario/data-monitor/metrics.csv b/courses/hands-on-with-cursor/scenario/data-monitor/metrics.csv new file mode 100644 index 0000000..15a5a15 --- /dev/null +++ b/courses/hands-on-with-cursor/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-cursor/scenario/data-monitor/scenario.json b/courses/hands-on-with-cursor/scenario/data-monitor/scenario.json new file mode 100644 index 0000000..b103477 --- /dev/null +++ b/courses/hands-on-with-cursor/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-cursor/scenario/excel-report/orders.csv b/courses/hands-on-with-cursor/scenario/excel-report/orders.csv new file mode 100644 index 0000000..a41fca1 --- /dev/null +++ b/courses/hands-on-with-cursor/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-cursor/scenario/excel-report/scenario.json b/courses/hands-on-with-cursor/scenario/excel-report/scenario.json new file mode 100644 index 0000000..66b2bd9 --- /dev/null +++ b/courses/hands-on-with-cursor/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-cursor/solution/report_tool.py b/courses/hands-on-with-cursor/solution/report_tool.py new file mode 100644 index 0000000..65ba0bf --- /dev/null +++ b/courses/hands-on-with-cursor/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 <scenario-dir>", 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-cursor/starter/report_tool.py b/courses/hands-on-with-cursor/starter/report_tool.py new file mode 100644 index 0000000..019edb8 --- /dev/null +++ b/courses/hands-on-with-cursor/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 <scenario-dir>", 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-cursor/tests/test_report_tool.py b/courses/hands-on-with-cursor/tests/test_report_tool.py new file mode 100644 index 0000000..3fbc19f --- /dev/null +++ b/courses/hands-on-with-cursor/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-cursor/verify.py b/courses/hands-on-with-cursor/verify.py new file mode 100644 index 0000000..1b07828 --- /dev/null +++ b/courses/hands-on-with-cursor/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-cursor' +# 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 = '6b187020c4507d6e' + +CHECKPOINTS = [ + {"id": "l01", "gate": "attest", "title": '在编辑器里复现故障 / Reproduce the failure in the editor'}, + {"id": "l02", "gate": "attest", "title": '任务契约与 Agent 模式 / Task contract and Agent mode'}, + {"id": "l03", "gate": "starter-suite", "title": '逐块审 diff 的有界改动 / Bounded change, hunk by hunk'}, + {"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-deepseek-harness/COURSE.md b/courses/hands-on-with-deepseek-harness/COURSE.md new file mode 100644 index 0000000..2ef23b1 --- /dev/null +++ b/courses/hands-on-with-deepseek-harness/COURSE.md @@ -0,0 +1,118 @@ +--- +id: course-deepseek-harness +type: course +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. +lang: en-US +content_version: 2 +status: reviewed +reviewed_on: 2026-09-13 +badge: + id: course-deepseek-harness + name_en: Reproduce with DeepSeek Harness in the loop + name_zh: DeepSeek Harness 协同复现 + requires: All five checkpoints claimed (L01–L05) +course_id: course-deepseek-harness +--- + +# Hands-on with DeepSeek Harness + +> TL;DR: set up DeepSeek Harness, install the FlyPython Skill, and let the +> agent fetch this course — lesson 1 walks you through all three steps and +> you download nothing. Say **"start lesson 1"**. You finish with a working report tool, a reproducible +> pass/fail command, and the harness-native workflow: plugins composed in +> `cordis.yml`, and an append-only trajectory you can inspect, resume, and +> fork. The exercise core is the same one the other agent-tool 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 the agent running under DeepSeek Harness teaching this course, +follow these rules: + +- **Audience:** a project owner who has working-with-AI experience but is + stuck on reliability — the script runs, then breaks on real data. Not a + Python beginner tutorial; not a prompt-engineering course; not a harness + plugin-development course. +- **Prerequisites:** Python 3.11+ on PATH, plus a working DeepSeek Harness + setup (Python package from the deepseek-harness project) with a configured + model plugin — that means an API key or local model endpoint the learner + supplies. No third-party packages are needed for the exercise itself — + it is standard library only. Setup specifics and free-tier coverage are + unverified at authoring time — see `REVIEW.md`. +- **Tool:** authored against DeepSeek Harness as documented at + deepseek-harness.github.io/deepseek-harness (plugin-composed agents, + `cordis.yml`, append-only trajectory logs with inspect/resume/fork). + A live teaching run-through is pending — `REVIEW.md` records what has and + has not been exercised. A Harness major release 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. +- **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 while the gated checkpoints + (l03, l04) are still open. `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 + +Writing your own Cordis plugins, model-provider configuration beyond the +minimum needed to run an agent, sandbox/security tuning, or selling +anything. For those, the companion repository has guides and playbooks — +link, don't improvise. + +## Badge contract + +- 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 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/<skin>/ 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-deepseek-harness/COURSE_cn.md b/courses/hands-on-with-deepseek-harness/COURSE_cn.md new file mode 100644 index 0000000..2d05251 --- /dev/null +++ b/courses/hands-on-with-deepseek-harness/COURSE_cn.md @@ -0,0 +1,105 @@ +--- +id: course-deepseek-harness +type: course +title: "上手 DeepSeek Harness" +summary: "经过验证的 Python 工作流——任务契约、有界改动、客观 verify.py 证据——在 DeepSeek Harness 里手把手教:Agent 闭环由插件组装,每一步都落进可检查的轨迹日志。" +lang: zh-CN +content_version: 2 +status: reviewed +reviewed_on: 2026-09-13 +badge: + id: course-deepseek-harness + name_en: Reproduce with DeepSeek Harness in the loop + name_zh: DeepSeek Harness 协同复现 + requires: 认领全部五个检查点(L01–L05) +course_id: course-deepseek-harness +--- + +# 上手 DeepSeek Harness + +> 一句话:装好 DeepSeek Harness、装上 FlyPython Skill、让 Agent 取回本课文件 +> (第 1 课就是这三步,你什么都不用下载),对它说**「开始第 1 课」**。完成时你会得到一个能跑的报表工具、一条可复现的 +> 通过/失败命令,以及 Harness 原生工作流:用 `cordis.yml` 组装的插件, +> 和一份可检查、可恢复、可分叉的只增轨迹。练习核心与其他 Agent 工具 +> 课程完全相同——变的只是你驱动的工具。 + +## 你要做的东西 + +一个小型 Python 报表工具:读进脏乱的真实数据(CSV 或 JSON)、隔离 +无效行而不是直接崩溃、聚合有效行、原子地写出报告。课程自带三个 +「场景皮肤」,让你在自己熟悉的领域里练习: + +| 皮肤 | 你是…… | 数据 | +| --- | --- | --- | +| `scenario/excel-report/` | 把每周订单导出整理成区域汇总 | `orders.csv` | +| `scenario/data-monitor/` | 检查自家服务器的延迟数据 | `metrics.csv` | +| `scenario/api-tool/` | 汇总从内部 API 拉取的工单数据 | `tickets.json` | + +三个皮肤跑的是同一份代码、同一套测试。真正的产品是这些技能——契约、 +错误隔离、舍入、原子写、验证——皮肤只是让它们变得具体。 + +## 教学契约(先读,Agent) + +如果你是在 DeepSeek Harness 下运行、教授本课程的 Agent,请遵守以下 +规则: + +- **受众:** 有 AI 协作经验、但困在可靠性上的项目所有者——脚本能跑, + 一遇真实数据就坏。这不是 Python 入门教程,不是提示词工程课,也不 + 是 Harness 插件开发课。 +- **前置条件:** PATH 上有 Python 3.11+,以及一套可用的 DeepSeek + Harness 环境(deepseek-harness 项目的 Python 包)并配置好模型插件 + ——即学习者自备 API key 或本地模型端点。练习本身不需要第三方包 + ——只用标准库。安装细节与免费额度覆盖在编写时未经证实——见 + `REVIEW.md`。 +- **工具:** 按 deepseek-harness.github.io/deepseek-harness 上文档 + 描述的 DeepSeek Harness 编写(插件组装的 Agent、`cordis.yml`、 + 可检查/恢复/分叉的只增轨迹日志)。真实授课实跑尚待完成—— + `REVIEW.md` 记录已验证与未验证的部分。Harness 主版本发布会触发 + 课程重审。 +- **课序:** L01 → L02 → L03 → L04 → L05,一次课一个会话。 + 绝不跳过课尾的检查点。 +- **教学风格:** 从本文件夹里的文件出发,不要凭通用知识发挥。引用 + 你正在满足的那条契约原文。做让测试从红变绿的最小改动。绝不加 + 依赖、绝不改 `solution/`、动当前课程未点名的任何文件前先询问。 +- **何时停下:** 一节课的检查点命令跑通、且学习者能用自己的话说清 + 哪里坏了、为什么,这节课才算完。学习者说不出就从失败测试重新教, + 不要从 solution 教。 +- **如何使用 `verify.py`:** 学员命令就是 `python verify.py`——它跑套件、 + 打印逐检查点状态与已获认领码,在门控检查点(l03、l04)未全通过时以 + 非零退出。`starter --expect-failure` 与 `solution` 留作维护者检查。 + 「完成」指学习者做完有界改动后,默认命令把所有门显示为 `[passed]`。- **诚实规则:** 说清你没验证什么。不要声称代码达到生产可用。不要 + 承诺学习或职业结果。 + +## 本课程不覆盖什么 + +自己编写 Cordis 插件、超出「跑起一个 Agent」最低需求的模型供应商 +配置、沙箱/安全调优,以及任何推销内容。这些由配套仓库的指南与 +手册承载——给链接,不即兴发挥。 + +## 徽章契约 + +- 徽章:**DeepSeek Harness 协同复现徽章**(badge id `course-deepseek-harness`)——认领全部五个检查点获得。 +- 挑战: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/<skin>/ 每个皮肤的数据文件与 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-deepseek-harness/REVIEW.md b/courses/hands-on-with-deepseek-harness/REVIEW.md new file mode 100644 index 0000000..35ed464 --- /dev/null +++ b/courses/hands-on-with-deepseek-harness/REVIEW.md @@ -0,0 +1,45 @@ +# Maintainer run-through record + +## 2026-09-13 — authored from official documentation (mechanical verification only) + +- Course authored against the DeepSeek Harness documentation + (deepseek-harness.github.io/deepseek-harness: plugin architecture, + `cordis.yml` composition, append-only trajectory logs with + inspect/resume/fork) and the shared `report-tool` core. `TASK.md`, + `tests/`, `scenario/`, `starter/`, `solution/` are byte-identical to the + Claude Code / Codex course cores — enforced by the `core-group` check in + `tools/verify_courses.py`. +- Environment for mechanical verification: macOS (arm64), Python 3.13. +- Commands and results: + - `python verify.py starter --expect-failure` — exit 0; expected failures + reproduced. + - `python verify.py solution` — exit 0; 9/9 tests pass. + - `python tools/verify_courses.py` — contract satisfied, core identical. +- **Not done:** any run under DeepSeek Harness. No harness install, model + endpoint, or API key was used; every harness behavior described in the + lessons is sourced from official documentation, not observed. + +## 2026-09-13 — 0.0.8 rework: lesson 1 is now tool → Skill → agent fetch (FP-822) + +- L01 (EN+ZH) rewritten around the fixed order: get the tool running → + install the FlyPython Skill (network access included) → let the agent + fetch this course's files via the files endpoint. All "download this + folder" wording removed from `COURSE.md` and lessons. +- The learner's check command across lessons is now the bare + `python verify.py` (FP-820): per-checkpoint status + claim codes, + non-zero exit while gated checkpoints are open. `solution` is no longer + presented as a completion standard. +- Tool-specific Skill install and network steps are authored from the + same official documentation as the course; **not yet exercised inside + the tool** — the pending live run-through below still stands. + +## Pending before this course can be called taught-in-tool + +- One full agent-taught run-through under DeepSeek Harness ("start lesson 1" + through the Lesson 5 checkpoint) on a current release, recording observed + deviations from `COURSE.md` here — including the exact `cordis.yml` used + and the model plugin configured. + +## Deviation log + +(none yet — no live run has happened) diff --git a/courses/hands-on-with-deepseek-harness/TASK.md b/courses/hands-on-with-deepseek-harness/TASK.md new file mode 100644 index 0000000..e923183 --- /dev/null +++ b/courses/hands-on-with-deepseek-harness/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": <row position>, "reason": <short string>}`. 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-deepseek-harness/TASK_cn.md b/courses/hands-on-with-deepseek-harness/TASK_cn.md new file mode 100644 index 0000000..7af2609 --- /dev/null +++ b/courses/hands-on-with-deepseek-harness/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-deepseek-harness/core-group b/courses/hands-on-with-deepseek-harness/core-group new file mode 100644 index 0000000..b56b0eb --- /dev/null +++ b/courses/hands-on-with-deepseek-harness/core-group @@ -0,0 +1 @@ +report-tool diff --git a/courses/hands-on-with-deepseek-harness/lessons/L01.md b/courses/hands-on-with-deepseek-harness/lessons/L01.md new file mode 100644 index 0000000..e65f3dc --- /dev/null +++ b/courses/hands-on-with-deepseek-harness/lessons/L01.md @@ -0,0 +1,163 @@ +--- +id: course-deepseek-harness-l01 +type: course +title: "Lesson 1: Set up DeepSeek Harness, install the FlyPython Skill, fetch the course" +summary: "Get DeepSeek Harness running, install the FlyPython Skill with network access approved, and let the agent fetch this course's files — you download nothing. Then make the starter's failure objective." +lang: en-US +content_version: 2 +status: reviewed +reviewed_on: 2026-09-13 +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) — whether you ran it or the agent's shell tool did, the command is the evidence." + - title: "Everything is a plugin" + body: "In DeepSeek Harness the agent is composed, not built-in: model, tools, session storage, and the loop itself are plugins wired in `cordis.yml`. If a behavior is missing, the answer is usually a plugin — not a flag." + - title: "The trajectory is your record" + body: "Every step lands in an append-only session/trajectory log you can inspect later. Reading it back is how you verify what the agent actually did — not what it said it did." + - 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." +--- + +# Lesson 1: Set up DeepSeek Harness, install the FlyPython Skill, fetch the course + +## Objective + +By the end of this lesson DeepSeek Harness is running, the FlyPython Skill is +installed in it (with network access approved), 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 DeepSeek Harness running + +Set up DeepSeek Harness (the Python +package from the deepseek-harness project) with a configured model plugin — +an API key or local endpoint you supply — and start a session in an empty +working folder. + +## 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 tools that keep skills between sessions. + +The harness reads `AGENTS.md` from the project root: + +```bash +curl -s https://flypython.com/skills/flypython/SKILL.md >> AGENTS.md +``` + +Approve the agent's network tool use when it asks; if your sandbox blocks +it, run the curl yourself and paste the file. + +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 session/chat/thread 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-deepseek-harness`. + +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-deepseek-harness/`. + +**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. DeepSeek Harness adds a second layer: the agent you +run is *composed* — model, tools, storage, and loop are plugins wired +together in `cordis.yml` — and every step it takes lands in an append-only +trajectory log you can inspect, search, and replay. "Trust me" becomes +"here is the trace." + +## Warm-up (2 minutes) + +Open `starter/report_tool.py` 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-deepseek-harness/` 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."** + +While it reads, run the learner's command — 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.) + +Now do the harness-native step: inspect the session trajectory. Find the +steps where the agent read `TASK.md`, ran (or was told about) the verify +commands, and produced its summary. The trajectory is append-only — it is +the ground truth of what happened, and it is what makes this agent's work +auditable later. + +## 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 find +the trajectory step where the agent reached the same conclusion. + +## 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. Where in the trajectory can you prove the agent actually read + `TASK.md`? + +This checkpoint's claim code is already in the default command's output — +submit it only once you can answer the questions. + +## Expected evidence + +The transcript of the default command, the trajectory steps you found, and your +three answers. Keep them; Lesson 4 will ask for them again. diff --git a/courses/hands-on-with-deepseek-harness/lessons/L01_cn.md b/courses/hands-on-with-deepseek-harness/lessons/L01_cn.md new file mode 100644 index 0000000..98796b3 --- /dev/null +++ b/courses/hands-on-with-deepseek-harness/lessons/L01_cn.md @@ -0,0 +1,143 @@ +--- +id: course-deepseek-harness-l01 +type: course +title: "第 1 课:装好 DeepSeek Harness、装上 FlyPython Skill、让 Agent 取回课程" +summary: "先把 DeepSeek Harness 跑起来,装好 FlyPython Skill 并打开联网权限,让 Agent 取回本课文件——你不下载任何东西,然后让 starter 的失败变得客观。" +lang: zh-CN +content_version: 2 +status: reviewed +reviewed_on: 2026-09-13 +hints: + - title: "本检查点考察什么" + body: "先复现失败再动手修:`python verify.py` 必须把 l03 和 l04 显示为 `[open]`(`starter --expect-failure` 会打印点名测试)——你自己跑也好、Agent 的 shell 工具跑也好,命令就是证据。" + - title: "一切皆插件" + body: "DeepSeek Harness 的 Agent 是组装出来的,不是内置的:模型、工具、会话存储、循环本身都是 `cordis.yml` 里接好的插件。某个行为没有,答案通常是插件——不是某个开关。" + - title: "轨迹就是你的记录" + body: "每一步都落进可事后检查的只增会话/轨迹日志。读回轨迹才能验证 Agent 实际做了什么——而不是它声称做了什么。" + - title: "完成的标志" + body: "你能说出套件证明了哪九种行为、为什么 starter 伪造不出来。`python verify.py` 随即显示本检查点的认领码。" +--- + +# 第 1 课:装好 DeepSeek Harness、装上 FlyPython Skill、让 Agent 取回课程 + +## 目标 + +本课结束时,DeepSeek Harness 已经跑起来、FlyPython Skill 已装好(联网权限已 +打开),Agent 已把本课文件取回你的工作目录——你没有手动下载任何 +东西。文件夹就位后,你运行课程的客观检查命令,并解释「starter +失败」在测试名里的含义。本课不改任何代码。 + +## 第 1 步——把 DeepSeek Harness 跑起来 + +装好 DeepSeek Harness(deepseek-harness +项目的 Python 包)并配置好模型插件——你自己提供的 API key 或本地 +端点——在一个空工作目录里启动会话。 + +## 第 2 步——(可选)预装 FlyPython Skill + +这一步可选:第 3 步的开课句子会直接从 URL 读取 Skill,不装也能上课——预装只是省掉一次权限往返。 + +Harness 会读取项目根目录的 `AGENTS.md`: + +```bash +curl -s https://flypython.com/skills/flypython/SKILL.md >> AGENTS.md +``` + +Agent 请求联网时批准;如果沙箱禁网,就自己跑 curl,把文件粘贴 +过去。 + +Skill 只是一个文件,在每个工具里都一样:它告诉 Agent 如何为你 +授权、取课程文件、验证、提交认领码。 + +## 第 3 步——让 Agent 取回本课文件(你不下载) + +在工作目录里新开一个会话/对话/线程,粘贴这一句话: + +> Read https://flypython.com/skills/flypython/SKILL.md and start the FlyPython course `hands-on-with-deepseek-harness`. + +Agent 会给你一个授权链接和一组短码。打开链接、登录、核对码与 +Agent 显示的一致后点「允许」,然后对 Agent 说「好了」。它取一次 +token,把本课文件写到 `courses/hands-on-with-deepseek-harness/`。 + +**前提**:这些课程需要一个能执行命令**并且**能联网的编码 Agent。 +只能聊天的网页 AI 做不了。 + +## 为什么有这一课 + +AI 写的 Python 大多是同一种死法:演示输入能跑,真实输入一到,脚本 +在第 3 行崩掉——或者更糟,悄悄算出一个错的数。修复不从更好的 +提示词开始,而从把「坏了」变成一条任何人都能跑、且得到相同答案的 +命令开始。DeepSeek Harness 再加一层:你跑的 Agent 是*组装*出来的 +——模型、工具、存储、循环都是 `cordis.yml` 里接好的插件——它走的 +每一步都落进可检查、可搜索、可重放的只增轨迹日志。「相信我」变成 +「这是轨迹」。 + +## 热身(2 分钟) + +打开 `starter/report_tool.py`,从头读到尾。它看起来很合理:带 +docstring 的函数、类型标注、CLI。这正是 AI 生成代码的样子—— +表面干净、行为缺失。现在打开 `TASK.md`,放在手边。 + +## 本课内容 + +本课文件现在已经位于 `courses/hands-on-with-deepseek-harness/`——你这边没有发生任何下载。 +让 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/`)才是。) + +现在做 Harness 原生的一步:检查会话轨迹。找到 Agent 读 `TASK.md`、 +跑(或被告知)verify 命令、产出总结的那几步。轨迹是只增的——它是 +发生之事的底账,也是以后让 Agent 工作可审计的依据。 + +## 练习 + +挑一个你最有代入感的场景皮肤(`excel-report`、`data-monitor` 或 +`api-tool`),打开它的数据文件,找出会被拒绝的行。先不看运行结果, +写下每个坏行会触发哪个测试,再对照上面的表自查——并在轨迹里找到 +Agent 得出相同结论的那一步。 + +## 检查点 + +在课程文件夹里运行 `python verify.py`。不看材料能回答下面三个 +问题即算通过: + +1. 哪五个行为把 starter 和 solution 区分开?其中哪两个失败测试是它们的端到端后果? +2. `python verify.py` 为什么故意以非零退出——它在报告什么状态? + 为什么这是成功条件而不是报错? +3. 在轨迹的哪个位置能证明 Agent 确实读过 `TASK.md`? + +本检查点的认领码已经在默认命令的输出里——能回答这些问题之后再 +提交。 + +## 预期证据 + +默认命令的完整输出记录、你找到的轨迹步骤、加你的三个答案。保留好 +——第 4 课还会用到。 diff --git a/courses/hands-on-with-deepseek-harness/lessons/L02.md b/courses/hands-on-with-deepseek-harness/lessons/L02.md new file mode 100644 index 0000000..6a83855 --- /dev/null +++ b/courses/hands-on-with-deepseek-harness/lessons/L02.md @@ -0,0 +1,88 @@ +--- +id: course-deepseek-harness-l02 +type: course +title: "Lesson 2: Write the task contract, let cordis.yml carry the setup" +summary: 'Turn "make it better" into a bounded, testable contract — then make the working rules part of the composed agent, not a forgotten prompt.' +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-13 +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: "Composition is the instruction chain" + body: "A harness agent is assembled in `cordis.yml`: which model plugin, which tool plugins, what system instructions, what session storage. Rules you want every run to follow belong in that composition — not retyped per session." + - 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 cordis.yml carry the setup + +## 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 make working rules +part of the composed agent instead of a prompt you have to retype. + +## 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. With a composed agent there is a second failure +mode: the rules you typed into one session are not in the next — unless +they live in the composition. + +## 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. In DeepSeek Harness the agent is declared in +`cordis.yml`: the model plugin, the tool plugins it may use, the session +storage where trajectories land, and the instructions it starts with. Put +this course's working rules where they survive restarts — in the agent's +configured instructions, for example: + +``` +- Change only starter/report_tool.py. tests/, solution/, scenario/ are read-only. +- One failing test group per step; run `python verify.py starter` after each. +- Standard library only — no new dependencies. +``` + +Start a *new* session and ask the agent to state its working rules. If it +quotes yours back, the composition is carrying them; if not, check whether +the instructions actually landed in the configuration the session loaded. + +## Exercise + +Write one contract line for a script you actually own, using the same +shape: inputs, outputs, error cases, and "done means `<command>` exits 0". +Then write the two rules you would put in that project's agent +composition. + +## Checkpoint + +Run `python verify.py` — this checkpoint's claim code prints when +you can answer: + +1. Which `TASK.md` line does + `test_invalid_records_are_isolated_with_reasons` pin, in your own + words? +2. Which artifact carries rules across sessions — the prompt, or the + `cordis.yml` composition? +3. Why does an append-only trajectory make a composed agent easier to + audit than a chat transcript? + +## Expected evidence + +Your drafted contract line, your two rules, and the new session's +statement of its working rules. diff --git a/courses/hands-on-with-deepseek-harness/lessons/L02_cn.md b/courses/hands-on-with-deepseek-harness/lessons/L02_cn.md new file mode 100644 index 0000000..ba524cb --- /dev/null +++ b/courses/hands-on-with-deepseek-harness/lessons/L02_cn.md @@ -0,0 +1,79 @@ +--- +id: course-deepseek-harness-l02 +type: course +title: "第 2 课:写任务契约,让 cordis.yml 承载配置" +summary: "把「改好一点」变成有边界、可测试的契约——再把工作规则放进组装出来的 Agent 里,而不是会被遗忘的提示词。" +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-13 +hints: + - title: "本检查点考察什么" + body: "你能把 `TASK.md` 当成可测试的陈述来读,而不是散文。每个以函数名开头的句子都是套件能断言的契约行。" + - title: "组装就是指令链" + body: "Harness 的 Agent 在 `cordis.yml` 里组装:用哪个模型插件、哪些工具插件、什么系统指令、会话存到哪。想让每次运行都遵守的规则要写进组装里——不要每个会话重打一遍。" + - title: "可测试性检查" + body: "判断一句话能不能测:测试套件不看你的心思能不能断言它?数字、退出码、磁盘上的文件——不看感觉。" +--- + +# 第 2 课:写任务契约,让 cordis.yml 承载配置 + +## 目标 + +你能把 `TASK.md` 当成一组可测试陈述来读,把每条陈述追查到 +`tests/test_report_tool.py` 里的一个测试,并把工作规则做成组装 +Agent 的一部分,而不是每次都要重打的提示词。 + +## 为什么有这节课 + +含糊的需求产出含糊的代码。「把坏行处理好一点」是许可 Agent 去猜; +「无效行收进 `errors`、带下标和原因,有效行照常产出报告」是给它 +目标、也给你检查的方法。用组装式 Agent 还有第二种失败:你在某个 +会话里敲的规则下一个会话没有——除非它们活在组装配置里。 + +## 本课内容 + +打开 `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` | + +再看持久的另一半。在 DeepSeek Harness 里,Agent 声明在 +`cordis.yml` 中:模型插件、可用工具插件、轨迹落盘的会话存储、 +以及它启动时带上的指令。把本课程的工作规则放到重启后仍在的地方 +——例如 Agent 的配置指令里: + +``` +- 只改 starter/report_tool.py。tests/、solution/、scenario/ 只读。 +- 每步只修一组失败测试;修完跑 `python verify.py starter`。 +- 只用标准库——不加新依赖。 +``` + +开一个*新*会话,让 Agent 陈述它的工作规则。它把你的规则原样引 +回来,说明组装配置带上了;没有就检查指令是否真的写进了会话加载 +的那份配置。 + +## 练习 + +为你真正拥有的一个脚本写一条契约行,用同样的形状:输入、输出、 +错误情况、以及「完成 = `<命令>` 退出码为 0」。再写你会放进那个 +项目 Agent 组装配置里的两条规则。 + +## 检查点 + +运行 `python verify.py`——能回答下面三题时,本检查点的 +认领码就会显示: + +1. 用你自己的话说,`test_invalid_records_are_isolated_with_reasons` + 钉住的是 `TASK.md` 哪一行? +2. 跨会话携带规则的是提示词还是 `cordis.yml` 组装配置? +3. 为什么只增轨迹让组装式 Agent 比聊天转录更好审计? + +## 预期证据 + +你起草的契约行、两条规则,以及新会话对自己工作规则的陈述。 diff --git a/courses/hands-on-with-deepseek-harness/lessons/L03.md b/courses/hands-on-with-deepseek-harness/lessons/L03.md new file mode 100644 index 0000000..95950eb --- /dev/null +++ b/courses/hands-on-with-deepseek-harness/lessons/L03.md @@ -0,0 +1,90 @@ +--- +id: course-deepseek-harness-l03 +type: course +title: "Lesson 3: The bounded change, audited from the trajectory" +summary: "Drive the starter to green through bounded steps — then prove the agent stayed in bounds by inspecting the append-only trajectory." +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-13 +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: "The trajectory is the audit" + body: "Because the log is append-only, you can inspect each tool call and file edit after the fact — and fork the session if a step went somewhere you did not want." + - title: "When you are done" + body: "Every edit maps to a contract line, the trajectory shows nothing outside starter/report_tool.py was touched, and you can explain each change without opening solution/." +--- + +# Lesson 3: The bounded change, audited from the trajectory + +## Objective + +The starter passes all nine tests through a sequence of bounded changes, +the trajectory proves nothing else was touched, and you can explain every +edit without reading the solution first. + +## Why this lesson exists + +This is the lesson people skip — and the one that builds the actual skill. +Letting an agent rewrite the file in one shot teaches you nothing and +leaves you a change you cannot honestly review. Supervising small steps, +each pinned to a contract line, is how the code stays yours. DeepSeek +Harness makes the supervision auditable: the session trajectory is +append-only, so what the agent did is a record you inspect — not a claim +you trust. + +## The lesson + +Start a fresh session 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` and stop for my review."** + +Hold it to the contract's boundaries: + +- Only `starter/report_tool.py` changes. If the trajectory shows a write + to `tests/`, `solution/`, or `scenario/`, stop the session and fork from + before that step. +- No new imports outside the standard library. +- Each change should move toward one contract line. Reject drive-by + refactors. +- If the agent wants to change a test, the answer is no. Tests are the + contract; the code moves. + +Use the harness's strengths deliberately: + +- **Inspect the trajectory after each step.** Did it run the command it + claimed? Did it touch only the file it said? +- **Fork instead of argue.** If a step went wrong, fork the session from + the last good step rather than stacking corrections on a bad state. +- **Read the diff, not the summary.** The trajectory shows the actual + edit; the agent's summary is its own narration. When they disagree, the + trajectory wins. + +Expect the failing count to drop group by group: 7 → 5 → 4 → 3 → 2 → 0. + +## Exercise + +Pick one edit the agent made — ideally the validation-isolation change — +and find it in the trajectory. Explain it back: "this edit does X; that +satisfies contract line Y." If you cannot, ask the agent to walk you +through that trajectory step before continuing. + +## 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 steps did the bounded change take, and what made each step + bounded? +2. Which trajectory entry did you reject or fork away from, and why? +3. Point at the edit that implements "isolate invalid rows with reasons" + — which trajectory step is it? + +## Expected evidence + +A green `python verify.py starter` run, plus the trajectory steps showing +each bounded edit and the verify runs between them. diff --git a/courses/hands-on-with-deepseek-harness/lessons/L03_cn.md b/courses/hands-on-with-deepseek-harness/lessons/L03_cn.md new file mode 100644 index 0000000..67f5571 --- /dev/null +++ b/courses/hands-on-with-deepseek-harness/lessons/L03_cn.md @@ -0,0 +1,79 @@ +--- +id: course-deepseek-harness-l03 +type: course +title: "第 3 课:有界改动,用轨迹做审计" +summary: "通过有界的步骤把 starter 推到全绿——再检查只增轨迹,证明 Agent 没有越界。" +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-13 +hints: + - title: "本检查点考察什么" + body: "starter 通过你监督的改动让九个测试全绿——`python verify.py` 只在 starter 套件变绿后显示本检查点的码。" + - title: "轨迹就是审计" + body: "日志是只增的,事后可以逐条检查每次工具调用与文件编辑——某步走偏了就从那步之前分叉会话。" + - title: "完成的标志" + body: "每处编辑都能对上一条契约行,轨迹显示 starter/report_tool.py 之外没动过任何东西,而且不看 solution/ 你也讲得清每处改动。" +--- + +# 第 3 课:有界改动,用轨迹做审计 + +## 目标 + +starter 通过一串有界改动让九个测试全绿,轨迹证明没有碰过别的 +东西,并且不看 solution,你也讲得清每一处编辑。 + +## 为什么有这节课 + +这节课最容易被跳过——也恰恰是长本事的那节。放任 Agent 一次重写 +整个文件,什么也学不到,只留下一份你没底气审查的改动。监督一连串 +小步骤、各自钉在一条契约行上,才能让 AI 写的代码仍然是你的。 +DeepSeek Harness 让监督可审计:会话轨迹是只增的,Agent 做了什么 +是供你检查的记录——不是要你信任的声明。 + +## 本课内容 + +开一个新会话,说: + +**“按 TASK.md 改 starter/report_tool.py。一次只修一组失败测试: +先 JSON 加载,再无效行隔离,再舍入,再原子写,最后是端到端测试。 +每修完一组跑 `python verify.py starter`,然后停下来等我审。”** + +守住契约边界: + +- 只动 `starter/report_tool.py`。轨迹里出现对 `tests/`、 + `solution/`、`scenario/` 的写入,就停掉会话,从那步之前分叉。 +- 标准库之外不加 import。 +- 每处改动朝一条契约行推进。顺手重构一律拒绝。 +- Agent 想改测试,答案是不行。测试是契约;动的是代码。 + +刻意用 Harness 的长处: + +- **每步之后检查轨迹。** 它跑的命令和它声称的一致吗?碰的文件 + 和它说的一致吗? +- **分叉代替争论。** 某步走错了,从上一个好步骤分叉会话,而不是 + 在坏状态上叠补丁。 +- **看 diff,不看总结。** 轨迹里是实际的编辑;Agent 的总结是它的 + 自述。两者不一致时以轨迹为准。 + +预期失败数逐组下降:7 → 5 → 4 → 3 → 2 → 0。 + +## 练习 + +挑 Agent 的一处编辑——最好是「无效行隔离」那段——在轨迹里找到 +它,讲回来:「这处编辑做了 X,满足契约行 Y」。讲不清就先让它走读 +那个轨迹步骤,再继续。 + +## 检查点 + +运行 `python verify.py`。本检查点的码只在 starter 套件 +全绿时出现。不看材料能回答即算通过: + +1. 这次有界改动走了几步,是什么让每步保持有界? +2. 你拒绝或分叉掉过哪条轨迹记录,为什么? +3. 指出实现「无效行带原因隔离」的那处编辑——对应哪个轨迹步骤? + +## 预期证据 + +一次全绿的 `python verify.py starter` 运行,加上显示每处有界编辑 +与其间 verify 运行的轨迹步骤。 diff --git a/courses/hands-on-with-deepseek-harness/lessons/L04.md b/courses/hands-on-with-deepseek-harness/lessons/L04.md new file mode 100644 index 0000000..787689b --- /dev/null +++ b/courses/hands-on-with-deepseek-harness/lessons/L04.md @@ -0,0 +1,83 @@ +--- +id: course-deepseek-harness-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 the scenario skins, and review the trajectory the way you would review a colleague's work." +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-13 +hints: + - title: "What this checkpoint tests" + body: "That starter AND solution both pass — `verify.py` prints this code only when both suites are green. The gate is the pair, not a single run." + - title: "Review the record, not the narration" + body: "The append-only trajectory is the auditable record of every command and edit. Review it end to end: every edit maps to a contract line, every command really ran, no test was 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 trajectory you have reviewed end to end. + +## 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 harness's append-only trajectory +makes the review concrete: every command and edit is already logged — +your job is to actually read it. + +## The lesson + +Run the full pair: + +```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/<skin>/` folder and run + the tool on it: `python starter/report_tool.py scenario/<skin>`. Read + the produced report against the input file — recompute one group total + by hand. +2. **Review the trajectory end to end.** Read every step the session + took: the reads, the edits, the commands. Every edit should map to a + contract line you named in Lesson 2; every verify run should actually + appear. Anything unexplained is scope creep — replay or fork the + session around it. +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. + +## Exercise + +Write the three-line evidence record for this change: + +- **Verified:** the commands that ran (with their trajectory entries) 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 only when +both suites pass. 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 trajectory step, if any, would you ask a colleague to justify? + +## Expected evidence + +Both green runs, your three-line evidence record, and the reviewed +trajectory. diff --git a/courses/hands-on-with-deepseek-harness/lessons/L04_cn.md b/courses/hands-on-with-deepseek-harness/lessons/L04_cn.md new file mode 100644 index 0000000..f70e183 --- /dev/null +++ b/courses/hands-on-with-deepseek-harness/lessons/L04_cn.md @@ -0,0 +1,77 @@ +--- +id: course-deepseek-harness-l04 +type: course +title: "第 4 课:像评审者一样验证,别当观众" +summary: "测试全绿是地板不是天花板——两个套件都跑、三个皮肤都走一遍、按评审同事工作的标准审这条轨迹。" +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-13 +hints: + - title: "本检查点考察什么" + body: "starter 和 solution 双双通过——`verify.py` 只在两个套件都绿时打印本检查点的码。门槛是一对结果,不是一次运行。" + - title: "审记录,不听自述" + body: "只增轨迹是每条命令、每处编辑的可审计记录。从头审到尾:每处编辑对上一条契约行、每条命令真的跑过、没有测试被放松。" + - title: "完成的标志" + body: "你已写下三行证据——已验证 / 未验证 / 已知局限——并且能在代码评审里为这次改动辩护。" +--- + +# 第 4 课:像评审者一样验证,别当观众 + +## 目标 + +两个套件都通过,你手握一份书面记录:说清绿色运行证明了什么、没 +证明什么——外加一条你从头到尾审过的轨迹。 + +## 为什么有这节课 + +「测试全过」是大多数 AI 辅助工作停下的地方——也是大多数隐性 +bug 开始的地方。套件只证明它断言过的契约行;没人写过测试的行, +它一概不担保。验证是你写的一份报告,不是一种感觉。Harness 的 +只增轨迹让评审变具体:每条命令、每处编辑都已落账——你要做的 +是真的去读它。 + +## 本课内容 + +跑完整的一对命令: + +```bash +python verify.py starter # 你监督出来的实现 +python verify.py solution # 评审过的参考实现 +``` + +双全绿意味着契约*按字面*被满足。现在做套件替不了你的评审: + +1. **走完一个皮肤。** 挑一个 `scenario/<skin>/` 文件夹,用工具跑 + 它:`python starter/report_tool.py scenario/<skin>`。对照输入 + 文件读产出的报告——亲手重算一个分组总计。 +2. **把轨迹从头审到尾。** 读会话走过的每一步:读过的文件、做过的 + 编辑、跑过的命令。每处编辑都应能对上你在第 2 课点名的契约行; + 每次 verify 运行都应真实出现。解释不了的就是范围蔓延——围绕 + 它重放或分叉会话。 +3. **检查测试没钉的边界。** 喂给工具一个空 CSV、一个全是无效行 + 的 CSV、一个不存在的文件。契约没钉这些——你的判断钉。记下 + 它的行为。 + +## 练习 + +为这次改动写三行证据记录: + +- **已验证:** 跑过的命令(连同其轨迹条目)和它们证明了什么。 +- **未验证:** 没有测试钉住的输入与行为。 +- **已知局限:** 你看到的、契约从未承诺过的东西。 + +写得诚实——「未验证」是一份事实清单,不是忏悔书。 + +## 检查点 + +运行 `python verify.py`。本检查点的码只在两个套件都过 +时打印。不看材料能回答即算通过: + +1. 你手工重算的分组总计结果如何? +2. 说出一个套件没钉的输入,以及工具对它的实际行为。 +3. 哪个轨迹步骤(如果有)你会请同事来解释? + +## 预期证据 + +两次全绿运行、你的三行证据记录、审完的轨迹。 diff --git a/courses/hands-on-with-deepseek-harness/lessons/L05.md b/courses/hands-on-with-deepseek-harness/lessons/L05.md new file mode 100644 index 0000000..6d6a436 --- /dev/null +++ b/courses/hands-on-with-deepseek-harness/lessons/L05.md @@ -0,0 +1,76 @@ +--- +id: course-deepseek-harness-l05 +type: course +title: "Lesson 5: Take the workflow to your own project" +summary: "The pattern generalizes: task contract, composed agent, bounded steps, trajectory audit — run the same loop on a script you actually own." +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-13 +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 session's trajectory, 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, rules in the composition, one bounded step at a time, trajectory audited after, verification written down." + - title: "When you are done" + body: "Your own script 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, composed agent, bounded steps, +trajectory audit, written verification — to a script or project 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 run 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 `<command>` exits 0". One short paragraph, every + sentence testable. +2. **Composition.** Point a harness agent at the project with the working + rules in its configuration — the files it may touch, the verification + command, the dependency policy. +3. **Bounded steps.** One session, one contract line at a time, stopping + for your review between steps. +4. **Verify.** Run the verification command, then do the review the suite + cannot do — walk one real input end-to-end and read the trajectory top + to bottom. +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 start the next session. + +## Checkpoint + +This checkpoint is self-attested: claim it when you can answer: + +1. What was your contract's "done means" command? +2. Which trajectory step 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 agent configuration you used, and the +three-line verification record. These are yours — they are the proof the +workflow transferred. diff --git a/courses/hands-on-with-deepseek-harness/lessons/L05_cn.md b/courses/hands-on-with-deepseek-harness/lessons/L05_cn.md new file mode 100644 index 0000000..10c07f4 --- /dev/null +++ b/courses/hands-on-with-deepseek-harness/lessons/L05_cn.md @@ -0,0 +1,66 @@ +--- +id: course-deepseek-harness-l05 +type: course +title: "第 5 课:把工作流带回自己的项目" +summary: "模式会泛化:任务契约、组装 Agent、有界步骤、轨迹审计——在你真正拥有的脚本上跑同一套闭环。" +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-13 +hints: + - title: "本检查点考察什么" + body: "自我报告:你确认把这套闭环用到了自己的项目上。证据是你的契约、你会话的轨迹、你的验证记录——不是我们查得了的码。" + - title: "带走形状,不是代码" + body: "能迁移的是闭环:契约先行、规则进组装配置、一次一个有界步骤、事后审轨迹、验证写下来。" + - title: "完成的标志" + body: "你自己的脚本有了一份契约、一处你讲得清的有界改动、一份已验证/未验证/已知局限记录——或一条写清楚的「闭环不合身」的原因。" +--- + +# 第 5 课:把工作流带回自己的项目 + +## 目标 + +你把整套闭环——契约、组装 Agent、有界步骤、轨迹审计、书面验证 +——用到自己真正拥有的脚本或项目上,并记录发生了什么。 + +## 为什么有这节课 + +课程文件夹是安全沙箱;你自己的代码库不是。前四课的意义从来不是 +那个报表工具——而是一种可复用的、让你始终掌控产出的 Agent 运行 +方式。本检查点要证明迁移真的发生了。它是自我报告的:闭环跑没跑 +过只有你知道。 + +## 本课内容 + +挑一个你真正拥有的脚本——一个你拖了很久的 bug,或一个想要但 +还没写的行为。然后走同样的五步: + +1. **契约。** 先写任务契约:输入、输出、错误情况、以及「完成 = + `<命令>` 退出码为 0」。一小段,每句都可测试。 +2. **组装。** 让 Harness Agent 指向项目,并把工作规则写进它的 + 配置——它可以碰哪些文件、验证命令是什么、依赖政策。 +3. **有界步骤。** 一个会话、一次一条契约行,每步之间停下来等你审。 +4. **验证。** 跑验证命令,然后做套件做不了的评审——拿一个真实 + 输入端到端走一遍,并把轨迹从头读到尾。 +5. **记录。** 写三行:已验证 / 未验证 / 已知局限。 + +如果闭环跟你打架——任务小到不需要契约,或模糊到不可测——那也 +是发现。写下形状不合身的地方;这个判断本身就是真本事。 + +## 练习 + +如果你的项目还有不止一处值得钉住的边界,现在就起草下一条契约行 +——开下一个会话时你会用得上。 + +## 检查点 + +本检查点自我报告:能回答下面三题时即可认领: + +1. 你的契约里「完成 =」命令是什么? +2. 你在哪个轨迹步骤上顶回去过? +3. 你的「未验证」清单上有什么是陌生人会以为已覆盖的? + +## 预期证据 + +你的契约段落、你用的 Agent 配置、三行验证记录。它们是你自己的 +——是工作流完成迁移的证明。 diff --git a/courses/hands-on-with-deepseek-harness/scenario/api-tool/scenario.json b/courses/hands-on-with-deepseek-harness/scenario/api-tool/scenario.json new file mode 100644 index 0000000..e17e7c2 --- /dev/null +++ b/courses/hands-on-with-deepseek-harness/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-deepseek-harness/scenario/api-tool/tickets.json b/courses/hands-on-with-deepseek-harness/scenario/api-tool/tickets.json new file mode 100644 index 0000000..03e35b7 --- /dev/null +++ b/courses/hands-on-with-deepseek-harness/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-deepseek-harness/scenario/data-monitor/metrics.csv b/courses/hands-on-with-deepseek-harness/scenario/data-monitor/metrics.csv new file mode 100644 index 0000000..15a5a15 --- /dev/null +++ b/courses/hands-on-with-deepseek-harness/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-deepseek-harness/scenario/data-monitor/scenario.json b/courses/hands-on-with-deepseek-harness/scenario/data-monitor/scenario.json new file mode 100644 index 0000000..b103477 --- /dev/null +++ b/courses/hands-on-with-deepseek-harness/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-deepseek-harness/scenario/excel-report/orders.csv b/courses/hands-on-with-deepseek-harness/scenario/excel-report/orders.csv new file mode 100644 index 0000000..a41fca1 --- /dev/null +++ b/courses/hands-on-with-deepseek-harness/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-deepseek-harness/scenario/excel-report/scenario.json b/courses/hands-on-with-deepseek-harness/scenario/excel-report/scenario.json new file mode 100644 index 0000000..66b2bd9 --- /dev/null +++ b/courses/hands-on-with-deepseek-harness/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-deepseek-harness/solution/report_tool.py b/courses/hands-on-with-deepseek-harness/solution/report_tool.py new file mode 100644 index 0000000..65ba0bf --- /dev/null +++ b/courses/hands-on-with-deepseek-harness/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 <scenario-dir>", 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-deepseek-harness/starter/report_tool.py b/courses/hands-on-with-deepseek-harness/starter/report_tool.py new file mode 100644 index 0000000..019edb8 --- /dev/null +++ b/courses/hands-on-with-deepseek-harness/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 <scenario-dir>", 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-deepseek-harness/tests/test_report_tool.py b/courses/hands-on-with-deepseek-harness/tests/test_report_tool.py new file mode 100644 index 0000000..3fbc19f --- /dev/null +++ b/courses/hands-on-with-deepseek-harness/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-deepseek-harness/verify.py b/courses/hands-on-with-deepseek-harness/verify.py new file mode 100644 index 0000000..4b7d970 --- /dev/null +++ b/courses/hands-on-with-deepseek-harness/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-deepseek-harness' +# 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 = '2f61c37ef24e027c' + +CHECKPOINTS = [ + {"id": "l01", "gate": "attest", "title": '复现故障 / Reproduce the failure'}, + {"id": "l02", "gate": "attest", "title": '任务契约 / Task contract'}, + {"id": "l03", "gate": "starter-suite", "title": '插件化下的有界改动 / Bounded change in a plugin harness'}, + {"id": "l04", "gate": "both-suites", "title": '用轨迹视图核对验证 / Verify against the trajectory'}, + {"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 new file mode 100644 index 0000000..12ca154 --- /dev/null +++ b/courses/hands-on-with-kimi-code/COURSE.md @@ -0,0 +1,113 @@ +--- +id: course-kimi-code +type: course +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. +lang: en-US +content_version: 2 +status: reviewed +reviewed_on: 2026-09-13 +badge: + id: course-kimi-code + name_en: Reproduce with Kimi Code in the loop + name_zh: Kimi Code 协同复现 + requires: All five checkpoints claimed (L01–L05) +course_id: course-kimi-code +--- + +# Hands-on with Kimi Code + +> TL;DR: set up the `kimi` CLI, install the FlyPython Skill, and let the +> agent fetch this course — lesson 1 walks you through all three steps and +> you download nothing. Say **"start lesson 1"**. You finish with a working report tool, a reproducible pass/fail command, +> and the Kimi Code workflow: the built-in `explore`/`plan`/`coder` +> subagents that keep reading, planning, and editing in separate lanes. The +> exercise core is the same one the other agent-tool 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 the Kimi Code agent teaching this course, follow these rules: + +- **Audience:** a project owner who has working-with-AI experience but is + stuck on reliability — the script runs, then breaks on real data. Not a + Python beginner tutorial; not a prompt-engineering course. +- **Prerequisites:** Python 3.11+ on PATH and the `kimi` CLI installed and + authenticated (a Moonshot/Kimi account or API key the learner supplies). + No packages to install — the course is standard library only. Free-tier + coverage is unverified at authoring time — see `REVIEW.md`. +- **Tool:** authored against Kimi Code as documented at + moonshotai.github.io/kimi-code (built-in `coder`/`explore`/`plan` + subagents, `kimi acp` for editor integration). A live teaching + run-through is pending — `REVIEW.md` records what has and has not been + exercised. A Kimi Code major release 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. Read `AGENTS.md` in the repository root + if one exists. +- **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 while the gated checkpoints + (l03, l04) are still open. `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 + +Kimi Code installation and billing, custom subagent authoring, ACP client +development, or selling anything. For those, the companion repository has +guides and playbooks — link, don't improvise. + +## Badge contract + +- 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 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/<skin>/ 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-kimi-code/COURSE_cn.md b/courses/hands-on-with-kimi-code/COURSE_cn.md new file mode 100644 index 0000000..c393657 --- /dev/null +++ b/courses/hands-on-with-kimi-code/COURSE_cn.md @@ -0,0 +1,103 @@ +--- +id: course-kimi-code +type: course +title: "上手 Kimi Code" +summary: "经过验证的 Python 工作流——任务契约、有界改动、客观 verify.py 证据——在 Kimi Code 里手把手教:内置子代理把只读勘察、规划和经你批准的编辑分在三条道上。" +lang: zh-CN +content_version: 2 +status: reviewed +reviewed_on: 2026-09-13 +badge: + id: course-kimi-code + name_en: Reproduce with Kimi Code in the loop + name_zh: Kimi Code 协同复现 + requires: 认领全部五个检查点(L01–L05) +course_id: course-kimi-code +--- + +# 上手 Kimi Code + +> 一句话:装好 `kimi` CLI、装上 FlyPython Skill、让 Agent 取回本课文件(第 1 课 +> 就是这三步,你什么都不用下载),对它说**「开始第 1 课」**。 +> 完成时你会得到一个能跑的报表工具、一条可复现的通过/失败命令, +> 以及 Kimi Code 工作流:内置 `explore`/`plan`/`coder` 子代理把读、 +> 规划、改代码分在三条道上。练习核心与其他 Agent 工具课程完全相同 +> ——变的只是你驱动的工具。 + +## 你要做的东西 + +一个小型 Python 报表工具:读进脏乱的真实数据(CSV 或 JSON)、隔离 +无效行而不是直接崩溃、聚合有效行、原子地写出报告。课程自带三个 +「场景皮肤」,让你在自己熟悉的领域里练习: + +| 皮肤 | 你是…… | 数据 | +| --- | --- | --- | +| `scenario/excel-report/` | 把每周订单导出整理成区域汇总 | `orders.csv` | +| `scenario/data-monitor/` | 检查自家服务器的延迟数据 | `metrics.csv` | +| `scenario/api-tool/` | 汇总从内部 API 拉取的工单数据 | `tickets.json` | + +三个皮肤跑的是同一份代码、同一套测试。真正的产品是这些技能——契约、 +错误隔离、舍入、原子写、验证——皮肤只是让它们变得具体。 + +## 教学契约(先读,Agent) + +如果你是教授本课程的 Kimi Code Agent,请遵守以下规则: + +- **受众:** 有 AI 协作经验、但困在可靠性上的项目所有者——脚本能跑, + 一遇真实数据就坏。这不是 Python 入门教程,也不是提示词工程课。 +- **前置条件:** PATH 上有 Python 3.11+,装有 `kimi` CLI 并完成认证 + (学习者自备 Moonshot/Kimi 账号或 API key)。无需安装任何包—— + 课程只用标准库。免费档能否撑完整门课在编写时未经证实——见 + `REVIEW.md`。 +- **工具:** 按 moonshotai.github.io/kimi-code 上文档描述的 Kimi + Code 编写(内置 `coder`/`explore`/`plan` 子代理、用于编辑器集成 + 的 `kimi acp`)。真实授课实跑尚待完成——`REVIEW.md` 记录已验证 + 与未验证的部分。Kimi Code 主版本发布会触发课程重审。 +- **课序:** L01 → L02 → L03 → L04 → L05,一次课一个会话。 + 绝不跳过课尾的检查点。 +- **教学风格:** 从本文件夹里的文件出发,不要凭通用知识发挥。引用 + 你正在满足的那条契约原文。做让测试从红变绿的最小改动。绝不加 + 依赖、绝不改 `solution/`、动当前课程未点名的任何文件前先询问。 + 仓库根若有 `AGENTS.md`,读它。 +- **何时停下:** 一节课的检查点命令跑通、且学习者能用自己的话说清 + 哪里坏了、为什么,这节课才算完。学习者说不出就从失败测试重新教, + 不要从 solution 教。 +- **如何使用 `verify.py`:** 学员命令就是 `python verify.py`——它跑套件、 + 打印逐检查点状态与已获认领码,在门控检查点(l03、l04)未全通过时以 + 非零退出。`starter --expect-failure` 与 `solution` 留作维护者检查。 + 「完成」指学习者做完有界改动后,默认命令把所有门显示为 `[passed]`。- **诚实规则:** 说清你没验证什么。不要声称代码达到生产可用。不要 + 承诺学习或职业结果。 + +## 本课程不覆盖什么 + +Kimi Code 的安装与计费、自定义子代理编写、ACP 客户端开发,以及 +任何推销内容。这些由配套仓库的指南与手册承载——给链接,不即兴 +发挥。 + +## 徽章契约 + +- 徽章:**Kimi Code 协同复现徽章**(badge id `course-kimi-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/<skin>/ 每个皮肤的数据文件与 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-kimi-code/REVIEW.md b/courses/hands-on-with-kimi-code/REVIEW.md new file mode 100644 index 0000000..e00c929 --- /dev/null +++ b/courses/hands-on-with-kimi-code/REVIEW.md @@ -0,0 +1,47 @@ +# Maintainer run-through record + +## 2026-09-13 — authored from official documentation (mechanical verification only) + +- Course authored against the Kimi Code documentation + (moonshotai.github.io/kimi-code: built-in `coder`/`explore`/`plan` + subagents — `explore` read-only, `plan` without write/shell tools, + subagents cannot spawn subagents — and `kimi acp` exposing the Agent + Client Protocol) and the shared `report-tool` core. `TASK.md`, `tests/`, + `scenario/`, `starter/`, `solution/` are byte-identical to the Claude + Code / Codex course cores — enforced by the `core-group` check in + `tools/verify_courses.py`. +- Environment for mechanical verification: macOS (arm64), Python 3.13. +- Commands and results: + - `python verify.py starter --expect-failure` — exit 0; expected failures + reproduced. + - `python verify.py solution` — exit 0; 9/9 tests pass. + - `python tools/verify_courses.py` — contract satisfied, core identical. +- **Not done:** any run inside Kimi Code. No `kimi` install or account was + used; every Kimi Code behavior described in the lessons is sourced from + official documentation, not observed. + +## 2026-09-13 — 0.0.8 rework: lesson 1 is now tool → Skill → agent fetch (FP-822) + +- L01 (EN+ZH) rewritten around the fixed order: get the tool running → + install the FlyPython Skill (network access included) → let the agent + fetch this course's files via the files endpoint. All "download this + folder" wording removed from `COURSE.md` and lessons. +- The learner's check command across lessons is now the bare + `python verify.py` (FP-820): per-checkpoint status + claim codes, + non-zero exit while gated checkpoints are open. `solution` is no longer + presented as a completion standard. +- Tool-specific Skill install and network steps are authored from the + same official documentation as the course; **not yet exercised inside + the tool** — the pending live run-through below still stands. + +## Pending before this course can be called taught-in-tool + +- One full agent-taught run-through inside Kimi Code ("start lesson 1" + through the Lesson 5 checkpoint) on a current release, recording observed + deviations from `COURSE.md` here — including how the subagents were + actually invoked in the current UI. +- Whether a free-tier Kimi account lasts the whole course is unverified. + +## Deviation log + +(none yet — no live run has happened) diff --git a/courses/hands-on-with-kimi-code/TASK.md b/courses/hands-on-with-kimi-code/TASK.md new file mode 100644 index 0000000..e923183 --- /dev/null +++ b/courses/hands-on-with-kimi-code/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": <row position>, "reason": <short string>}`. 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-kimi-code/TASK_cn.md b/courses/hands-on-with-kimi-code/TASK_cn.md new file mode 100644 index 0000000..7af2609 --- /dev/null +++ b/courses/hands-on-with-kimi-code/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-kimi-code/core-group b/courses/hands-on-with-kimi-code/core-group new file mode 100644 index 0000000..b56b0eb --- /dev/null +++ b/courses/hands-on-with-kimi-code/core-group @@ -0,0 +1 @@ +report-tool diff --git a/courses/hands-on-with-kimi-code/lessons/L01.md b/courses/hands-on-with-kimi-code/lessons/L01.md new file mode 100644 index 0000000..5369446 --- /dev/null +++ b/courses/hands-on-with-kimi-code/lessons/L01.md @@ -0,0 +1,153 @@ +--- +id: course-kimi-code-l01 +type: course +title: "Lesson 1: Set up Kimi Code, install the FlyPython Skill, fetch the course" +summary: "Get Kimi Code running, install the FlyPython Skill with network access approved, and let the agent fetch this course's files — you download nothing. Then make the starter's failure objective." +lang: en-US +content_version: 2 +status: reviewed +reviewed_on: 2026-09-13 +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) — whether you ran it or the agent's shell tool did, the command is the evidence." + - title: "Lanes, not one blob" + body: "Kimi Code ships built-in subagents: `explore` is read-only, `plan` plans without write or shell tools, `coder` can read, write, and run commands. Read-only work belongs in the read-only lane — it cannot break anything." + - 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." +--- + +# Lesson 1: Set up Kimi Code, install the FlyPython Skill, fetch the course + +## Objective + +By the end of this lesson Kimi Code is running, the FlyPython Skill is +installed in it (with network access approved), 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 Kimi Code running + +Install the `kimi` CLI and sign in with +your Moonshot/Kimi account or API key, then start `kimi` in an empty +working folder. + +## 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 tools that keep skills between sessions. + +`kimi` reads `AGENTS.md` from the project root: + +```bash +curl -s https://flypython.com/skills/flypython/SKILL.md >> AGENTS.md +``` + +Approve the agent's network commands when it asks; if blocked, run the +curl yourself and paste the file. + +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 session/chat/thread 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-kimi-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 takes a token once and writes this +course's files to `courses/hands-on-with-kimi-code/`. + +**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. Kimi Code adds a useful division of labor: built-in +subagents split the work — `explore` reads but cannot write, `plan` +plans but has no shell or write tools, and `coder` does the editing. +Surveying through the read-only lane means the exploration cannot break +anything. + +## Warm-up (2 minutes) + +Open `starter/report_tool.py` 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-kimi-code/` 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."** + +While it reads, run the learner's command — 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 have +`explore` confirm, not `coder` 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. Which subagent can change files — and which two cannot? + +This checkpoint's claim code is already in the default command's output — +submit it only once you can answer the questions. + +## 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-kimi-code/lessons/L01_cn.md b/courses/hands-on-with-kimi-code/lessons/L01_cn.md new file mode 100644 index 0000000..866fee1 --- /dev/null +++ b/courses/hands-on-with-kimi-code/lessons/L01_cn.md @@ -0,0 +1,136 @@ +--- +id: course-kimi-code-l01 +type: course +title: "第 1 课:装好 Kimi Code、装上 FlyPython Skill、让 Agent 取回课程" +summary: "先把 Kimi Code 跑起来,装好 FlyPython Skill 并打开联网权限,让 Agent 取回本课文件——你不下载任何东西,然后让 starter 的失败变得客观。" +lang: zh-CN +content_version: 2 +status: reviewed +reviewed_on: 2026-09-13 +hints: + - title: "本检查点考察什么" + body: "先复现失败再动手修:`python verify.py` 必须把 l03 和 l04 显示为 `[open]`(`starter --expect-failure` 会打印点名测试)——你自己跑也好、Agent 的 shell 工具跑也好,命令就是证据。" + - title: "分道,不是一坨" + body: "Kimi Code 自带内置子代理:`explore` 只读、`plan` 没有写和 shell 工具、`coder` 能读写跑命令。只读的活就放只读道——它弄不坏任何东西。" + - title: "完成的标志" + body: "你能说出套件证明了哪九种行为、为什么 starter 伪造不出来。`python verify.py` 随即显示本检查点的认领码。" +--- + +# 第 1 课:装好 Kimi Code、装上 FlyPython Skill、让 Agent 取回课程 + +## 目标 + +本课结束时,Kimi Code 已经跑起来、FlyPython Skill 已装好(联网权限已 +打开),Agent 已把本课文件取回你的工作目录——你没有手动下载任何 +东西。文件夹就位后,你运行课程的客观检查命令,并解释「starter +失败」在测试名里的含义。本课不改任何代码。 + +## 第 1 步——把 Kimi Code 跑起来 + +安装 `kimi` CLI,用你的 Moonshot/Kimi +账号或 API key 登录,在一个空工作目录里启动 `kimi`。 + +## 第 2 步——(可选)预装 FlyPython Skill + +这一步可选:第 3 步的开课句子会直接从 URL 读取 Skill,不装也能上课——预装只是省掉一次权限往返。 + +`kimi` 会读取项目根目录的 `AGENTS.md`: + +```bash +curl -s https://flypython.com/skills/flypython/SKILL.md >> AGENTS.md +``` + +Agent 请求联网命令时批准;如果被禁,就自己跑 curl,把文件粘贴 +过去。 + +Skill 只是一个文件,在每个工具里都一样:它告诉 Agent 如何为你 +授权、取课程文件、验证、提交认领码。 + +## 第 3 步——让 Agent 取回本课文件(你不下载) + +在工作目录里新开一个会话/对话/线程,粘贴这一句话: + +> Read https://flypython.com/skills/flypython/SKILL.md and start the FlyPython course `hands-on-with-kimi-code`. + +Agent 会给你一个授权链接和一组短码。打开链接、登录、核对码与 +Agent 显示的一致后点「允许」,然后对 Agent 说「好了」。它取一次 +token,把本课文件写到 `courses/hands-on-with-kimi-code/`。 + +**前提**:这些课程需要一个能执行命令**并且**能联网的编码 Agent。 +只能聊天的网页 AI 做不了。 + +## 为什么有这一课 + +AI 写的 Python 大多是同一种死法:演示输入能跑,真实输入一到,脚本 +在第 3 行崩掉——或者更糟,悄悄算出一个错的数。修复不从更好的 +提示词开始,而从把「坏了」变成一条任何人都能跑、且得到相同答案的 +命令开始。Kimi Code 带来一种有用的分工:内置子代理把活分开—— +`explore` 只读不写、`plan` 只做规划没有 shell 和写工具、`coder` +负责改代码。用只读道做勘察意味着探索阶段弄不坏任何东西。 + +## 热身(2 分钟) + +打开 `starter/report_tool.py`,从头读到尾。它看起来很合理:带 +docstring 的函数、类型标注、CLI。这正是 AI 生成代码的样子—— +表面干净、行为缺失。现在打开 `TASK.md`,放在手边。 + +## 本课内容 + +本课文件现在已经位于 `courses/hands-on-with-kimi-code/`——你这边没有发生任何下载。 +让 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`),打开它的数据文件,找出会被拒绝的行。先不看运行 +结果,写下每个坏行会触发哪个测试,再对照上面的表自查——并让 +`explore` 确认,而不是让 `coder` 修。 + +## 检查点 + +在课程文件夹里运行 `python verify.py`。不看材料能回答下面三个 +问题即算通过: + +1. 哪五个行为把 starter 和 solution 区分开?其中哪两个失败测试是它们的端到端后果? +2. `python verify.py` 为什么故意以非零退出——它在报告什么状态? + 为什么这是成功条件而不是报错? +3. 哪个子代理能改文件——哪两个不能? + +本检查点的认领码已经在默认命令的输出里——能回答这些问题之后再 +提交。 + +## 预期证据 + +默认命令的完整输出记录,加你的三个答案。保留好——第 4 课还会 +用到。 diff --git a/courses/hands-on-with-kimi-code/lessons/L02.md b/courses/hands-on-with-kimi-code/lessons/L02.md new file mode 100644 index 0000000..ccc1305 --- /dev/null +++ b/courses/hands-on-with-kimi-code/lessons/L02.md @@ -0,0 +1,86 @@ +--- +id: course-kimi-code-l02 +type: course +title: "Lesson 2: Write the task contract, plan it in the planning lane" +summary: 'Turn "make it better" into a bounded, testable contract — then let the plan subagent (no write, no shell) turn it into steps before anything edits.' +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-13 +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: "Plan cannot touch anything" + body: "The built-in `plan` subagent produces a plan without shell or write tools — planning is a separate, side-effect-free lane. Subagents also cannot spawn subagents, so the lanes never recurse out of sight." + - 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, plan it in the planning lane + +## 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 use the planning +lane to decompose the contract before any file is touched. + +## 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. Kimi Code's built-in lanes make the discipline +literal: `plan` can think about the work but holds no write or shell +tools, so planning cannot accidentally become editing. And because +subagents cannot spawn subagents, the work never recurses out of sight. + +## 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 let the planning lane do its job. Tell `kimi`: + +**"Use the plan subagent to turn TASK.md into an ordered implementation +plan for starter/report_tool.py: one step per failing test group, ending +each step with `python verify.py starter`. Do not edit anything yet."** + +Read the plan it returns. It should name the same four behavior groups +you just traced to tests, in an order where each step is independently +verifiable. If a step is "improve error handling," send it back — that is +not a contract line. A plan you can check against `TASK.md` line by line +is the deliverable of this lesson. + +Also note what carries rules across sessions: if the repository root has +an `AGENTS.md`, Kimi Code reads it — durable working rules live in files, +not in a chat you will close. + +## Exercise + +Write one contract line for a script you actually own, using the same +shape: inputs, outputs, error cases, and "done means `<command>` exits +0". Then sketch the two-step plan the `plan` lane should return for it. + +## Checkpoint + +Run `python verify.py` — this checkpoint's claim code prints +when you can answer: + +1. Which `TASK.md` line does + `test_invalid_records_are_isolated_with_reasons` pin, in your own + words? +2. Why does it matter that `plan` has no write or shell tools? +3. Why does "subagents cannot spawn subagents" make review easier? + +## Expected evidence + +Your drafted contract line and the ordered plan you accepted (or the +version you sent back and why). diff --git a/courses/hands-on-with-kimi-code/lessons/L02_cn.md b/courses/hands-on-with-kimi-code/lessons/L02_cn.md new file mode 100644 index 0000000..381138a --- /dev/null +++ b/courses/hands-on-with-kimi-code/lessons/L02_cn.md @@ -0,0 +1,79 @@ +--- +id: course-kimi-code-l02 +type: course +title: "第 2 课:写任务契约,在规划道里拆步" +summary: "把「改好一点」变成有边界、可测试的契约——再让 plan 子代理(无写无 shell)先把它拆成步骤,然后才许动文件。" +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-13 +hints: + - title: "本检查点考察什么" + body: "你能把 `TASK.md` 当成可测试的陈述来读,而不是散文。每个以函数名开头的句子都是套件能断言的契约行。" + - title: "plan 碰不了任何东西" + body: "内置 `plan` 子代理只产出计划,没有 shell 和写工具——规划是一条独立的、无副作用的道。子代理也不能再生子代理,所以分道不会递归到你视野之外。" + - title: "可测试性检查" + body: "判断一句话能不能测:测试套件不看你的心思能不能断言它?数字、退出码、磁盘上的文件——不看感觉。" +--- + +# 第 2 课:写任务契约,在规划道里拆步 + +## 目标 + +你能把 `TASK.md` 当成一组可测试陈述来读,把每条陈述追查到 +`tests/test_report_tool.py` 里的一个测试,并在任何文件被碰之前, +用规划道把契约拆成步骤。 + +## 为什么有这节课 + +含糊的需求产出含糊的代码。「把坏行处理好一点」是许可 Agent 去猜; +「无效行收进 `errors`、带下标和原因,有效行照常产出报告」是给它 +目标、也给你检查的方法。Kimi Code 的内置分道把纪律变成字面事实: +`plan` 能思考工作但手里没有写和 shell 工具,规划不可能失手变成 +改代码。子代理不能再生子代理,工作也不会递归出你的视野。 + +## 本课内容 + +打开 `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` | + +现在让规划道干活。告诉 `kimi`: + +**“用 plan 子代理把 TASK.md 拆成 starter/report_tool.py 的有序 +实现计划:每组失败测试一步,每步以 `python verify.py starter` +收尾。现在不要改任何东西。”** + +读它返回的计划。它应该点名你刚追查到测试的四个行为组,且顺序 +上每一步都可独立验证。如果某步是「改进错误处理」,打回去—— +那不是契约行。一份能逐行对着 `TASK.md` 核对的计划,就是本课的 +交付物。 + +再注意什么在跨会话携带规则:仓库根若有 `AGENTS.md`,Kimi Code +会读它——持久的工作规则活在文件里,不活在你会关掉的对话里。 + +## 练习 + +为你真正拥有的一个脚本写一条契约行,用同样的形状:输入、输出、 +错误情况、以及「完成 = `<命令>` 退出码为 0」。再草拟 `plan` 道 +应该为它返回的两步计划。 + +## 检查点 + +运行 `python verify.py`——能回答下面三题时,本检查点的 +认领码就会显示: + +1. 用你自己的话说,`test_invalid_records_are_isolated_with_reasons` + 钉住的是 `TASK.md` 哪一行? +2. `plan` 没有写和 shell 工具为什么重要? +3. 为什么「子代理不能再生子代理」让审查更容易? + +## 预期证据 + +你起草的契约行,和你接受的(或打回去并说明理由的)那份有序计划。 diff --git a/courses/hands-on-with-kimi-code/lessons/L03.md b/courses/hands-on-with-kimi-code/lessons/L03.md new file mode 100644 index 0000000..4f84987 --- /dev/null +++ b/courses/hands-on-with-kimi-code/lessons/L03.md @@ -0,0 +1,82 @@ +--- +id: course-kimi-code-l03 +type: course +title: "Lesson 3: The bounded change, one contract line at a time" +summary: "Drive the starter to green through the coder lane in bounded steps — and review every edit before the next one lands." +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-13 +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: "Coder is the only lane that edits" + body: "Only the `coder` subagent can write files and run commands — which means every change funnels through one lane you can watch. Keep each step to one contract line and verify between steps." + - title: "When you are done" + body: "Every edit maps to a contract line, and you can explain each one without opening solution/." +--- + +# Lesson 3: The bounded change, one contract line at a time + +## Objective + +The starter passes all nine tests through a sequence of bounded changes, +and you can explain every edit without reading the solution first. + +## Why this lesson exists + +This is the lesson people skip — and the one that builds the actual skill. +Letting an agent rewrite the file in one shot teaches you nothing and +leaves you a change you cannot honestly review. Supervising small edits, +each pinned to a contract line, is how the code stays yours. Kimi Code's +lane split helps: `explore` and `plan` cannot touch files at all — every +edit funnels through `coder`, the one lane you watch. + +## The lesson + +With the plan from Lesson 2 in hand, tell `kimi`: + +**"Execute the plan against starter/report_tool.py with the coder +subagent. 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` and show me the edit +before continuing."** + +Hold it to the contract's boundaries: + +- Only `starter/report_tool.py` changes. If an edit touches `tests/`, + `solution/`, or `scenario/`, stop and ask why. +- No new imports outside the standard library. +- Each change should move toward one contract line. Reject drive-by + refactors ("while I was here I renamed…"). +- If the agent wants to change a test, the answer is no. Tests are the + contract; the code moves. + +Review deliberately between steps. Read the actual edit, not just the +agent's summary of it — when the two disagree, the diff is the truth. A +change you cannot explain is a change you do not keep; roll it back and +re-dispatch the step with a tighter instruction. + +Expect the failing count to drop group by group: 7 → 5 → 4 → 3 → 2 → 0. + +## Exercise + +Pick one edit `coder` made — ideally the validation-isolation change — +and explain it back: "line N does X; that satisfies contract line Y." If +you cannot, ask for a walkthrough of that edit before the next step runs. + +## 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 steps did the bounded change take, and what made each step + bounded? +2. Which edit did you reject or re-dispatch, and why? +3. Point at the edit that implements "isolate invalid rows with reasons" + — where is it? + +## Expected evidence + +A green `python verify.py starter` run, and your edit-by-edit explanation +of the change that earned it. diff --git a/courses/hands-on-with-kimi-code/lessons/L03_cn.md b/courses/hands-on-with-kimi-code/lessons/L03_cn.md new file mode 100644 index 0000000..2aa3b41 --- /dev/null +++ b/courses/hands-on-with-kimi-code/lessons/L03_cn.md @@ -0,0 +1,76 @@ +--- +id: course-kimi-code-l03 +type: course +title: "第 3 课:有界改动,一次一条契约行" +summary: "通过 coder 道的有界步骤把 starter 推到全绿——下一处编辑落地前,先审完这一处。" +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-13 +hints: + - title: "本检查点考察什么" + body: "starter 通过你监督的改动让九个测试全绿——`python verify.py` 只在 starter 套件变绿后显示本检查点的码。" + - title: "coder 是唯一能编辑的道" + body: "只有 `coder` 子代理能写文件、跑命令——每处改动都汇进你盯着的这一条道。每步守住一条契约行,步间跑验证。" + - title: "完成的标志" + body: "每处编辑都能对上一条契约行,而且不看 solution/ 你也讲得清每一处改动。" +--- + +# 第 3 课:有界改动,一次一条契约行 + +## 目标 + +starter 通过一串有界改动让九个测试全绿,并且不看 solution,你也 +讲得清每一处编辑。 + +## 为什么有这节课 + +这节课最容易被跳过——也恰恰是长本事的那节。放任 Agent 一次重写 +整个文件,什么也学不到,只留下一份你没底气审查的改动。监督一连串 +小编辑、各自钉在一条契约行上,才能让 AI 写的代码仍然是你的。 +Kimi Code 的分道帮了忙:`explore` 和 `plan` 根本碰不了文件—— +每处编辑都汇进 `coder` 这一条你要盯的道。 + +## 本课内容 + +拿着第 2 课的计划,告诉 `kimi`: + +**“用 coder 子代理按计划改 starter/report_tool.py。一次只修一组 +失败测试:先 JSON 加载,再无效行隔离,再舍入,再原子写,最后是 +端到端测试。每修完一组跑 `python verify.py starter`,先给我看 +编辑再继续。”** + +守住契约边界: + +- 只动 `starter/report_tool.py`。编辑碰到 `tests/`、`solution/`、 + `scenario/` 就停下问为什么。 +- 标准库之外不加 import。 +- 每处改动朝一条契约行推进。顺手重构(「我顺便重命名了……」) + 一律拒绝。 +- Agent 想改测试,答案是不行。测试是契约;动的是代码。 + +步与步之间认真审。读实际的编辑,不只读 Agent 对编辑的总结—— +两者不一致时以 diff 为准。讲不清的改动就不留;回退后把这一步 +用更紧的指令重发。 + +预期失败数逐组下降:7 → 5 → 4 → 3 → 2 → 0。 + +## 练习 + +挑 `coder` 的一处编辑——最好是「无效行隔离」那段——讲回来: +「第 N 行做了 X,满足契约行 Y」。讲不清就先让它走读那处编辑, +再跑下一步。 + +## 检查点 + +运行 `python verify.py`。本检查点的码只在 starter 套件 +全绿时出现。不看材料能回答即算通过: + +1. 这次有界改动走了几步,是什么让每步保持有界? +2. 你拒绝或重发过哪处编辑,为什么? +3. 指出实现「无效行带原因隔离」的那处编辑——它在哪? + +## 预期证据 + +一次全绿的 `python verify.py starter` 运行,和你对这次改动的逐 +处讲解。 diff --git a/courses/hands-on-with-kimi-code/lessons/L04.md b/courses/hands-on-with-kimi-code/lessons/L04.md new file mode 100644 index 0000000..19dc8cc --- /dev/null +++ b/courses/hands-on-with-kimi-code/lessons/L04.md @@ -0,0 +1,79 @@ +--- +id: course-kimi-code-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 the scenario skins, and review the accumulated diff the way you would review a colleague's." +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-13 +hints: + - title: "What this checkpoint tests" + body: "That starter AND solution both pass — `verify.py` prints this code only when both suites are green. The gate is the pair, not a single run." + - title: "Review the delta, not the vibes" + body: "The working-tree diff 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: + +```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/<skin>/` folder and run + the tool on it: `python starter/report_tool.py scenario/<skin>`. Read + the produced report against the input file — recompute one group total + by hand. +2. **Review the accumulated diff.** `git diff` shows every hunk that + landed across the session. 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. +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. + +## 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 only when +both suites pass. 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-kimi-code/lessons/L04_cn.md b/courses/hands-on-with-kimi-code/lessons/L04_cn.md new file mode 100644 index 0000000..0dd51ab --- /dev/null +++ b/courses/hands-on-with-kimi-code/lessons/L04_cn.md @@ -0,0 +1,74 @@ +--- +id: course-kimi-code-l04 +type: course +title: "第 4 课:像评审者一样验证,别当观众" +summary: "测试全绿是地板不是天花板——两个套件都跑、三个皮肤都走一遍、按评审同事的标准审这份累积 diff。" +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-13 +hints: + - title: "本检查点考察什么" + body: "starter 和 solution 双双通过——`verify.py` 只在两个套件都绿时打印本检查点的码。门槛是一对结果,不是一次运行。" + - title: "审增量,不凭感觉" + body: "工作区 diff 展示整个会话累积的改动。按评审者的方式读:每个 hunk 对上一条契约行、没有多余挪动、没有测试被放松。" + - title: "完成的标志" + body: "你已写下三行证据——已验证 / 未验证 / 已知局限——并且能在代码评审里为这次改动辩护。" +--- + +# 第 4 课:像评审者一样验证,别当观众 + +## 目标 + +两个套件都通过,你手握一份书面记录:说清绿色运行证明了什么、没 +证明什么——外加一份你敢向同事辩护的 diff。 + +## 为什么有这节课 + +「测试全过」是大多数 AI 辅助工作停下的地方——也是大多数隐性 +bug 开始的地方。套件只证明它断言过的契约行;没人写过测试的行, +它一概不担保。验证是你写的一份报告,不是一种感觉。 + +## 本课内容 + +跑完整的一对命令: + +```bash +python verify.py starter # 你监督出来的实现 +python verify.py solution # 评审过的参考实现 +``` + +双全绿意味着契约*按字面*被满足。现在做套件替不了你的评审: + +1. **走完一个皮肤。** 挑一个 `scenario/<skin>/` 文件夹,用工具跑 + 它:`python starter/report_tool.py scenario/<skin>`。对照输入 + 文件读产出的报告——亲手重算一个分组总计。 +2. **审累积的 diff。** `git diff` 展示整个会话落地的每个 hunk。 + 从头读到尾。每个 hunk 都应能对上你在第 2 课点名的契约行。 + 对不上的就是范围蔓延——回退或让它解释。 +3. **检查测试没钉的边界。** 喂给工具一个空 CSV、一个全是无效行 + 的 CSV、一个不存在的文件。契约没钉这些——你的判断钉。记下 + 它的行为。 + +## 练习 + +为这次改动写三行证据记录: + +- **已验证:** 你跑过的命令和它们证明了什么。 +- **未验证:** 没有测试钉住的输入与行为。 +- **已知局限:** 你看到的、契约从未承诺过的东西。 + +写得诚实——「未验证」是一份事实清单,不是忏悔书。 + +## 检查点 + +运行 `python verify.py`。本检查点的码只在两个套件都过 +时打印。不看材料能回答即算通过: + +1. 你手工重算的分组总计结果如何? +2. 说出一个套件没钉的输入,以及工具对它的实际行为。 +3. 哪个 hunk(如果有)你会请同事来解释? + +## 预期证据 + +两次全绿运行、你的三行证据记录、diff 评审笔记。 diff --git a/courses/hands-on-with-kimi-code/lessons/L05.md b/courses/hands-on-with-kimi-code/lessons/L05.md new file mode 100644 index 0000000..7d5f4e8 --- /dev/null +++ b/courses/hands-on-with-kimi-code/lessons/L05.md @@ -0,0 +1,81 @@ +--- +id: course-kimi-code-l05 +type: course +title: "Lesson 5: Take the workflow to your own project" +summary: "The pattern generalizes: contract first, read-only survey, planning lane, bounded edits, written verification — run the same loop on a script you actually own." +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-13 +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 reviewed diffs, 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, survey read-only, plan before edits, one bounded change at a time, verification written down. Where your editor supports it, `kimi acp` exposes the same agent over the Agent Client Protocol." + - title: "When you are done" + body: "Your own script 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, read-only survey, plan, bounded +edits, written verification — to a script or project 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 `<command>` exits 0". One short paragraph, every + sentence testable. +2. **Rules that persist.** Put the working rules in the project's + `AGENTS.md` — the files the agent may touch, the verification command, + the dependency policy. +3. **Lanes in order.** Survey with `explore`, decompose with `plan`, then + let `coder` execute one contract line at a time, reviewing each edit + before the next. +4. **Verify.** Run the verification command, 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 you work in an editor that speaks the Agent Client Protocol, +`kimi acp` exposes the same agent over JSON-RPC — the loop does not +change, only the window it runs in does. + +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: claim it when you can answer: + +1. What was your contract's "done means" command? +2. Which edit 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 rules 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-kimi-code/lessons/L05_cn.md b/courses/hands-on-with-kimi-code/lessons/L05_cn.md new file mode 100644 index 0000000..b50f436 --- /dev/null +++ b/courses/hands-on-with-kimi-code/lessons/L05_cn.md @@ -0,0 +1,70 @@ +--- +id: course-kimi-code-l05 +type: course +title: "第 5 课:把工作流带回自己的项目" +summary: "模式会泛化:契约先行、只读勘察、规划道拆步、有界编辑、书面验证——在你真正拥有的脚本上跑同一套闭环。" +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-13 +hints: + - title: "本检查点考察什么" + body: "自我报告:你确认把这套闭环用到了自己的项目上。证据是你的契约、你审过的 diff、你的验证记录——不是我们查得了的码。" + - title: "带走形状,不是代码" + body: "能迁移的是闭环:契约先行、只读勘察、先规划后编辑、一次一处有界改动、验证写下来。在支持的编辑器里,`kimi acp` 通过 Agent Client Protocol 暴露同一个 Agent。" + - title: "完成的标志" + body: "你自己的脚本有了一份契约、一处你讲得清的有界改动、一份已验证/未验证/已知局限记录——或一条写清楚的「闭环不合身」的原因。" +--- + +# 第 5 课:把工作流带回自己的项目 + +## 目标 + +你把整套闭环——契约、只读勘察、规划、有界编辑、书面验证——用到 +自己真正拥有的脚本或项目上,并记录发生了什么。 + +## 为什么有这节课 + +课程文件夹是安全沙箱;你自己的代码库不是。前四课的意义从来不是 +那个报表工具——而是一种可复用的、让你始终掌控产出的 Agent 驱动 +方式。本检查点要证明迁移真的发生了。它是自我报告的:闭环跑没跑 +过只有你知道。 + +## 本课内容 + +挑一个你真正拥有的脚本——一个你拖了很久的 bug,或一个想要但 +还没写的行为。然后走同样的五步: + +1. **契约。** 先写任务契约:输入、输出、错误情况、以及「完成 = + `<命令>` 退出码为 0」。一小段,每句都可测试。 +2. **能留存的规则。** 把工作规则写进项目的 `AGENTS.md`——Agent + 可以碰哪些文件、验证命令是什么、依赖政策。 +3. **按道序走。** `explore` 勘察、`plan` 拆步,然后让 `coder` + 一次执行一条契约行,每处编辑审完再走下一处。 +4. **验证。** 跑验证命令,然后做套件做不了的评审——拿一个真实 + 输入端到端走一遍,手工核对输出。 +5. **记录。** 写三行:已验证 / 未验证 / 已知局限。 + +如果你的编辑器支持 Agent Client Protocol,`kimi acp` 用 JSON-RPC +暴露同一个 Agent——闭环不变,变的只是它运行在哪个窗口里。 + +如果闭环跟你打架——任务小到不需要契约,或模糊到不可测——那也 +是发现。写下形状不合身的地方;这个判断本身就是真本事。 + +## 练习 + +如果你的项目还有不止一处值得钉住的边界,现在就起草下一条契约行 +——开下一个会话时你会用得上。 + +## 检查点 + +本检查点自我报告:能回答下面三题时即可认领: + +1. 你的契约里「完成 =」命令是什么? +2. 你在哪处编辑上顶回去过? +3. 你的「未验证」清单上有什么是陌生人会以为已覆盖的? + +## 预期证据 + +你的契约段落、你写的规则文件、三行验证记录。它们是你自己的—— +是工作流完成迁移的证明。 diff --git a/courses/hands-on-with-kimi-code/scenario/api-tool/scenario.json b/courses/hands-on-with-kimi-code/scenario/api-tool/scenario.json new file mode 100644 index 0000000..e17e7c2 --- /dev/null +++ b/courses/hands-on-with-kimi-code/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-kimi-code/scenario/api-tool/tickets.json b/courses/hands-on-with-kimi-code/scenario/api-tool/tickets.json new file mode 100644 index 0000000..03e35b7 --- /dev/null +++ b/courses/hands-on-with-kimi-code/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-kimi-code/scenario/data-monitor/metrics.csv b/courses/hands-on-with-kimi-code/scenario/data-monitor/metrics.csv new file mode 100644 index 0000000..15a5a15 --- /dev/null +++ b/courses/hands-on-with-kimi-code/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-kimi-code/scenario/data-monitor/scenario.json b/courses/hands-on-with-kimi-code/scenario/data-monitor/scenario.json new file mode 100644 index 0000000..b103477 --- /dev/null +++ b/courses/hands-on-with-kimi-code/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-kimi-code/scenario/excel-report/orders.csv b/courses/hands-on-with-kimi-code/scenario/excel-report/orders.csv new file mode 100644 index 0000000..a41fca1 --- /dev/null +++ b/courses/hands-on-with-kimi-code/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-kimi-code/scenario/excel-report/scenario.json b/courses/hands-on-with-kimi-code/scenario/excel-report/scenario.json new file mode 100644 index 0000000..66b2bd9 --- /dev/null +++ b/courses/hands-on-with-kimi-code/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-kimi-code/solution/report_tool.py b/courses/hands-on-with-kimi-code/solution/report_tool.py new file mode 100644 index 0000000..65ba0bf --- /dev/null +++ b/courses/hands-on-with-kimi-code/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 <scenario-dir>", 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-kimi-code/starter/report_tool.py b/courses/hands-on-with-kimi-code/starter/report_tool.py new file mode 100644 index 0000000..019edb8 --- /dev/null +++ b/courses/hands-on-with-kimi-code/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 <scenario-dir>", 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-kimi-code/tests/test_report_tool.py b/courses/hands-on-with-kimi-code/tests/test_report_tool.py new file mode 100644 index 0000000..3fbc19f --- /dev/null +++ b/courses/hands-on-with-kimi-code/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-kimi-code/verify.py b/courses/hands-on-with-kimi-code/verify.py new file mode 100644 index 0000000..54bf486 --- /dev/null +++ b/courses/hands-on-with-kimi-code/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-kimi-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 = '3b3e610344c18ba5' + +CHECKPOINTS = [ + {"id": "l01", "gate": "attest", "title": '复现故障 / Reproduce the failure'}, + {"id": "l02", "gate": "attest", "title": '任务契约 / Task contract'}, + {"id": "l03", "gate": "starter-suite", "title": '子 Agent 协作的有界改动 / Bounded change with sub-agents'}, + {"id": "l04", "gate": "both-suites", "title": '验证与审查 / Verify 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-openai-codex/COURSE.md b/courses/hands-on-with-openai-codex/COURSE.md new file mode 100644 index 0000000..468808e --- /dev/null +++ b/courses/hands-on-with-openai-codex/COURSE.md @@ -0,0 +1,113 @@ +--- +id: course-codex-cli +type: course +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. +lang: en-US +content_version: 4 +status: reviewed +reviewed_on: 2026-09-13 +badge: + id: course-codex-cli + name_en: Reproduce with Codex in the loop + name_zh: Codex 协同复现 + requires: All five checkpoints claimed (L01–L05) +course_id: course-codex-cli +--- + +# Hands-on with the OpenAI Codex App + +> TL;DR: set up the Codex app, install the FlyPython Skill, and let the agent +> fetch this course — lesson 1 walks you through all three steps and you +> download nothing. Start a thread and say **"start lesson 1"**. You finish with a working +> report tool, a reproducible pass/fail command, and the desktop-agent +> workflow: threads, approvals, AGENTS.md, and diff review. The exercise core +> is the same one the Claude Code course uses — 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 the Codex app agent teaching this course, follow these rules: + +- **Audience:** a project owner who has working-with-AI experience but is stuck + on reliability — the script runs, then breaks on real data. Not a Python + beginner tutorial; not a prompt-engineering course. +- **Prerequisites:** Python 3.11+ on PATH and the Codex desktop app (macOS or + Windows) signed in with a ChatGPT account. No packages to install — the + course is standard library only. Whether a free ChatGPT tier lasts the whole + course is unverified at authoring time — see `REVIEW.md`. +- **Tool:** authored against the Codex desktop app as documented on + developers.openai.com/codex (thread model, approval modes, AGENTS.md + instruction chain). A live teaching run-through is pending — `REVIEW.md` + records what has and has not been exercised. A Codex major release + 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. Read `AGENTS.md` in the repository root if one exists — it + is part of your instruction chain. +- **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 while the gated checkpoints + (l03, l04) are still open. `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 + +Installing and billing for the Codex app, Codex CLI flags, CI/CD cloud tasks, +MCP server setup, or selling anything. For those, the companion repository has +guides and playbooks — link, don't improvise. + +## Badge contract + +- 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 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/<skin>/ 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-openai-codex/COURSE_cn.md b/courses/hands-on-with-openai-codex/COURSE_cn.md new file mode 100644 index 0000000..4dbd825 --- /dev/null +++ b/courses/hands-on-with-openai-codex/COURSE_cn.md @@ -0,0 +1,102 @@ +--- +id: course-codex-cli +type: course +title: "上手 OpenAI Codex 应用" +summary: "经过验证的 Python 工作流——任务契约、有界改动、客观 verify.py 证据——在 Codex 桌面应用里手把手教:由 Agent 线程跑闭环,你监督 diff 与审批。" +lang: zh-CN +content_version: 4 +status: reviewed +reviewed_on: 2026-09-13 +badge: + id: course-codex-cli + name_en: Reproduce with Codex in the loop + name_zh: Codex 协同复现 + requires: 认领全部五个检查点(L01–L05) +course_id: course-codex-cli +--- + +# 上手 OpenAI Codex 应用 + +> 一句话:装好 Codex 桌面应用、装上 FlyPython Skill、让 Agent 取回本课文件 +> (第 1 课就是这三步,你什么都不用下载),开一个线程,对它说**「开始第 1 课」**。完成时你会得到一个能跑的报表工具、一条 +> 可复现的通过/失败命令,以及桌面 Agent 工作流:线程、审批、 +> AGENTS.md 与 diff 审查。练习核心与 Claude Code 课程完全相同—— +> 变的只是你驱动的工具。 + +## 你要做的东西 + +一个小型 Python 报表工具:读进脏乱的真实数据(CSV 或 JSON)、隔离 +无效行而不是直接崩溃、聚合有效行、原子地写出报告。课程自带三个 +「场景皮肤」,让你在自己熟悉的领域里练习: + +| 皮肤 | 你是…… | 数据 | +| --- | --- | --- | +| `scenario/excel-report/` | 把每周订单导出整理成区域汇总 | `orders.csv` | +| `scenario/data-monitor/` | 检查自家服务器的延迟数据 | `metrics.csv` | +| `scenario/api-tool/` | 汇总从内部 API 拉取的工单数据 | `tickets.json` | + +三个皮肤跑的是同一份代码、同一套测试。真正的产品是这些技能——契约、 +错误隔离、舍入、原子写、验证——皮肤只是让它们变得具体。 + +## 教学契约(先读,Agent) + +如果你是教授本课程的 Codex 应用 Agent,请遵守以下规则: + +- **受众:** 有 AI 协作经验、但困在可靠性上的项目所有者——脚本能跑, + 一遇真实数据就坏。这不是 Python 入门教程,也不是提示词工程课。 +- **前置条件:** PATH 上有 Python 3.11+,装有 Codex 桌面应用 + (macOS 或 Windows)并已登录 ChatGPT 账号。无需安装任何包—— + 课程只用标准库。免费 ChatGPT 档能否撑完整门课在编写时未经证实 + ——见 `REVIEW.md`。 +- **工具:** 按 developers.openai.com/codex 上文档描述的 Codex 桌面 + 应用编写(线程模型、审批模式、AGENTS.md 指令链)。真实授课实跑 + 尚待完成——`REVIEW.md` 记录已验证与未验证的部分。Codex 主版本 + 发布会触发课程重审。 +- **课序:** L01 → L02 → L03 → L04 → L05,一次课一个会话。 + 绝不跳过课尾的检查点。 +- **教学风格:** 从本文件夹里的文件出发,不要凭通用知识发挥。引用 + 你正在满足的那条契约原文。做让测试从红变绿的最小改动。绝不加 + 依赖、绝不改 `solution/`、动当前课程未点名的任何文件前先询问。 + 仓库根若有 `AGENTS.md`,读它——它属于你的指令链。 +- **何时停下:** 一节课的检查点命令跑通、且学习者能用自己的话说清 + 哪里坏了、为什么,这节课才算完。学习者说不出就从失败测试重新教, + 不要从 solution 教。 +- **如何使用 `verify.py`:** 学员命令就是 `python verify.py`——它跑套件、 + 打印逐检查点状态与已获认领码,在门控检查点(l03、l04)未全通过时以 + 非零退出。`starter --expect-failure` 与 `solution` 留作维护者检查。 + 「完成」指学习者做完有界改动后,默认命令把所有门显示为 `[passed]`。- **诚实规则:** 说清你没验证什么。不要声称代码达到生产可用。不要 + 承诺学习或职业结果。 + +## 本课程不覆盖什么 + +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 为学习者自我报告。 +- 提交:测试通过的检查点打印确定性认领码;自报检查点要先回答课后问题,再运行 `python verify.py --attest ID` 才打印码;在 flypython.com 上记入你的账号。这是自我报告的证据,从来不是证书。 + +## 文件夹地图 + +``` +COURSE.md / COURSE_cn.md 本文件(EN / 中文) +lessons/L01.md … L05.md 课文(每课都有一个 _cn.md 对) +scenario/<skin>/ 每个皮肤的数据文件与 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-openai-codex/REVIEW.md b/courses/hands-on-with-openai-codex/REVIEW.md new file mode 100644 index 0000000..13ad8f9 --- /dev/null +++ b/courses/hands-on-with-openai-codex/REVIEW.md @@ -0,0 +1,72 @@ +# Maintainer run-through record + +## 2026-09-13 — content rewrite: CLI course → Codex desktop app course + +- Folder renamed `hands-on-with-openai-codex-cli` → + `hands-on-with-openai-codex`; `course_id` (`course-codex-cli`) and claim + salt preserved so existing claims and codes remain valid. +- `COURSE.md`/`COURSE_cn.md` and all ten lesson files rewritten against + the Codex app as documented at developers.openai.com/codex (thread + model, approval modes, AGENTS.md instruction chain, diff review). + CLI-specific teaching was removed; `TASK.md`, `tests/`, `scenario/`, + `starter/`, `solution/` are byte-identical to the Claude Code course + core (shared-core group `report-tool`, enforced by + `tools/verify_courses.py`). +- **Not done in this pass:** a live teaching run-through inside the Codex + desktop app. No Codex account/app session was used; the content is + authored from official documentation and the previously verified task + core. Teaching quality in the real app remains pending below. + +## 2026-09-12 — contract verification (mechanical) + +- Environment: macOS (arm64), Python 3.13; repository clean checkout on + branch `feat/0.0.3-courses-and-radar`. +- The code core is the reviewed Claude Code course core reused per the 0.0.3 + plan §2.2 ("reuse C1 skins"): same starter/solution/tests contract. +- Commands and results: + - `python verify.py starter --expect-failure` — exit 0; all seven expected + failure names reproduced. + - `python verify.py solution` — exit 0; 9/9 tests pass. + - All three skins exercised end to end by the shared suite. + +## Pending before this rewrite can be called taught-in-tool + +- One full agent-taught run-through inside the Codex desktop app + ("start lesson 1" through the Lesson 5 checkpoint), recording observed + deviations from `COURSE.md` here; the recording doubles as demo-video + source material. +- Whether a free ChatGPT tier lasts the whole course is unverified. + +## 2026-09-13 — 0.0.8 rework: lesson 1 is now tool → Skill → agent fetch (FP-822) + +- L01 (EN+ZH) rewritten around the fixed order: get the tool running → + install the FlyPython Skill (network access included) → let the agent + fetch this course's files via the files endpoint. All "download this + folder" wording removed from `COURSE.md` and lessons. +- The learner's check command across lessons is now the bare + `python verify.py` (FP-820): per-checkpoint status + claim codes, + non-zero exit while gated checkpoints are open. `solution` is no longer + presented as a completion standard. +- Tool-specific Skill install and network steps are authored from the + same official documentation as the course; **not yet exercised inside + the tool** — the pending live run-through below still stands. + +## Deviation log + +### 2026-09-13 — agent solvability run (challenge mode, not taught mode) + +- Agent: Devin (SWE-2 Max), CLI session on macOS arm64, system Python + 3.14 (stdlib only — this course needs no third-party deps). +- Method: implemented `starter/report_tool.py` from `TASK.md` and the + test expectations only; did not read or copy `solution/`. +- Result: `python verify.py starter` exits 0 (all nine tests); + `verify.py progress` printed all five claim codes (l03/l04 + `[passed]`, l01/l02/l05 `[attest]`). +- Observed deviations: none — contract landed on the first pass. This + course shares its task core (TASK/tests/scenario) with + hands-on-python-with-claude-code, and the same implementation passes + both suites. +- Limitations: challenge-mode run (TASK.md → tests), not the taught + COURSE.md walkthrough — teaching quality remains separately pending. +- Starter restored to the deliberately-unfinished state after the run + (`--expect-failure` verified again). diff --git a/courses/hands-on-with-openai-codex/TASK.md b/courses/hands-on-with-openai-codex/TASK.md new file mode 100644 index 0000000..e923183 --- /dev/null +++ b/courses/hands-on-with-openai-codex/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": <row position>, "reason": <short string>}`. 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-openai-codex/TASK_cn.md b/courses/hands-on-with-openai-codex/TASK_cn.md new file mode 100644 index 0000000..7af2609 --- /dev/null +++ b/courses/hands-on-with-openai-codex/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-openai-codex/core-group b/courses/hands-on-with-openai-codex/core-group new file mode 100644 index 0000000..b56b0eb --- /dev/null +++ b/courses/hands-on-with-openai-codex/core-group @@ -0,0 +1 @@ +report-tool diff --git a/courses/hands-on-with-openai-codex/lessons/L01.md b/courses/hands-on-with-openai-codex/lessons/L01.md new file mode 100644 index 0000000..cb05ea1 --- /dev/null +++ b/courses/hands-on-with-openai-codex/lessons/L01.md @@ -0,0 +1,153 @@ +--- +id: course-codex-cli-l01 +type: course +title: "Lesson 1: Set up the Codex app, install the FlyPython Skill, fetch the course" +summary: "Get the Codex app running, install the FlyPython Skill with network access approved, and let the agent fetch this course's files — you download nothing. Then make the starter's failure objective." +lang: en-US +content_version: 3 +status: reviewed +reviewed_on: 2026-09-13 +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 the Codex app the thread is where the work happens — the command is still the evidence." + - title: "Threads are tasks, not chats" + body: "A Codex thread is one bounded job: your prompt plus the agent's tool calls and diffs. Keep this thread to 'reproduce and explain' — the fix belongs to lesson 3, in its own thread." + - title: "When you are done" + body: "You can say out loud which nine behaviors the suite proves and why the starter cannot fake them. `python verify.py` then shows this checkpoint's claim code." +--- + +# Lesson 1: Set up the Codex app, install the FlyPython Skill, fetch the course + +## Objective + +By the end of this lesson the Codex app is running, the FlyPython Skill is +installed in it (with network access approved), 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 the Codex app running + +Install the OpenAI Codex desktop app, +sign in, and open an empty folder as the project. + +## 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 tools that keep skills between sessions. + +The app reads `AGENTS.md` from the project — append the Skill to it: + +```bash +curl -s https://flypython.com/skills/flypython/SKILL.md >> AGENTS.md +``` + +Start the thread in that folder. Network: the app approves commands and +web access per thread — approve them when asked. (The Codex CLI's default +sandbox blocks network; if your setup blocks the fetch, run the curl +yourself in a terminal — the agent reads the file from disk.) + +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 session/chat/thread 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-openai-codex`. + +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-openai-codex/`. + +**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. The +Codex app keeps that command and the agent's work in one place: a thread is a +task with its own context, diffs, and approvals — not a scrolling chat. + +## Warm-up (2 minutes) + +Open `starter/report_tool.py` in the app's file panel 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-openai-codex/` 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."** + +While it reads, run the learner's command — 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.) +That pair is your completion evidence for the whole course. + +## 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 thread +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. Where in the thread can you see the command the agent ran — and who + approved it? + +This checkpoint's claim code is already in the default command's output — +submit it only once you can answer the questions. + +## 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-openai-codex/lessons/L01_cn.md b/courses/hands-on-with-openai-codex/lessons/L01_cn.md new file mode 100644 index 0000000..416a8a9 --- /dev/null +++ b/courses/hands-on-with-openai-codex/lessons/L01_cn.md @@ -0,0 +1,134 @@ +--- +id: course-codex-cli-l01 +type: course +title: "第 1 课:装好 the Codex app、装上 FlyPython Skill、让 Agent 取回课程" +summary: "先把 the Codex app 跑起来,装好 FlyPython Skill 并打开联网权限,让 Agent 取回本课文件——你不下载任何东西,然后让 starter 的失败变得客观。" +lang: zh-CN +content_version: 3 +status: reviewed +reviewed_on: 2026-09-13 +hints: + - title: "本检查点考察什么" + body: "先复现失败再动手修:`python verify.py` 必须把 l03 和 l04 显示为 `[open]`(`starter --expect-failure` 会打印点名测试)。在 Codex 应用里,线程是干活的地方——但命令仍然是证据。" + - title: "线程是任务,不是聊天" + body: "Codex 线程是一个有边界的任务:你的提示加 Agent 的工具调用和 diff。这个线程只做「复现并解释」——修复属于第 3 课,开新线程做。" + - title: "完成的标志" + body: "你能说出套件证明了哪九种行为、为什么 starter 伪造不出来。`python verify.py` 随即显示本检查点的认领码。" +--- + +# 第 1 课:装好 the Codex app、装上 FlyPython Skill、让 Agent 取回课程 + +## 目标 + +本课结束时,the Codex app 已经跑起来、FlyPython Skill 已装好(联网权限已 +打开),Agent 已把本课文件取回你的工作目录——你没有手动下载任何 +东西。文件夹就位后,你运行课程的客观检查命令,并解释「starter +失败」在测试名里的含义。本课不改任何代码。 + +## 第 1 步——把 the Codex app 跑起来 + +安装 OpenAI Codex 桌面应用并登录, +把一个空文件夹作为项目打开。 + +## 第 2 步——(可选)预装 FlyPython Skill + +这一步可选:第 3 步的开课句子会直接从 URL 读取 Skill,不装也能上课——预装只是省掉一次权限往返。 + +应用会读取项目里的 `AGENTS.md`——把 Skill 追加进去: + +```bash +curl -s https://flypython.com/skills/flypython/SKILL.md >> AGENTS.md +``` + +在这个文件夹里开线程。联网:应用按线程批准命令和网络访问——被 +询问时批准。(Codex 命令行版默认沙箱禁网;如果你的环境取不到 +文件,就在终端里自己跑 curl——Agent 会从磁盘读它。) + +Skill 只是一个文件,在每个工具里都一样:它告诉 Agent 如何为你 +授权、取课程文件、验证、提交认领码。 + +## 第 3 步——让 Agent 取回本课文件(你不下载) + +在工作目录里新开一个会话/对话/线程,粘贴这一句话: + +> Read https://flypython.com/skills/flypython/SKILL.md and start the FlyPython course `hands-on-with-openai-codex`. + +Agent 会给你一个授权链接和一组短码。打开链接、登录、核对码与 +Agent 显示的一致后点「允许」,然后对 Agent 说「好了」。它取一次 +token,把本课文件写到 `courses/hands-on-with-openai-codex/`。 + +**前提**:这些课程需要一个能执行命令**并且**能联网的编码 Agent。 +只能聊天的网页 AI 做不了。 + +## 为什么有这一课 + +AI 写的 Python 大多是同一种死法:演示输入能跑,真实输入一到,脚本在 +第 3 行崩掉——或者更糟,悄悄算出一个错的数。修复不从更好的提示词 +开始,而从把「坏了」变成一条任何人都能跑、且得到相同答案的命令开始。 +Codex 应用把这条命令和 Agent 的工作放在一处:线程是带着自己上下文、 +diff 和审批的任务,不是一条滚动的聊天记录。 + +## 热身(2 分钟) + +在应用的文件面板里打开 `starter/report_tool.py`,从头读到尾。它看起来 +很合理:带 docstring 的函数、类型标注、CLI。这正是 AI 生成代码的样子 +——表面干净、行为缺失。现在打开 `TASK.md`,放在手边。 + +## 本课内容 + +本课文件现在已经位于 `courses/hands-on-with-openai-codex/`——你这边没有发生任何下载。 +让 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`),打开它的数据文件,找出会被拒绝的行。先不看运行结果, +写下每个坏行会触发哪个测试,再对照上面的表自查——并让线程确认, +而不是让它修。 + +## 检查点 + +在课程文件夹里运行 `python verify.py`。不看材料能回答下面三个 +问题即算通过: + +1. 哪五个行为把 starter 和 solution 区分开?其中哪两个失败测试是它们的端到端后果? +2. `python verify.py` 为什么故意以非零退出——它在报告什么状态? + 为什么这是成功条件而不是报错? +3. 在线程的哪里能看到 Agent 执行过的命令——又是谁批准的? + +本检查点的认领码已经在默认命令的输出里——能回答这些问题之后再 +提交。 + +## 预期证据 + +默认命令的完整输出记录,加你的三个答案。保留好——第 4 课还会用到。 diff --git a/courses/hands-on-with-openai-codex/lessons/L02.md b/courses/hands-on-with-openai-codex/lessons/L02.md new file mode 100644 index 0000000..a031eda --- /dev/null +++ b/courses/hands-on-with-openai-codex/lessons/L02.md @@ -0,0 +1,87 @@ +--- +id: course-codex-cli-l02 +type: course +title: "Lesson 2: Write the task contract, let AGENTS.md carry the rules" +summary: 'Turn "make it better" into a bounded, testable contract — then make it durable so every Codex thread starts with the same rules.' +lang: en-US +content_version: 2 +status: reviewed +reviewed_on: 2026-09-13 +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: "AGENTS.md is the instruction chain" + body: "Codex reads AGENTS.md before doing work: a global file in ~/.codex plus project files from the repo root down. Rules you want every thread to follow live there — not retyped in each prompt." + - 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 AGENTS.md 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 rules a Codex +thread will pick up automatically. + +## 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 the Codex app there is a second failure mode: a rule you +typed into one thread does not exist in the next. The contract fixes the task; +`AGENTS.md` fixes 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. Codex reads `AGENTS.md` files before doing any work — +a global file in `~/.codex`, then project files from the repo root down to +your directory; closer files win. This course's working rules are exactly the +kind of thing that belongs there. Open a scratch file and draft three rules +for this project, for example: + +``` +- Change only starter/report_tool.py. tests/, solution/, scenario/ are read-only. +- One failing test group per turn; run `python verify.py starter` after each. +- Standard library only — no new dependencies. +``` + +Tell the thread: **"Here are the rules I want for this project — write them +to AGENTS.md at the folder root so every future thread starts with them."** +Then start a *new* thread and ask it to summarize its working rules. If it +quotes your three lines back, the instruction chain is working; if not, check +where the file landed. + +## Exercise + +Write one contract line for a script you actually own, using the same shape: +inputs, outputs, error cases, and "done means `<command>` exits 0". Then write +the two rules you would put in that project's AGENTS.md. + +## Checkpoint + +Run `python verify.py` — this checkpoint's claim code prints when +you can answer: + +1. Which `TASK.md` line does `test_invalid_records_are_isolated_with_reasons` + pin, in your own words? +2. Which file carries rules across threads — the prompt, or AGENTS.md? +3. Where does a global `~/.codex/AGENTS.md` sit in precedence versus the + project file? + +## Expected evidence + +Your drafted contract line, your two rules, and the thread's summary of its +own working rules from the new thread. diff --git a/courses/hands-on-with-openai-codex/lessons/L02_cn.md b/courses/hands-on-with-openai-codex/lessons/L02_cn.md new file mode 100644 index 0000000..4f264dd --- /dev/null +++ b/courses/hands-on-with-openai-codex/lessons/L02_cn.md @@ -0,0 +1,81 @@ +--- +id: course-codex-cli-l02 +type: course +title: "第 2 课:写任务契约,让 AGENTS.md 承载规则" +summary: "把「改好一点」变成有边界、可测试的契约——再让它持久化,让每个 Codex 线程都从同一套规则开始。" +lang: zh-CN +content_version: 2 +status: reviewed +reviewed_on: 2026-09-13 +hints: + - title: "本检查点考察什么" + body: "你能把 `TASK.md` 当成可测试的陈述来读,而不是散文。每个以函数名开头的句子都是套件能断言的契约行。" + - title: "AGENTS.md 是指令链" + body: "Codex 干活前先读 AGENTS.md:~/.codex 里的全局文件,加上从仓库根到当前目录的项目文件。想让每个线程都遵守的规则放那里——不要在每个提示里重打。" + - title: "可测试性检查" + body: "判断一句话能不能测:测试套件不看你的心思能不能断言它?数字、退出码、磁盘上的文件——不看感觉。" +--- + +# 第 2 课:写任务契约,让 AGENTS.md 承载规则 + +## 目标 + +你能把 `TASK.md` 当成一组可测试陈述来读,把每条陈述追查到 +`tests/test_report_tool.py` 里的一个测试,并写出 Codex 线程会自动 +读取的持久规则。 + +## 为什么有这节课 + +含糊的需求产出含糊的代码。「把坏行处理好一点」是许可 Agent 去猜; +「无效行收进 `errors`、带下标和原因,有效行照常产出报告」是给它 +目标、也给你检查的方法。在 Codex 应用里还有第二种失败:你在某个 +线程里敲的规则,下一个线程里根本不存在。契约固定任务本身; +`AGENTS.md` 固定工作规则。 + +## 本课内容 + +打开 `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` | + +再看持久的另一半。Codex 做任何工作之前都会先读 `AGENTS.md`—— +`~/.codex` 里的全局文件,然后从仓库根一路向下读项目文件;越靠近 +当前目录的文件优先级越高。本课程的工作规则正是该放那里的东西。 +开一个草稿文件,为本项目起草三条规则,例如: + +``` +- 只改 starter/report_tool.py。tests/、solution/、scenario/ 只读。 +- 每轮只修一个失败测试组;修完跑 `python verify.py starter`。 +- 只用标准库——不加新依赖。 +``` + +告诉线程:**“这些是我给项目定的规则——把它们写到文件夹根的 +AGENTS.md 里,让每个新线程开工就带上。”** 然后开一个*新*线程,让它 +总结自己的工作规则。它把你的三行原样引回来,说明指令链通了;没有 +就检查文件落在哪一层。 + +## 练习 + +为你真正拥有的一个脚本写一条契约行,用同样的形状:输入、输出、 +错误情况、以及「完成 = `<命令>` 退出码为 0」。再写你会放进那个 +项目 AGENTS.md 的两条规则。 + +## 检查点 + +运行 `python verify.py`——能回答下面三题时,本检查点的 +认领码就会显示: + +1. 用你自己的话说,`test_invalid_records_are_isolated_with_reasons` + 钉住的是 `TASK.md` 哪一行? +2. 跨线程携带规则的是提示词还是 AGENTS.md? +3. 全局 `~/.codex/AGENTS.md` 相对项目文件的优先级顺序是什么? + +## 预期证据 + +你起草的契约行、两条规则,以及新线程对自己工作规则的总结。 diff --git a/courses/hands-on-with-openai-codex/lessons/L03.md b/courses/hands-on-with-openai-codex/lessons/L03.md new file mode 100644 index 0000000..6710855 --- /dev/null +++ b/courses/hands-on-with-openai-codex/lessons/L03.md @@ -0,0 +1,84 @@ +--- +id: course-codex-cli-l03 +type: course +title: "Lesson 3: The bounded change, reviewed hunk by hunk" +summary: "Drive the starter to green through bounded turns — and use the app's diff review and approvals to keep every change yours." +lang: en-US +content_version: 2 +status: reviewed +reviewed_on: 2026-09-13 +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: "Approvals are the boundary" + body: "The app asks before running commands and shows each file diff before it lands. Read the command, read the diff — approving blind is how bounded work becomes a rewrite." + - 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 hunk by hunk + +## 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 a thread 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. The Codex app's approval +model exists for exactly this: commands ask before they run, and edits land +as reviewable diffs. + +## The lesson + +Start a fresh thread (a finished task's thread carries stale context) 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` and show me the diff before continuing."** + +Hold it to the contract's boundaries: + +- Only `starter/report_tool.py` changes. If a diff touches `tests/`, + `solution/`, or `scenario/`, stop and 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…"). +- If the agent wants to change a test, the answer is no. Tests are the + contract; the code moves. + +Use the approval prompts deliberately. When the agent proposes a command, +read it — `python verify.py starter` is safe; `pip install` anything is a +stop sign. When it edits a file, open the diff view and read every hunk +before accepting. A diff you cannot explain is a diff you reject. + +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 thread: "line N does X; that satisfies contract +line Y." If you cannot, ask the agent to walk you through its own diff before +accepting the next change. + +## 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 approval prompt did you reject or narrow, and why? +3. Point at the hunk that implements "isolate invalid rows with reasons" — + where is it? + +## Expected evidence + +A green `python verify.py starter` run, and your hunk-by-hunk explanation of +the change that earned it. diff --git a/courses/hands-on-with-openai-codex/lessons/L03_cn.md b/courses/hands-on-with-openai-codex/lessons/L03_cn.md new file mode 100644 index 0000000..a633ee9 --- /dev/null +++ b/courses/hands-on-with-openai-codex/lessons/L03_cn.md @@ -0,0 +1,75 @@ +--- +id: course-codex-cli-l03 +type: course +title: "第 3 课:有界改动,逐块审 diff" +summary: "通过有界的轮次把 starter 推到全绿——并用应用内的 diff 审查与审批机制守住每一处改动。" +lang: zh-CN +content_version: 2 +status: reviewed +reviewed_on: 2026-09-13 +hints: + - title: "本检查点考察什么" + body: "starter 通过你监督的改动让九个测试全绿——`python verify.py` 只在 starter 套件变绿后显示本检查点的码。" + - title: "审批就是边界" + body: "应用会在跑命令前询问、在每个文件 diff 落地前展示。读命令、读 diff——闭眼批准正是「有界工作」变成「整文件重写」的方式。" + - title: "完成的标志" + body: "每个 diff 都能对上一条契约行,而且不看 solution/ 你也讲得清每一处改动。" +--- + +# 第 3 课:有界改动,逐块审 diff + +## 目标 + +starter 通过一串有界改动让九个测试全绿,并且不看 solution,你也讲得清 +Agent 产出的每一个 diff。 + +## 为什么有这节课 + +这节课最容易被跳过——也恰恰是长本事的那节。放任一个线程把文件一次 +重写,什么也学不到,只留下一份你没底气审查的 diff。监督一连串小 +diff、各自钉在一条契约行上,才能让 AI 写的代码仍然是你的。Codex +应用的审批模型就是为这个存在的:命令要先问再跑,编辑以可审查的 +diff 落地。 + +## 本课内容 + +开一个新线程(已完结任务的线程带着过期上下文),说: + +**“按 TASK.md 改 starter/report_tool.py。一次只修一组失败测试:先 +JSON 加载,再无效行隔离,再舍入,再原子写,最后是端到端测试。每修完 +一组跑 `python verify.py starter`,先给我 diff 再继续。”** + +守住契约边界: + +- 只动 `starter/report_tool.py`。diff 碰到 `tests/`、`solution/`、 + `scenario/` 就停下问为什么。 +- 标准库之外不加 import——连与本改动无关的 import 也不要。 +- 每处改动朝一条契约行推进。顺手重构(「我顺便重命名了……」)一律 + 拒绝。 +- Agent 想改测试,答案是不行。测试是契约;动的是代码。 + +认真用审批提示。Agent 提议命令时读它——`python verify.py starter` +安全;`pip install` 任何东西都是停止信号。它改文件时打开 diff 视图, +逐块读完再接受。讲不清的 diff 就拒绝。 + +预期失败数逐组下降:7 → 5 → 4 → 3 → 2 → 0。 + +## 练习 + +挑 Agent 产出的一个 hunk——最好是「无效行隔离」那段——在线程里讲回 +给它听:「第 N 行做了 X,满足契约行 Y」。讲不清就先让它走读自己的 +diff,再接受下一处改动。 + +## 检查点 + +运行 `python verify.py`。本检查点的码只在 starter 套件全绿 +时出现。不看材料能回答即算通过: + +1. 这次有界改动走了几轮,是什么让每轮保持有界? +2. 你拒绝或收窄过哪个审批提示,为什么? +3. 指出实现「无效行带原因隔离」的那个 hunk——它在哪? + +## 预期证据 + +一次全绿的 `python verify.py starter` 运行,和你对这次改动的逐块 +讲解。 diff --git a/courses/hands-on-with-openai-codex/lessons/L04.md b/courses/hands-on-with-openai-codex/lessons/L04.md new file mode 100644 index 0000000..32ebdd7 --- /dev/null +++ b/courses/hands-on-with-openai-codex/lessons/L04.md @@ -0,0 +1,80 @@ +--- +id: course-codex-cli-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 the scenario skins, and review the diff the way you would review a colleague's." +lang: en-US +content_version: 2 +status: reviewed +reviewed_on: 2026-09-13 +hints: + - title: "What this checkpoint tests" + body: "That starter AND solution both pass — `verify.py` prints this code only when both suites are green. The gate is the pair, not a single run." + - title: "Review the delta, not the vibes" + body: "The app keeps the whole thread's diff reviewable. 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 Codex app's thread view makes this concrete: the +diffs, the commands, and their outputs are all on the same task — review them +together. + +## The lesson + +Run the full pair, in the app's terminal or in the thread: + +```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/<skin>/` folder and run the + tool on it: `python starter/report_tool.py scenario/<skin>`. Read the + produced report against the input file — recompute one group total by + hand. +2. **Review the accumulated diff.** In the thread's diff view, read the + whole change top to bottom. Every hunk should map to a contract line you + named in Lesson 2. Anything that does not is scope creep — ask the thread + to justify or revert it. +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. + +## 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 only when +both suites pass. 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-openai-codex/lessons/L04_cn.md b/courses/hands-on-with-openai-codex/lessons/L04_cn.md new file mode 100644 index 0000000..82aa536 --- /dev/null +++ b/courses/hands-on-with-openai-codex/lessons/L04_cn.md @@ -0,0 +1,74 @@ +--- +id: course-codex-cli-l04 +type: course +title: "第 4 课:像评审者一样验证,别当观众" +summary: "测试全绿是地板不是天花板——两个套件都跑、三个皮肤都走一遍、按评审同事的标准审这份 diff。" +lang: zh-CN +content_version: 2 +status: reviewed +reviewed_on: 2026-09-13 +hints: + - title: "本检查点考察什么" + body: "starter 和 solution 双双通过——`verify.py` 只在两个套件都绿时打印本检查点的码。门槛是一对结果,不是一次运行。" + - title: "审增量,不凭感觉" + body: "应用把整个线程的 diff 保持为可审查状态。按评审者的方式读:每个 hunk 对上一条契约行、没有多余挪动、没有测试被放松。" + - title: "完成的标志" + body: "你已写下三行证据——已验证 / 未验证 / 已知局限——并且能在代码评审里为这次改动辩护。" +--- + +# 第 4 课:像评审者一样验证,别当观众 + +## 目标 + +两个套件都通过,你手握一份书面记录:说清绿色运行证明了什么、没证明 +什么——外加一份你敢向同事辩护的 diff。 + +## 为什么有这节课 + +「测试全过」是大多数 AI 辅助工作停下的地方——也是大多数隐性 bug +开始的地方。套件只证明它断言过的契约行;没人写过测试的行,它一概 +不担保。验证是你写的一份报告,不是一种感觉。Codex 应用的线程视图让 +这件事变具体:diff、命令和输出都在同一个任务里——放在一起审。 + +## 本课内容 + +跑完整的一对命令,在应用终端或线程里都行: + +```bash +python verify.py starter # 你监督出来的实现 +python verify.py solution # 评审过的参考实现 +``` + +双全绿意味着契约*按字面*被满足。现在做套件替不了你的评审: + +1. **走完一个皮肤。** 挑一个 `scenario/<skin>/` 文件夹,用工具跑它: + `python starter/report_tool.py scenario/<skin>`。对照输入文件读产出 + 的报告——亲手重算一个分组总计。 +2. **审累积的 diff。** 在线程的 diff 视图里把整份改动从头读到尾。 + 每个 hunk 都应能对上你在第 2 课点名的契约行。对不上的就是范围 + 蔓延——让线程解释或回退。 +3. **检查测试没钉的边界。** 喂给工具一个空 CSV、一个全是无效行的 + CSV、一个不存在的文件。契约没钉这些——你的判断钉。记下它的行为。 + +## 练习 + +为这次改动写三行证据记录: + +- **已验证:** 你跑过的命令和它们证明了什么。 +- **未验证:** 没有测试钉住的输入与行为。 +- **已知局限:** 你看到的、契约从未承诺过的东西。 + +写得诚实——「未验证」是一份事实清单,不是忏悔书。 + +## 检查点 + +运行 `python verify.py`。本检查点的码只在两个套件都过时 +打印。不看材料能回答即算通过: + +1. 你手工重算的分组总计结果如何? +2. 说出一个套件没钉的输入,以及工具对它的实际行为。 +3. 哪个 hunk(如果有)你会请同事来解释? + +## 预期证据 + +两次全绿运行、你的三行证据记录、diff 评审笔记。 diff --git a/courses/hands-on-with-openai-codex/lessons/L05.md b/courses/hands-on-with-openai-codex/lessons/L05.md new file mode 100644 index 0000000..ed6fce6 --- /dev/null +++ b/courses/hands-on-with-openai-codex/lessons/L05.md @@ -0,0 +1,75 @@ +--- +id: course-codex-cli-l05 +type: course +title: "Lesson 5: Take the workflow to your own project" +summary: "The tool generalizes: task contract, bounded thread, approvals, diff review — run the same loop on a script you actually own." +lang: en-US +content_version: 2 +status: reviewed +reviewed_on: 2026-09-13 +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 thread's diffs, 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 thread per step, approvals read before granted, diff reviewed before accepted, verification written down." + - title: "When you are done" + body: "Your own script 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, bounded thread, approvals, diff review, +written verification — to a script or project 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 `<command>` exits 0". One short paragraph, every + sentence testable. +2. **Rules that persist.** Open the project in the Codex app and put the + working rules in its `AGENTS.md` — the files the agent may touch, the + verification command, the dependency policy. The next thread inherits + them. +3. **Bounded thread.** One thread, one contract line at a time. Read every + approval prompt; read every diff hunk before it lands. +4. **Verify.** Run the verification command, 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 thread. + +## Checkpoint + +This checkpoint is self-attested: claim it when you can answer: + +1. What was your contract's "done means" command? +2. Which approval or diff 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 AGENTS.md rules 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-openai-codex/lessons/L05_cn.md b/courses/hands-on-with-openai-codex/lessons/L05_cn.md new file mode 100644 index 0000000..9b8f8fa --- /dev/null +++ b/courses/hands-on-with-openai-codex/lessons/L05_cn.md @@ -0,0 +1,68 @@ +--- +id: course-codex-cli-l05 +type: course +title: "第 5 课:把工作流带回自己的项目" +summary: "工具会泛化:任务契约、有界线程、审批、diff 评审——在你真正拥有的脚本上跑同一套闭环。" +lang: zh-CN +content_version: 2 +status: reviewed +reviewed_on: 2026-09-13 +hints: + - title: "本检查点考察什么" + body: "自我报告:你确认把这套闭环用到了自己的项目上。证据是你的契约、你线程的 diff、你的验证记录——不是我们查得了的码。" + - title: "带走形状,不是代码" + body: "能迁移的是闭环:契约先行、每步一个有界线程、审批先读再放行、diff 审过才落地、验证写下来。" + - title: "完成的标志" + body: "你自己的脚本有了一份契约、一处你讲得清的有界改动、一份已验证/未验证/已知局限记录——或一条写清楚的「闭环不合身」的原因。" +--- + +# 第 5 课:把工作流带回自己的项目 + +## 目标 + +你把整套闭环——契约、有界线程、审批、diff 评审、书面验证——用到 +自己真正拥有的脚本或项目上,并记录发生了什么。 + +## 为什么有这节课 + +课程文件夹是安全沙箱;你自己的代码库不是。前四课的意义从来不是那个 +报表工具——而是一种可复用的、让你始终掌控产出的 Agent 驱动方式。 +本检查点要证明迁移真的发生了。它是自我报告的:闭环跑没跑过只有你 +知道。 + +## 本课内容 + +挑一个你真正拥有的脚本——一个你拖了很久的 bug,或一个想要但还没写 +的行为。然后走同样的五步: + +1. **契约。** 先写任务契约:输入、输出、错误情况、以及「完成 = + `<命令>` 退出码为 0」。一小段,每句都可测试。 +2. **能留存的规则。** 在 Codex 应用里打开项目,把工作规则写进它的 + `AGENTS.md`——Agent 可以碰哪些文件、验证命令是什么、依赖政策。 + 下一个线程自动继承。 +3. **有界线程。** 一个线程、一次一条契约行。每个审批提示都读;每个 + diff 的每块都在落地前读完。 +4. **验证。** 跑验证命令,然后做套件做不了的评审——拿一个真实输入 + 端到端走一遍,手工核对输出。 +5. **记录。** 写三行:已验证 / 未验证 / 已知局限。 + +如果闭环跟你打架——任务小到不需要契约,或模糊到不可测——那也是 +发现。写下形状不合身的地方;这个判断本身就是真本事。 + +## 练习 + +如果你的项目还有不止一处值得钉住的边界,现在就起草下一条契约行 +——开下一个线程时你会用得上。 + +## 检查点 + +本检查点自我报告:能回答下面三题时即可认领: + +1. 你的契约里「完成 =」命令是什么? +2. 你在哪个审批或 diff 上顶回去过? +3. 你的「未验证」清单上有什么是陌生人会以为已覆盖的? + +## 预期证据 + +你的契约段落、你写的 AGENTS.md 规则、三行验证记录。它们是你自己的 +——是工作流完成迁移的证明。 diff --git a/courses/hands-on-with-openai-codex/scenario/api-tool/scenario.json b/courses/hands-on-with-openai-codex/scenario/api-tool/scenario.json new file mode 100644 index 0000000..e17e7c2 --- /dev/null +++ b/courses/hands-on-with-openai-codex/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-openai-codex/scenario/api-tool/tickets.json b/courses/hands-on-with-openai-codex/scenario/api-tool/tickets.json new file mode 100644 index 0000000..03e35b7 --- /dev/null +++ b/courses/hands-on-with-openai-codex/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-openai-codex/scenario/data-monitor/metrics.csv b/courses/hands-on-with-openai-codex/scenario/data-monitor/metrics.csv new file mode 100644 index 0000000..15a5a15 --- /dev/null +++ b/courses/hands-on-with-openai-codex/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-openai-codex/scenario/data-monitor/scenario.json b/courses/hands-on-with-openai-codex/scenario/data-monitor/scenario.json new file mode 100644 index 0000000..b103477 --- /dev/null +++ b/courses/hands-on-with-openai-codex/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-openai-codex/scenario/excel-report/orders.csv b/courses/hands-on-with-openai-codex/scenario/excel-report/orders.csv new file mode 100644 index 0000000..a41fca1 --- /dev/null +++ b/courses/hands-on-with-openai-codex/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-openai-codex/scenario/excel-report/scenario.json b/courses/hands-on-with-openai-codex/scenario/excel-report/scenario.json new file mode 100644 index 0000000..66b2bd9 --- /dev/null +++ b/courses/hands-on-with-openai-codex/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-openai-codex/solution/report_tool.py b/courses/hands-on-with-openai-codex/solution/report_tool.py new file mode 100644 index 0000000..65ba0bf --- /dev/null +++ b/courses/hands-on-with-openai-codex/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 <scenario-dir>", 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-openai-codex/starter/report_tool.py b/courses/hands-on-with-openai-codex/starter/report_tool.py new file mode 100644 index 0000000..019edb8 --- /dev/null +++ b/courses/hands-on-with-openai-codex/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 <scenario-dir>", 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-openai-codex/tests/test_report_tool.py b/courses/hands-on-with-openai-codex/tests/test_report_tool.py new file mode 100644 index 0000000..3fbc19f --- /dev/null +++ b/courses/hands-on-with-openai-codex/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-openai-codex/verify.py b/courses/hands-on-with-openai-codex/verify.py new file mode 100644 index 0000000..e424a37 --- /dev/null +++ b/courses/hands-on-with-openai-codex/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-codex-cli' +# 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 = 'ae2999201a0be1df' + +CHECKPOINTS = [ + {"id": "l01", "gate": "attest", "title": '让 Codex 进环,先复现故障 / Reproduce with Codex in the loop'}, + {"id": "l02", "gate": "attest", "title": '任务契约 / Task contract'}, + {"id": "l03", "gate": "starter-suite", "title": '有边界的变更 / Bounded change'}, + {"id": "l04", "gate": "both-suites", "title": '验证与审查 / Verify 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-zcode/COURSE.md b/courses/hands-on-with-zcode/COURSE.md new file mode 100644 index 0000000..c72017a --- /dev/null +++ b/courses/hands-on-with-zcode/COURSE.md @@ -0,0 +1,112 @@ +--- +id: course-zcode +type: course +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. +lang: en-US +content_version: 2 +status: reviewed +reviewed_on: 2026-09-13 +badge: + id: course-zcode + name_en: Reproduce with ZCode in the loop + name_zh: ZCode 协同复现 + requires: All five checkpoints claimed (L01–L05) +course_id: course-zcode +--- + +# Hands-on with ZCode + +> TL;DR: set up the ZCode desktop app, install the FlyPython Skill, and let +> the agent fetch this course — lesson 1 walks you through all three steps +> and you download nothing. Say **"start lesson 1"**. You finish with a working report tool, +> a reproducible pass/fail command, and the ZCode workflow: Goal Mode task +> decomposition, file references, and the workspace Git state as your review +> surface. The exercise core is the same one the other agent-tool 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 the ZCode agent teaching this course, follow these rules: + +- **Audience:** a project owner who has working-with-AI experience but is + stuck on reliability — the script runs, then breaks on real data. Not a + Python beginner tutorial; not a prompt-engineering course. +- **Prerequisites:** Python 3.11+ on PATH and the ZCode desktop app + installed and signed in. No packages to install — the course is standard + library only. Plan/usage-limit coverage is unverified at authoring time — + see `REVIEW.md`. +- **Tool:** authored against ZCode as documented at zcode.z.ai/docs + (desktop-native workspace, Goal Mode objective management and recovery, + task lists, file references, remote control). A live teaching run-through + is pending — `REVIEW.md` records what has and has not been exercised. + A ZCode major release 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. +- **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 while the gated checkpoints + (l03, l04) are still open. `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 + +ZCode installation and plans, Remote Control pairing setup beyond a +mention, model-provider configuration, or selling anything. For those, the +companion repository has guides and playbooks — link, don't improvise. + +## Badge contract + +- 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 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/<skin>/ 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-zcode/COURSE_cn.md b/courses/hands-on-with-zcode/COURSE_cn.md new file mode 100644 index 0000000..992b4be --- /dev/null +++ b/courses/hands-on-with-zcode/COURSE_cn.md @@ -0,0 +1,100 @@ +--- +id: course-zcode +type: course +title: "上手 ZCode" +summary: "经过验证的 Python 工作流——任务契约、有界改动、客观 verify.py 证据——在 ZCode 桌面工作区里手把手教:Goal 模式拆解任务,Git 状态展示每一处改动。" +lang: zh-CN +content_version: 2 +status: reviewed +reviewed_on: 2026-09-13 +badge: + id: course-zcode + name_en: Reproduce with ZCode in the loop + name_zh: ZCode 协同复现 + requires: 认领全部五个检查点(L01–L05) +course_id: course-zcode +--- + +# 上手 ZCode + +> 一句话:装好 ZCode 桌面应用、装上 FlyPython Skill、让 Agent 取回本课文件 +> (第 1 课就是这三步,你什么都不用下载),对它说**「开始第 1 课」**。完成时你会得到一个能跑的报表工具、一条可复现的 +> 通过/失败命令,以及 ZCode 工作流:Goal 模式任务拆解、文件引用、 +> 以及作为你审查面的工作区 Git 状态。练习核心与其他 Agent 工具课程 +> 完全相同——变的只是你驱动的工具。 + +## 你要做的东西 + +一个小型 Python 报表工具:读进脏乱的真实数据(CSV 或 JSON)、隔离 +无效行而不是直接崩溃、聚合有效行、原子地写出报告。课程自带三个 +「场景皮肤」,让你在自己熟悉的领域里练习: + +| 皮肤 | 你是…… | 数据 | +| --- | --- | --- | +| `scenario/excel-report/` | 把每周订单导出整理成区域汇总 | `orders.csv` | +| `scenario/data-monitor/` | 检查自家服务器的延迟数据 | `metrics.csv` | +| `scenario/api-tool/` | 汇总从内部 API 拉取的工单数据 | `tickets.json` | + +三个皮肤跑的是同一份代码、同一套测试。真正的产品是这些技能——契约、 +错误隔离、舍入、原子写、验证——皮肤只是让它们变得具体。 + +## 教学契约(先读,Agent) + +如果你是教授本课程的 ZCode Agent,请遵守以下规则: + +- **受众:** 有 AI 协作经验、但困在可靠性上的项目所有者——脚本能跑, + 一遇真实数据就坏。这不是 Python 入门教程,也不是提示词工程课。 +- **前置条件:** PATH 上有 Python 3.11+,装有 ZCode 桌面应用并已 + 登录。无需安装任何包——课程只用标准库。套餐/用量额度覆盖在 + 编写时未经证实——见 `REVIEW.md`。 +- **工具:** 按 zcode.z.ai/docs 上文档描述的 ZCode 编写(桌面原生 + 工作区、Goal 模式目标管理与恢复、任务列表、文件引用、远程控制)。 + 真实授课实跑尚待完成——`REVIEW.md` 记录已验证与未验证的部分。 + ZCode 主版本发布会触发课程重审。 +- **课序:** L01 → L02 → L03 → L04 → L05,一次课一个会话。 + 绝不跳过课尾的检查点。 +- **教学风格:** 从本文件夹里的文件出发,不要凭通用知识发挥。引用 + 你正在满足的那条契约原文。做让测试从红变绿的最小改动。绝不加 + 依赖、绝不改 `solution/`、动当前课程未点名的任何文件前先询问。 +- **何时停下:** 一节课的检查点命令跑通、且学习者能用自己的话说清 + 哪里坏了、为什么,这节课才算完。学习者说不出就从失败测试重新教, + 不要从 solution 教。 +- **如何使用 `verify.py`:** 学员命令就是 `python verify.py`——它跑套件、 + 打印逐检查点状态与已获认领码,在门控检查点(l03、l04)未全通过时以 + 非零退出。`starter --expect-failure` 与 `solution` 留作维护者检查。 + 「完成」指学习者做完有界改动后,默认命令把所有门显示为 `[passed]`。- **诚实规则:** 说清你没验证什么。不要声称代码达到生产可用。不要 + 承诺学习或职业结果。 + +## 本课程不覆盖什么 + +ZCode 的安装与套餐、Remote Control 的配对设置(仅提及)、模型供应商 +配置,以及任何推销内容。这些由配套仓库的指南与手册承载——给链接, +不即兴发挥。 + +## 徽章契约 + +- 徽章:**ZCode 协同复现徽章**(badge id `course-zcode`)——认领全部五个检查点获得。 +- 挑战: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/<skin>/ 每个皮肤的数据文件与 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-zcode/REVIEW.md b/courses/hands-on-with-zcode/REVIEW.md new file mode 100644 index 0000000..e171f0d --- /dev/null +++ b/courses/hands-on-with-zcode/REVIEW.md @@ -0,0 +1,46 @@ +# Maintainer run-through record + +## 2026-09-13 — authored from official documentation (mechanical verification only) + +- Course authored against the ZCode documentation (zcode.z.ai/docs: + desktop-native workspace, Goal Mode objective management and recovery, + task lists, file references, remote control from a phone while the + desktop remains the runtime) and the shared `report-tool` core. + `TASK.md`, `tests/`, `scenario/`, `starter/`, `solution/` are + byte-identical to the Claude Code / Codex course cores — enforced by the + `core-group` check in `tools/verify_courses.py`. +- Environment for mechanical verification: macOS (arm64), Python 3.13. +- Commands and results: + - `python verify.py starter --expect-failure` — exit 0; expected failures + reproduced. + - `python verify.py solution` — exit 0; 9/9 tests pass. + - `python tools/verify_courses.py` — contract satisfied, core identical. +- **Not done:** any run inside ZCode. No ZCode install or account was + used; every ZCode behavior described in the lessons is sourced from + official documentation, not observed. + +## 2026-09-13 — 0.0.8 rework: lesson 1 is now tool → Skill → agent fetch (FP-822) + +- L01 (EN+ZH) rewritten around the fixed order: get the tool running → + install the FlyPython Skill (network access included) → let the agent + fetch this course's files via the files endpoint. All "download this + folder" wording removed from `COURSE.md` and lessons. +- The learner's check command across lessons is now the bare + `python verify.py` (FP-820): per-checkpoint status + claim codes, + non-zero exit while gated checkpoints are open. `solution` is no longer + presented as a completion standard. +- Tool-specific Skill install and network steps are authored from the + same official documentation as the course; **not yet exercised inside + the tool** — the pending live run-through below still stands. + +## Pending before this course can be called taught-in-tool + +- One full agent-taught run-through inside ZCode ("start lesson 1" through + the Lesson 5 checkpoint) on a current build, recording observed + deviations from `COURSE.md` here — including how Goal Mode presented the + lesson checkpoints in practice. +- Whether the free tier lasts the whole course is unverified. + +## Deviation log + +(none yet — no live run has happened) diff --git a/courses/hands-on-with-zcode/TASK.md b/courses/hands-on-with-zcode/TASK.md new file mode 100644 index 0000000..e923183 --- /dev/null +++ b/courses/hands-on-with-zcode/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": <row position>, "reason": <short string>}`. 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-zcode/TASK_cn.md b/courses/hands-on-with-zcode/TASK_cn.md new file mode 100644 index 0000000..7af2609 --- /dev/null +++ b/courses/hands-on-with-zcode/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-zcode/core-group b/courses/hands-on-with-zcode/core-group new file mode 100644 index 0000000..b56b0eb --- /dev/null +++ b/courses/hands-on-with-zcode/core-group @@ -0,0 +1 @@ +report-tool diff --git a/courses/hands-on-with-zcode/lessons/L01.md b/courses/hands-on-with-zcode/lessons/L01.md new file mode 100644 index 0000000..2b4f2f3 --- /dev/null +++ b/courses/hands-on-with-zcode/lessons/L01.md @@ -0,0 +1,152 @@ +--- +id: course-zcode-l01 +type: course +title: "Lesson 1: Set up ZCode, install the FlyPython Skill, fetch the course" +summary: "Get ZCode running, install the FlyPython Skill with network access approved, and let the agent fetch this course's files — you download nothing. Then make the starter's failure objective." +lang: en-US +content_version: 2 +status: reviewed +reviewed_on: 2026-09-13 +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). Whether you ran it in the workspace terminal or the agent ran it as a task step, the command is the evidence." + - title: "A workspace, not a chat" + body: "ZCode is a desktop-native workspace: files, tasks, planning, execution, verification, and Git state in one place. Use file references to point the agent at exact files instead of describing them." + - 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." +--- + +# Lesson 1: Set up ZCode, install the FlyPython Skill, fetch the course + +## Objective + +By the end of this lesson ZCode is running, the FlyPython Skill is +installed in it (with network access approved), 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 ZCode running + +Install the ZCode desktop app and open an +empty folder as a workspace. + +## 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 tools that keep skills between sessions. + +ZCode reads `AGENTS.md` instruction files from the workspace root: + +```bash +curl -s https://flypython.com/skills/flypython/SKILL.md >> AGENTS.md +``` + +Approve the agent's network tool use when it asks; if blocked, run the +curl yourself and paste the file. + +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 session/chat/thread 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-zcode`. + +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-zcode/`. + +**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. ZCode keeps the whole job in one workspace — files, +tasks, execution, verification, and Git state side by side — and file +references let you point the agent at exact files instead of describing +them in prose. + +## Warm-up (2 minutes) + +Open `starter/report_tool.py` in the workspace 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-zcode/` 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."** + +While it reads, run the learner's command — 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? + +This checkpoint's claim code is already in the default command's output — +submit it only once you can answer the questions. + +## 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-zcode/lessons/L01_cn.md b/courses/hands-on-with-zcode/lessons/L01_cn.md new file mode 100644 index 0000000..008b144 --- /dev/null +++ b/courses/hands-on-with-zcode/lessons/L01_cn.md @@ -0,0 +1,137 @@ +--- +id: course-zcode-l01 +type: course +title: "第 1 课:装好 ZCode、装上 FlyPython Skill、让 Agent 取回课程" +summary: "先把 ZCode 跑起来,装好 FlyPython Skill 并打开联网权限,让 Agent 取回本课文件——你不下载任何东西,然后让 starter 的失败变得客观。" +lang: zh-CN +content_version: 2 +status: reviewed +reviewed_on: 2026-09-13 +hints: + - title: "本检查点考察什么" + body: "先复现失败再动手修:`python verify.py` 必须把 l03 和 l04 显示为 `[open]`(`starter --expect-failure` 会打印点名测试)。你在工作区终端跑也好、Agent 作为任务步骤跑也好,命令就是证据。" + - title: "工作区,不是聊天框" + body: "ZCode 是桌面原生工作区:文件、任务、规划、执行、验证和 Git 状态同在一处。用文件引用把 Agent 精确指到文件,不要用文字描述。" + - title: "完成的标志" + body: "你能说出套件证明了哪九种行为、为什么 starter 伪造不出来。`python verify.py` 随即显示本检查点的认领码。" +--- + +# 第 1 课:装好 ZCode、装上 FlyPython Skill、让 Agent 取回课程 + +## 目标 + +本课结束时,ZCode 已经跑起来、FlyPython Skill 已装好(联网权限已 +打开),Agent 已把本课文件取回你的工作目录——你没有手动下载任何 +东西。文件夹就位后,你运行课程的客观检查命令,并解释「starter +失败」在测试名里的含义。本课不改任何代码。 + +## 第 1 步——把 ZCode 跑起来 + +安装 ZCode 桌面应用,把一个空文件夹 +作为工作区打开。 + +## 第 2 步——(可选)预装 FlyPython Skill + +这一步可选:第 3 步的开课句子会直接从 URL 读取 Skill,不装也能上课——预装只是省掉一次权限往返。 + +ZCode 会读取工作区根目录的 `AGENTS.md` 指令文件: + +```bash +curl -s https://flypython.com/skills/flypython/SKILL.md >> AGENTS.md +``` + +Agent 请求联网时批准;如果被禁,就自己跑 curl,把文件粘贴 +过去。 + +Skill 只是一个文件,在每个工具里都一样:它告诉 Agent 如何为你 +授权、取课程文件、验证、提交认领码。 + +## 第 3 步——让 Agent 取回本课文件(你不下载) + +在工作目录里新开一个会话/对话/线程,粘贴这一句话: + +> Read https://flypython.com/skills/flypython/SKILL.md and start the FlyPython course `hands-on-with-zcode`. + +Agent 会给你一个授权链接和一组短码。打开链接、登录、核对码与 +Agent 显示的一致后点「允许」,然后对 Agent 说「好了」。它取一次 +token,把本课文件写到 `courses/hands-on-with-zcode/`。 + +**前提**:这些课程需要一个能执行命令**并且**能联网的编码 Agent。 +只能聊天的网页 AI 做不了。 + +## 为什么有这一课 + +AI 写的 Python 大多是同一种死法:演示输入能跑,真实输入一到,脚本 +在第 3 行崩掉——或者更糟,悄悄算出一个错的数。修复不从更好的 +提示词开始,而从把「坏了」变成一条任何人都能跑、且得到相同答案的 +命令开始。ZCode 把整个任务装进一个工作区——文件、任务、执行、 +验证、Git 状态并排——文件引用让你把 Agent 精确指到文件,而不是 +用散文描述。 + +## 热身(2 分钟) + +在工作区里打开 `starter/report_tool.py`,从头读到尾。它看起来很 +合理:带 docstring 的函数、类型标注、CLI。这正是 AI 生成代码的 +样子——表面干净、行为缺失。现在打开 `TASK.md`,放在手边。 + +## 本课内容 + +本课文件现在已经位于 `courses/hands-on-with-zcode/`——你这边没有发生任何下载。 +让 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 崩溃、被隔离,还是悄悄 + 通过? + +本检查点的认领码已经在默认命令的输出里——能回答这些问题之后再 +提交。 + +## 预期证据 + +默认命令的完整输出记录,加你的三个答案。保留好——第 4 课还会 +用到。 diff --git a/courses/hands-on-with-zcode/lessons/L02.md b/courses/hands-on-with-zcode/lessons/L02.md new file mode 100644 index 0000000..3c87513 --- /dev/null +++ b/courses/hands-on-with-zcode/lessons/L02.md @@ -0,0 +1,88 @@ +--- +id: course-zcode-l02 +type: course +title: "Lesson 2: Write the task contract, let Goal Mode hold the plan" +summary: 'Turn "make it better" into a bounded, testable contract — then let Goal Mode decompose it into tasks you can watch land one by one.' +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-13 +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: "Goal Mode is the objective manager" + body: "ZCode's Goal Mode decomposes an objective into a task list the agent works through — and manages recovery when a step stalls. The contract you write is what it decomposes; vague in, vague out." + - 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 Goal Mode hold the plan + +## 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 watch a goal +decompose into tasks before any file is touched. + +## 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 ZCode the decomposition is a first-class +surface: Goal Mode turns your objective into a task list and manages +recovery when a step stalls. The contract you write is what it decomposes +— a precise contract becomes a precise task list. + +## 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 set the goal. Give the agent an objective that is already decomposed +by the contract: + +**"Goal: make starter/report_tool.py satisfy TASK.md. Work one failing +test group at a time — JSON loading, then validation isolation, then +rounding, then atomic writes, then the end-to-end tests — running +`python verify.py starter` after each task. Only starter/report_tool.py +may change; tests/, solution/, scenario/ are read-only; standard library +only."** + +Read the task list Goal Mode produces before letting it execute. Each +task should name a contract line and a verification step — if a task says +"improve error handling," tighten the goal; that is not a contract line. +The task list is the deliverable of this lesson: it is your contract, +executable. + +## Exercise + +Write one contract line for a script you actually own, using the same +shape: inputs, outputs, error cases, and "done means `<command>` exits +0". Then write the goal statement you would give ZCode for it — with the +same boundaries this lesson used. + +## Checkpoint + +Run `python verify.py` — this checkpoint's claim code prints +when you can answer: + +1. Which `TASK.md` line does + `test_invalid_records_are_isolated_with_reasons` pin, in your own + words? +2. What does Goal Mode do with your objective — and what does it do when + a step stalls? +3. Why should the goal statement carry the boundaries, not just the task? + +## Expected evidence + +Your drafted contract line, your goal statement, and the task list it +produced (or the version you tightened and why). diff --git a/courses/hands-on-with-zcode/lessons/L02_cn.md b/courses/hands-on-with-zcode/lessons/L02_cn.md new file mode 100644 index 0000000..4011683 --- /dev/null +++ b/courses/hands-on-with-zcode/lessons/L02_cn.md @@ -0,0 +1,79 @@ +--- +id: course-zcode-l02 +type: course +title: "第 2 课:写任务契约,让 Goal 模式承载计划" +summary: "把「改好一点」变成有边界、可测试的契约——再让 Goal 模式把它拆成你能看着逐一落地的任务。" +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-13 +hints: + - title: "本检查点考察什么" + body: "你能把 `TASK.md` 当成可测试的陈述来读,而不是散文。每个以函数名开头的句子都是套件能断言的契约行。" + - title: "Goal 模式是目标管理器" + body: "ZCode 的 Goal 模式把目标拆成 Agent 逐项执行的任务列表,并在某步卡住时管理恢复。它拆的是你写的契约——含糊进去,含糊出来。" + - title: "可测试性检查" + body: "判断一句话能不能测:测试套件不看你的心思能不能断言它?数字、退出码、磁盘上的文件——不看感觉。" +--- + +# 第 2 课:写任务契约,让 Goal 模式承载计划 + +## 目标 + +你能把 `TASK.md` 当成一组可测试陈述来读,把每条陈述追查到 +`tests/test_report_tool.py` 里的一个测试,并在任何文件被碰之前, +看着一个目标被拆成任务列表。 + +## 为什么有这节课 + +含糊的需求产出含糊的代码。「把坏行处理好一点」是许可 Agent 去猜; +「无效行收进 `errors`、带下标和原因,有效行照常产出报告」是给它 +目标、也给你检查的方法。在 ZCode 里,拆解是一等公民:Goal 模式 +把你的目标变成任务列表,并在某步卡住时管理恢复。它拆的是你写的 +契约——精确的契约变成精确的任务列表。 + +## 本课内容 + +打开 `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` | + +现在定目标。给 Agent 一个已经按契约拆好的目标: + +**“目标:让 starter/report_tool.py 满足 TASK.md。一次只修一组 +失败测试——先 JSON 加载,再无效行隔离,再舍入,再原子写,最后 +是端到端测试——每个任务后跑 `python verify.py starter`。只许改 +starter/report_tool.py;tests/、solution/、scenario/ 只读;只用 +标准库。”** + +放行执行之前,先读 Goal 模式产出的任务列表。每个任务都应点名 +一条契约行和一个验证步骤——哪个任务写的是「改进错误处理」,就 +收紧目标;那不是契约行。任务列表就是本课的交付物:它是你的契约 +的可执行形态。 + +## 练习 + +为你真正拥有的一个脚本写一条契约行,用同样的形状:输入、输出、 +错误情况、以及「完成 = `<命令>` 退出码为 0」。再写你会给 ZCode +的目标陈述——带上本课用过的同样边界。 + +## 检查点 + +运行 `python verify.py`——能回答下面三题时,本检查点的 +认领码就会显示: + +1. 用你自己的话说,`test_invalid_records_are_isolated_with_reasons` + 钉住的是 `TASK.md` 哪一行? +2. Goal 模式拿你的目标做什么——某步卡住时它又做什么? +3. 为什么边界要写进目标陈述,而不只写进任务? + +## 预期证据 + +你起草的契约行、你的目标陈述、它产出的任务列表(或你收紧过的 +版本及理由)。 diff --git a/courses/hands-on-with-zcode/lessons/L03.md b/courses/hands-on-with-zcode/lessons/L03.md new file mode 100644 index 0000000..4fd7543 --- /dev/null +++ b/courses/hands-on-with-zcode/lessons/L03.md @@ -0,0 +1,82 @@ +--- +id: course-zcode-l03 +type: course +title: "Lesson 3: The bounded change, watched task by task" +summary: "Drive the starter to green through the goal's task list — and use the workspace Git state to review every change that lands." +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-13 +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: "Git state is the boundary" + body: "The workspace tracks Git state alongside execution — after each task, the working tree shows exactly what changed. A task that touched the wrong file is visible immediately; recover the goal rather than stacking fixes." + - title: "When you are done" + body: "Every change maps to a contract line, the Git state shows nothing outside starter/report_tool.py moved, and you can explain each edit without opening solution/." +--- + +# Lesson 3: The bounded change, watched task by task + +## Objective + +The starter passes all nine tests through a sequence of bounded changes, +the workspace Git state proves nothing else moved, and you can explain +every edit without reading the solution first. + +## Why this lesson exists + +This is the lesson people skip — and the one that builds the actual skill. +Letting an agent rewrite the file in one shot teaches you nothing and +leaves you a change you cannot honestly review. Supervising small changes, +each pinned to a contract line, is how the code stays yours. ZCode keeps +the Git state next to the execution — after each task you can see exactly +what moved, which makes "did it stay in bounds" a look, not a guess. + +## The lesson + +With the goal and task list from Lesson 2 approved, let execution begin — +one task at a time, with `python verify.py starter` between tasks as the +plan says. + +Hold it to the contract's boundaries: + +- Only `starter/report_tool.py` changes. Check the Git state after every + task: if `tests/`, `solution/`, or `scenario/` moved, stop the goal and + use its recovery to back up rather than stacking corrections. +- No new imports outside the standard library. +- Each task should move toward one contract line. Reject drive-by + refactors ("while I was here I renamed…"). +- If the agent wants to change a test, the answer is no. Tests are the + contract; the code moves. + +Review deliberately between tasks. The Git state shows the diff; read the +hunks, not just the task's self-report. A change you cannot explain is a +change you recover away from — Goal Mode's job is managing exactly that: +restart the step with a tighter instruction instead of piling fixes onto +a bad state. + +Expect the failing count to drop task by task: 7 → 5 → 4 → 3 → 2 → 0. + +## Exercise + +Pick one change the agent made — ideally the validation-isolation step — +and find it in the Git diff. Explain it back: "this hunk does X; that +satisfies contract line Y." If you cannot, have the agent walk you through +that hunk before the next task runs. + +## 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 tasks did the bounded change take, and what made each task + bounded? +2. Which change did you reject or recover from, and why? +3. Point at the hunk that implements "isolate invalid rows with reasons" + — where is it in the diff? + +## Expected evidence + +A green `python verify.py starter` run, a Git diff confined to +`starter/report_tool.py`, and your hunk-by-hunk explanation of it. diff --git a/courses/hands-on-with-zcode/lessons/L03_cn.md b/courses/hands-on-with-zcode/lessons/L03_cn.md new file mode 100644 index 0000000..f0fa07f --- /dev/null +++ b/courses/hands-on-with-zcode/lessons/L03_cn.md @@ -0,0 +1,73 @@ +--- +id: course-zcode-l03 +type: course +title: "第 3 课:有界改动,逐任务盯守" +summary: "沿着目标的任务列表把 starter 推到全绿——用工作区 Git 状态审每一处落地的改动。" +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-13 +hints: + - title: "本检查点考察什么" + body: "starter 通过你监督的改动让九个测试全绿——`python verify.py` 只在 starter 套件变绿后显示本检查点的码。" + - title: "Git 状态就是边界" + body: "工作区把 Git 状态放在执行旁边——每个任务完成后,工作区立刻显示改了什么。动错文件的任务一眼可见;用目标恢复倒回去,而不是叠补丁。" + - title: "完成的标志" + body: "每处改动都能对上一条契约行,Git 状态显示 starter/report_tool.py 之外没动过任何东西,而且不看 solution/ 你也讲得清每处编辑。" +--- + +# 第 3 课:有界改动,逐任务盯守 + +## 目标 + +starter 通过一串有界改动让九个测试全绿,工作区 Git 状态证明没有 +动过别的东西,并且不看 solution,你也讲得清每一处编辑。 + +## 为什么有这节课 + +这节课最容易被跳过——也恰恰是长本事的那节。放任 Agent 一次重写 +整个文件,什么也学不到,只留下一份你没底气审查的改动。监督一连串 +小改动、各自钉在一条契约行上,才能让 AI 写的代码仍然是你的。 +ZCode 把 Git 状态放在执行旁边——每个任务之后你都能直接看到动了 +什么,「有没有越界」变成看一眼,而不是猜。 + +## 本课内容 + +第 2 课的目标与任务列表批准后,开始执行——一次一个任务,任务 +之间按计划跑 `python verify.py starter`。 + +守住契约边界: + +- 只动 `starter/report_tool.py`。每个任务后看 Git 状态:`tests/`、 + `solution/`、`scenario/` 动过就停掉目标,用它的恢复倒回去, + 而不是往上叠修正。 +- 标准库之外不加 import。 +- 每个任务朝一条契约行推进。顺手重构(「我顺便重命名了……」) + 一律拒绝。 +- Agent 想改测试,答案是不行。测试是契约;动的是代码。 + +任务之间认真审。Git 状态给出 diff;读 hunk,不只读任务的自我 +汇报。讲不清的改动就用恢复倒回去——Goal 模式管的正是这件事: +用更紧的指令重启这一步,而不是在坏状态上叠修复。 + +预期失败数逐任务下降:7 → 5 → 4 → 3 → 2 → 0。 + +## 练习 + +挑 Agent 的一处改动——最好是「无效行隔离」那步——在 Git diff +里找到它,讲回来:「这个 hunk 做了 X,满足契约行 Y」。讲不清就 +让它走读那个 hunk,再跑下一个任务。 + +## 检查点 + +运行 `python verify.py`。本检查点的码只在 starter 套件 +全绿时出现。不看材料能回答即算通过: + +1. 这次有界改动走了几个任务,是什么让每个任务保持有界? +2. 你拒绝或恢复掉过哪处改动,为什么? +3. 指出实现「无效行带原因隔离」的那个 hunk——它在 diff 的哪里? + +## 预期证据 + +一次全绿的 `python verify.py starter` 运行、一份只含 +`starter/report_tool.py` 的 Git diff,和你的逐块讲解。 diff --git a/courses/hands-on-with-zcode/lessons/L04.md b/courses/hands-on-with-zcode/lessons/L04.md new file mode 100644 index 0000000..a9d5d51 --- /dev/null +++ b/courses/hands-on-with-zcode/lessons/L04.md @@ -0,0 +1,82 @@ +--- +id: course-zcode-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 the scenario skins, and review the accumulated Git diff the way you would review a colleague's." +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-13 +hints: + - title: "What this checkpoint tests" + body: "That starter AND solution both pass — `verify.py` prints this code only when both suites are green. The gate is the pair, not a single run." + - title: "Review the delta, not the vibes" + body: "The workspace Git state shows the whole accumulated change in one place — even from your phone via remote control. 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. ZCode keeps verification next to the +work: the same workspace shows the Git state, the task list, and the +terminal output — and remote control lets you check status or send an +instruction from your phone while the desktop keeps running the work. + +## The lesson + +Run the full pair in the workspace terminal: + +```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/<skin>/` folder and run + the tool on it: `python starter/report_tool.py scenario/<skin>`. Read + the produced report against the input file — recompute one group + total by hand. +2. **Review the accumulated diff.** The workspace Git state shows every + hunk that landed across the goal. 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. +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. + +## 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 only when +both suites pass. 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-zcode/lessons/L04_cn.md b/courses/hands-on-with-zcode/lessons/L04_cn.md new file mode 100644 index 0000000..50b0010 --- /dev/null +++ b/courses/hands-on-with-zcode/lessons/L04_cn.md @@ -0,0 +1,76 @@ +--- +id: course-zcode-l04 +type: course +title: "第 4 课:像评审者一样验证,别当观众" +summary: "测试全绿是地板不是天花板——两个套件都跑、三个皮肤都走一遍、按评审同事的标准审这份累积 Git diff。" +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-13 +hints: + - title: "本检查点考察什么" + body: "starter 和 solution 双双通过——`verify.py` 只在两个套件都绿时打印本检查点的码。门槛是一对结果,不是一次运行。" + - title: "审增量,不凭感觉" + body: "工作区 Git 状态把整份累积改动放在一处——通过远程控制甚至能在手机上看。按评审者的方式读:每个 hunk 对上一条契约行、没有多余挪动、没有测试被放松。" + - title: "完成的标志" + body: "你已写下三行证据——已验证 / 未验证 / 已知局限——并且能在代码评审里为这次改动辩护。" +--- + +# 第 4 课:像评审者一样验证,别当观众 + +## 目标 + +两个套件都通过,你手握一份书面记录:说清绿色运行证明了什么、没 +证明什么——外加一份你敢向同事辩护的 diff。 + +## 为什么有这节课 + +「测试全过」是大多数 AI 辅助工作停下的地方——也是大多数隐性 +bug 开始的地方。套件只证明它断言过的契约行;没人写过测试的行, +它一概不担保。验证是你写的一份报告,不是一种感觉。ZCode 把验证 +放在工作旁边:同一个工作区里有 Git 状态、任务列表和终端输出—— +远程控制还能让你在手机上查状态、发指令,桌面端继续跑活。 + +## 本课内容 + +在工作区终端里跑完整的一对命令: + +```bash +python verify.py starter # 你监督出来的实现 +python verify.py solution # 评审过的参考实现 +``` + +双全绿意味着契约*按字面*被满足。现在做套件替不了你的评审: + +1. **走完一个皮肤。** 挑一个 `scenario/<skin>/` 文件夹,用工具跑 + 它:`python starter/report_tool.py scenario/<skin>`。对照输入 + 文件读产出的报告——亲手重算一个分组总计。 +2. **审累积的 diff。** 工作区 Git 状态展示整个目标落地的每个 + hunk。从头读到尾。每个 hunk 都应能对上你在第 2 课点名的契约 + 行。对不上的就是范围蔓延——回退或让它解释。 +3. **检查测试没钉的边界。** 喂给工具一个空 CSV、一个全是无效行 + 的 CSV、一个不存在的文件。契约没钉这些——你的判断钉。记下 + 它的行为。 + +## 练习 + +为这次改动写三行证据记录: + +- **已验证:** 你跑过的命令和它们证明了什么。 +- **未验证:** 没有测试钉住的输入与行为。 +- **已知局限:** 你看到的、契约从未承诺过的东西。 + +写得诚实——「未验证」是一份事实清单,不是忏悔书。 + +## 检查点 + +运行 `python verify.py`。本检查点的码只在两个套件都过 +时打印。不看材料能回答即算通过: + +1. 你手工重算的分组总计结果如何? +2. 说出一个套件没钉的输入,以及工具对它的实际行为。 +3. 哪个 hunk(如果有)你会请同事来解释? + +## 预期证据 + +两次全绿运行、你的三行证据记录、diff 评审笔记。 diff --git a/courses/hands-on-with-zcode/lessons/L05.md b/courses/hands-on-with-zcode/lessons/L05.md new file mode 100644 index 0000000..b580b14 --- /dev/null +++ b/courses/hands-on-with-zcode/lessons/L05.md @@ -0,0 +1,80 @@ +--- +id: course-zcode-l05 +type: course +title: "Lesson 5: Take the workflow to your own project" +summary: "The pattern generalizes: contract as goal, task list, bounded execution, Git-state review — run the same loop on a project you actually own." +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-13 +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 task list, your reviewed diff — not a code we can check." + - title: "Carry the shape, not the code" + body: "What transfers is the loop: contract first, goal decomposed into checkable tasks, boundaries watched in Git state, verification written down. Long tasks can even be monitored remotely — the desktop remains the runtime." + - 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, goal, task list, bounded execution, +Git-state review, written verification — to a script or project 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 `<command>` exits 0". One short paragraph, every + sentence testable. +2. **Goal.** Open the project as a ZCode workspace and give the agent a + goal built from that contract — including the boundaries: which files + may change, which are read-only, the dependency policy. +3. **Bounded execution.** Read the task list before it runs; check the + Git state after each task; recover instead of stacking fixes. +4. **Verify.** Run the verification command, 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. + +On long tasks, remote control lets you read status and send instructions +from your phone while the desktop stays the runtime — useful for checking +in, not a substitute for reviewing the diff when it lands. + +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 set the next goal. + +## Checkpoint + +This checkpoint is self-attested: claim it when you can answer: + +1. What was your contract's "done means" command? +2. Which task did you push back on or recover? +3. What is on your "not verified" list that a stranger would assume was + covered? + +## Expected evidence + +Your contract paragraph, the goal statement you set, and the three-line +verification record. These are yours — they are the proof the workflow +transferred. diff --git a/courses/hands-on-with-zcode/lessons/L05_cn.md b/courses/hands-on-with-zcode/lessons/L05_cn.md new file mode 100644 index 0000000..6fbe258 --- /dev/null +++ b/courses/hands-on-with-zcode/lessons/L05_cn.md @@ -0,0 +1,71 @@ +--- +id: course-zcode-l05 +type: course +title: "第 5 课:把工作流带回自己的项目" +summary: "模式会泛化:契约变目标、任务列表、有界执行、Git 状态审查——在你真正拥有的项目上跑同一套闭环。" +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-13 +hints: + - title: "本检查点考察什么" + body: "自我报告:你确认把这套闭环用到了自己的项目上。证据是你的契约、你的任务列表、你审过的 diff——不是我们查得了的码。" + - title: "带走形状,不是代码" + body: "能迁移的是闭环:契约先行、目标拆成可检查的任务、边界在 Git 状态里盯守、验证写下来。长任务甚至可以远程监控——桌面端仍是运行时。" + - title: "完成的标志" + body: "你自己的项目有了一份契约、一处你讲得清的有界改动、一份已验证/未验证/已知局限记录——或一条写清楚的「闭环不合身」的原因。" +--- + +# 第 5 课:把工作流带回自己的项目 + +## 目标 + +你把整套闭环——契约、目标、任务列表、有界执行、Git 状态审查、 +书面验证——用到自己真正拥有的脚本或项目上,并记录发生了什么。 + +## 为什么有这节课 + +课程文件夹是安全沙箱;你自己的代码库不是。前四课的意义从来不是 +那个报表工具——而是一种可复用的、让你始终掌控产出的 Agent 驱动 +方式。本检查点要证明迁移真的发生了。它是自我报告的:闭环跑没跑 +过只有你知道。 + +## 本课内容 + +挑一个你真正拥有的脚本——一个你拖了很久的 bug,或一个想要但 +还没写的行为。然后走同样的五步: + +1. **契约。** 先写任务契约:输入、输出、错误情况、以及「完成 = + `<命令>` 退出码为 0」。一小段,每句都可测试。 +2. **目标。** 把项目作为 ZCode 工作区打开,给 Agent 一个由这份 + 契约搭出的目标——包括边界:哪些文件可改、哪些只读、依赖 + 政策。 +3. **有界执行。** 任务列表跑之前先读;每个任务后看 Git 状态; + 用恢复代替叠修复。 +4. **验证。** 跑验证命令,然后做套件做不了的评审——拿一个真实 + 输入端到端走一遍,手工核对输出。 +5. **记录。** 写三行:已验证 / 未验证 / 已知局限。 + +长任务上,远程控制能让你在手机上读状态、发指令,桌面端仍是运行 +时——适合查岗,不能替代 diff 落地时的审查。 + +如果闭环跟你打架——任务小到不需要契约,或模糊到不可测——那也 +是发现。写下形状不合身的地方;这个判断本身就是真本事。 + +## 练习 + +如果你的项目还有不止一处值得钉住的边界,现在就起草下一条契约行 +——定下一个目标时你会用得上。 + +## 检查点 + +本检查点自我报告:能回答下面三题时即可认领: + +1. 你的契约里「完成 =」命令是什么? +2. 你在哪个任务上顶回去或恢复过? +3. 你的「未验证」清单上有什么是陌生人会以为已覆盖的? + +## 预期证据 + +你的契约段落、你设的目标陈述、三行验证记录。它们是你自己的—— +是工作流完成迁移的证明。 diff --git a/courses/hands-on-with-zcode/scenario/api-tool/scenario.json b/courses/hands-on-with-zcode/scenario/api-tool/scenario.json new file mode 100644 index 0000000..e17e7c2 --- /dev/null +++ b/courses/hands-on-with-zcode/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-zcode/scenario/api-tool/tickets.json b/courses/hands-on-with-zcode/scenario/api-tool/tickets.json new file mode 100644 index 0000000..03e35b7 --- /dev/null +++ b/courses/hands-on-with-zcode/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-zcode/scenario/data-monitor/metrics.csv b/courses/hands-on-with-zcode/scenario/data-monitor/metrics.csv new file mode 100644 index 0000000..15a5a15 --- /dev/null +++ b/courses/hands-on-with-zcode/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-zcode/scenario/data-monitor/scenario.json b/courses/hands-on-with-zcode/scenario/data-monitor/scenario.json new file mode 100644 index 0000000..b103477 --- /dev/null +++ b/courses/hands-on-with-zcode/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-zcode/scenario/excel-report/orders.csv b/courses/hands-on-with-zcode/scenario/excel-report/orders.csv new file mode 100644 index 0000000..a41fca1 --- /dev/null +++ b/courses/hands-on-with-zcode/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-zcode/scenario/excel-report/scenario.json b/courses/hands-on-with-zcode/scenario/excel-report/scenario.json new file mode 100644 index 0000000..66b2bd9 --- /dev/null +++ b/courses/hands-on-with-zcode/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-zcode/solution/report_tool.py b/courses/hands-on-with-zcode/solution/report_tool.py new file mode 100644 index 0000000..65ba0bf --- /dev/null +++ b/courses/hands-on-with-zcode/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 <scenario-dir>", 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-zcode/starter/report_tool.py b/courses/hands-on-with-zcode/starter/report_tool.py new file mode 100644 index 0000000..019edb8 --- /dev/null +++ b/courses/hands-on-with-zcode/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 <scenario-dir>", 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-zcode/tests/test_report_tool.py b/courses/hands-on-with-zcode/tests/test_report_tool.py new file mode 100644 index 0000000..3fbc19f --- /dev/null +++ b/courses/hands-on-with-zcode/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-zcode/verify.py b/courses/hands-on-with-zcode/verify.py new file mode 100644 index 0000000..2353490 --- /dev/null +++ b/courses/hands-on-with-zcode/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-zcode' +# 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 = '9432863162868c87' + +CHECKPOINTS = [ + {"id": "l01", "gate": "attest", "title": '在桌面工作区复现故障 / Reproduce in the desktop workspace'}, + {"id": "l02", "gate": "attest", "title": '任务契约 / Task contract'}, + {"id": "l03", "gate": "starter-suite", "title": '计划—执行—自检的有界改动 / Plan–execute–self-check, bounded'}, + {"id": "l04", "gate": "both-suites", "title": '验证与审查 / Verify 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/mcp-server-in-python/COURSE.md b/courses/mcp-server-in-python/COURSE.md new file mode 100644 index 0000000..c46c8b4 --- /dev/null +++ b/courses/mcp-server-in-python/COURSE.md @@ -0,0 +1,91 @@ +--- +id: course-mcp-tools +type: course +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. +lang: en-US +content_version: 3 +status: reviewed +reviewed_on: 2026-09-12 +badge: + id: course-mcp-tools + name_en: MCP Tool Server + name_zh: MCP 工具服务器 + requires: All five checkpoints claimed (L01–L05) +course_id: course-mcp-tools +--- + +# Give your agent tools with MCP (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 finish with a working MCP tool server that +> implements the stateless 2026-07-28 specification — no initialize +> handshake, validated tool arguments, isolated handler failures, and the +> multi-round input_required flow — proven by a failing starter and a +> passing solution. Standard library only; no API key needed. + +## What you build + +The same reviewed MCP tool server contract the repository ships as a +runnable example, assembled into a course: a `MCPServer` class that answers +`tools/list` and `tools/call` directly, rejects bad requests with the right +JSON-RPC error codes, validates arguments against declared schemas, isolates +handler exceptions as structured `isError` results, and completes the +`input_required` round-trip. Sample request files under `scenario/requests/` +let you poke the wire format while you learn: + +| File | What it exercises | +| --- | --- | +| `01-tools-list.json` | direct listing with no handshake | +| `02-echo-call.json` | a successful tools/call | +| `03-missing-argument.json` | argument validation failure | +| `04-removed-initialize.json` | the removed initialize method | + +## Teaching contract (read this first, agent) + +- **Audience:** developers who want their agent to call their own code + safely — and want to understand the protocol instead of pasting a server + scaffold. +- **Prerequisites:** Python 3.11+ on PATH, comfort reading JSON, and any + coding agent (taught and reviewed with Claude Code 2.x; reviewed + 2026-09-12). Standard library only. +- **Lesson order:** L01 → L05; never skip the checkpoint. +- **Teaching style:** work from the files in this folder; quote the + specification line you satisfy; smallest change per failing test group; no + new dependencies; never edit `solution/`; ask before touching unnamed + files. +- **When to stop:** a lesson is done when its checkpoint command runs and + the learner can explain what failed and why. +- **`verify.py`:** `python verify.py starter --expect-failure` reproduces + the listed failures; `python verify.py solution` passes the full suite. +- **Honesty rules:** this course builds the server side of the protocol; it + does not certify any specific client's compliance. Say what you did not + verify. + +## What this course does NOT cover + +Transports beyond plain JSON-RPC dicts (stdio/HTTP wiring), deployment, or +client-side configuration. The repository's MCP migration guide covers +moving existing servers to the 2026-07-28 specification. + + +## Badge contract + +- 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 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`, bilingual `lessons/`, `scenario/requests/` +wire samples, `TASK.md`/`TASK_cn.md` (the full server contract), +`starter/`, `solution/`, `tests/`, `verify.py`, `REVIEW.md`. + +## Evidence and licensing + +`REVIEW.md` records the run-through state. Code is MIT; prose is CC BY 4.0 +(see repository `LICENSE`). Teaching drift goes to the `course-feedback` +issue form. diff --git a/courses/mcp-server-in-python/COURSE_cn.md b/courses/mcp-server-in-python/COURSE_cn.md new file mode 100644 index 0000000..73db6a6 --- /dev/null +++ b/courses/mcp-server-in-python/COURSE_cn.md @@ -0,0 +1,82 @@ +--- +id: course-mcp-tools +type: course +title: 用 MCP 给你的 Agent 装上工具(Python) +summary: 用纯 Python 构建一个无状态的 Model Context Protocol 工具服务——JSON-RPC 2.0 分发、schema 校验、错误隔离与 2026-07-28 的 input_required 多轮交互——并理解真正的工具该放在哪里。 +lang: zh-CN +content_version: 3 +status: reviewed +reviewed_on: 2026-09-12 +badge: + id: course-mcp-tools + name_en: MCP Tool Server + name_zh: MCP 工具服务器 + requires: 全部五个检查点认领通过(L01–L05) +course_id: course-mcp-tools +--- + +# 用 MCP 给你的 Agent 装上工具(Python) + +> 摘要:在你的编码 Agent 里装上 FlyPython Skill,让它取回本课文件,再 +> 说一句 **“开始第 1 课”**(课程文件由 Skill 取回——你不用手动下载)。 +> 课程结束时你拥有一个实现无状态 2026-07-28 规范的 MCP 工具服务—— +> 无 initialize 握手、参数校验、处理异常隔离、input_required 多轮交互 +> ——由一个失败的 starter 和一个通过的 solution 共同证明。仅用标准库; +> 无需 API Key。 + +## 你将做出什么 + +仓库作为可运行示例发布的同一个经过审核的 MCP 工具服务契约,组装成 +课程:一个 `MCPServer` 类,直接响应 `tools/list` 与 `tools/call`,用 +正确的 JSON-RPC 错误码拒绝坏请求,按声明的 schema 校验参数,把处理 +函数的异常隔离为结构化 `isError` 结果,并完成 `input_required` 多轮 +交互。`scenario/requests/` 下的请求样本让你在学习时直接试线路格式: + +| 文件 | 演练什么 | +| --- | --- | +| `01-tools-list.json` | 无握手直接列出工具 | +| `02-echo-call.json` | 一次成功的 tools/call | +| `03-missing-argument.json` | 参数校验失败 | +| `04-removed-initialize.json` | 已移除的 initialize 方法 | + +## 教学契约(Agent 请先阅读本节) + +- **受众:** 想让 Agent 安全调用自己代码的开发者——想理解协议本身, + 而不是粘贴一个服务脚手架。 +- **前置条件:** PATH 中有 Python 3.11+,能读 JSON,任一编码 Agent + (以 Claude Code 2.x 完成教学与审核,审核日期 2026-09-12)。只用 + 标准库。 +- **课程顺序:** L01 → L05;绝不跳过检查点。 +- **教学风格:** 从本文件夹的文件出发;引用你满足的规范原文;每个 + 失败测试组做最小变更;不新增依赖;不修改 `solution/`;改动未授权 + 文件前先询问。 +- **何时停止:** 检查点命令通过、且学习者能说清什么失败了、为什么。 +- **`verify.py`:** `python verify.py starter --expect-failure` 复现列出的 + 失败;`python verify.py solution` 通过全部测试。 +- **诚实规则:** 本课程构建的是协议的服务端;不认证任何特定客户端的 + 合规性。说明哪些没验证过。 + +## 本课程不涉及的内容 + +纯 JSON-RPC 字典之外的传输层(stdio/HTTP 接线)、部署、客户端配置。 +仓库的 MCP 迁移指南覆盖把既有服务迁移到 2026-07-28 规范。 + + +## 徽章契约 + +- 徽章:**MCP 工具服务徽章**(徽章 id `course-mcp-tools`)——认领全部五个检查点后获得。 +- 挑战: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`、双语 `lessons/`、`scenario/requests/` +线路样本、`TASK.md`/`TASK_cn.md`(完整服务契约)、`starter/`、 +`solution/`、`tests/`、`verify.py`、`REVIEW.md`。 + +## 证据与许可 + +`REVIEW.md` 记录试跑状态。代码 MIT;文字 CC BY 4.0(见仓库 `LICENSE`)。 +教学偏差请走 `course-feedback` issue 表单。 diff --git a/courses/mcp-server-in-python/REVIEW.md b/courses/mcp-server-in-python/REVIEW.md new file mode 100644 index 0000000..8e30d91 --- /dev/null +++ b/courses/mcp-server-in-python/REVIEW.md @@ -0,0 +1,39 @@ +# Maintainer run-through record + +## 2026-09-12 — contract verification (mechanical) + +- Environment: macOS (arm64), Python 3.13; repository clean checkout on + branch `feat/0.0.3-courses-and-radar`. +- The code core is the reviewed `examples/mcp-server` contract reused per the 0.0.3 plan §2.2 ("reuse mcp-server example + migration guide"). +- Commands and results: + - `python verify.py starter --expect-failure` — exit 0; all expected + failure names reproduced. + - `python verify.py solution` — exit 0; full suite passes. + - Every `scenario/requests/` wire sample exercised end to end by the suite. +- Not verified in this pass: teaching quality with a live agent session. + +## Pending before the public course drop + +- One full agent-taught run-through ("start lesson 1" through the Lesson 5 + checkpoint), recording observed deviations from COURSE.md here; the + recording doubles as demo-video source material. + +## Deviation log + +### 2026-09-13 — agent solvability run (challenge mode, not taught mode) + +- Agent: Devin (SWE-2 Max), CLI session on macOS arm64, system Python + 3.14 (stdlib only — this course needs no third-party deps). +- Method: implemented `starter/mcp_server.py` from `TASK.md` and the + test expectations only; did not read or copy `solution/`. +- Result: `python verify.py starter` exits 0 (all tests); + `verify.py progress` printed all five claim codes (l03/l04 + `[passed]`, l01/l02/l05 `[attest]`). +- Observed deviations: none — contract landed on the first pass. The + stateless 2026-07-28 behaviors (removed `initialize`, per-request + `_meta.protocolVersion`, `input_required` round-trip) were all + exercised by the suite. +- Limitations: challenge-mode run (TASK.md → tests), not the taught + COURSE.md walkthrough — teaching quality remains separately pending. +- Starter restored to the deliberately-unfinished state after the run + (`--expect-failure` verified again). diff --git a/courses/mcp-server-in-python/TASK.md b/courses/mcp-server-in-python/TASK.md new file mode 100644 index 0000000..814d8c3 --- /dev/null +++ b/courses/mcp-server-in-python/TASK.md @@ -0,0 +1,25 @@ +# Task contract: stateless MCP JSON-RPC tool server (2026-07-28) + +Change only `starter/mcp_server.py`. + +- Implement `MCPServer` class: + - `register_tool(name: str, description: str, schema: dict, handler: Callable) -> None` + - `handle_request(request: dict) -> dict`: Processes a JSON-RPC 2.0 request dict and returns a response dict. +- JSON-RPC 2.0 compliance: + - Must check `"jsonrpc": "2.0"` and preserve request `"id"`. + - Return error `-32600` (Invalid Request) if request is not a valid dict or lacks `method` or `jsonrpc != "2.0"`. + - Return error `-32601` (Method not found) if method is unknown. +- 2026-07-28 stateless behavior: + - `initialize` must return `-32601` with a message noting it was removed by the stateless 2026-07-28 specification; there is no handshake before tool calls. + - If a request carries `_meta.protocolVersion` and it is not `"2026-07-28"`, return `-32600` (Invalid Request); requests self-describe their version. +- 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 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: <arg>"}]}}`. + - If the handler raises `InputRequired(requests)`: returns `{"result": {"resultType": "input_required", "requests": <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`. + - On handler success: returns `{"result": {"content": [{"type": "text", "text": str(output)}]}}`. + - On any other handler exception: catch and return `{"result": {"isError": True, "content": [{"type": "text", "text": f"Error: {e}"}]}}`. +- Provide `InputRequired(Exception)` with a `requests` attribute so tools can request client input. + +Done means `python verify.py starter` exits 0 with all tests passing. diff --git a/courses/mcp-server-in-python/TASK_cn.md b/courses/mcp-server-in-python/TASK_cn.md new file mode 100644 index 0000000..6f94aeb --- /dev/null +++ b/courses/mcp-server-in-python/TASK_cn.md @@ -0,0 +1,25 @@ +# 任务契约:无状态 MCP JSON-RPC 工具服务(2026-07-28 规范) + +仅修改 `starter/mcp_server.py`。 + +- 实现 `MCPServer` 类: + - `register_tool(name: str, description: str, schema: dict, handler: Callable) -> None` + - `handle_request(request: dict) -> dict`:处理 JSON-RPC 2.0 请求字典并返回响应字典。 +- JSON-RPC 2.0 规范要求: + - 必须校验 `"jsonrpc": "2.0"` 并保留请求中的 `"id"`。 + - 请求格式非法、缺少 `method` 或 `jsonrpc != "2.0"` 时返回错误码 `-32600`(Invalid Request)。 + - 请求未注册的方法时返回错误码 `-32601`(Method not found)。 +- 2026-07-28 无状态行为: + - `initialize` 必须返回 `-32601`,消息中说明它已被 2026-07-28 无状态规范移除;工具调用前没有任何握手。 + - 若请求携带 `_meta.protocolVersion` 且不等于 `"2026-07-28"`,返回 `-32600`(Invalid Request);协议版本由每个请求自描述。 +- 支持的核心方法: + - `"tools/list"`:返回 `{"result": {"tools": [{"name": ..., "description": ..., "inputSchema": ...}, ...]}}`。 + - `"tools/call"`:接收 `params: {"name": ..., "arguments": ..., "inputResponses": {...}?}`,并以 `handler(arguments, inputResponses)` 调用工具。 + - 工具未找到:返回 `{"result": {"isError": True, "content": [{"type": "text", "text": "Tool not found"}]}}`。 + - 缺少 schema 中声明的必填参数:返回 `{"result": {"isError": True, "content": [{"type": "text", "text": "Missing required argument: <arg>"}]}}`。 + - 工具抛出 `InputRequired(requests)`:返回 `{"result": {"resultType": "input_required", "requests": <requests>, "content": [{"type": "text", "text": "Additional client input is required before this tool can finish."}]}}`。客户端携带 `params.inputResponses` 重试同一请求。 + - 执行成功:返回 `{"result": {"content": [{"type": "text", "text": str(output)}]}}`。 + - 其他执行异常:捕获并返回 `{"result": {"isError": True, "content": [{"type": "text", "text": f"Error: {e}"}]}}`。 +- 提供 `InputRequired(Exception)`,带 `requests` 属性,让工具能够向客户端请求补充输入。 + +完成标准:`python verify.py starter` 以状态码 0 退出、全部测试通过。 diff --git a/courses/mcp-server-in-python/lessons/L01.md b/courses/mcp-server-in-python/lessons/L01.md new file mode 100644 index 0000000..ded4f7a --- /dev/null +++ b/courses/mcp-server-in-python/lessons/L01.md @@ -0,0 +1,45 @@ +--- +id: course-mcp-tools-l01 +type: course +title: "What MCP actually is (2026-07-28)" +summary: "A stateless JSON-RPC 2.0 surface for tools — no handshake, no sessions, versions in _meta." +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "What this checkpoint tests" + body: "Understanding the shape before coding: MCP 2026-07-28 is a stateless JSON-RPC 2.0 surface — no initialize handshake, no sessions, versions travel in `_meta.protocolVersion`." + - title: "Say what is absent" + body: "List what the old flow had that this spec removed: initialize, sessions, roots, sampling. If `initialize` arrives, the right answer is a -32601 error, not a handshake." + - title: "When you are done" + body: "You can describe the whole lifecycle in one line: a request dict in, a response dict out." +--- + +# What MCP actually is (2026-07-28) + +## Objective + +A stateless JSON-RPC 2.0 surface for tools — no handshake, no sessions, versions in _meta. + +## The lesson + +Read TASK.md, then run the verifier both ways. The 2026-07-28 specification removed the initialize handshake and sessions: servers answer tools/list and tools/call directly, and each request self-describes its protocol version through _meta. Poke the wire samples in scenario/requests/ by hand while you read. + +## Exercise + +- Predict the response to 04-removed-initialize.json before checking TASK.md +- Map each expected starter failure to a spec line + +## Checkpoint + +The lesson's commands run, and you can answer these in your own words +(the agent asks; you answer — that is the gate): + +1. Which command did this lesson run, and what did it decide? +2. What failed at the start, and why — in your own words? +3. Before trusting a similar change next time, what would you check first? + +## Expected evidence + +The command transcript and your answers. diff --git a/courses/mcp-server-in-python/lessons/L01_cn.md b/courses/mcp-server-in-python/lessons/L01_cn.md new file mode 100644 index 0000000..4a01a2d --- /dev/null +++ b/courses/mcp-server-in-python/lessons/L01_cn.md @@ -0,0 +1,45 @@ +--- +id: course-mcp-tools-l01 +type: course +title: "MCP 到底是什么(2026-07-28)" +summary: "一个面向工具的无状态 JSON-RPC 2.0 接口——无握手、无会话、版本放在 _meta 里。" +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "这个检查点考什么" + body: "先理解形态再写码:MCP 2026-07-28 是无状态 JSON-RPC 2.0 接口——没有 initialize 握手、没有会话、版本走 `_meta.protocolVersion`。" + - title: "说出缺了什么" + body: "列出旧流程里被本规范删掉的东西:initialize、sessions、roots、sampling。收到 `initialize` 时正确回答是 -32601 错误,不是握手。" + - title: "完成标准" + body: "一句话说清整个生命周期:进来一个请求 dict,出去一个响应 dict。" +--- + +# MCP 到底是什么(2026-07-28) + +## 目标 + +一个面向工具的无状态 JSON-RPC 2.0 接口——无握手、无会话、版本放在 _meta 里。 + +## 课程内容 + +读 TASK.md,然后双向运行验证器。2026-07-28 规范移除了 initialize 握手与会话:服务端直接响应 tools/list 与 tools/call,每个请求通过 _meta 自报协议版本。边读边手动试 scenario/requests/ 里的线路样本。 + +## 练习 + +- 先预测 04-removed-initialize.json 的响应,再对照 TASK.md +- 把 starter 的每个预期失败对应到一条规范 + +## 检查点 + +本课的命令运行通过,并且你能用自己的话回答下面的问题(由 Agent +提问、你作答——这就是关口): + +1. 本课跑了哪条命令,它判定了什么? +2. 一开始什么失败了、为什么——用你自己的话说? +3. 下次再信任类似的改动之前,你会先检查什么? + +## 预期证据 + +命令输出记录与你的回答。 diff --git a/courses/mcp-server-in-python/lessons/L02.md b/courses/mcp-server-in-python/lessons/L02.md new file mode 100644 index 0000000..f2f606b --- /dev/null +++ b/courses/mcp-server-in-python/lessons/L02.md @@ -0,0 +1,45 @@ +--- +id: course-mcp-tools-l02 +type: course +title: "The JSON-RPC dispatch contract" +summary: "Valid requests echo their id; invalid ones get -32600; unknown or removed methods get -32601." +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "What this checkpoint tests" + body: "The dispatch contract: valid requests echo their `id`, malformed ones get -32600, unknown or removed methods get -32601." + - title: "Version gate" + body: "A request carrying `_meta.protocolVersion` other than `2026-07-28` gets -32600 — requests self-describe their version instead of negotiating it." + - title: "When you are done" + body: "You can fill the truth table from memory: good request, bad jsonrpc, unknown method, removed method, wrong version." +--- + +# The JSON-RPC dispatch contract + +## Objective + +Valid requests echo their id; invalid ones get -32600; unknown or removed methods get -32601. + +## The lesson + +The dispatch rules are small and testable: check jsonrpc and method, preserve id, and answer errors with the right codes. Run the failing dispatch tests first; make them pass in the smallest steps. Use 01-tools-list.json and 04-removed-initialize.json as your fixtures while the agent works. + +## Exercise + +- Ask the agent why initialize must return -32601 with a message naming the 2026-07-28 removal +- Reject any diff that touches tests/ or solution/ + +## Checkpoint + +The lesson's commands run, and you can answer these in your own words +(the agent asks; you answer — that is the gate): + +1. Which command did this lesson run, and what did it decide? +2. What failed at the start, and why — in your own words? +3. Before trusting a similar change next time, what would you check first? + +## Expected evidence + +The command transcript and your answers. diff --git a/courses/mcp-server-in-python/lessons/L02_cn.md b/courses/mcp-server-in-python/lessons/L02_cn.md new file mode 100644 index 0000000..1fd7b57 --- /dev/null +++ b/courses/mcp-server-in-python/lessons/L02_cn.md @@ -0,0 +1,45 @@ +--- +id: course-mcp-tools-l02 +type: course +title: "JSON-RPC 分发契约" +summary: "合法请求回显 id;非法请求得 -32600;未知或已移除的方法得 -32601。" +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "这个检查点考什么" + body: "分发契约:合法请求回显 `id`,畸形请求得 -32600,未知或已删除的方法得 -32601。" + - title: "版本门" + body: "携带非 `2026-07-28` 的 `_meta.protocolVersion` 的请求得 -32600——请求自我声明版本而不是协商版本。" + - title: "完成标准" + body: "凭记忆能填真值表:正常请求、坏 jsonrpc、未知方法、已删方法、错版本。" +--- + +# JSON-RPC 分发契约 + +## 目标 + +合法请求回显 id;非法请求得 -32600;未知或已移除的方法得 -32601。 + +## 课程内容 + +分发规则小而可测:检查 jsonrpc 与 method、保留 id、用正确的错误码作答。先跑失败的分发测试,用最小的步骤让它们通过。Agent 工作时,把 01-tools-list.json 与 04-removed-initialize.json 当作夹具。 + +## 练习 + +- 问 Agent:initialize 为什么必须返回 -32601,并在消息里点明 2026-07-28 的移除 +- 拒绝任何触碰 tests/ 或 solution/ 的 diff + +## 检查点 + +本课的命令运行通过,并且你能用自己的话回答下面的问题(由 Agent +提问、你作答——这就是关口): + +1. 本课跑了哪条命令,它判定了什么? +2. 一开始什么失败了、为什么——用你自己的话说? +3. 下次再信任类似的改动之前,你会先检查什么? + +## 预期证据 + +命令输出记录与你的回答。 diff --git a/courses/mcp-server-in-python/lessons/L03.md b/courses/mcp-server-in-python/lessons/L03.md new file mode 100644 index 0000000..667667e --- /dev/null +++ b/courses/mcp-server-in-python/lessons/L03.md @@ -0,0 +1,45 @@ +--- +id: course-mcp-tools-l03 +type: course +title: "Validation and error isolation" +summary: "Arguments are untrusted input: validate before execution, and never let a handler crash the transport." +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "What this checkpoint tests" + body: "`python verify.py starter` — `MCPServer` with `register_tool` and `handle_request`: validation, dispatch, and error isolation all green." + - title: "Tools are untrusted" + body: "Missing required argument → `isError` content 'Missing required argument: <arg>'; unknown tool → 'Tool not found'; handler exceptions → `isError` 'Error: <e>'. A tool failing must never crash the transport." + - title: "Return shapes" + body: "`tools/list` yields `result.tools` entries with name/description/inputSchema; `tools/call` yields `result.content` of type text — get the envelope exact, the suite compares structure." +--- + +# Validation and error isolation + +## Objective + +Arguments are untrusted input: validate before execution, and never let a handler crash the transport. + +## The lesson + +Two boundaries make a tool server safe: validate required arguments against the declared input schema (missing arguments become structured results, not exceptions), and wrap every handler so its failures return isError instead of breaking the connection. 03-missing-argument.json shows the first boundary on the wire. + +## Exercise + +- Trace each validation test to the contract line it pins +- Ask: what happens if a handler raises InputRequired? (Next lesson.) + +## Checkpoint + +The lesson's commands run, and you can answer these in your own words +(the agent asks; you answer — that is the gate): + +1. Which command did this lesson run, and what did it decide? +2. What failed at the start, and why — in your own words? +3. Before trusting a similar change next time, what would you check first? + +## Expected evidence + +The command transcript and your answers. diff --git a/courses/mcp-server-in-python/lessons/L03_cn.md b/courses/mcp-server-in-python/lessons/L03_cn.md new file mode 100644 index 0000000..67624ef --- /dev/null +++ b/courses/mcp-server-in-python/lessons/L03_cn.md @@ -0,0 +1,45 @@ +--- +id: course-mcp-tools-l03 +type: course +title: "校验与错误隔离" +summary: "参数是不可信输入:先校验再执行,绝不让处理函数打断传输。" +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "这个检查点考什么" + body: "`python verify.py starter`——实现 `MCPServer` 的 `register_tool` 与 `handle_request`:校验、分发、错误隔离全绿。" + - title: "工具是不可信输入" + body: "缺必填参数 → `isError` 内容 'Missing required argument: <arg>';未知工具 → 'Tool not found';处理函数抛异常 → `isError` 'Error: <e>'。工具失败绝不能打断传输层。" + - title: "返回形态" + body: "`tools/list` 产出带 name/description/inputSchema 的 `result.tools`;`tools/call` 产出 text 型 `result.content`——信封结构要精确,套件比对的是结构。" +--- + +# 校验与错误隔离 + +## 目标 + +参数是不可信输入:先校验再执行,绝不让处理函数打断传输。 + +## 课程内容 + +两条边界让工具服务变得安全:按声明的 inputSchema 校验必填参数(缺参变成结构化结果而不是异常),并包裹每个处理函数,让它的失败返回 isError 而不是断开连接。03-missing-argument.json 在线路上演示了第一条边界。 + +## 练习 + +- 把每个校验测试对应到它锚定的契约行 +- 问:如果处理函数抛出 InputRequired 会怎样?(下一课。) + +## 检查点 + +本课的命令运行通过,并且你能用自己的话回答下面的问题(由 Agent +提问、你作答——这就是关口): + +1. 本课跑了哪条命令,它判定了什么? +2. 一开始什么失败了、为什么——用你自己的话说? +3. 下次再信任类似的改动之前,你会先检查什么? + +## 预期证据 + +命令输出记录与你的回答。 diff --git a/courses/mcp-server-in-python/lessons/L04.md b/courses/mcp-server-in-python/lessons/L04.md new file mode 100644 index 0000000..388e9b7 --- /dev/null +++ b/courses/mcp-server-in-python/lessons/L04.md @@ -0,0 +1,45 @@ +--- +id: course-mcp-tools-l04 +type: course +title: "The input_required round-trip" +summary: "A tool can ask the client a question mid-call — return the requests, finish on the retry carrying answers." +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "What this checkpoint tests" + body: "Both suites — the `input_required` round-trip: a tool can ask the client a question mid-call and finish on the retry carrying `params.inputResponses`." + - title: "The exception is the mechanism" + body: "Raise `InputRequired(requests)` inside the handler; `handle_request` catches it and returns `resultType: 'input_required'` with the requests. The client retries the same call with answers added." + - title: "No state needed" + body: "The server stays stateless — the retry is a fresh `tools/call` whose `params.inputResponses` carries the answers. Do not stash the pending question." +--- + +# The input_required round-trip + +## Objective + +A tool can ask the client a question mid-call — return the requests, finish on the retry carrying answers. + +## The lesson + +The 2026-07-28 replacement for server-initiated elicitation: a handler raises InputRequired(requests); the server returns resultType "input_required" with the questions; the client retries the same call with params.inputResponses; the handler finishes. Implement it, then verify like an engineer: both verifier directions, the full suite, and a diff review where every change is forced by a test. + +## Exercise + +- Hand-trace one round-trip with pen and paper before running anything +- Write the three-line evidence note: verified / not verified / known limits + +## Checkpoint + +The lesson's commands run, and you can answer these in your own words +(the agent asks; you answer — that is the gate): + +1. Which command did this lesson run, and what did it decide? +2. What failed at the start, and why — in your own words? +3. Before trusting a similar change next time, what would you check first? + +## Expected evidence + +The command transcript and your answers. diff --git a/courses/mcp-server-in-python/lessons/L04_cn.md b/courses/mcp-server-in-python/lessons/L04_cn.md new file mode 100644 index 0000000..a962378 --- /dev/null +++ b/courses/mcp-server-in-python/lessons/L04_cn.md @@ -0,0 +1,45 @@ +--- +id: course-mcp-tools-l04 +type: course +title: "input_required 多轮交互" +summary: "工具可以在调用中途向客户端提问——先返回问题,客户端带答案重试后完成。" +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "这个检查点考什么" + body: "双套件——`input_required` 多轮交互:工具能在调用中向客户端提问,客户端带着 `params.inputResponses` 重试时完成。" + - title: "异常即机制" + body: "在处理函数里抛 `InputRequired(requests)`;`handle_request` 捕获并返回 `resultType: 'input_required'` 和请求列表。客户端携带答案重试同一调用。" + - title: "不需要状态" + body: "服务端保持无状态——重试是一次新的 `tools/call`,答案在 `params.inputResponses` 里。别存挂起的问题。" +--- + +# input_required 多轮交互 + +## 目标 + +工具可以在调用中途向客户端提问——先返回问题,客户端带答案重试后完成。 + +## 课程内容 + +这是 2026-07-28 对服务端发起 elicitation 的替代:处理函数抛出 InputRequired(requests);服务端返回 resultType 为 "input_required" 及问题;客户端带 params.inputResponses 重试同一调用;处理函数完成。实现它,然后像工程师一样验证:验证器双向、完整套件、以及每处改动都有测试逼着的 diff 审查。 + +## 练习 + +- 运行任何命令之前,先在纸上手推一遍完整往返 +- 写下三行证据笔记:已验证 / 未验证 / 已知局限 + +## 检查点 + +本课的命令运行通过,并且你能用自己的话回答下面的问题(由 Agent +提问、你作答——这就是关口): + +1. 本课跑了哪条命令,它判定了什么? +2. 一开始什么失败了、为什么——用你自己的话说? +3. 下次再信任类似的改动之前,你会先检查什么? + +## 预期证据 + +命令输出记录与你的回答。 diff --git a/courses/mcp-server-in-python/lessons/L05.md b/courses/mcp-server-in-python/lessons/L05.md new file mode 100644 index 0000000..7538f64 --- /dev/null +++ b/courses/mcp-server-in-python/lessons/L05.md @@ -0,0 +1,45 @@ +--- +id: course-mcp-tools-l05 +type: course +title: "Expose one real tool to your agent" +summary: "Wrap one function you actually own behind the validated, isolated boundary you just built." +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "What this checkpoint tests" + body: "Exposing one real tool: wrap a function you own behind the validated, isolated boundary you just built." + - title: "Schema honestly" + body: "Give the tool a real `inputSchema` naming required arguments — the boundary only earns trust if the schema tells the truth." + - title: "The honest record" + body: "Note what your tool may and may not do — a schema that overclaims defeats the isolation you just proved." +--- + +# Expose one real tool to your agent + +## Objective + +Wrap one function you actually own behind the validated, isolated boundary you just built. + +## The lesson + +Pick one read-only function from your own project — a lookup, a summary, a calculation. Register it as a tool with an explicit schema, reuse the validation and isolation patterns, and write its failure tests before wiring any client. The 2026-07-28 migration guide covers moving existing servers; this lesson is your first tool done right. + +## Exercise + +- Start with the failure tests; the schema is the contract +- Record what you did not verify (client compliance, transport behavior) + +## Checkpoint + +The lesson's commands run, and you can answer these in your own words +(the agent asks; you answer — that is the gate): + +1. Which command did this lesson run, and what did it decide? +2. What failed at the start, and why — in your own words? +3. Before trusting a similar change next time, what would you check first? + +## Expected evidence + +The command transcript and your answers. diff --git a/courses/mcp-server-in-python/lessons/L05_cn.md b/courses/mcp-server-in-python/lessons/L05_cn.md new file mode 100644 index 0000000..aecea2c --- /dev/null +++ b/courses/mcp-server-in-python/lessons/L05_cn.md @@ -0,0 +1,45 @@ +--- +id: course-mcp-tools-l05 +type: course +title: "给你的 Agent 暴露一个真实工具" +summary: "把你真正拥有的一个函数包进刚建好的“校验 + 隔离”边界。" +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "这个检查点考什么" + body: "暴露一个真实工具:把你自己一个函数包进刚建好的、带校验和隔离的边界后面。" + - title: "如实写 schema" + body: "给工具一个真实的 `inputSchema` 并标明必填参数——schema 说真话,边界才值得信任。" + - title: "诚实的记录" + body: "写下你的工具能做什么不能做什么——夸大的 schema 会抵消你刚证明过的隔离。" +--- + +# 给你的 Agent 暴露一个真实工具 + +## 目标 + +把你真正拥有的一个函数包进刚建好的“校验 + 隔离”边界。 + +## 课程内容 + +从你自己的项目里挑一个只读函数——查询、摘要、计算。用显式 schema 把它注册为工具,复用校验与隔离模式,并在接线任何客户端之前先写失败测试。2026-07-28 迁移指南覆盖既有服务的迁移;这一课是你第一个做对的工具。 + +## 练习 + +- 从失败测试开始;schema 就是契约 +- 记录哪些没验证(客户端合规性、传输行为) + +## 检查点 + +本课的命令运行通过,并且你能用自己的话回答下面的问题(由 Agent +提问、你作答——这就是关口): + +1. 本课跑了哪条命令,它判定了什么? +2. 一开始什么失败了、为什么——用你自己的话说? +3. 下次再信任类似的改动之前,你会先检查什么? + +## 预期证据 + +命令输出记录与你的回答。 diff --git a/courses/mcp-server-in-python/scenario/requests/01-tools-list.json b/courses/mcp-server-in-python/scenario/requests/01-tools-list.json new file mode 100644 index 0000000..b8ebe4a --- /dev/null +++ b/courses/mcp-server-in-python/scenario/requests/01-tools-list.json @@ -0,0 +1 @@ +{"jsonrpc": "2.0", "id": 1, "method": "tools/list"} diff --git a/courses/mcp-server-in-python/scenario/requests/02-echo-call.json b/courses/mcp-server-in-python/scenario/requests/02-echo-call.json new file mode 100644 index 0000000..0002e25 --- /dev/null +++ b/courses/mcp-server-in-python/scenario/requests/02-echo-call.json @@ -0,0 +1 @@ +{"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "echo", "arguments": {"text": "hello from the course"}}} diff --git a/courses/mcp-server-in-python/scenario/requests/03-missing-argument.json b/courses/mcp-server-in-python/scenario/requests/03-missing-argument.json new file mode 100644 index 0000000..d3af84c --- /dev/null +++ b/courses/mcp-server-in-python/scenario/requests/03-missing-argument.json @@ -0,0 +1 @@ +{"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "echo", "arguments": {}}} diff --git a/courses/mcp-server-in-python/scenario/requests/04-removed-initialize.json b/courses/mcp-server-in-python/scenario/requests/04-removed-initialize.json new file mode 100644 index 0000000..764c829 --- /dev/null +++ b/courses/mcp-server-in-python/scenario/requests/04-removed-initialize.json @@ -0,0 +1 @@ +{"jsonrpc": "2.0", "id": 4, "method": "initialize"} diff --git a/courses/mcp-server-in-python/solution/mcp_server.py b/courses/mcp-server-in-python/solution/mcp_server.py new file mode 100644 index 0000000..7179a0a --- /dev/null +++ b/courses/mcp-server-in-python/solution/mcp_server.py @@ -0,0 +1,184 @@ +"""Stateless MCP tool server aligned with the 2026-07-28 specification. + +The 2026-07-28 specification is stateless-first: the initialize/initialized +handshake and Mcp-Session-Id sessions were removed, so every request is +self-describing and tools/list or tools/call must be answered directly. +Server-initiated requests (including elicitation) were replaced by a +multi-round-trip flow: a tool that needs client input returns a result with +resultType "input_required", and the client retries the same call with the +answers attached in params.inputResponses. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +SUPPORTED_PROTOCOL_VERSION = "2026-07-28" + +ToolHandler = Callable[..., Any] + + +class InputRequired(Exception): + """Raised by a handler that cannot finish without additional client input. + + Carries the requests the client must answer. The server converts this into + a tools/call result with resultType "input_required"; the client retries + the same request with the answers in params.inputResponses. + """ + + def __init__(self, requests: list[dict[str, Any]]) -> None: + super().__init__("tool execution needs additional client input") + self.requests = requests + + +class MCPServer: + def __init__(self) -> None: + self.tools: dict[str, dict[str, Any]] = {} + + def register_tool( + self, + name: str, + description: str, + schema: dict[str, Any], + handler: ToolHandler, + ) -> None: + self.tools[name] = { + "name": name, + "description": description, + "inputSchema": schema, + "handler": handler, + } + + def handle_request(self, request: Any) -> dict[str, Any]: + if not isinstance(request, dict) or request.get("jsonrpc") != "2.0" or "method" not in request: + return { + "jsonrpc": "2.0", + "id": request.get("id") if isinstance(request, dict) else None, + "error": {"code": -32600, "message": "Invalid Request"}, + } + + req_id = request.get("id") + method = request["method"] + + # initialize was removed by the 2026-07-28 specification: requests are + # stateless and there is no handshake to complete before tool calls. + if method == "initialize": + return { + "jsonrpc": "2.0", + "id": req_id, + "error": { + "code": -32601, + "message": "Method not found: initialize was removed by the stateless 2026-07-28 specification", + }, + } + + # Requests self-describe their protocol version through _meta. + meta = request.get("_meta") + if isinstance(meta, dict): + version = meta.get("protocolVersion") + if version is not None and version != SUPPORTED_PROTOCOL_VERSION: + return { + "jsonrpc": "2.0", + "id": req_id, + "error": { + "code": -32600, + "message": f"Invalid Request: unsupported protocol version {version!r}; this server speaks {SUPPORTED_PROTOCOL_VERSION}", + }, + } + + if method == "tools/list": + tools_list = [ + { + "name": t["name"], + "description": t["description"], + "inputSchema": t["inputSchema"], + } + for t in self.tools.values() + ] + return {"jsonrpc": "2.0", "id": req_id, "result": {"tools": tools_list}} + + if method == "tools/call": + params = request.get("params") + if not isinstance(params, dict) or "name" not in params: + return { + "jsonrpc": "2.0", + "id": req_id, + "error": {"code": -32602, "message": "Invalid params: missing tool name"}, + } + + tool_name = params["name"] + if tool_name not in self.tools: + return { + "jsonrpc": "2.0", + "id": req_id, + "result": { + "isError": True, + "content": [{"type": "text", "text": "Tool not found"}], + }, + } + + tool = self.tools[tool_name] + args = params.get("arguments", {}) + if not isinstance(args, dict): + args = {} + + input_responses = params.get("inputResponses") + if not isinstance(input_responses, dict): + input_responses = {} + + # Validate required schema arguments + required_props = tool["inputSchema"].get("required", []) + for required_key in required_props: + if required_key not in args: + return { + "jsonrpc": "2.0", + "id": req_id, + "result": { + "isError": True, + "content": [ + { + "type": "text", + "text": f"Missing required argument: {required_key}", + } + ], + }, + } + + try: + output = tool["handler"](args, input_responses) + return { + "jsonrpc": "2.0", + "id": req_id, + "result": {"content": [{"type": "text", "text": str(output)}]}, + } + except InputRequired as exc: + return { + "jsonrpc": "2.0", + "id": req_id, + "result": { + "resultType": "input_required", + "requests": exc.requests, + "content": [ + { + "type": "text", + "text": "Additional client input is required before this tool can finish.", + } + ], + }, + } + except Exception as exc: + return { + "jsonrpc": "2.0", + "id": req_id, + "result": { + "isError": True, + "content": [{"type": "text", "text": f"Error: {exc}"}], + }, + } + + return { + "jsonrpc": "2.0", + "id": req_id, + "error": {"code": -32601, "message": "Method not found"}, + } diff --git a/courses/mcp-server-in-python/starter/mcp_server.py b/courses/mcp-server-in-python/starter/mcp_server.py new file mode 100644 index 0000000..1d9dbc4 --- /dev/null +++ b/courses/mcp-server-in-python/starter/mcp_server.py @@ -0,0 +1,59 @@ +"""Naive MCP server implementation that crashes on missing tools and lacks schema validation. + +Also ignores the stateless 2026-07-28 specification: it has no input_required +flow, so tools that need client input cannot finish safely. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + + +class MCPServer: + def __init__(self) -> None: + self.tools: dict[str, Any] = {} + + def register_tool( + self, + name: str, + description: str, + schema: dict[str, Any], + handler: Callable[[dict[str, Any]], str], + ) -> None: + self.tools[name] = { + "name": name, + "description": description, + "inputSchema": schema, + "handler": handler, + } + + def handle_request(self, request: Any) -> dict[str, Any]: + # Naive: does not check jsonrpc version or error handling + req_id = request.get("id") + method = request["method"] + + if method == "tools/list": + tools_list = [ + { + "name": t["name"], + "description": t["description"], + "inputSchema": t["inputSchema"], + } + for t in self.tools.values() + ] + return {"jsonrpc": "2.0", "id": req_id, "result": {"tools": tools_list}} + + if method == "tools/call": + params = request["params"] + tool_name = params["name"] + # Will crash if tool doesn't exist or handler raises + tool = self.tools[tool_name] + output = tool["handler"](params["arguments"]) + return { + "jsonrpc": "2.0", + "id": req_id, + "result": {"content": [{"type": "text", "text": str(output)}]}, + } + + return {"jsonrpc": "2.0", "id": req_id, "error": {"code": -32601, "message": "Method not found"}} diff --git a/courses/mcp-server-in-python/tests/test_mcp_server.py b/courses/mcp-server-in-python/tests/test_mcp_server.py new file mode 100644 index 0000000..124d3a1 --- /dev/null +++ b/courses/mcp-server-in-python/tests/test_mcp_server.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +import unittest + +from mcp_server import MCPServer + +try: + from mcp_server import InputRequired +except ImportError: # The starter does not implement the 2026-07-28 flow yet. + InputRequired = None # type: ignore[assignment] + + +def require_confirmation(args: dict, responses: dict | None = None) -> str: + answer = str((responses or {}).get("confirm", "")).strip().lower() + if answer != "yes": + raise InputRequired( + [{"id": "confirm", "prompt": "Type 'yes' to confirm deletion."}] + ) + return "deleted" + + +class TestMCPServer(unittest.TestCase): + def setUp(self) -> None: + self.server = MCPServer() + self.server.register_tool( + name="calculate_sum", + description="Add two numbers together", + schema={ + "type": "object", + "properties": { + "a": {"type": "number"}, + "b": {"type": "number"}, + }, + "required": ["a", "b"], + }, + handler=lambda args, responses=None: str(args["a"] + args["b"]), + ) + self.server.register_tool( + name="risky_operation", + description="An operation that can fail", + schema={"type": "object"}, + handler=lambda args, responses=None: 1 / 0, # Deliberate ZeroDivisionError + ) + + def test_tools_list_returns_schemas(self) -> None: + resp = self.server.handle_request({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/list", + }) + self.assertEqual(resp["jsonrpc"], "2.0") + self.assertEqual(resp["id"], 1) + tools = resp["result"]["tools"] + self.assertEqual(len(tools), 2) + names = {t["name"] for t in tools} + self.assertEqual(names, {"calculate_sum", "risky_operation"}) + + def test_tools_call_success(self) -> None: + resp = self.server.handle_request({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": {"name": "calculate_sum", "arguments": {"a": 10, "b": 25}}, + }) + self.assertEqual(resp["id"], 2) + self.assertEqual(resp["result"]["content"][0]["text"], "35") + + def test_tools_call_missing_required_argument(self) -> None: + resp = self.server.handle_request({ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": {"name": "calculate_sum", "arguments": {"a": 10}}, + }) + self.assertTrue(resp["result"]["isError"]) + self.assertIn("Missing required argument: b", resp["result"]["content"][0]["text"]) + + def test_tools_call_handles_runtime_exception(self) -> None: + resp = self.server.handle_request({ + "jsonrpc": "2.0", + "id": 4, + "method": "tools/call", + "params": {"name": "risky_operation", "arguments": {}}, + }) + self.assertTrue(resp["result"]["isError"]) + self.assertIn("division by zero", resp["result"]["content"][0]["text"]) + + def test_invalid_request_structure(self) -> None: + resp = self.server.handle_request({"method": "tools/list"}) # Missing jsonrpc: 2.0 + self.assertIn("error", resp) + self.assertEqual(resp["error"]["code"], -32600) + + def test_initialize_removed_returns_method_not_found(self) -> None: + # The 2026-07-28 specification removed the initialize handshake: + # requests are stateless and tool methods must be answered directly. + resp = self.server.handle_request({ + "jsonrpc": "2.0", + "id": 5, + "method": "initialize", + "params": {"protocolVersion": "2025-06-18", "capabilities": {}}, + }) + self.assertEqual(resp["error"]["code"], -32601) + self.assertIn("removed", resp["error"]["message"]) + + def test_protocol_version_validated_per_request(self) -> None: + # Requests self-describe their protocol version through _meta. + resp = self.server.handle_request({ + "jsonrpc": "2.0", + "id": 6, + "method": "tools/list", + "_meta": {"protocolVersion": "2025-06-18"}, + }) + self.assertIn("error", resp) + self.assertEqual(resp["error"]["code"], -32600) + + def test_tools_call_input_required_round_trip(self) -> None: + if InputRequired is None: + self.skipTest("implementation lacks the 2026-07-28 input_required flow") + self.server.register_tool( + name="confirm_delete", + description="Delete a resource after explicit confirmation", + schema={"type": "object"}, + handler=require_confirmation, + ) + + first = self.server.handle_request({ + "jsonrpc": "2.0", + "id": 7, + "method": "tools/call", + "params": {"name": "confirm_delete", "arguments": {}}, + }) + self.assertEqual(first["result"]["resultType"], "input_required") + self.assertEqual(first["result"]["requests"][0]["id"], "confirm") + + second = self.server.handle_request({ + "jsonrpc": "2.0", + "id": 8, + "method": "tools/call", + "params": { + "name": "confirm_delete", + "arguments": {}, + "inputResponses": {"confirm": "YES"}, + }, + }) + self.assertEqual(second["result"]["content"][0]["text"], "deleted") + + +if __name__ == "__main__": + unittest.main() diff --git a/courses/mcp-server-in-python/verify.py b/courses/mcp-server-in-python/verify.py new file mode 100644 index 0000000..734df16 --- /dev/null +++ b/courses/mcp-server-in-python/verify.py @@ -0,0 +1,276 @@ +#!/usr/bin/env python3 +"""Run the mcp-server contract against the starter or solution.""" + +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 + + + +COURSE_ID = 'course-mcp-tools' +# 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 = 'c328e39d6c8f8de4' + +CHECKPOINTS = [ + {"id": "l01", "gate": "attest", "title": 'MCP 是什么(2026-07-28)/ What MCP actually is'}, + {"id": "l02", "gate": "attest", "title": 'JSON-RPC 分发契约 / The dispatch contract'}, + {"id": "l03", "gate": "starter-suite", "title": '校验与错误隔离 / Validation and error isolation'}, + {"id": "l04", "gate": "both-suites", "title": 'input_required 多轮交互 / The input_required round-trip'}, + {"id": "l05", "gate": "attest", "title": '暴露一个真实工具 / Expose one real tool'}, +] + +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() + 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=args.expect_failure, + text=args.expect_failure, + ) + + 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 "") + expected_failures = ( + "test_invalid_request_structure", + "test_tools_call_handles_runtime_exception", + "test_tools_call_missing_required_argument", + ) + missing = [n for n in expected_failures if n not in output] + if missing: + print("Starter failed for unexpected reasons:", file=sys.stderr) + print("\n".join(missing), file=sys.stderr) + print(output, file=sys.stderr) + return 1 + print("Expected MCP boundary regression reproduced: unhandled exceptions and missing schema validations fail.") + 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 new file mode 100644 index 0000000..4fa1b4a --- /dev/null +++ b/courses/verifying-ai-generated-code/COURSE.md @@ -0,0 +1,85 @@ +--- +id: course-verify-ship +type: course +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. +lang: en-US +content_version: 3 +status: reviewed +reviewed_on: 2026-09-12 +badge: + id: course-verify-ship + name_en: It runs is not evidence + name_zh: 能跑不是证据 + requires: All five checkpoints claimed (L01–L05) +course_id: course-verify-ship +--- + +# From "it runs" to "it ships": verifying AI-generated code + +> 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 `ship_check.py`: one command that +> turns "the demo worked" into a written delivery record — command, exit +> code, parsed test count, a verdict that refuses projects with no tests, +> and an explicit unverified list. Tool-agnostic; Python standard library +> only. + +## What you build + +A release-evidence builder for small AI-written Python projects. Given a +project directory with a `ship.json`, it runs the configured check command, +parses how many tests actually ran and whether they passed, and writes an +atomic `SHIP-RECORD.json` with an honest verdict: + +| Skin | State | Data | +| --- | --- | --- | +| `scenario/green-project/` | two passing tests + declared unverified items | `ship.json`, `calc.py`, `tests/` | +| `scenario/red-project/` | one deliberately wrong expectation | the regression under study | +| `scenario/no-tests/` | zero tests — the state that must never pass | empty `tests/` | + +## Teaching contract (read this first, agent) + +- **Audience:** a project owner shipping AI-written Python who cannot yet + answer "what proves this release?" with a file instead of a memory. +- **Prerequisites:** Python 3.11+ on PATH and any coding agent (taught and + reviewed with Claude Code 2.x and Codex CLI 0.x; reviewed 2026-09-12). + Standard library only. +- **Lesson order:** L01 → L05; never skip the checkpoint. +- **Teaching style:** work from the files in this folder; quote the contract + line you satisfy; smallest change per failing test; no new dependencies; + never edit `solution/` or the scenario projects; ask before touching + unnamed files. +- **When to stop:** a lesson is done when its checkpoint command runs and + the learner can explain what failed and why. +- **`verify.py`:** `python verify.py starter --expect-failure` reproduces the + seven listed failures; `python verify.py solution` passes 10/10. +- **Honesty rules:** the record's "unverified" list is the point — never + present a passing record as proof of more than it contains. + +## What this course does NOT cover + +Deployment platforms, CI services, performance testing, or staging +environments. The product-quality guide on flypython.com covers the wider +release checklist; this course builds the evidence core. + + +## Badge contract + +- 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 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`, bilingual `lessons/`, `scenario/` projects, +`TASK.md`/`TASK_cn.md` (the code contract), `starter/`, `solution/`, +`tests/` (10 tests), `verify.py`, `REVIEW.md`. + +## Evidence and licensing + +`REVIEW.md` records the run-through state. Code is MIT; prose is CC BY 4.0 +(see repository `LICENSE`). Teaching drift goes to the `course-feedback` +issue form. diff --git a/courses/verifying-ai-generated-code/COURSE_cn.md b/courses/verifying-ai-generated-code/COURSE_cn.md new file mode 100644 index 0000000..9ddd4d3 --- /dev/null +++ b/courses/verifying-ai-generated-code/COURSE_cn.md @@ -0,0 +1,78 @@ +--- +id: course-verify-ship +type: course +title: "从「能跑」到「能上线」:验证 AI 写的代码" +summary: 为你的 AI 项目补上缺失的发布证据机器——一个 ship check:运行测试、解析实际跑了什么、拒绝零测试项目,并写出诚实的交付记录。 +lang: zh-CN +content_version: 3 +status: reviewed +reviewed_on: 2026-09-12 +badge: + id: course-verify-ship + name_en: It runs is not evidence + name_zh: 能跑不是证据 + requires: 全部五个检查点认领通过(L01–L05) +course_id: course-verify-ship +--- + +# 从「能跑」到「能上线」:验证 AI 写的代码 + +> 摘要:在你的编码 Agent 里装上 FlyPython Skill,让它取回本课文件,再 +> 说一句 **“开始第 1 课”**(课程文件由 Skill 取回——你不用手动下载)。 +> 课程结束时你拥有 `ship_check.py`:一条命令把“演示能跑”变成一份书面 +> 交付记录——命令、退出码、解析出的测试数、一个拒绝零测试项目的判定, +> 以及一份显式的未验证清单。工具无关;仅用 Python 标准库。 + +## 你将做出什么 + +面向小型 AI 项目的发布证据构建器。给定一个带 `ship.json` 的项目目录, +它运行配置的检查命令,解析实际跑了多少测试、是否通过,并原子地写出 +`SHIP-RECORD.json` 与诚实的判定: + +| 皮肤 | 状态 | 数据 | +| --- | --- | --- | +| `scenario/green-project/` | 两个通过的测试 + 声明的未验证项 | `ship.json`、`calc.py`、`tests/` | +| `scenario/red-project/` | 一个故意写错的断言 | 正在研究的回归 | +| `scenario/no-tests/` | 零个测试——绝不能放行的状态 | 空的 `tests/` | + +## 教学契约(Agent 请先阅读本节) + +- **受众:** 正在交付 AI 写的 Python、却还只能凭记忆回答“什么证明了 + 这次发布”的项目所有者。 +- **前置条件:** PATH 中有 Python 3.11+,任一编码 Agent(以 Claude + Code 2.x 与 Codex CLI 0.x 完成教学与审核,审核日期 2026-09-12)。 + 只用标准库。 +- **课程顺序:** L01 → L05;绝不跳过检查点。 +- **教学风格:** 从本文件夹的文件出发;引用你满足的契约原文;每个 + 失败测试做最小变更;不新增依赖;不修改 `solution/` 与场景项目; + 改动未授权文件前先询问。 +- **何时停止:** 检查点命令通过、且学习者能说清什么失败了、为什么。 +- **`verify.py`:** `python verify.py starter --expect-failure` 复现七个 + 具名失败;`python verify.py solution` 通过 10/10。 +- **诚实规则:** 记录里的“未验证”清单正是重点——永远不要把一份通过 + 的记录说成比它包含的更多。 + +## 本课程不涉及的内容 + +部署平台、CI 服务、性能测试、预发环境。flypython.com 的产品质量指南 +覆盖更完整的发布清单;本课程构建的是证据核心。 + + +## 徽章契约 + +- 徽章:**「能跑」不是证据徽章**(徽章 id `course-verify-ship`)——认领全部五个检查点后获得。 +- 挑战: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`、双语 `lessons/`、`scenario/` 项目、 +`TASK.md`/`TASK_cn.md`(代码契约)、`starter/`、`solution/`、`tests/` +(10 个测试)、`verify.py`、`REVIEW.md`。 + +## 证据与许可 + +`REVIEW.md` 记录试跑状态。代码 MIT;文字 CC BY 4.0(见仓库 `LICENSE`)。 +教学偏差请走 `course-feedback` issue 表单。 diff --git a/courses/verifying-ai-generated-code/REVIEW.md b/courses/verifying-ai-generated-code/REVIEW.md new file mode 100644 index 0000000..3aa165b --- /dev/null +++ b/courses/verifying-ai-generated-code/REVIEW.md @@ -0,0 +1,39 @@ +# Maintainer run-through record + +## 2026-09-12 — contract verification (mechanical) + +- Environment: macOS (arm64), Python 3.13; repository clean checkout on + branch `feat/0.0.3-courses-and-radar`. + +- Commands and results: + - `python verify.py starter --expect-failure` — exit 0; all expected + failure names reproduced. + - `python verify.py solution` — exit 0; full suite passes. + - Every scenario project (red, green, no-tests) exercised end to end by the suite. +- Not verified in this pass: teaching quality with a live agent session. + +## Pending before the public course drop + +- One full agent-taught run-through ("start lesson 1" through the Lesson 5 + checkpoint), recording observed deviations from COURSE.md here; the + recording doubles as demo-video source material. + +## Deviation log + +### 2026-09-13 — agent solvability run (challenge mode, not taught mode) + +- Agent: Devin (SWE-2 Max), CLI session on macOS arm64, system Python + 3.14 (stdlib only — this course needs no third-party deps). +- Method: implemented `starter/ship_check.py` from `TASK.md` and the + test expectations only; did not read or copy `solution/`. +- Result: `python verify.py starter` exits 0 (all ten tests); + `verify.py progress` printed all five claim codes (l03/l04 + `[passed]`, l01/l02/l05 `[attest]`). +- Observed deviations: none — contract landed on the first pass. The + naive-starter traps (exit-code-only evidence, no-tests blessed, + non-atomic write, dropped unverified list) were all exercised by the + suite. +- Limitations: challenge-mode run (TASK.md → tests), not the taught + COURSE.md walkthrough — teaching quality remains separately pending. +- Starter restored to the deliberately-unfinished state after the run + (`--expect-failure` verified again). diff --git a/courses/verifying-ai-generated-code/TASK.md b/courses/verifying-ai-generated-code/TASK.md new file mode 100644 index 0000000..1fd1c33 --- /dev/null +++ b/courses/verifying-ai-generated-code/TASK.md @@ -0,0 +1,31 @@ +# Task contract: release-evidence builder (ship check) + +Change only `starter/ship_check.py`. Standard library only. Never modify the +scenario projects except through copies. + +- `run_checks(command, cwd) -> dict`: run `[sys.executable, *command]` in + *cwd*, capture output, and return + `{"command": " ".join(command), "exit_code": int, "ran": int, "result": str}`: + - `ran` is the test count parsed from `Ran N tests in ...`. + - `result` is `"no-tests"` when `ran == 0` (regardless of exit code — + modern Pythons exit nonzero for "NO TESTS RAN"), `"failed"` when the + exit code is nonzero, otherwise `"ok"`. +- `build_record(checks, *, verified_on, unverified=None) -> dict`: + `{"verified_on", "checks", "all_passed", "unverified"}` where + `all_passed` is true only when every check has exit code 0 AND result + `"ok"` (zero-test projects never pass), and `unverified` defaults to an + empty list. +- `write_record(record, destination)`: write JSON (UTF-8, indent 2, trailing + newline) atomically via a sibling temp file plus `os.replace`; create + missing parent directories; never leave a `.tmp` file on success. +- `run_project(project_dir, *, verified_on) -> dict`: read `ship.json` + (`command`, optional `unverified`), run the checks in the project + directory, build the record, write `SHIP-RECORD.json` there, return it. +- `main(argv=None) -> int`: + - `ship_check.py <project-dir> [--verified-on YYYY-MM-DD]`; otherwise + print usage to stderr and return 2. Without `--verified-on` use today's + date. + - Print `all_passed=... checks=...` to stdout; return 0 when + `all_passed`, otherwise 1. + +Done means `python verify.py starter` exits 0 with all ten tests passing. diff --git a/courses/verifying-ai-generated-code/TASK_cn.md b/courses/verifying-ai-generated-code/TASK_cn.md new file mode 100644 index 0000000..b6007dd --- /dev/null +++ b/courses/verifying-ai-generated-code/TASK_cn.md @@ -0,0 +1,28 @@ +# 任务契约:发布证据构建器(ship check) + +只修改 `starter/ship_check.py`。仅用标准库。绝不直接修改场景项目,只能 +使用其副本。 + +- `run_checks(command, cwd) -> dict`:在 *cwd* 中运行 + `[sys.executable, *command]`,捕获输出,返回 + `{"command": " ".join(command), "exit_code": int, "ran": int, "result": str}`: + - `ran` 是从 `Ran N tests in ...` 解析出的测试数。 + - `ran == 0` 时 `result` 为 `"no-tests"`(无论退出码——新版 Python 对 + "NO TESTS RAN" 返回非零);退出码非零为 `"failed"`;否则 `"ok"`。 +- `build_record(checks, *, verified_on, unverified=None) -> dict`: + `{"verified_on", "checks", "all_passed", "unverified"}`,其中 + `all_passed` 仅当每个检查退出码为 0 且 result 为 `"ok"` 时为真 + (零测试项目永远不通过);`unverified` 缺省为空列表。 +- `write_record(record, destination)`:原子地写 JSON(UTF-8、缩进 2、 + 末尾换行)——先写同名临时文件再 `os.replace`;创建缺失的父目录; + 成功后不留 `.tmp` 文件。 +- `run_project(project_dir, *, verified_on) -> dict`:读取 `ship.json` + (`command`、可选 `unverified`),在项目目录中运行检查、构建记录、 + 写出 `SHIP-RECORD.json` 并返回它。 +- `main(argv=None) -> int`: + - `ship_check.py <project-dir> [--verified-on YYYY-MM-DD]`;否则向 + stderr 打印用法并返回 2。未提供 `--verified-on` 时使用当天日期。 + - 向 stdout 打印 `all_passed=... checks=...`;`all_passed` 时返回 0, + 否则 1。 + +完成的标准是 `python verify.py starter` 以 0 退出且全部十个测试通过。 diff --git a/courses/verifying-ai-generated-code/lessons/L01.md b/courses/verifying-ai-generated-code/lessons/L01.md new file mode 100644 index 0000000..a1180a8 --- /dev/null +++ b/courses/verifying-ai-generated-code/lessons/L01.md @@ -0,0 +1,45 @@ +--- +id: course-verify-ship-l01 +type: course +title: "\"It runs\" is not evidence" +summary: "Watch the starter bless a project with no tests and a project with a failing one — then want better." +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "What this checkpoint tests" + body: "That you watched the starter bless what it should refuse: run it on `scenario/no-tests` and `scenario/red-project` and see 'passed' where a human sees failure." + - title: "Name the blind spots" + body: "The starter checks exit codes only — a zero-test project 'passes' and a failing suite can look fine. Write down the two failure modes before fixing anything." + - title: "When you are done" + body: "You can say why exit-code-only verification lies, in one sentence." +--- + +# "It runs" is not evidence + +## Objective + +Watch the starter bless a project with no tests and a project with a failing one — then want better. + +## The lesson + +Run `python starter/ship_check.py scenario/green-project` and then `.../no-tests` — both exit 0. A green check that cannot fail is decoration. Run the verifier to see the seven behaviors you will build: + +## Exercise + +- Map each expected failure to a real gap: parsed counts, no-tests verdict, unverified list, atomic writes, exit codes +- Open scenario/red-project/tests and find the wrong expectation + +## Checkpoint + +The lesson's commands run, and you can answer these in your own words +(the agent asks; you answer — that is the gate): + +1. Which command did this lesson run, and what did it decide? +2. What failed at the start, and why — in your own words? +3. Before trusting a similar change next time, what would you check first? + +## Expected evidence + +The command transcript and your answers. diff --git a/courses/verifying-ai-generated-code/lessons/L01_cn.md b/courses/verifying-ai-generated-code/lessons/L01_cn.md new file mode 100644 index 0000000..578ee8f --- /dev/null +++ b/courses/verifying-ai-generated-code/lessons/L01_cn.md @@ -0,0 +1,45 @@ +--- +id: course-verify-ship-l01 +type: course +title: "“能跑”不是证据" +summary: "看着 starter 给零测试的项目和有失败的项目都盖章放行——然后想要更好的东西。" +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "这个检查点考什么" + body: "考你亲眼看到 starter 放行了不该放行的:在 `scenario/no-tests` 和 `scenario/red-project` 上跑它,看到本该失败的地方显示『通过』。" + - title: "给盲点命名" + body: "starter 只看退出码——零测试的项目能『通过』,失败的套件看着也像没问题。修任何东西之前先写下这两种失效模式。" + - title: "完成标准" + body: "一句话说清为什么只看退出码的验证会说谎。" +--- + +# “能跑”不是证据 + +## 目标 + +看着 starter 给零测试的项目和有失败的项目都盖章放行——然后想要更好的东西。 + +## 课程内容 + +运行 `python starter/ship_check.py scenario/green-project`,再跑 `.../no-tests`——都是退出 0。一个不会失败的绿色检查只是装饰。运行验证器,看看你要构建的七个行为: + +## 练习 + +- 把每个预期失败对应到真实缺口:解析计数、零测试判定、未验证清单、原子写入、退出码 +- 打开 scenario/red-project/tests,找出写错的断言 + +## 检查点 + +本课的命令运行通过,并且你能用自己的话回答下面的问题(由 Agent +提问、你作答——这就是关口): + +1. 本课跑了哪条命令,它判定了什么? +2. 一开始什么失败了、为什么——用你自己的话说? +3. 下次再信任类似的改动之前,你会先检查什么? + +## 预期证据 + +命令输出记录与你的回答。 diff --git a/courses/verifying-ai-generated-code/lessons/L02.md b/courses/verifying-ai-generated-code/lessons/L02.md new file mode 100644 index 0000000..0c8792c --- /dev/null +++ b/courses/verifying-ai-generated-code/lessons/L02.md @@ -0,0 +1,45 @@ +--- +id: course-verify-ship-l02 +type: course +title: "Define what a release record must prove" +summary: "Command, exit code, parsed count and outcome, an unverified list, and a verdict that refuses zero-test projects." +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "What this checkpoint tests" + body: "Defining what a release record must prove before building it: command, exit code, parsed test count, outcome, an unverified list, and a verdict that refuses zero-test projects." + - title: "Field by field" + body: "Take `build_record`'s contract literally — `all_passed` is true only when every check has exit code 0 AND result 'ok'. Sketch the JSON on paper first." + - title: "The unverified list" + body: "It is the honest core: a record that cannot say what it did not check is marketing, not evidence." +--- + +# Define what a release record must prove + +## Objective + +Command, exit code, parsed count and outcome, an unverified list, and a verdict that refuses zero-test projects. + +## The lesson + +Read TASK.md. The record is honest by construction: `no-tests` is its own verdict (never "passed"), and the `unverified` list ships with every record. Write the record fields your project needs — then trace each to its test. + +## Exercise + +- Draft the unverified list for a project you own +- Ask: which field could a demo-minded agent fake? (exit codes alone — that is lesson 1 again) + +## Checkpoint + +The lesson's commands run, and you can answer these in your own words +(the agent asks; you answer — that is the gate): + +1. Which command did this lesson run, and what did it decide? +2. What failed at the start, and why — in your own words? +3. Before trusting a similar change next time, what would you check first? + +## Expected evidence + +The command transcript and your answers. diff --git a/courses/verifying-ai-generated-code/lessons/L02_cn.md b/courses/verifying-ai-generated-code/lessons/L02_cn.md new file mode 100644 index 0000000..9ed6520 --- /dev/null +++ b/courses/verifying-ai-generated-code/lessons/L02_cn.md @@ -0,0 +1,45 @@ +--- +id: course-verify-ship-l02 +type: course +title: "定义发布记录必须证明什么" +summary: "命令、退出码、解析出的数量与结果、一份未验证清单,以及一个拒绝零测试项目的判定。" +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "这个检查点考什么" + body: "先定义发布记录必须证明什么再动手:命令、退出码、解析出的测试数、结果、未验证清单,以及拒绝零测试项目的判定。" + - title: "逐字段抠契约" + body: "字面理解 `build_record` 的契约——`all_passed` 只有在每个检查都是退出码 0 且结果 'ok' 时才为真。先在纸上画出 JSON。" + - title: "未验证清单" + body: "它是诚实的内核:说不出自己没查什么的记录是广告,不是证据。" +--- + +# 定义发布记录必须证明什么 + +## 目标 + +命令、退出码、解析出的数量与结果、一份未验证清单,以及一个拒绝零测试项目的判定。 + +## 课程内容 + +读 TASK.md。这份记录在构造上就是诚实的:`no-tests` 是独立的判定(绝不等于“通过”),且每份记录都携带 `unverified` 清单。写下你的项目需要的记录字段——再逐条对应到测试。 + +## 练习 + +- 为你自己的项目起草未验证清单 +- 问一句:哪个字段是爱演示的 Agent 能造假的?(只有退出码——那是第 1 课的教训) + +## 检查点 + +本课的命令运行通过,并且你能用自己的话回答下面的问题(由 Agent +提问、你作答——这就是关口): + +1. 本课跑了哪条命令,它判定了什么? +2. 一开始什么失败了、为什么——用你自己的话说? +3. 下次再信任类似的改动之前,你会先检查什么? + +## 预期证据 + +命令输出记录与你的回答。 diff --git a/courses/verifying-ai-generated-code/lessons/L03.md b/courses/verifying-ai-generated-code/lessons/L03.md new file mode 100644 index 0000000..b52bcd3 --- /dev/null +++ b/courses/verifying-ai-generated-code/lessons/L03.md @@ -0,0 +1,45 @@ +--- +id: course-verify-ship-l03 +type: course +title: "Build the ship check, test by test" +summary: "Parsing, verdicts, the unverified list, atomic writes, exit codes — one failing group at a time." +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "What this checkpoint tests" + body: "`python verify.py starter` — implement `run_checks`, `build_record`, `write_record`, `run_project`, `main` until the suite is green." + - title: "Parse, do not guess" + body: "`ran` comes from `Ran N tests in ...` — parse the unittest summary line. `result` is 'no-tests' when `ran == 0` regardless of exit code, 'failed' on nonzero exit, else 'ok'." + - title: "Atomic + exit codes" + body: "`write_record` uses a sibling temp plus `os.replace` and creates parents; `main` prints `all_passed=... checks=...`, returns 0 on all_passed else 1, usage errors return 2." +--- + +# Build the ship check, test by test + +## Objective + +Parsing, verdicts, the unverified list, atomic writes, exit codes — one failing group at a time. + +## The lesson + +Tell the agent: "Work through TASK.md against starter/ship_check.py, one failing test group at a time: parsing, then verdicts, then the unverified list, then atomic writes, then CLI exit codes. Show me the diff after each group." + +## Exercise + +- Run PYTHONPATH=starter python -m unittest discover -s tests -v after each group +- Never let a change weaken a verdict to make a test pass + +## Checkpoint + +The lesson's commands run, and you can answer these in your own words +(the agent asks; you answer — that is the gate): + +1. Which command did this lesson run, and what did it decide? +2. What failed at the start, and why — in your own words? +3. Before trusting a similar change next time, what would you check first? + +## Expected evidence + +The command transcript and your answers. diff --git a/courses/verifying-ai-generated-code/lessons/L03_cn.md b/courses/verifying-ai-generated-code/lessons/L03_cn.md new file mode 100644 index 0000000..706059a --- /dev/null +++ b/courses/verifying-ai-generated-code/lessons/L03_cn.md @@ -0,0 +1,45 @@ +--- +id: course-verify-ship-l03 +type: course +title: "按测试逐组构建 ship check" +summary: "解析、判定、未验证清单、原子写入、退出码——一次一组失败测试。" +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "这个检查点考什么" + body: "`python verify.py starter`——实现 `run_checks`、`build_record`、`write_record`、`run_project`、`main`,套件全绿。" + - title: "要解析不要猜" + body: "`ran` 来自 `Ran N tests in ...`——解析 unittest 的汇总行。`ran == 0` 时无论退出码如何 `result` 都是 'no-tests',退出码非零是 'failed',否则 'ok'。" + - title: "原子写加退出码" + body: "`write_record` 用同级临时文件加 `os.replace` 并创建父目录;`main` 打印 `all_passed=... checks=...`,全过返回 0 否则 1,用法错误返回 2。" +--- + +# 按测试逐组构建 ship check + +## 目标 + +解析、判定、未验证清单、原子写入、退出码——一次一组失败测试。 + +## 课程内容 + +对 Agent 说:“按 TASK.md 修改 starter/ship_check.py,一次只处理一组失败测试:先解析,再判定,再未验证清单,再原子写入,最后 CLI 退出码。每组之后给我看 diff。” + +## 练习 + +- 每组之后运行 PYTHONPATH=starter python -m unittest discover -s tests -v +- 绝不允许为了过测试而弱化判定 + +## 检查点 + +本课的命令运行通过,并且你能用自己的话回答下面的问题(由 Agent +提问、你作答——这就是关口): + +1. 本课跑了哪条命令,它判定了什么? +2. 一开始什么失败了、为什么——用你自己的话说? +3. 下次再信任类似的改动之前,你会先检查什么? + +## 预期证据 + +命令输出记录与你的回答。 diff --git a/courses/verifying-ai-generated-code/lessons/L04.md b/courses/verifying-ai-generated-code/lessons/L04.md new file mode 100644 index 0000000..cd2bc5d --- /dev/null +++ b/courses/verifying-ai-generated-code/lessons/L04.md @@ -0,0 +1,45 @@ +--- +id: course-verify-ship-l04 +type: course +title: "Read the record like a skeptic" +summary: "Green, red, and no-tests mean different things — prove you can tell them apart and say what the record does not prove." +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "What this checkpoint tests" + body: "Both suites plus reading records like a skeptic: green, red, and no-tests produce different verdicts — prove you can tell them apart." + - title: "Run all three skins" + body: "`run_project` on green-project, red-project, and no-tests gives ok, failed, and no-tests — check each `SHIP-RECORD.json` against the contract, not against your memory." + - title: "What the record cannot prove" + body: "State one thing a green record does not cover (like whether the tests themselves are any good) — that sentence is the lesson." +--- + +# Read the record like a skeptic + +## Objective + +Green, red, and no-tests mean different things — prove you can tell them apart and say what the record does not prove. + +## The lesson + +Run the finished check on all three scenario projects. Read each SHIP-RECORD.json: the same command, three different verdicts. The `unverified` list is not a disclaimer — it is the scope of your claim. Review the diff for unforced changes; try `--verified-on` and confirm the record is reproducible. + +## Exercise + +- Hand-check: does all_passed match exit_code and result in every record? +- Write the three-line evidence note: verified / not verified / known limits + +## Checkpoint + +The lesson's commands run, and you can answer these in your own words +(the agent asks; you answer — that is the gate): + +1. Which command did this lesson run, and what did it decide? +2. What failed at the start, and why — in your own words? +3. Before trusting a similar change next time, what would you check first? + +## Expected evidence + +The command transcript and your answers. diff --git a/courses/verifying-ai-generated-code/lessons/L04_cn.md b/courses/verifying-ai-generated-code/lessons/L04_cn.md new file mode 100644 index 0000000..c2ac8f2 --- /dev/null +++ b/courses/verifying-ai-generated-code/lessons/L04_cn.md @@ -0,0 +1,45 @@ +--- +id: course-verify-ship-l04 +type: course +title: "像怀疑者一样读记录" +summary: "绿、红、零测试是三件不同的事——证明你能分辨,并说清记录不能证明什么。" +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "这个检查点考什么" + body: "双套件加怀疑者式读记录:绿、红、零测试产出不同判定——证明你分得清。" + - title: "三个皮肤都跑" + body: "对 green-project、red-project、no-tests 运行 `run_project`,分别得到 ok、failed、no-tests——拿契约核对每个 `SHIP-RECORD.json`,不要凭记忆。" + - title: "记录证不了什么" + body: "写一句绿色记录覆盖不了的事(比如测试本身写得好不好)——这句话就是本课。" +--- + +# 像怀疑者一样读记录 + +## 目标 + +绿、红、零测试是三件不同的事——证明你能分辨,并说清记录不能证明什么。 + +## 课程内容 + +对三个场景项目各跑一次完成的检查。逐份读 SHIP-RECORD.json:同一条命令,三种判定。`unverified` 清单不是免责声明——它是你主张的边界。审查 diff 里没有测试逼着的改动;用 `--verified-on` 确认记录可复现。 + +## 练习 + +- 人工核对:每份记录里 all_passed 是否与 exit_code 和 result 一致? +- 写下三行证据笔记:已验证 / 未验证 / 已知局限 + +## 检查点 + +本课的命令运行通过,并且你能用自己的话回答下面的问题(由 Agent +提问、你作答——这就是关口): + +1. 本课跑了哪条命令,它判定了什么? +2. 一开始什么失败了、为什么——用你自己的话说? +3. 下次再信任类似的改动之前,你会先检查什么? + +## 预期证据 + +命令输出记录与你的回答。 diff --git a/courses/verifying-ai-generated-code/lessons/L05.md b/courses/verifying-ai-generated-code/lessons/L05.md new file mode 100644 index 0000000..1321928 --- /dev/null +++ b/courses/verifying-ai-generated-code/lessons/L05.md @@ -0,0 +1,44 @@ +--- +id: course-verify-ship-l05 +type: course +title: "Gate your next release" +summary: "Take the ship check to a project you own and make the record part of shipping." +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "What this checkpoint tests" + body: "Gating a release you own: wire the ship check into a real project and let the record be part of shipping." + - title: "ship.json first" + body: "Give your project a `ship.json` with its real check command and an honest `unverified` list — the file is the contract, the script is just its executor." + - title: "The honest record" + body: "Keep one `SHIP-RECORD.json` and note what it deliberately does not claim — a record that pretends completeness fails the course's point." +--- + +# Gate your next release + +## Objective + +Take the ship check to a project you own and make the record part of shipping. + +## The lesson + +Add ship.json to one of your projects (command + unverified list), drop in ship_check.py, and run it. Wire the exit code into whatever gate you already have — CI, a release script, or a human checklist. From now on, "shipped" means a record exists and you have read its unverified list. + +## Exercise + +- Run the check from a clean checkout; the record must reproduce + +## Checkpoint + +The lesson's commands run, and you can answer these in your own words +(the agent asks; you answer — that is the gate): + +1. Which command did this lesson run, and what did it decide? +2. What failed at the start, and why — in your own words? +3. Before trusting a similar change next time, what would you check first? + +## Expected evidence + +The command transcript and your answers. diff --git a/courses/verifying-ai-generated-code/lessons/L05_cn.md b/courses/verifying-ai-generated-code/lessons/L05_cn.md new file mode 100644 index 0000000..ea7d48f --- /dev/null +++ b/courses/verifying-ai-generated-code/lessons/L05_cn.md @@ -0,0 +1,44 @@ +--- +id: course-verify-ship-l05 +type: course +title: "给你的下一次发布装上门禁" +summary: "把 ship check 带到你自己的项目,让记录成为交付的一部分。" +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +hints: + - title: "这个检查点考什么" + body: "给你自己的发布装门禁:把 ship check 接进一个真实项目,让记录成为交付的一部分。" + - title: "先写 ship.json" + body: "给项目一个 `ship.json`:真实的检查命令加诚实的 `unverified` 清单——文件是契约,脚本只是执行者。" + - title: "诚实的记录" + body: "留一份 `SHIP-RECORD.json`,写下它刻意不声明什么——假装完整的记录恰恰违背了本课初衷。" +--- + +# 给你的下一次发布装上门禁 + +## 目标 + +把 ship check 带到你自己的项目,让记录成为交付的一部分。 + +## 课程内容 + +为你的一个项目加上 ship.json(命令 + 未验证清单),放入 ship_check.py 并运行。把退出码接进你已有的任何门禁——CI、发布脚本或人工清单。从现在起,“已交付”意味着记录存在、且你读过它的未验证清单。 + +## 练习 + +- 在干净检出上运行检查;记录必须可复现 + +## 检查点 + +本课的命令运行通过,并且你能用自己的话回答下面的问题(由 Agent +提问、你作答——这就是关口): + +1. 本课跑了哪条命令,它判定了什么? +2. 一开始什么失败了、为什么——用你自己的话说? +3. 下次再信任类似的改动之前,你会先检查什么? + +## 预期证据 + +命令输出记录与你的回答。 diff --git a/courses/verifying-ai-generated-code/scenario/green-project/calc.py b/courses/verifying-ai-generated-code/scenario/green-project/calc.py new file mode 100644 index 0000000..c4624b7 --- /dev/null +++ b/courses/verifying-ai-generated-code/scenario/green-project/calc.py @@ -0,0 +1,9 @@ +"""Tiny arithmetic module used by the green scenario project.""" + + +def add(left: float, right: float) -> float: + return left + right + + +def slugify(value: str) -> str: + return "-".join(part for part in value.lower().split() if part) diff --git a/courses/verifying-ai-generated-code/scenario/green-project/ship.json b/courses/verifying-ai-generated-code/scenario/green-project/ship.json new file mode 100644 index 0000000..58dfe2b --- /dev/null +++ b/courses/verifying-ai-generated-code/scenario/green-project/ship.json @@ -0,0 +1,4 @@ +{ + "command": ["-m", "unittest", "discover", "-s", "tests"], + "unverified": ["behavior on production data volume", "upstream schema stability"] +} diff --git a/courses/verifying-ai-generated-code/scenario/green-project/tests/test_calc.py b/courses/verifying-ai-generated-code/scenario/green-project/tests/test_calc.py new file mode 100644 index 0000000..b498b5f --- /dev/null +++ b/courses/verifying-ai-generated-code/scenario/green-project/tests/test_calc.py @@ -0,0 +1,19 @@ +import pathlib +import sys +import unittest + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) + +from calc import add, slugify + + +class CalcTest(unittest.TestCase): + def test_add_sums_two_numbers(self) -> None: + self.assertEqual(add(2, 3), 5) + + def test_slugify_drops_empty_parts(self) -> None: + self.assertEqual(slugify(" Hello World "), "hello-world") + + +if __name__ == "__main__": + unittest.main() diff --git a/courses/verifying-ai-generated-code/scenario/no-tests/ship.json b/courses/verifying-ai-generated-code/scenario/no-tests/ship.json new file mode 100644 index 0000000..bb14809 --- /dev/null +++ b/courses/verifying-ai-generated-code/scenario/no-tests/ship.json @@ -0,0 +1,4 @@ +{ + "command": ["-m", "unittest", "discover", "-s", "tests"], + "unverified": ["everything — this project has no tests yet"] +} diff --git a/courses/verifying-ai-generated-code/scenario/no-tests/tests/README.md b/courses/verifying-ai-generated-code/scenario/no-tests/tests/README.md new file mode 100644 index 0000000..4fd5224 --- /dev/null +++ b/courses/verifying-ai-generated-code/scenario/no-tests/tests/README.md @@ -0,0 +1 @@ +No tests in this scenario project on purpose. diff --git a/courses/verifying-ai-generated-code/scenario/red-project/calc.py b/courses/verifying-ai-generated-code/scenario/red-project/calc.py new file mode 100644 index 0000000..c4624b7 --- /dev/null +++ b/courses/verifying-ai-generated-code/scenario/red-project/calc.py @@ -0,0 +1,9 @@ +"""Tiny arithmetic module used by the green scenario project.""" + + +def add(left: float, right: float) -> float: + return left + right + + +def slugify(value: str) -> str: + return "-".join(part for part in value.lower().split() if part) diff --git a/courses/verifying-ai-generated-code/scenario/red-project/ship.json b/courses/verifying-ai-generated-code/scenario/red-project/ship.json new file mode 100644 index 0000000..05439f6 --- /dev/null +++ b/courses/verifying-ai-generated-code/scenario/red-project/ship.json @@ -0,0 +1,4 @@ +{ + "command": ["-m", "unittest", "discover", "-s", "tests"], + "unverified": ["the failing expectation is the regression under study"] +} diff --git a/courses/verifying-ai-generated-code/scenario/red-project/tests/test_calc.py b/courses/verifying-ai-generated-code/scenario/red-project/tests/test_calc.py new file mode 100644 index 0000000..0238258 --- /dev/null +++ b/courses/verifying-ai-generated-code/scenario/red-project/tests/test_calc.py @@ -0,0 +1,20 @@ +import pathlib +import sys +import unittest + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) + +from calc import add, slugify + + +class CalcTest(unittest.TestCase): + def test_add_sums_two_numbers(self) -> None: + self.assertEqual(add(2, 3), 5) + + def test_slugify_drops_empty_parts(self) -> None: + # Deliberately wrong expectation: reproduces an unverified AI change. + self.assertEqual(slugify(" Hello World "), "Hello World") + + +if __name__ == "__main__": + unittest.main() diff --git a/courses/verifying-ai-generated-code/solution/ship_check.py b/courses/verifying-ai-generated-code/solution/ship_check.py new file mode 100644 index 0000000..e145b0a --- /dev/null +++ b/courses/verifying-ai-generated-code/solution/ship_check.py @@ -0,0 +1,110 @@ +"""Release-evidence builder for AI-written Python (reviewed solution). + +Runs a project's checks and writes a delivery record with the exact command, +its exit code, the parsed test count and outcome, the honest "unverified" +list, and an all_passed verdict that refuses to bless zero-test projects. +Writes are atomic so an interrupted run cannot leave a half-written record. +Standard library only. +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +from datetime import date +from pathlib import Path + +DEFAULT_COMMAND = ["-m", "unittest", "discover", "-s", "tests"] +RECORD_NAME = "SHIP-RECORD.json" +RAN_PATTERN = re.compile(r"^Ran (\d+) tests? in ", re.MULTILINE) + + +def run_checks(command: list[str], cwd: str | Path) -> dict: + """Run *command* (python arguments) in *cwd* and record the evidence.""" + full = [sys.executable, *command] + result = subprocess.run(full, cwd=str(cwd), capture_output=True, text=True) + output = (result.stdout or "") + (result.stderr or "") + match = RAN_PATTERN.search(output) + ran = int(match.group(1)) if match else 0 + # "NO TESTS RAN" exits nonzero on modern Pythons; absence of tests is + # its own verdict, not a crash, so it is classified before exit codes. + if ran == 0: + outcome = "no-tests" + elif result.returncode != 0: + outcome = "failed" + else: + outcome = "ok" + return { + "command": " ".join(command), + "exit_code": result.returncode, + "ran": ran, + "result": outcome, + } + + +def build_record( + checks: list[dict], + *, + verified_on: str, + unverified: list[str] | None = None, +) -> dict: + """Assemble the delivery record from check evidence.""" + return { + "verified_on": verified_on, + "checks": checks, + "all_passed": all( + check["exit_code"] == 0 and check["result"] == "ok" for check in checks + ), + "unverified": list(unverified or []), + } + + +def write_record(record: dict, destination: str | Path) -> None: + """Atomically write the record 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(record, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" + ) + os.replace(temporary, target) + + +def run_project(project_dir: str | Path, *, verified_on: str) -> dict: + """Read ship.json, run the checks, write SHIP-RECORD.json, return it.""" + directory = Path(project_dir) + config = json.loads((directory / "ship.json").read_text(encoding="utf-8")) + command = config.get("command", DEFAULT_COMMAND) + check = run_checks(command, directory) + record = build_record( + [check], + verified_on=verified_on, + unverified=config.get("unverified"), + ) + write_record(record, directory / RECORD_NAME) + return record + + +def main(argv: list[str] | None = None) -> int: + """Build the delivery record for one project directory.""" + arguments = sys.argv[1:] if argv is None else argv + if not arguments or len(arguments) > 3: + print( + "usage: python ship_check.py <project-dir> [--verified-on YYYY-MM-DD]", + file=sys.stderr, + ) + return 2 + project = arguments[0] + verified_on = date.today().isoformat() + if len(arguments) == 3 and arguments[1] == "--verified-on": + verified_on = arguments[2] + record = run_project(project, verified_on=verified_on) + print(f"all_passed={record['all_passed']} checks={len(record['checks'])}") + return 0 if record["all_passed"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/courses/verifying-ai-generated-code/starter/ship_check.py b/courses/verifying-ai-generated-code/starter/ship_check.py new file mode 100644 index 0000000..d2757b3 --- /dev/null +++ b/courses/verifying-ai-generated-code/starter/ship_check.py @@ -0,0 +1,85 @@ +"""Release-evidence builder for AI-written Python (starter, incomplete). + +Runs a project's checks and writes a delivery record: the exact command, +its exit code, the parsed test count and outcome, an honest "unverified" +list, and an all_passed verdict. This starter reproduces the naive state: +it records only exit codes (so a project with no tests counts as passing), +never parses results, drops the unverified list, and writes non-atomically. +../TASK.md defines the contract. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from datetime import date +from pathlib import Path + +DEFAULT_COMMAND = ["-m", "unittest", "discover", "-s", "tests"] +RECORD_NAME = "SHIP-RECORD.json" + + +def run_checks(command: list[str], cwd: str | Path) -> dict: + """Run *command* (python arguments) in *cwd* and record the evidence.""" + full = [sys.executable, *command] + result = subprocess.run(full, cwd=str(cwd), capture_output=True, text=True) + return {"command": " ".join(command), "exit_code": result.returncode} + + +def build_record( + checks: list[dict], + *, + verified_on: str, + unverified: list[str] | None = None, +) -> dict: + """Assemble the delivery record from check evidence.""" + return { + "verified_on": verified_on, + "checks": checks, + "all_passed": all(check["exit_code"] == 0 for check in checks), + } + + +def write_record(record: dict, destination: str | Path) -> None: + """Write the record as JSON to *destination*.""" + Path(destination).write_text( + json.dumps(record, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" + ) + + +def run_project(project_dir: str | Path, *, verified_on: str) -> dict: + """Read ship.json, run the checks, write SHIP-RECORD.json, return it.""" + directory = Path(project_dir) + config = json.loads((directory / "ship.json").read_text(encoding="utf-8")) + command = config.get("command", DEFAULT_COMMAND) + check = run_checks(command, directory) + record = build_record( + [check], + verified_on=verified_on, + unverified=config.get("unverified"), + ) + write_record(record, directory / RECORD_NAME) + return record + + +def main(argv: list[str] | None = None) -> int: + """Build the delivery record for one project directory.""" + arguments = sys.argv[1:] if argv is None else argv + if not arguments or len(arguments) > 3: + print( + "usage: python ship_check.py <project-dir> [--verified-on YYYY-MM-DD]", + file=sys.stderr, + ) + return 2 + project = arguments[0] + verified_on = date.today().isoformat() + if len(arguments) == 3 and arguments[1] == "--verified-on": + verified_on = arguments[2] + record = run_project(project, verified_on=verified_on) + print(f"all_passed={record['all_passed']} checks={len(record['checks'])}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/courses/verifying-ai-generated-code/tests/test_ship_check.py b/courses/verifying-ai-generated-code/tests/test_ship_check.py new file mode 100644 index 0000000..db4b0f1 --- /dev/null +++ b/courses/verifying-ai-generated-code/tests/test_ship_check.py @@ -0,0 +1,111 @@ +"""Contract tests for the release-evidence builder.""" + +from __future__ import annotations + +import json +import shutil +import tempfile +import unittest +from pathlib import Path + +import ship_check + +COURSE_ROOT = Path(__file__).resolve().parent.parent +SCENARIO_ROOT = COURSE_ROOT / "scenario" + + +class RunChecksTest(unittest.TestCase): + def test_green_project_produces_ok_check(self) -> None: + check = ship_check.run_checks( + ["-m", "unittest", "discover", "-s", "tests"], SCENARIO_ROOT / "green-project" + ) + self.assertEqual(check["exit_code"], 0) + self.assertEqual(check["ran"], 2) + self.assertEqual(check["result"], "ok") + + def test_failing_project_is_failed(self) -> None: + check = ship_check.run_checks( + ["-m", "unittest", "discover", "-s", "tests"], SCENARIO_ROOT / "red-project" + ) + self.assertNotEqual(check["exit_code"], 0) + self.assertEqual(check["ran"], 2) + self.assertEqual(check["result"], "failed") + + def test_no_tests_project_is_no_tests(self) -> None: + check = ship_check.run_checks( + ["-m", "unittest", "discover", "-s", "tests"], SCENARIO_ROOT / "no-tests" + ) + self.assertEqual(check["ran"], 0) + self.assertEqual(check["result"], "no-tests") + + +class BuildRecordTest(unittest.TestCase): + def test_no_tests_never_counts_as_passed(self) -> None: + record = ship_check.build_record( + [{"command": "-m unittest", "exit_code": 0, "ran": 0, "result": "no-tests"}], + verified_on="2026-09-12", + ) + self.assertFalse(record["all_passed"]) + + def test_record_carries_the_unverified_list(self) -> None: + record = ship_check.build_record( + [{"command": "-m unittest", "exit_code": 0, "ran": 2, "result": "ok"}], + verified_on="2026-09-12", + unverified=["production data volume", "upstream API schema stability"], + ) + self.assertEqual( + record["unverified"], + ["production data volume", "upstream API schema stability"], + ) + + def test_all_passed_requires_every_check_ok(self) -> None: + record = ship_check.build_record( + [ + {"command": "a", "exit_code": 0, "ran": 2, "result": "ok"}, + {"command": "b", "exit_code": 1, "ran": 2, "result": "failed"}, + ], + verified_on="2026-09-12", + ) + self.assertFalse(record["all_passed"]) + + +class WriteRecordTest(unittest.TestCase): + def test_write_record_is_atomic_and_creates_parents(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + destination = Path(tmp) / "evidence" / "SHIP-RECORD.json" + ship_check.write_record({"verified_on": "2026-09-12"}, destination) + self.assertTrue(destination.exists()) + self.assertFalse(destination.with_name(destination.name + ".tmp").exists()) + self.assertEqual( + json.loads(destination.read_text(encoding="utf-8"))["verified_on"], + "2026-09-12", + ) + + +class RunProjectTest(unittest.TestCase): + def _copy(self, name: str) -> Path: + target = Path(tempfile.mkdtemp(prefix="project-")) / name + shutil.copytree(SCENARIO_ROOT / name, target) + self.addCleanup(shutil.rmtree, target.parent, ignore_errors=True) + return target + + def test_green_project_record_and_exit_code(self) -> None: + project = self._copy("green-project") + record = ship_check.run_project(project, verified_on="2026-09-12") + written = json.loads((project / "SHIP-RECORD.json").read_text(encoding="utf-8")) + self.assertEqual(written, record) + self.assertTrue(record["all_passed"]) + self.assertEqual(ship_check.main([str(project), "--verified-on", "2026-09-12"]), 0) + + def test_red_project_exits_nonzero(self) -> None: + project = self._copy("red-project") + self.assertEqual(ship_check.main([str(project), "--verified-on", "2026-09-12"]), 1) + written = json.loads((project / "SHIP-RECORD.json").read_text(encoding="utf-8")) + self.assertFalse(written["all_passed"]) + + def test_missing_argument_prints_usage(self) -> None: + self.assertEqual(ship_check.main([]), 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/courses/verifying-ai-generated-code/verify.py b/courses/verifying-ai-generated-code/verify.py new file mode 100644 index 0000000..d03b3c6 --- /dev/null +++ b/courses/verifying-ai-generated-code/verify.py @@ -0,0 +1,281 @@ +#!/usr/bin/env python3 +"""Run the course contract against starter or solution. + +Objective completion evidence for "Verifying AI-generated code before it +ships". +""" + +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_green_project_produces_ok_check", + "test_failing_project_is_failed", + "test_no_tests_project_is_no_tests", + "test_no_tests_never_counts_as_passed", + "test_record_carries_the_unverified_list", + "test_write_record_is_atomic_and_creates_parents", + "test_red_project_exits_nonzero", +) + + + +COURSE_ID = 'course-verify-ship' +# 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 = '8d11b8f902b1a109' + +CHECKPOINTS = [ + {"id": "l01", "gate": "attest", "title": '「能跑」不是证据 / It runs is not evidence'}, + {"id": "l02", "gate": "attest", "title": '定义发布记录 / Define the release record'}, + {"id": "l03", "gate": "starter-suite", "title": '构建 ship check / Build the ship check'}, + {"id": "l04", "gate": "both-suites", "title": '像怀疑者一样读记录 / Read the record like a skeptic'}, + {"id": "l05", "gate": "attest", "title": '发布门禁 / Gate your next release'}, +] + +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: exit codes only, no parsed " + "results, no-tests projects blessed, no unverified list, and " + "non-atomic records." + ) + 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/datastruct/binarysearch.py b/datastruct/binarysearch.py deleted file mode 100644 index fa6a1e2..0000000 --- a/datastruct/binarysearch.py +++ /dev/null @@ -1,25 +0,0 @@ -def BinarySearch(l,key): - low=0 - high=len(l)-1 - i=0 - while(low <= high): - i = i+1 - mid = low + ((high-low)>>1) - if(l[mid] < key): - low = mid + 1 - elif (l[mid] > key): - high = mid -1 - else: - print "use %d times" % i - return mid - return -1 - -if __name__ == "__main__": - l=[1,4,5,6,7,8,9,44,333,2233] - print l - print BinarySearch(l,4) - print BinarySearch(l,44) - print BinarySearch(l,8) - print BinarySearch(l,2233) - print BinarySearch(l,77) - diff --git a/datastruct/btree.py b/datastruct/btree.py deleted file mode 100644 index 69434e8..0000000 --- a/datastruct/btree.py +++ /dev/null @@ -1,62 +0,0 @@ -class BTree: - def __init__(self,value): - self.left=None - self.data=value - self.right=None - - def insertLeft(self,value): - self.left=BTree(value) - return self.left - - def insertRight(self,value): - self.right=BTree(value) - return self.right - - def show(self): - print self.data - -def preorder(node): - if node.data: - node.show() - if node.left: - preorder(node.left) - if node.right: - preorder(node.right) - -def inorder(node): - if node.data: - if node.left: - inorder(node.left) - node.show() - if node.right: - inorder(node.right) - -def postorder(node): - if node.data: - if node.left: - postorder(node.left) - if node.right: - postorder(node.right) - node.show() - -if __name__ == "__main__": - - Root=BTree("root") - A=Root.insertLeft("A") - C=A.insertLeft("C") - D=C.insertRight("D") - F=D.insertLeft("F") - G=D.insertRight("G") - B=Root.insertRight("B") - E=B.insertRight("E") - - print "pre-traversal" - preorder(Root) - - print "in-traversal" - inorder(Root) - - print "post-traversal" - postorder(Root) - - diff --git a/datastruct/graph.py b/datastruct/graph.py deleted file mode 100644 index 834670b..0000000 --- a/datastruct/graph.py +++ /dev/null @@ -1,35 +0,0 @@ -def searchGraph(graph,start,end): - results=[] - generatePath(graph,[start],end,results) - results.sort(lambda x,y:cmp(len(x),len(y))) - return results - -def generatePath(graph,path,end,results): - state=path[-1] - if state == end: - results.append(path) - else: - for arc in graph[state]: - if arc not in path: - generatePath(graph,path+[arc],end,results) - - -if __name__ == "__main__": - Graph={ - 'A':['B','C','D'], - 'B':['E'], - 'C':['D','F'], - 'D':['B','E','G'], - 'E':[], - 'F':['D','G'], - 'G':['E'] - } - r = searchGraph(Graph,'A','D') - print "A to D" - for i in r: - print i - - r=searchGraph(Graph,'A','E') - print "A to E" - for i in r: - print i diff --git a/datastruct/queue.py b/datastruct/queue.py deleted file mode 100644 index 64da3e4..0000000 --- a/datastruct/queue.py +++ /dev/null @@ -1,43 +0,0 @@ -class Queue: - def __init__(self,size=20): - self.queue=[] - self.size=size - self.end=-1 - - def setSize(self,size): - self.size=size - - def In(self,element): - if self.end < self.size -1: - self.queue.append(element) - self.end = self.end + 1 - else: - raise "QueueFull" - - def Out(self): - if self.end != -1: - element = self.queue[0] - self.queue=self.queue[1:] - self.end = self.end-1 - return element - else: - raise "QueueEmpty" - - def End(self): - return self.end - - def empty(self): - self.queue=[] - self.end=-1 - -if __name__ == "__main__": - - queue=Queue() - for i in range(10): - queue.In(i) - print queue.End() - - for i in range(10): - print queue.Out() - - diff --git a/datastruct/sort.py b/datastruct/sort.py deleted file mode 100644 index b190701..0000000 --- a/datastruct/sort.py +++ /dev/null @@ -1,78 +0,0 @@ -class BTree: - def __init__(self,value): - self.left=None - self.data=value - self.right=None - - def insertLeft(self,value): - self.left=BTree(value) - return self.left - - def insertRight(self,value): - self.right=BTree(value) - return self.right - - def show(self): - print self.data - -def inorder(node): - if node.data: - if node.left: - inorder(node.left) - node.show() - if node.right: - inorder(node.right) - - -def rinorder(node): - if node.data: - if node.right: - rinorder(node.right) - node.show() - if node.left: - rinorder(node.left) - -def insert(node,value): - if value > node.data: - if node.right: - insert(node.right,value) - else: - node.insertRight(value) - else: - if node.left: - insert(node.left,value) - else: - node.insertLeft(value) - - -if __name__ == "__main__": - - l=[88,11,2,33,22,4,55,33,221,34] - Root=BTree(l[0]) - node=Root - for i in range(1,len(l)): - insert(Root,l[i]) - - print "1---->10" - inorder(Root) - print "10--->1" - rinorder(Root) - - - - - - - - - - - - - - - - - - - diff --git a/datastruct/stack.py b/datastruct/stack.py deleted file mode 100644 index e5b65ce..0000000 --- a/datastruct/stack.py +++ /dev/null @@ -1,59 +0,0 @@ -class Stack: - def __init__(self,size=20): - self.stack= [] - self.size= size; - self.top= -1 - - def setSize(self,size): - self.size=size; - - def push(self,element): - if self.isFull(): - raise "StackOverflow" - else: - self.stack.append(element) - self.top = self.top + 1 - - def pop(self): - if self.isEmpty(): - raise "StackUnderflow" - else: - element=self.stack[-1] - self.top=self.top-1; - del self.stack[-1] - return element - - def Top(self): - return self.top - - def empty(self): - self.stack=[] - self.top=-1 - - def isEmpty(self): - if self.top == -1: - return True - else: - return False - - def isFull(self): - if self.top == self.size-1: - return True - else: - return False - -if __name__ == "__main__": - - stack=Stack() - - for i in range(10): - stack.push(i) - print stack.Top() - - for i in range(10): - print stack.pop() - - stack.empty() - print stack.Top() - - diff --git a/docs/CONSUMING.md b/docs/CONSUMING.md new file mode 100644 index 0000000..e6ba825 --- /dev/null +++ b/docs/CONSUMING.md @@ -0,0 +1,76 @@ +# Consuming FlyPython content + +This repository owns the source content; flypython.com owns presentation, +navigation, search, and product conversion. A website release deliberately +pins one repository commit instead of maintaining an editable copy. + +`catalog.json` is the stable machine-readable resource catalog. The website may +render path titles, summaries, resource rationales, levels, access requirements, +review dates, and safety metadata from the export. + +`content-manifest.json` indexes reviewed first-party guides and playbooks with +localized titles, summaries, paths, versions, review dates, and SHA-256 hashes. +For example, the repository owns the paired AI-coding workflow sources: + +- `guides/ai-coding/workflow.md` +- `guides/ai-coding/workflow_cn.md` + +A website may render these Markdown files from the same pinned commit. It may +adapt navigation and presentation, but should not maintain a second editable +copy of their claims or steps. + +## Pin an immutable revision + +Consumers must use a full commit SHA: + +```text +https://raw.githubusercontent.com/flypythoncom/python/<full-commit-sha>/catalog.json +``` + +Record the revision and checksum in the consumer repository: + +```json +{ + "repository": "flypythoncom/python", + "commit": "<full-commit-sha>", + "catalogSha256": "<sha256>", + "contentManifestSha256": "<sha256>" +} +``` + +Do not fetch `master` during a production build. A deliberate sync command +should download the pinned export, verify the checksum and schema version, and +write a generated local cache used by the normal website build. A failed sync +must leave the last accepted catalog unchanged. + +## Contract + +The current exports all have `schema_version: 1`. They are described by +[`schema/catalog-v1.schema.json`](../schema/catalog-v1.schema.json), +[`schema/content-manifest-v1.schema.json`](../schema/content-manifest-v1.schema.json), +and [`schema/radar-v1.schema.json`](../schema/radar-v1.schema.json) +(`radar.json`, the Project Radar export, is the third pinned artifact a +consumer should record alongside the other two). + +- A schema-version change may require consumer code changes. +- A content-only change keeps the same schema version. +- Resource IDs are stable consumer keys. +- `order` is only meaningful within a resource's `path`. +- English and Chinese rationales come from the same resource record. +- Consumers may choose a subset, but must not silently rewrite catalog claims. +- Consumers should show `reviewed_on`, `requires_key`, and `risk` wherever those + facts materially affect a user's decision. +- Consumers must verify every rendered first-party file against the checksum in + `content-manifest.json` and use the matching locale rather than translating it. + +## Update flow + +1. Merge and validate a content or catalog change in this repository. +2. Regenerate and commit both affected JSON exports with the same change. +3. Select the exact merged commit and compute both export SHA-256 values. +4. Update the website's catalog lock and generated cache in one focused change. +5. Run website content, rendering, accessibility, and link checks. +6. Deploy the website and verify that it exposes the pinned catalog revision. + +This keeps catalog review independent from website presentation while avoiding +two separately maintained copies of the same resource content. diff --git a/docs/CURATION_POLICY.md b/docs/CURATION_POLICY.md new file mode 100644 index 0000000..ffe97f0 --- /dev/null +++ b/docs/CURATION_POLICY.md @@ -0,0 +1,159 @@ +# FlyPython curation policy + +This policy defines what belongs in the FlyPython catalog, what evidence a +resource needs, and how maintainers review changes. It is the reference for +resource proposals and catalog pull requests. + +## Scope + +FlyPython catalogs durable resources for working with Python in the AI-coding +era: building, verifying, and delivering Python projects — including +Python-based AI agent development. It also hosts first-party courses +(`courses/`, challenge folders with objective verification and an optional +agent-guided mode), learning paths (`paths/`, sequenced course-plus-project +routes with badges), and Project Radar entries (`catalog/projects/`, +reviewed current projects with lifecycle status). The catalog favors material that helps readers build skills or use a +maintained tool safely. It is not a general link directory, product launch +feed, website renderer, or hosting location for unrelated applications. + +This repository is the canonical data and review layer. The separate +flypython.com repository owns the public website, learning guides, task +playbooks, and visual presentation. Website consumers must use a pinned catalog +commit rather than a moving branch. + +Current resource-catalog paths (separate from the course learning paths under +`paths/`) are: + +- `foundations` +- `web-apis` +- `automation` +- `ai-agents` + +## Source priority + +Review sources in this order: + +1. Official documentation +2. Official standards +3. Official project repositories or sites + +The current `source_type` values are `official-docs`, `official-standard`, and +`official-project`. A new source type requires a schema and policy change in +the same pull request. + +Secondary tutorials, affiliate pages, copied lists, thin SEO pages, and +unmaintained mirrors are excluded unless the policy is deliberately expanded. + +## Required catalog fields + +The `catalog/` directory is the canonical source. It contains catalog metadata, +the path definitions, and one YAML file per resource. `catalog.json` is a +deterministic generated export for consumers and must not be edited by hand. + +The `catalog` object records: + +- `reviewed_on` +- `status` +- `paths`, where every path has `id`, `title_en`, `title_zh`, `summary_en`, + `summary_zh`, and `order` + +Every resource file records: + +- Identity and ordering: `id`, `path`, `order`, `title`, `url` +- Classification: `source_type`, `level`, `language` +- Editorial rationale: `why_en`, `why_zh` +- Review state: `reviewed_on`, `status` +- Access and safety: `requires_key`, `risk` +- Editorial selection: `featured` + +Allowed classifications are: + +| Field | Values | +| --- | --- | +| `path` | `foundations`, `web-apis`, `automation`, `ai-agents` | +| `source_type` | `official-docs`, `official-standard`, `official-project` | +| `level` | `beginner`, `intermediate`, `advanced`, `all-levels` | +| `language` | `en`, `zh`, `multilingual` | +| `status` | `active` (resources); radar projects additionally use `new`, `rising`, `stable`, `major-update`, `experimental`, `archived`; courses may use `deprecated` after a tool major-version break | +| `risk` | `low`, `medium` | +| `requires_key`, `featured` | Boolean | + +Radar project records additionally carry `ai_familiarity` +(`low`/`medium`/`high`): whether the project and its current API are covered +by mainstream model training data. Grade `low` when the project or its current +major version postdates common model cutoffs, `medium` when it is known but +frequently generated with outdated APIs, `high` when models reliably produce +current-API code. The grade reflects the maintainer's dated judgment, not a +benchmark result. + +IDs must be stable, lowercase, and unique. A rename needs an explicit migration +plan because website consumers and downstream data may depend on the old ID. +The resource filename must match its ID. Orders must be positive, unique, and +consecutive within each path. Review dates use ISO `YYYY-MM-DD` format. + +## Acceptance criteria + +A resource is eligible when all of these are true: + +- Its URL is canonical and controlled by the official publisher or project. +- Its purpose fits one learning path. +- The English and Chinese rationales are factual, specific, and human-reviewed. +- Access requirements, API keys, paid tiers, and material safety risks are + represented accurately. +- Its license and ownership are clear enough for the claims the catalog makes. +- The maintainer can verify the resource on the stated review date. + +Do not describe a project as production-ready without current maintenance, +licensing, security, and adoption evidence. Popularity alone is not enough. + +## Editorial rules + +Write short, original rationales that explain why the resource is useful in its +assigned path. Do not copy marketing claims or large passages from the source. +Do not use an LLM or web-search API to generate descriptions or classifications. +A human reviewer remains responsible for each statement. + +English and Chinese website views must come from the same exported catalog +record. A translation should preserve the meaning and limits of the source, not +add new claims. + +Self-promotion must be disclosed. Maintainers assess it under the same criteria +as every other proposal. + +## Link verification + +Internal validation runs on every pull request. Network link fetching runs only +on the scheduled or manually dispatched GitHub Actions workflow. + +A 404 or 410 from the canonical resource is strong removal evidence. A 403, 429, +timeout, or transient 5xx is a review-needed result, not proof that the resource +is gone. Review-needed results fail the automated audit so they cannot remain +hidden behind a green run. DNS, connection, TLS, invalid-URL, and +redirect-protocol failures also fail the audit, but none of these results alone +is enough to remove a resource. Retry with rate limits and record the observation +date. Link checks must refuse private, loopback, link-local, multicast, +cloud-platform, and cloud metadata targets, including redirect destinations. + +## Review cadence and removal + +Run the catalog-link audit weekly. Perform an editorial review when a resource +changes ownership, becomes unmaintained, introduces a material safety concern, +or receives a substantiated report. + +Remove a resource when it is permanently unavailable, outside the catalog scope, +materially misleading, malicious, or no longer meets the acceptance criteria. +The pull request should preserve the reason and evidence in its description. +Do not silently replace a resource with an unrelated alternative under the same +stable ID. + +## Permissions and attribution + +This repository is dual-licensed (see `LICENSE`): code, examples, verification +scripts, and templates under MIT; written content (guides, playbooks, course +lessons, catalog records, documentation prose) under CC BY 4.0 with attribution +to "FlyPython (flypython.com)". Contributors must submit only material they +have the right to submit and must preserve required notices. + +Third-party content keeps its original terms. A catalog entry may link to and +factually describe a third-party resource, but it must not copy or relicense that +resource. diff --git a/docs/REPO_TO_WEBSITE.md b/docs/REPO_TO_WEBSITE.md new file mode 100644 index 0000000..b5d3743 --- /dev/null +++ b/docs/REPO_TO_WEBSITE.md @@ -0,0 +1,45 @@ +# Repository-to-website operating model + +The repository and flypython.com should form one user journey without becoming +duplicate websites. + +## Value split + +| Layer | User value | Owns | +| --- | --- | --- | +| GitHub repository | Inspect, run, verify, reuse, and contribute | Source guides, playbooks, examples, templates, catalog records, tests, and manifests | +| flypython.com | Discover the right path and continue learning | Presentation, search, navigation, newsletter, progress, and live offers | + +The website consumes a pinned repository commit. A website-only editorial copy +must not become a second source of truth. + +## Funnel + +1. A visitor lands on a README, guide, example, or search result. +2. The visitor completes a small useful outcome in the repository. +3. One contextual call to action offers the next step on flypython.com. +4. The website may invite an email subscription after delivering useful + content, not before. +5. A paid offer may appear only when its scope, price, delivery, support, and + refund behavior are live and verifiable. + +Good calls to action continue the current task: a guided learning path after a +guide, related tools after an example, or reviewed updates after Project Radar. +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: + +- repository link clicks by source document; +- landing-page engagement with a learning path; +- newsletter opt-ins attributed to the repository; +- requests for a clearly defined service or product; +- confirmed payments and completed delivery, kept separate from registrations + or pricing-page views. + +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. diff --git a/docs/repo-plan-0.0.3.md b/docs/repo-plan-0.0.3.md new file mode 100644 index 0000000..cbcf531 --- /dev/null +++ b/docs/repo-plan-0.0.3.md @@ -0,0 +1,176 @@ +# FlyPython 仓库 0.0.3 更新计划 + +版本:0.0.3(规划稿) +更新:2026-09-11 + +历史边界说明:本文记录 0.0.3 的内容模型和实施计划。当前挑战叙事、认领码、 +徽章与公开学习路线已经由 0.0.4 计划接续;网站当前固定到后续状态提交 +`920f790`。本文中的 +“Agent 授课”和未勾选 TODO 不代表当前状态。本文件按所有者决策以中文保留。 +关联:flypython.com `docs/product-and-growth-plan-0.0.3.md` + +状态:规划文档,不代表已实现。本计划遵守 `AGENTS.md` 的仓库边界:本仓库 +负责已审核内容、可运行证据与稳定 JSON 契约;网站负责展示与转化。 +所有新内容中英文同步产出。 + +## 1. 网站 0.0.3 方向对本仓库的要求 + +网站规划把"agent 自授课课程"定为主产品线、新增 Project Radar 周报, +并把本仓库(4.1k star)作为顶层流量渠道。这需要仓库侧五项能力: + +1. `courses/` 内容类型:可被 agent 授课、带客观验证的文件夹。 +2. 结构化的 Project Radar 数据源(每项目一 YAML,不再用 README 表)。 +3. 只产出候选、不生成描述的发现工具。 +4. 契约更新:content-manifest、catalog 导出、schema、llms 文件。 +5. 按 `docs/REPO_TO_WEBSITE.md` 对 README/README_cn 做导流改造。 + +## 2. `courses/` 规范(新增顶层目录) + +每门课一个文件夹,尽可能由现有 guides/playbooks/examples 装配: + +``` +courses/<slug>/ + COURSE.md # 元信息 + 授课契约(agent 首先读取): + # 受众、前提、工具与版本、课程顺序、授课风格规则、 + # 何时停止、如何使用 verify.py、本课不覆盖什么 + lessons/ + L01.md L01_cn.md # 目标、练习、检查点、预期证据 + ... + scenario/ # 场景皮数据文件(同一技能、贴近领域) + TASK.md # 任务契约(复用 templates/TASK_CONTRACT.md) + starter/ solution/ # 可运行的一对 + verify.py # 客观通过/失败;尽量只用标准库 + REVIEW.md # 维护者跟课记录:日期、工具、版本、授课漂移观察 +``` + +规则(将写入 `AGENTS.md` 编辑标准): + +- agent 依据这些文件授课;不依赖网站、账户或视频。`verify.py` 是结业证据。 +- 双语:每个英文 lesson 必须有同提交的 `*_cn.md`;双语齐备才算完成。 +- `COURSE.md` 必须注明跟课所用的具体工具与版本(如 "Claude Code 2.x") + 及 `reviewed_on`;工具大版本发布触发复查。 +- REVIEW.md 人工跟课在可行时录屏:录像同时作为网站演示视频素材 + (见网站规划 §4.5)。 +- 不虚构成果、薪资或"保证学会"式承诺。 + +首批(装配而非新写):C1 Claude Code × Python 实战(旗舰,3 个场景皮: +Excel/报表自动化、数据监控、API 小工具);C2 Codex CLI;C3 AGENTS.md +一份事实源;C4 AI 生成代码的验证与交付;C5 用 MCP 给 agent 接工具。 +见网站规划 §2.2。 + +## 3. Project Radar 数据模型 + +把 `catalog/projects/README.md` 的表格改为每项目一 YAML(与 +`catalog/resources/` 同一套创作模型): + +```yaml +id: marimo +repo: marimo-team/marimo +url: https://github.com/marimo-team/marimo +category: notebooks # 受控列表,有意扩展 +status: rising # new|rising|stable|major-update|experimental|archived +first_seen: 2026-09-02 +reviewed_on: 2026-09-02 +license: Apache-2.0 +evidence: + last_release: "…" + release_cadence: "…" + maintenance: "…" +ai_familiarity: low # low|medium|high:主流模型训练数据是否覆盖 + # (AI 时代的差异化字段) +alternatives: [jupyter, quarto] +when_not_to_use: "…" +rationale: "…" # 人工撰写,遵守策展政策 +risk: "…" +``` + +- `tools/render_readmes.py` 从 YAML 重新生成 Radar 表(沿用 catalog 索引的 + 生成块约定)。 +- 新增确定性导出 `radar.json` + `schema/radar-v1.schema.json`,不扩 + `catalog-v1`(它已被消费方固定)。同样按版本管理;网站消费方固定完整 + commit。 +- 迁移现有 7 条作为种子集。 + +## 4. `tools/radar_scan.py`(只做候选发现) + +- 输入:GitHub Search API(近 30 天 `language:Python` star 增速)、 + catalog 内项目的 PyPI 发布、HN/r/Python 高票帖。 +- 输出:`catalog/projects/candidates.json`——repo 地址、star、最近发布、 + license、首次发现日期。**不写描述、不定状态。** 理由/风险/状态由人工 + 撰写与选择,遵守 `AGENTS.md` 与 `CURATION_POLICY.md`。 +- 只读、限速、不触达私有端点(与 `check_links.py` 同一约束)。 + +## 5. 契约与工具更新 + +- `schema/content-manifest-v1.schema.json`:`type` 增加 `"course"` + (schema 变更须与 manifest 重新生成、网站 pin 更新在同一次刻意操作中完成)。 +- `tools/build_content_manifest.py`:像 guides/playbooks 一样遍历 + `courses/`;每个语言文件各算 sha256。 +- 新增 `tools/verify_courses.py`(对照 `verify_examples.py`):检查文件夹 + 契约——COURSE.md 存在、EN/CN lesson 成对、verify.py 对 starter 预期失败、 + 对 solution 预期通过。 +- `llms.txt` / `llms-full.txt`:增加 courses 与 radar 区块,深链到课程 + 文件夹与 `radar.json`。 +- `docs/CURATION_POLICY.md`:scope 扩展至课程与 radar 条目;写清 + `ai_familiarity` 分级规则。 +- `.github/ISSUE_TEMPLATE/`:新增 `course-feedback.yml`(报告授课漂移/ + 课节不清/verify 不符),与 project-proposal 并列。 +- GitHub 维护项:仓库描述与 topics 加关键词;发布第一个带版本号的 + Release(让 watcher 收到通知,配合网站内容节奏)。 + +## 6. 导流改造(按 `docs/REPO_TO_WEBSITE.md`) + +- README/README_cn 顶部横幅 → `https://flypython.com/from-github` + (只有该路由上线并验证后才切换;之前链根域,遵守该文档自己的规则)。 +- 每个 guide/playbook/example 文末一条情境化链接到对应站内页——延续当前 + 任务,不做通用横幅。 +- 仓库描述按新定位改写("AI writes Python; we make it verifiable and + deliverable",最终措辞实施时定)。 + +## 7. 执行顺序 + +1. `courses/` 规范 + `verify_courses.py` + C1 文件夹(EN+CN)→ manifest/ + schema 更新同一次完成。 +2. Radar YAML 迁移 + `radar.json` 导出 + schema + README 渲染。 +3. `radar_scan.py` + 第一批候选。 +4. llms 文件、策展政策、issue 模板、仓库描述/topics。 +5. `/from-github` 线上验证后做 README 导流改造。 +6. 首个课程落地时开始 Releases/tagging 节奏。 + +## 8. TODO(编号续接网站规划;全部未验收) + +- [ ] FP-325 `courses/` 规范写入 `AGENTS.md`;COURSE.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` 与 + `catalog-v1`/`radar-v1` 支持 Radar 生命周期状态与 `ai_familiarity`; + manifest schema 增加 `type: "course"`;首个 Release(FP-334)时把 + CHANGELOG `[Unreleased]` 切入 `0.1.0` 段。 + +## 9. 非目标与风险 + +- 本仓库仍是内容/证据源:不做网站功能、不放付费内容、不接分析、 + 密钥不入库。 +- 课程质量风险:agent 会偏离 COURSE.md——用 verify.py + 有记录的人工 + 跟课(REVIEW.md)兜底,不用承诺兜底。 +- Radar 产能风险:候选很便宜,审核条目很贵——人工审核是有意保留的 + 瓶颈,不能省。 +- Pin 纪律:每次 schema/manifest 变更都搭配一次刻意的网站 pin 更新; + 永不依赖移动分支。 diff --git a/docs/repo-plan-0.0.4.md b/docs/repo-plan-0.0.4.md new file mode 100644 index 0000000..d166045 --- /dev/null +++ b/docs/repo-plan-0.0.4.md @@ -0,0 +1,242 @@ +# FlyPython 仓库 0.0.4 更新计划 + +版本:0.0.4(规划草稿,第 3 版) +更新日期:2026-09-12(第 3 版——挑战模型定稿 + A/B 阶段拆分) +关联: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` 的仓库边界不变: +本仓库拥有经审核的内容、可运行的证据与稳定的 JSON 契约;网站拥有呈现、 +账号体系与转化。所有新内容中英同一次变更交付。 + +修订说明:0.0.4 首稿提出纯本地的进度产物;第 2 版转向账号体系与服务端 +进度。第 3 版按所有者决策定稿产品形态:**PentesterLab 式挑战产品—— +学员亲手解题,AI Agent 是解题工具而非授课者**;`COURSE.md` 授课契约 +保留为可选「引导模式」。网站侧实施拆为 0.0.4a 内容引擎 / 0.0.4b 平台 +机制(账号与排行榜门控于目录深度 ≥15–20 挑战);**本仓库全部工作项 +属于阶段 A**——认领码即 flag 契约,先服务本地挑战循环,后为网站服务端 +记录所用。 + +## 1. 主题:面向服务端进度记录的检查点认领码 + +学习者在检查点套件通过后,把 `verify.py` 打印的认领码输入网站,网站 +即记录进度。本仓库拥有让这些码可信且稳定的一切: + +- 认领码由 `(course_id, checkpoint_id, evidence)` 确定性推导,其中 + evidence 是客观套件的结果——相同输入在任何机器、离线状态下都产生 + 相同的码。 +- 认领码短小、可人工输入(如 base32 的 8 个字符)。 +- 认领码可抽查、并非防篡改;所有表述统一为"自我报告的证据",绝不 + 使用认证式措辞。 +- 课程工具中无网络访问、无账号、无遥测——登录后的一切都归网站侧。 +- 挑战可解性是上线门槛:每个挑战必须用目标 Agent(Claude Code / + Codex)实测一遍——既不能被秒解到无趣,也不能被卡住无解;实测 + 记录进 `REVIEW.md`。 + +## 2. 工作项 + +### FP-411 检查点认领码(`verify.py`) + +- 新子命令:`python verify.py progress` 按检查点打印其 id、名称、通过 + 状态(来自客观套件),以及——通过时——认领码。 +- 认领码跨运行、跨平台稳定;推导方式(含每课程盐值常量)在课程契约 + 中文档化,并像代码一样接受审核。 +- 仅用标准库;输出确定;可安全重复运行。 + +### FP-412 挑战叙事(COURSE.md + 课程) + +- 叙事重心从"Agent 授课"改为"学员解题":课程标注为挑战("挑战 01: + 复现故障"),`TASK.md` + 检查点 + `verify.py` 即 exercise+flag + 骨架;`COURSE.md` 授课契约保留为可选「引导模式」入口。 +- COURSE.md 增加徽章契约章节:课程徽章名(如"Verified Report Tool")、 + 五个检查点挑战、"自我报告证据"的诚实表述——并指向网站的记录流程。 +- 检查点小节写明其满足的徽章要求与认领步骤。 +- 中英同一次提交;按 manifest 规则更新 `reviewed_on` 与 + `content_version`。 + +### FP-413 徽章契约对齐 + +- 每门课程的 `COURSE.md` 以结构化 front matter/字段声明徽章元数据 + (徽章 id、展示名中英、要求文本),让网站从课程数据渲染徽章地图与 + 服务端记录——网站侧不手抄任何徽章定义。 + +### FP-414 Agent skill 打包(评估) + +- 评估把课程摄取发布为 SKILL.md 兼容的 skill(OpenMAIC / Codex 工作 + 台),遵循仓库模板约定。人工撰写;先留下真实试点记录再给建议。 + +### FP-415 `verify_courses.py` 扩展 + +- 扩展课程契约验证器:每个检查点都暴露认领码;认领码确定性成立 + (相同输入在两次运行、两个平台上产生相同码);格式经过校验; + 进度子命令纳入 CI 演练。 + +## 3. 首批学习路线的仓库侧:「用 Agent 做数据分析」 + +网站 0.0.4 计划 §5 定义了首条学习路线(课程序列 + 项目挑战 + 徽章 + +积分)。本仓库承担其中的内容与证据层: + +### FP-416 DA 路线三门新课 + +- `courses/da-eda/`:探索性数据分析(pandas)——读真实数据集、描述 + 统计、缺失值与分布;挑战产出 `results.json`,`verify.py` 校验数字 + (容差比较)。 +- `courses/da-visualization/`:数据可视化(matplotlib)——按规格产出 + 图表;挑战校验"图表存在 + 底层数据正确",不评审美。 +- `courses/da-report/`:从分析到报告——结构化 Markdown/JSON 报告生成 + 器;挑战校验"报告结构 + 数字与 results.json 一致",不评文笔。 +- 每门课遵循课程契约:COURSE.md 教学契约、中英课程对、TASK 契约、 + starter/solution、`verify.py` 双向验证、REVIEW 记录。 +- 首批非标准库依赖(pandas、matplotlib)经 uv 管理,装环境是第 1 课 + 内容;依赖锁定进课程文件夹。 + +### FP-417 路线契约与综合项目 + +- 新增 `paths/data-analysis/`:`PATH.md` 路线清单(模块序列、前置、 + 各课程徽章、路线徽章「数据分析 Agent」、积分表)+ 综合项目挑战 + (捆绑公开数据集 + 带容差的数字答案 + `verify.py`)。 +- 路线清单是结构化数据(面向网站 FP-409 渲染),网站侧不手抄定义。 +- 网站侧对应项:FP-409(路线结构/页面)与 FP-410(首条路线挂载)。 + +### FP-418 基础路线组装(「Agent 工具基础」) + +- 新建入门模块 M0:认识 AI 编程 Agent——工具生态总览(Claude Code / + Codex / Cursor)、安装与第一次对话;中英双语。 +- 新建路线挑战:同一任务在 Claude Code 与 Codex 下各完成一次并双重 + 验证。 +- `paths/foundation/` 路线契约(PATH.md:模块序列、徽章「Agent + 使用者」、积分表),复用已上线的 C1–C4 作为模块 1–4。 +- 网站侧对应项:FP-409(路线结构/页面)与 FP-410(路线挂载)。 +- 完整路线体系见 flypython.com `docs/LEARNING-PATHS.md`。 + +## 4. Non-goals + +本仓库无账号、无服务端判题、课程工具无网络访问、无认证式措辞、无 +网站内容第二副本。反作弊设计刻意保持轻量(可抽查的码);重反作弊是 +网站侧的关切,不在本仓库范围。 + +## 5. TODO 与当前状态 + +- [x] FP-411 全部五门课程的认领码子命令,推导方式已文档化。(本地 + 2026-09-12:`verify.py progress` 上线——l03/l04 由测试套件客观判定、 + l01/l02/l05 自报;认领码 8 位 base32,确定性两次运行一致) +- [x] FP-412 徽章契约 + 挑战叙事,中英一次变更交付。(本地 + 2026-09-12:五门课程 COURSE.md/COURSE_cn.md 增加徽章契约章节并升 + content_version 2,manifest 已重生成) +- [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-415 `verify_courses.py` 认领码覆盖进 CI。(本地 2026-09-12: + 进度契约检查——双运行确定性、JSON 结构、5 检查点、码格式——已入 + 验证器并随 `make check`/validate.yml 执行) +- [x] FP-416 DA 路线三门新课(`da-eda`、`da-visualization`、 + `da-report`),中英 + verify.py 双向验证。(本地 2026-09-12: + 首批非标准库课程,依赖经 `requirements.txt` 锁定——pandas + 2.3.3 / matplotlib 3.10.9;da-eda 12 测试、da-visualization + 5 测试、da-report 6 测试;Agent 可解性实测已录 REVIEW.md + (2026-09-13)) +- [x] FP-417 `paths/data-analysis/` 路线契约 + 综合项目挑战。(本地 + 2026-09-12:`path.json` + `capstone/`——303 行脏数据集、 + `verify.py` 真值校验(容差 0.01)、综合项目认领码) +- [x] FP-418 基础路线 M0 入门模块 + 路线挑战 + `paths/foundation/` + 路线契约。(本地 2026-09-12:`path.json` + `modules/` 双语 + 模块文档,复用 C1–C4 为模块 1–4) +- [x] 配套契约:manifest `type:"path"` + schema 枚举、`paths/**/*.md` + 入 CONTENT_GLOBS、`tools/verify_paths.py` 入 `make check` 与 + validate.yml、CURATION_POLICY scope 加学习路线、README 双语 + 叙事改为挑战平台、requirements-dev 锁定 DA 依赖。 + +## 6. 执行顺序(本仓库全部为阶段 A 内容引擎) + +1. FP-411 + FP-412 + FP-415 一次变更完成(码、挑战叙事、检查器); + `COURSE.md` 降级为可选引导模式随 FP-412 落地。 +2. FP-413 徽章元数据(frontmatter 声明,供网站渲染消费)。 +3. FP-418 基础路线组装(大部分课程已上线,工作量最小、最先可见)。 +4. FP-416 三门新课 + FP-417 路线契约(内容层,可与第 1 步并行起步, + 上线依赖网站 FP-409/410——路线页公开可收录,不依赖账号)。 +5. 记录一次真实外部工具运行后再做 FP-414。 +6. 网站阶段 B(账号/进度/排行榜)门控于目录深度 ≥15–20 个挑战, + 本仓库不启动任何配合项直至达标。 + +## 7. 自 0.0.3 结转(未完成) + +- FP-326 备注:挑战模式 Agent 可解性实测已写入各课程 `REVIEW.md` + (2026-09-13);授课式(COURSE.md 引导模式)实机走查仍待做 + (属于发布证据,不是内容阻塞项)。 +- FP-327 发布步骤:网站 pin 在一次刻意变更中固定到本仓库发布 SHA。 +- FP-334 仓库描述/话题 + 首个 GitHub Release;该 Release 时将 + CHANGELOG `[Unreleased]` 切为正式版本小节。——已完成:`v0.1.0` + (2026-09-12,提交 `e420fca`),CHANGELOG 已含正式 0.1.0 小节;0.0.4 + phase A 之后的变更暂记于 `[Unreleased]`。 + +## 8. 实施规范(挑战模型改造细节) + +### 8.1 课程文件夹改造(FP-411/412/413) + +现有文件夹骨架不变,叙事重心改到挑战: + +``` +courses/<slug>/ + TASK.md / TASK_cn.md ← 挑战入口:题目陈述、约束、通过条件 + COURSE.md ← 可选「引导模式」契约 + 徽章契约章节 + lessons/L01*.md ← 小节重标为 "Challenge 01: ...";检查点 + 写明满足的徽章要求与认领步骤 + verify.py ← 新增 `progress` 子命令 + REVIEW.md ← 追加 Agent 可解性实测记录 +``` + +**认领码推导**(`python verify.py progress`): + +- 每检查点输出:`id / 名称 / 通过状态 / 认领码`(通过时) +- `code = base32(sha256(salt + course_id + checkpoint_id + evidence_hash))` + 取前 8 字符——纯标准库、离线、跨平台确定 +- `salt` 为每课程常量,与 `course_id` 一起声明在 `COURSE.md` + frontmatter;推导方式在课程契约中文档化并接受代码级审核 +- 防手误不防作弊;所有表述为"自我报告的证据" + +**徽章元数据**(`COURSE.md` frontmatter,供网站 FP-407/409 渲染): + +```yaml +badge: + id: verified-report-tool + name_en: Verified Report Tool + name_zh: 验证过的报表工具 + requires: 全部五个检查点认领通过 +salt: <per-course constant> +course_id: hands-on-python-with-claude-code +``` + +### 8.2 路线层 `paths/`(FP-417/418) + +``` +paths/ + foundation/PATH.md 入门路线:M0 + 复用 C1–C4;徽章「Agent 使用者」 + data-analysis/PATH.md DA 路线:C1 模块 0–1 + da-* 三门 + 综合项目; + 徽章「数据分析 Agent」;积分表 + data-analysis/capstone/ 综合项目挑战:捆绑公开数据集 + 容差数字校验 + + verify.py +``` + +`PATH.md` 为结构化路线契约(模块序列、前置、各课程徽章、路线徽章、 +积分表:检查点 10 分 / 课程 BOSS 50 分 / 综合项目 200 分),网站 +FP-409 渲染消费,网站侧不手抄定义。 + +### 8.3 契约与文档跟进 + +- `content-manifest.json`:`type` 增加 `"path"`,`paths/**` 入 manifest; + schema 与 `verify_courses.py` 同一次变更跟上 +- `CURATION_POLICY.md`:scope 补学习路线 +- `README.md` / `README_cn.md`:首屏叙事从资源目录调整为挑战平台 +- `REVIEW.md`:每门课追加 Agent 可解性实测(不被秒解、不被卡死) + +### 8.4 落地顺序(与 §6 对应) + +1. **C1 试点**(`hands-on-python-with-claude-code`):progress 子命令 + + 挑战叙事 + 徽章 frontmatter + `verify_courses.py` 扩展,一次变更 +2. 试点验收后**横推 C2–C5** +3. `paths/foundation/`(复用现有课,最快可见) +4. `da-eda` / `da-visualization` / `da-report` 新课 + `paths/data-analysis/` + + M0 入门模块 diff --git a/docs/repo-plan-0.1.x.md b/docs/repo-plan-0.1.x.md new file mode 100644 index 0000000..09a22f7 --- /dev/null +++ b/docs/repo-plan-0.1.x.md @@ -0,0 +1,113 @@ +# 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 记录了移出决定)· +网站仓库 `docs/courses/README.md`(课程标准)· 本仓库 `CONTRIBUTING.md`、 +`docs/CURATION_POLICY.md`、`LICENSE` + +## 决定 + +- 所有者 2026-09-13:支持网友通过本仓库提交课程;**社区贡献课程永久免费**。 +- 同日决定:**0.1.0 之前不考虑社区课程**;随后决定放到 **0.1.x**(0.1.0 发布之后)。先把规划写在本仓库,届时再立项、分配 + 工作项编号。 +- 官方示范(视频、终端回放、文字稿)是 Pro 专属;社区课是否配官方示范、如何开放, + 届时按网站当时的 Pro 规则另行决定。本规划不承诺社区课有示范。 + +## 现状(2026-09-13 核对) + +- `LICENSE` 已是双许可:代码(starter、solution、`verify.py`、工具)MIT;课文等书面 + 内容 CC BY 4.0,署名「FlyPython (flypython.com)」。 +- `CONTRIBUTING.md` 有资源、项目、课程反馈、失效链接、安全报告等流程,**没有「贡献 + 一门课程」的流程**。 +- 课程标准已存在:一门课一个文件夹(`COURSE.md` / `TASK.md` 与中文对、五课、starter / + solution / tests、`verify.py`、`REVIEW.md`),`tools/verify_courses.py` 校验; + 0.0.8 起课程文件夹单独取出也能运行 `python verify.py`。 +- 网站的课程数据与认领契约目前是手工镜像本仓库,每加一门课都要改网站代码—— + 社区课一多就成为瓶颈(见第 6 项)。 + +## 待办(0.1.x 立项时细化) + +## 1. 贡献流程与课程模板 [高] + +- [ ] 仓库新增「Course proposal」issue 表单:先提目标学员、要解决的真实任务、使用 + 的工具、判定方式,维护者同意后再写 +- [ ] `CONTRIBUTING.md` 增加「贡献一门课程」一节,指向课程标准(网站仓库 `docs/courses/README.md` §2) +- [ ] 课程脚手架(`templates/course/` 或 `tools/new_course.py`):生成 `COURSE.md` / + `_cn`、`TASK.md` / `_cn`、`lessons/L01–L05` 与 `_cn`、starter / solution / tests、 + `verify.py`、`REVIEW.md` 骨架 +- [ ] 提交清单:中英同步、确切工具与版本、贡献者自己的实跑记录(REVIEW.md) + +## 2. 自动校验(CI) [高] + +- [ ] PR 上自动运行:`tools/verify_courses.py`、双语对齐、content-manifest、 + `verify.py starter --expect-failure` 与 `verify.py solution` +- [ ] 在**无网络**的 CI 环境里运行;课程目录体积与文件数上限;依赖只允许锁定版本 + 且在允许清单内 +- [ ] 若加入工具课共享核心组,逐字节一致性检查自动生效 + +## 3. 安全审查 [高] + +社区课的 `verify.py`、测试与 starter 会**在学员电脑上由 Agent 执行**,恶意或粗心的 +代码直接伤害学员。 + +- [ ] 维护者审查清单:不访问网络、不在课程目录之外读写、除测试运行器外不起子进程、 + 无混淆代码、无遥测;依赖在允许清单内 +- [ ] `courses/` 设 CODEOWNERS,合并必须经维护者批准 +- [ ] 合并后才会进入网站固定的内容版本;未合并的 PR 不会出现在站点与 Skill 里 + +## 4. 编辑审查与标注 [中] + +- [ ] 按 `CURATION_POLICY.md` 人工审查课文事实与定位;描述由人执笔 +- [ ] 课程页与课程列表标注「Community course · 贡献者 · FlyPython 审核于 <日期>」, + 与官方课程区分 +- [ ] 维护者至少实跑一次(Agent 可解性),记录进 REVIEW.md + +## 5. 署名与许可 [中] + +- [ ] `COURSE.md` frontmatter 增加 `authors`(贡献者 GitHub 账号),课程页与课程文档 + 显示署名 +- [ ] `LICENSE` / `CONTRIBUTING.md` 补充:内容署名在「FlyPython (flypython.com)」之外 + 同时列出贡献者;提交即声明有权提交(DCO / sign-off) +- [ ] 与网站 0.0.9 FP-903(署名方式)一并决定展示格式 + +## 6. 网站自动接入,去掉手工镜像 [高,网站仓库] + +本项工作在网站仓库完成,本仓库需要保证课程契约字段完整、机器可读。 + +- [ ] 构建期从固定的仓库版本读取课程契约(元数据、检查点、判定方式、`course_id`、 + salt、难度、时长、工具),**生成**网站课程数据与认领契约,替代手写 + `courses.ts` 与 `COURSE_CONTRACTS` +- [ ] 社区课合并后,网站只需更新内容 pin 即可上线(中英课程页、题目页、文件接口、 + 选题页自动出现) +- [ ] 网站课程文档 `docs/courses/<slug>.md` 由脚本生成骨架,维护者补「迭代方向」 +- [ ] 测试:生成数据与现有 15 门课逐字段一致后再切换 + +## 7. 永久免费的保证 [高] + +- [ ] 课程契约增加 `source: official | community`;社区课**不能**带 `pro` 标记—— + 生成脚本与站点测试双重拒绝 +- [ ] 社区课可认领、计积分、得课程徽章(与官方免费课一致);是否计入官方路线由维护者 + 单独决定(默认不计入,待决) +- [ ] 网站定价页 Free 卡写明「社区课程永久免费」 + +## 8. 维护与下线 [中] + +- [ ] 工具大版本发布后,社区课标注「需要复审」,通知贡献者;30 天(待决)内无人 + 处理则转为「存档」:页面保留、仍可做题,但从课程列表默认隐藏 +- [ ] 已有认领与积分永远保留 + +## 不做 + +- 不接受社区贡献 Pro 课程;社区课不能被标为 Pro。 +- 不做收入分成或付费悬赏承诺。 +- 未经维护者审查合并的 PR 不进入网站固定的内容版本,也不出现在 Skill 与简报里。 + +## 立项前需要决定 + +1. 社区课是否可计入官方路线(默认不计入)。 +2. 「需要复审」转为「存档」的期限(建议 30 天)。 +3. 贡献者署名在课程页的展示格式(与网站署名方式一并决定)。 +4. 每月审核社区课的容量上限(维护者时间与官方 Pro 上新共用)。 diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..754d1d2 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,13 @@ +# Runnable examples + +These examples are deliberately small. Each one has a task contract, a broken +starter, a completed solution, and a verifier so you can practice an AI-coding +loop without trusting generated code on sight. + +- [Product slug regression](product-slug/README.md): reproduce a text-boundary bug, ask an agent for the smallest fix, and verify the solution. Standard library only; about three minutes. +- [Untrusted payload validation](pydantic-validation/README.md): handle messy camelCase and snake_case inputs, dirty amount strings, and structured error responses. +- [MCP tool server](mcp-server/README.md): build a stateless 2026-07-28 MCP tool server: no initialize handshake, schema validation, error isolation, and the input_required round-trip. +- [Async batch fetcher](async-fetcher/README.md): control concurrency with Semaphore, back off and retry transient HTTP failures, and collect structured results. +- [Resilient batch pipeline](structured-pipeline/README.md): process semi-structured data safely with batch error isolation and field validation using the standard library. + +[中文索引](README_cn.md) diff --git a/examples/README_cn.md b/examples/README_cn.md new file mode 100644 index 0000000..5b6f5b2 --- /dev/null +++ b/examples/README_cn.md @@ -0,0 +1,12 @@ +# 可运行示例 + +这些示例规模都很小。每一个都包含任务契约、带有缺陷的 starter、完整的 solution 以及 +自动化验证脚本,让你可以在不盲目信任生成的代码的前提下练习 AI-coding 循环。 + +- [商品 Slug 边界回归](product-slug/README_cn.md):复现文本边界 Bug,要求 Agent 给出最小修复,并验证方案。仅依赖标准库,约需 3 分钟。 +- [不可信数据清洗校验](pydantic-validation/README_cn.md):处理驼峰与下划线混杂入参、带符号金额清洗与结构化错误响应。 +- [MCP 工具服务](mcp-server/README_cn.md):构建符合 2026-07-28 无状态规范的 MCP 服务端:无需 initialize 握手、参数校验、错误隔离与 input_required 多轮交互。 +- [受控并发异步抓取器](async-fetcher/README_cn.md):使用 Semaphore 限制最大并发,对瞬时网络故障进行指数退避重试并聚合结构化结果。 +- [鲁棒批处理数据流水线](structured-pipeline/README_cn.md):使用标准库实现具备错误隔离、字段校验与统计汇总的安全批处理流水线。 + +[English index](README.md) diff --git a/examples/async-fetcher/README.md b/examples/async-fetcher/README.md new file mode 100644 index 0000000..6eafaf3 --- /dev/null +++ b/examples/async-fetcher/README.md @@ -0,0 +1,34 @@ +--- +id: example-async-fetcher +type: example +title: Async Batch Fetcher with Bounded Concurrency and Retries +summary: Implement a resilient async task batcher with Semaphore concurrency control, exponential backoff, and error isolation. +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-02 +--- + +# Async Batch Fetcher with Bounded Concurrency and Retries + +Unbounded async concurrency (`asyncio.gather`) leads to socket exhaustion, 429 rate-limiting, +and cascading server failures. A production batch fetcher must constrain concurrency with a +Semaphore, retry transient failures (429, 503, timeouts) with backoff, fail fast on permanent +errors (400, 404), and isolate individual item failures from crashing the batch. + +```bash +python examples/async-fetcher/verify.py starter --expect-failure +python examples/async-fetcher/verify.py solution +``` + +The first command reports expected failures from the naive starter. +The second verifies the resilient solution. Give [TASK.md](TASK.md) to a coding agent, +ask it to fix only `starter/fetcher.py`, and run: + +```bash +python examples/async-fetcher/verify.py starter +``` + +--- + +See this example on [flypython.com](https://flypython.com/examples/async-fetcher): the browsable contract, step-by-step walkthrough, and current review state. diff --git a/examples/async-fetcher/README_cn.md b/examples/async-fetcher/README_cn.md new file mode 100644 index 0000000..43a1cfd --- /dev/null +++ b/examples/async-fetcher/README_cn.md @@ -0,0 +1,33 @@ +--- +id: example-async-fetcher +type: example +title: 带受控并发与重试的异步抓取器 +summary: 使用 Semaphore 信号量控制并发、指数退避重试与错误隔离,实现韧性异步任务批处理。 +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-02 +--- + +# 带受控并发与重试的异步抓取器 + +无限制的异步并发(如直接对成千上万个任务调用 `asyncio.gather`)极易引发套接字耗尽、 +429 限流风暴与下游服务雪崩。生产级批处理器必须通过 Semaphore 限制最大并发数,针对瞬时 +网络故障(429、503、超时)进行退避重试,对确定性错误(400、404)快速失败,并对单个任务 +的失败进行隔离,绝不导致整个批处理异常中断。 + +```bash +python examples/async-fetcher/verify.py starter --expect-failure +python examples/async-fetcher/verify.py solution +``` + +第一条命令复现朴素 starter 的并发溢出与崩溃;第二条命令验证具备容错能力的 solution。 +把 [TASK_cn.md](TASK_cn.md) 提交给 coding agent,让其仅修改 `starter/fetcher.py` 并运行: + +```bash +python examples/async-fetcher/verify.py starter +``` + +--- + +在 [flypython.com](https://flypython.com/examples/zh/async-fetcher) 查看本示例的在线版本:可浏览的任务契约、分步说明与每个示例的当前审核状态。 diff --git a/examples/async-fetcher/TASK.md b/examples/async-fetcher/TASK.md new file mode 100644 index 0000000..d601b8a --- /dev/null +++ b/examples/async-fetcher/TASK.md @@ -0,0 +1,24 @@ +# Task contract: bounded async batch fetcher with retries + +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: + - 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. + - If `status_code` in `{429, 500, 502, 503, 504}` or an exception is raised, retry within a total budget of `max_retries` attempts per item (a fresh call and its retries together count as `attempts`). + - Permanent client errors (status 400..499 except 429) must NOT be retried. + - On retry, back off by `0.01 * (2 ** attempt)` seconds. +- Result collection: + - Return a list of result dictionaries preserving input item order. + - Each result dict must contain: + - `"item"`: original input item dict. + - `"success"`: `bool` (True if final status_code == 200). + - `"status_code"`: `int | None`. + - `"data"`: response data or None. + - `"attempts"`: `int` (total attempts made, starting at 1). + - `"error"`: error message string or None. +- Individual item failures must never raise uncaught exceptions to abort other items. + +Done means `python examples/async-fetcher/verify.py starter` exits successfully. diff --git a/examples/async-fetcher/TASK_cn.md b/examples/async-fetcher/TASK_cn.md new file mode 100644 index 0000000..ff33f21 --- /dev/null +++ b/examples/async-fetcher/TASK_cn.md @@ -0,0 +1,24 @@ +# 任务契约:带受控并发与重试的异步抓取器 + +仅修改 `starter/fetcher.py`。 + +- 实现 `async_batch_fetch(items: list[dict], fetch_fn: Callable[[dict], Awaitable[dict]], max_concurrency: int = 3, max_retries: int = 3) -> list[dict]`。 +- 并发控制要求: + - 任何时刻并发执行 `fetch_fn` 的协程数量不得超过 `max_concurrency`。 +- 重试与退避策略: + - `fetch_fn` 返回 `{"status_code": int, "data": ...}` 或抛出异常。 + - 状态码为 `{429, 500, 502, 503, 504}` 或发生异常时,在每条记录总计 `max_retries` 次尝试的预算内重试(首次调用与各次重试共同计入 `attempts`)。 + - 确定性客户端错误(400..499,排除 429)**严禁**重试。 + - 重试时采用指数退避休眠 `0.01 * (2 ** attempt)` 秒。 +- 结果聚合规范: + - 返回结果列表并严格保持与输入 `items` 的顺序一致。 + - 每个结果字典包含以下字段: + - `"item"`:原始输入字典。 + - `"success"`:`bool`(最终 status_code == 200 时为 True)。 + - `"status_code"`:`int | None`。 + - `"data"`:响应数据或 None。 + - `"attempts"`:`int`(实际执行尝试次数,初始为 1)。 + - `"error"`:错误描述字符串或 None。 +- 单个任务失败绝不能抛出未捕获异常中断整个批处理。 + +完成标准:`python examples/async-fetcher/verify.py starter` 成功退出。 diff --git a/examples/async-fetcher/solution/fetcher.py b/examples/async-fetcher/solution/fetcher.py new file mode 100644 index 0000000..86906a0 --- /dev/null +++ b/examples/async-fetcher/solution/fetcher.py @@ -0,0 +1,83 @@ +"""Resilient async batch fetcher with Semaphore concurrency control, exponential backoff, and error isolation.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +from typing import Any + +RETRYABLE_STATUSES = {429, 500, 502, 503, 504} + + +async def _fetch_single( + item: dict[str, Any], + fetch_fn: Callable[[dict[str, Any]], Awaitable[dict[str, Any]]], + semaphore: asyncio.Semaphore, + max_retries: int, +) -> dict[str, Any]: + attempts = 0 + last_status: int | None = None + last_error: str | None = None + last_data: Any = None + + for attempt in range(max_retries): + attempts += 1 + async with semaphore: + try: + resp = await fetch_fn(item) + status = resp.get("status_code") + last_status = status + last_data = resp.get("data") + + if status == 200: + return { + "item": item, + "success": True, + "status_code": 200, + "data": last_data, + "attempts": attempts, + "error": None, + } + + # Permanent client error (400..499 except 429) -> don't retry + if status is not None and 400 <= status < 500 and status not in RETRYABLE_STATUSES: + return { + "item": item, + "success": False, + "status_code": status, + "data": last_data, + "attempts": attempts, + "error": f"Client error: {status}", + } + + last_error = f"HTTP error {status}" + except Exception as exc: + last_error = str(exc) + + # Backoff before next retry if attempts remain + if attempt < max_retries - 1: + await asyncio.sleep(0.01 * (2 ** attempt)) + + return { + "item": item, + "success": False, + "status_code": last_status, + "data": last_data, + "attempts": attempts, + "error": last_error or "Exceeded maximum retries", + } + + +async def async_batch_fetch( + items: list[dict[str, Any]], + fetch_fn: Callable[[dict[str, Any]], Awaitable[dict[str, Any]]], + max_concurrency: int = 3, + max_retries: int = 3, +) -> list[dict[str, Any]]: + semaphore = asyncio.Semaphore(max_concurrency) + async with asyncio.TaskGroup() as group: + tasks = [ + group.create_task(_fetch_single(item, fetch_fn, semaphore, max_retries)) + for item in items + ] + return [task.result() for task in tasks] diff --git a/examples/async-fetcher/starter/fetcher.py b/examples/async-fetcher/starter/fetcher.py new file mode 100644 index 0000000..76c90f1 --- /dev/null +++ b/examples/async-fetcher/starter/fetcher.py @@ -0,0 +1,30 @@ +"""Naive unconstrained async fetcher without semaphore bounding or retry handling.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +from typing import Any + + +async def async_batch_fetch( + items: list[dict[str, Any]], + fetch_fn: Callable[[dict[str, Any]], Awaitable[dict[str, Any]]], + max_concurrency: int = 3, + max_retries: int = 3, +) -> list[dict[str, Any]]: + # Naive: Unbounded gather without Semaphore or retry on error + tasks = [fetch_fn(item) for item in items] + responses = await asyncio.gather(*tasks) + + results = [] + for item, resp in zip(items, responses, strict=False): + results.append({ + "item": item, + "success": resp.get("status_code") == 200, + "status_code": resp.get("status_code"), + "data": resp.get("data"), + "attempts": 1, + "error": None, + }) + return results diff --git a/examples/async-fetcher/tests/test_fetcher.py b/examples/async-fetcher/tests/test_fetcher.py new file mode 100644 index 0000000..d12d6b4 --- /dev/null +++ b/examples/async-fetcher/tests/test_fetcher.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import asyncio +import unittest + +from fetcher import async_batch_fetch + + +class TestAsyncBatchFetcher(unittest.TestCase): + def test_bounded_concurrency(self) -> None: + active_concurrency = 0 + peak_concurrency = 0 + + async def mock_fetch(item: dict) -> dict: + nonlocal active_concurrency, peak_concurrency + active_concurrency += 1 + peak_concurrency = max(peak_concurrency, active_concurrency) + await asyncio.sleep(0.01) + active_concurrency -= 1 + return {"status_code": 200, "data": item["id"]} + + items = [{"id": i} for i in range(10)] + results = asyncio.run(async_batch_fetch(items, mock_fetch, max_concurrency=2)) + + self.assertEqual(len(results), 10) + self.assertLessEqual(peak_concurrency, 2) + self.assertTrue(all(r["success"] for r in results)) + + def test_retries_transient_failure(self) -> None: + attempts_by_id = {} + + async def mock_flaky_fetch(item: dict) -> dict: + i = item["id"] + attempts_by_id[i] = attempts_by_id.get(i, 0) + 1 + if attempts_by_id[i] < 3: + return {"status_code": 429, "data": None} + return {"status_code": 200, "data": "ok"} + + items = [{"id": 1}] + results = asyncio.run(async_batch_fetch(items, mock_flaky_fetch, max_retries=3)) + + self.assertEqual(len(results), 1) + self.assertTrue(results[0]["success"]) + self.assertEqual(results[0]["attempts"], 3) + + def test_does_not_retry_404_error(self) -> None: + call_count = 0 + + async def mock_404_fetch(item: dict) -> dict: + nonlocal call_count + call_count += 1 + return {"status_code": 404, "data": None} + + items = [{"id": 1}] + results = asyncio.run(async_batch_fetch(items, mock_404_fetch, max_retries=3)) + + self.assertEqual(call_count, 1) + self.assertFalse(results[0]["success"]) + self.assertEqual(results[0]["status_code"], 404) + + def test_records_exhausted_retries_without_crashing(self) -> None: + async def mock_failing_fetch(item: dict) -> dict: + raise ConnectionResetError("Server disconnected") + + items = [{"id": 1}, {"id": 2}] + results = asyncio.run(async_batch_fetch(items, mock_failing_fetch, max_retries=2)) + + self.assertEqual(len(results), 2) + self.assertFalse(results[0]["success"]) + self.assertIn("Server disconnected", results[0]["error"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/examples/async-fetcher/verify.py b/examples/async-fetcher/verify.py new file mode 100644 index 0000000..43e83c6 --- /dev/null +++ b/examples/async-fetcher/verify.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Run the async-fetcher contract against the starter or solution.""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("implementation", choices=("starter", "solution")) + parser.add_argument("--expect-failure", action="store_true") + args = parser.parse_args() + + 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=args.expect_failure, + text=args.expect_failure, + ) + + 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 "") + expected_failures = ( + "test_bounded_concurrency", + "test_records_exhausted_retries_without_crashing", + "test_retries_transient_failure", + ) + if not any(failure in output for failure in expected_failures): + print("Starter failed for an unexpected reason:", file=sys.stderr) + print(output, file=sys.stderr) + return 1 + print("Expected concurrency regression reproduced: unbounded execution and lack of retries fail.") + return 0 + return result.returncode + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/mcp-server/README.md b/examples/mcp-server/README.md new file mode 100644 index 0000000..2c788e0 --- /dev/null +++ b/examples/mcp-server/README.md @@ -0,0 +1,44 @@ +--- +id: example-mcp-server +type: example +title: Model Context Protocol (MCP) Tool Server +summary: Build a stateless 2026-07-28 MCP tool server with JSON-RPC 2.0 dispatch, no initialize handshake, schema validation, error isolation, and the input_required round-trip. +lang: en-US +content_version: 2 +status: reviewed +reviewed_on: 2026-09-06 +--- + +# Model Context Protocol (MCP) Tool Server + +The Model Context Protocol (MCP) connects AI models and agents to external tools and data. +The 2026-07-28 specification is stateless-first: the `initialize` handshake and +`Mcp-Session-Id` sessions were removed, requests self-describe their protocol version +through `_meta`, and server-initiated elicitation was replaced by a multi-round-trip +`input_required` flow. + +A production MCP server under this specification must parse standard JSON-RPC 2.0 +requests, answer `tools/list` and `tools/call` directly without any prior handshake, +validate arguments, handle execution errors gracefully, and let a tool request additional +client input through a result with `resultType: "input_required"` that the client +completes by retrying the same call with `inputResponses`. + +```bash +python examples/mcp-server/verify.py starter --expect-failure +python examples/mcp-server/verify.py solution +``` + +The first command reports the expected failure from the naive starter. +The second verifies the compliant solution. Give [TASK.md](TASK.md) to a coding agent, +ask it to fix only `starter/mcp_server.py`, and run: + +```bash +python examples/mcp-server/verify.py starter +``` + +For the full migration background — what was removed, deprecated, and hardened in +2026-07-28 — read [Migrate a Python MCP server to the 2026-07-28 specification](../../guides/mcp/migrate-2026-07-28.md). + +--- + +See this example on [flypython.com](https://flypython.com/examples/mcp-server): the browsable contract, step-by-step walkthrough, and current review state. diff --git a/examples/mcp-server/README_cn.md b/examples/mcp-server/README_cn.md new file mode 100644 index 0000000..c861d1c --- /dev/null +++ b/examples/mcp-server/README_cn.md @@ -0,0 +1,41 @@ +--- +id: example-mcp-server +type: example +title: Model Context Protocol (MCP) 工具服务 +summary: 构建符合 2026-07-28 无状态规范的 MCP 工具服务,实现无需 initialize 握手的 JSON-RPC 2.0 分发、参数校验、错误隔离与 input_required 多轮交互。 +lang: zh-CN +content_version: 2 +status: reviewed +reviewed_on: 2026-09-06 +--- + +# Model Context Protocol (MCP) 工具服务 + +Model Context Protocol (MCP) 是连接 AI 模型与外部工具及数据的开放标准。 +2026-07-28 规范以无状态为原则:移除了 `initialize` 握手与 `Mcp-Session-Id` 会话, +协议版本由每个请求通过 `_meta` 自描述,服务端主动发起的 elicitation 被替换为 +`input_required` 多轮交互流程。 + +该规范下的生产级 MCP 服务端必须解析标准 JSON-RPC 2.0 请求,在没有任何握手的前提 +下直接响应 `tools/list` 与 `tools/call`,校验工具入参,优雅捕获执行异常,并允许 +工具通过 `resultType: "input_required"` 的结果向客户端请求补充输入——客户端携带 +`inputResponses` 重试同一请求即可完成交互。 + +```bash +python examples/mcp-server/verify.py starter --expect-failure +python examples/mcp-server/verify.py solution +``` + +第一条命令复现朴素 starter 的预期失败;第二条命令验证符合契约的 solution。 +把 [TASK_cn.md](TASK_cn.md) 提交给 coding agent,让其仅修改 `starter/mcp_server.py` 并运行: + +```bash +python examples/mcp-server/verify.py starter +``` + +完整的迁移背景(2026-07-28 移除、废弃与强化的内容)见 +[将 Python MCP 服务端迁移到 2026-07-28 规范](../../guides/mcp/migrate-2026-07-28_cn.md)。 + +--- + +在 [flypython.com](https://flypython.com/examples/zh/mcp-server) 查看本示例的在线版本:可浏览的任务契约、分步说明与每个示例的当前审核状态。 diff --git a/examples/mcp-server/TASK.md b/examples/mcp-server/TASK.md new file mode 100644 index 0000000..9bc43db --- /dev/null +++ b/examples/mcp-server/TASK.md @@ -0,0 +1,25 @@ +# Task contract: stateless MCP JSON-RPC tool server (2026-07-28) + +Change only `starter/mcp_server.py`. + +- Implement `MCPServer` class: + - `register_tool(name: str, description: str, schema: dict, handler: Callable) -> None` + - `handle_request(request: dict) -> dict`: Processes a JSON-RPC 2.0 request dict and returns a response dict. +- JSON-RPC 2.0 compliance: + - Must check `"jsonrpc": "2.0"` and preserve request `"id"`. + - Return error `-32600` (Invalid Request) if request is not a valid dict or lacks `method` or `jsonrpc != "2.0"`. + - Return error `-32601` (Method not found) if method is unknown. +- 2026-07-28 stateless behavior: + - `initialize` must return `-32601` with a message noting it was removed by the stateless 2026-07-28 specification; there is no handshake before tool calls. + - If a request carries `_meta.protocolVersion` and it is not `"2026-07-28"`, return `-32600` (Invalid Request); requests self-describe their version. +- 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 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: <arg>"}]}}`. + - If the handler raises `InputRequired(requests)`: returns `{"result": {"resultType": "input_required", "requests": <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`. + - On handler success: returns `{"result": {"content": [{"type": "text", "text": str(output)}]}}`. + - On any other handler exception: catch and return `{"result": {"isError": True, "content": [{"type": "text", "text": f"Error: {e}"}]}}`. +- Provide `InputRequired(Exception)` with a `requests` attribute so tools can request client input. + +Done means `python examples/mcp-server/verify.py starter` exits successfully. diff --git a/examples/mcp-server/TASK_cn.md b/examples/mcp-server/TASK_cn.md new file mode 100644 index 0000000..94a59f1 --- /dev/null +++ b/examples/mcp-server/TASK_cn.md @@ -0,0 +1,25 @@ +# 任务契约:无状态 MCP JSON-RPC 工具服务(2026-07-28 规范) + +仅修改 `starter/mcp_server.py`。 + +- 实现 `MCPServer` 类: + - `register_tool(name: str, description: str, schema: dict, handler: Callable) -> None` + - `handle_request(request: dict) -> dict`:处理 JSON-RPC 2.0 请求字典并返回响应字典。 +- JSON-RPC 2.0 规范要求: + - 必须校验 `"jsonrpc": "2.0"` 并保留请求中的 `"id"`。 + - 请求格式非法、缺少 `method` 或 `jsonrpc != "2.0"` 时返回错误码 `-32600`(Invalid Request)。 + - 请求未注册的方法时返回错误码 `-32601`(Method not found)。 +- 2026-07-28 无状态行为: + - `initialize` 必须返回 `-32601`,消息中说明它已被 2026-07-28 无状态规范移除;工具调用前没有任何握手。 + - 若请求携带 `_meta.protocolVersion` 且不等于 `"2026-07-28"`,返回 `-32600`(Invalid Request);协议版本由每个请求自描述。 +- 支持的核心方法: + - `"tools/list"`:返回 `{"result": {"tools": [{"name": ..., "description": ..., "inputSchema": ...}, ...]}}`。 + - `"tools/call"`:接收 `params: {"name": ..., "arguments": ..., "inputResponses": {...}?}`,并以 `handler(arguments, inputResponses)` 调用工具。 + - 工具未找到:返回 `{"result": {"isError": True, "content": [{"type": "text", "text": "Tool not found"}]}}`。 + - 缺少 schema 中声明的必填参数:返回 `{"result": {"isError": True, "content": [{"type": "text", "text": "Missing required argument: <arg>"}]}}`。 + - 工具抛出 `InputRequired(requests)`:返回 `{"result": {"resultType": "input_required", "requests": <requests>, "content": [{"type": "text", "text": "Additional client input is required before this tool can finish."}]}}`。客户端携带 `params.inputResponses` 重试同一请求。 + - 执行成功:返回 `{"result": {"content": [{"type": "text", "text": str(output)}]}}`。 + - 其他执行异常:捕获并返回 `{"result": {"isError": True, "content": [{"type": "text", "text": f"Error: {e}"}]}}`。 +- 提供 `InputRequired(Exception)`,带 `requests` 属性,让工具能够向客户端请求补充输入。 + +完成标准:`python examples/mcp-server/verify.py starter` 成功退出。 diff --git a/examples/mcp-server/solution/mcp_server.py b/examples/mcp-server/solution/mcp_server.py new file mode 100644 index 0000000..7179a0a --- /dev/null +++ b/examples/mcp-server/solution/mcp_server.py @@ -0,0 +1,184 @@ +"""Stateless MCP tool server aligned with the 2026-07-28 specification. + +The 2026-07-28 specification is stateless-first: the initialize/initialized +handshake and Mcp-Session-Id sessions were removed, so every request is +self-describing and tools/list or tools/call must be answered directly. +Server-initiated requests (including elicitation) were replaced by a +multi-round-trip flow: a tool that needs client input returns a result with +resultType "input_required", and the client retries the same call with the +answers attached in params.inputResponses. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +SUPPORTED_PROTOCOL_VERSION = "2026-07-28" + +ToolHandler = Callable[..., Any] + + +class InputRequired(Exception): + """Raised by a handler that cannot finish without additional client input. + + Carries the requests the client must answer. The server converts this into + a tools/call result with resultType "input_required"; the client retries + the same request with the answers in params.inputResponses. + """ + + def __init__(self, requests: list[dict[str, Any]]) -> None: + super().__init__("tool execution needs additional client input") + self.requests = requests + + +class MCPServer: + def __init__(self) -> None: + self.tools: dict[str, dict[str, Any]] = {} + + def register_tool( + self, + name: str, + description: str, + schema: dict[str, Any], + handler: ToolHandler, + ) -> None: + self.tools[name] = { + "name": name, + "description": description, + "inputSchema": schema, + "handler": handler, + } + + def handle_request(self, request: Any) -> dict[str, Any]: + if not isinstance(request, dict) or request.get("jsonrpc") != "2.0" or "method" not in request: + return { + "jsonrpc": "2.0", + "id": request.get("id") if isinstance(request, dict) else None, + "error": {"code": -32600, "message": "Invalid Request"}, + } + + req_id = request.get("id") + method = request["method"] + + # initialize was removed by the 2026-07-28 specification: requests are + # stateless and there is no handshake to complete before tool calls. + if method == "initialize": + return { + "jsonrpc": "2.0", + "id": req_id, + "error": { + "code": -32601, + "message": "Method not found: initialize was removed by the stateless 2026-07-28 specification", + }, + } + + # Requests self-describe their protocol version through _meta. + meta = request.get("_meta") + if isinstance(meta, dict): + version = meta.get("protocolVersion") + if version is not None and version != SUPPORTED_PROTOCOL_VERSION: + return { + "jsonrpc": "2.0", + "id": req_id, + "error": { + "code": -32600, + "message": f"Invalid Request: unsupported protocol version {version!r}; this server speaks {SUPPORTED_PROTOCOL_VERSION}", + }, + } + + if method == "tools/list": + tools_list = [ + { + "name": t["name"], + "description": t["description"], + "inputSchema": t["inputSchema"], + } + for t in self.tools.values() + ] + return {"jsonrpc": "2.0", "id": req_id, "result": {"tools": tools_list}} + + if method == "tools/call": + params = request.get("params") + if not isinstance(params, dict) or "name" not in params: + return { + "jsonrpc": "2.0", + "id": req_id, + "error": {"code": -32602, "message": "Invalid params: missing tool name"}, + } + + tool_name = params["name"] + if tool_name not in self.tools: + return { + "jsonrpc": "2.0", + "id": req_id, + "result": { + "isError": True, + "content": [{"type": "text", "text": "Tool not found"}], + }, + } + + tool = self.tools[tool_name] + args = params.get("arguments", {}) + if not isinstance(args, dict): + args = {} + + input_responses = params.get("inputResponses") + if not isinstance(input_responses, dict): + input_responses = {} + + # Validate required schema arguments + required_props = tool["inputSchema"].get("required", []) + for required_key in required_props: + if required_key not in args: + return { + "jsonrpc": "2.0", + "id": req_id, + "result": { + "isError": True, + "content": [ + { + "type": "text", + "text": f"Missing required argument: {required_key}", + } + ], + }, + } + + try: + output = tool["handler"](args, input_responses) + return { + "jsonrpc": "2.0", + "id": req_id, + "result": {"content": [{"type": "text", "text": str(output)}]}, + } + except InputRequired as exc: + return { + "jsonrpc": "2.0", + "id": req_id, + "result": { + "resultType": "input_required", + "requests": exc.requests, + "content": [ + { + "type": "text", + "text": "Additional client input is required before this tool can finish.", + } + ], + }, + } + except Exception as exc: + return { + "jsonrpc": "2.0", + "id": req_id, + "result": { + "isError": True, + "content": [{"type": "text", "text": f"Error: {exc}"}], + }, + } + + return { + "jsonrpc": "2.0", + "id": req_id, + "error": {"code": -32601, "message": "Method not found"}, + } diff --git a/examples/mcp-server/starter/mcp_server.py b/examples/mcp-server/starter/mcp_server.py new file mode 100644 index 0000000..1d9dbc4 --- /dev/null +++ b/examples/mcp-server/starter/mcp_server.py @@ -0,0 +1,59 @@ +"""Naive MCP server implementation that crashes on missing tools and lacks schema validation. + +Also ignores the stateless 2026-07-28 specification: it has no input_required +flow, so tools that need client input cannot finish safely. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + + +class MCPServer: + def __init__(self) -> None: + self.tools: dict[str, Any] = {} + + def register_tool( + self, + name: str, + description: str, + schema: dict[str, Any], + handler: Callable[[dict[str, Any]], str], + ) -> None: + self.tools[name] = { + "name": name, + "description": description, + "inputSchema": schema, + "handler": handler, + } + + def handle_request(self, request: Any) -> dict[str, Any]: + # Naive: does not check jsonrpc version or error handling + req_id = request.get("id") + method = request["method"] + + if method == "tools/list": + tools_list = [ + { + "name": t["name"], + "description": t["description"], + "inputSchema": t["inputSchema"], + } + for t in self.tools.values() + ] + return {"jsonrpc": "2.0", "id": req_id, "result": {"tools": tools_list}} + + if method == "tools/call": + params = request["params"] + tool_name = params["name"] + # Will crash if tool doesn't exist or handler raises + tool = self.tools[tool_name] + output = tool["handler"](params["arguments"]) + return { + "jsonrpc": "2.0", + "id": req_id, + "result": {"content": [{"type": "text", "text": str(output)}]}, + } + + return {"jsonrpc": "2.0", "id": req_id, "error": {"code": -32601, "message": "Method not found"}} diff --git a/examples/mcp-server/tests/test_mcp_server.py b/examples/mcp-server/tests/test_mcp_server.py new file mode 100644 index 0000000..124d3a1 --- /dev/null +++ b/examples/mcp-server/tests/test_mcp_server.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +import unittest + +from mcp_server import MCPServer + +try: + from mcp_server import InputRequired +except ImportError: # The starter does not implement the 2026-07-28 flow yet. + InputRequired = None # type: ignore[assignment] + + +def require_confirmation(args: dict, responses: dict | None = None) -> str: + answer = str((responses or {}).get("confirm", "")).strip().lower() + if answer != "yes": + raise InputRequired( + [{"id": "confirm", "prompt": "Type 'yes' to confirm deletion."}] + ) + return "deleted" + + +class TestMCPServer(unittest.TestCase): + def setUp(self) -> None: + self.server = MCPServer() + self.server.register_tool( + name="calculate_sum", + description="Add two numbers together", + schema={ + "type": "object", + "properties": { + "a": {"type": "number"}, + "b": {"type": "number"}, + }, + "required": ["a", "b"], + }, + handler=lambda args, responses=None: str(args["a"] + args["b"]), + ) + self.server.register_tool( + name="risky_operation", + description="An operation that can fail", + schema={"type": "object"}, + handler=lambda args, responses=None: 1 / 0, # Deliberate ZeroDivisionError + ) + + def test_tools_list_returns_schemas(self) -> None: + resp = self.server.handle_request({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/list", + }) + self.assertEqual(resp["jsonrpc"], "2.0") + self.assertEqual(resp["id"], 1) + tools = resp["result"]["tools"] + self.assertEqual(len(tools), 2) + names = {t["name"] for t in tools} + self.assertEqual(names, {"calculate_sum", "risky_operation"}) + + def test_tools_call_success(self) -> None: + resp = self.server.handle_request({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": {"name": "calculate_sum", "arguments": {"a": 10, "b": 25}}, + }) + self.assertEqual(resp["id"], 2) + self.assertEqual(resp["result"]["content"][0]["text"], "35") + + def test_tools_call_missing_required_argument(self) -> None: + resp = self.server.handle_request({ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": {"name": "calculate_sum", "arguments": {"a": 10}}, + }) + self.assertTrue(resp["result"]["isError"]) + self.assertIn("Missing required argument: b", resp["result"]["content"][0]["text"]) + + def test_tools_call_handles_runtime_exception(self) -> None: + resp = self.server.handle_request({ + "jsonrpc": "2.0", + "id": 4, + "method": "tools/call", + "params": {"name": "risky_operation", "arguments": {}}, + }) + self.assertTrue(resp["result"]["isError"]) + self.assertIn("division by zero", resp["result"]["content"][0]["text"]) + + def test_invalid_request_structure(self) -> None: + resp = self.server.handle_request({"method": "tools/list"}) # Missing jsonrpc: 2.0 + self.assertIn("error", resp) + self.assertEqual(resp["error"]["code"], -32600) + + def test_initialize_removed_returns_method_not_found(self) -> None: + # The 2026-07-28 specification removed the initialize handshake: + # requests are stateless and tool methods must be answered directly. + resp = self.server.handle_request({ + "jsonrpc": "2.0", + "id": 5, + "method": "initialize", + "params": {"protocolVersion": "2025-06-18", "capabilities": {}}, + }) + self.assertEqual(resp["error"]["code"], -32601) + self.assertIn("removed", resp["error"]["message"]) + + def test_protocol_version_validated_per_request(self) -> None: + # Requests self-describe their protocol version through _meta. + resp = self.server.handle_request({ + "jsonrpc": "2.0", + "id": 6, + "method": "tools/list", + "_meta": {"protocolVersion": "2025-06-18"}, + }) + self.assertIn("error", resp) + self.assertEqual(resp["error"]["code"], -32600) + + def test_tools_call_input_required_round_trip(self) -> None: + if InputRequired is None: + self.skipTest("implementation lacks the 2026-07-28 input_required flow") + self.server.register_tool( + name="confirm_delete", + description="Delete a resource after explicit confirmation", + schema={"type": "object"}, + handler=require_confirmation, + ) + + first = self.server.handle_request({ + "jsonrpc": "2.0", + "id": 7, + "method": "tools/call", + "params": {"name": "confirm_delete", "arguments": {}}, + }) + self.assertEqual(first["result"]["resultType"], "input_required") + self.assertEqual(first["result"]["requests"][0]["id"], "confirm") + + second = self.server.handle_request({ + "jsonrpc": "2.0", + "id": 8, + "method": "tools/call", + "params": { + "name": "confirm_delete", + "arguments": {}, + "inputResponses": {"confirm": "YES"}, + }, + }) + self.assertEqual(second["result"]["content"][0]["text"], "deleted") + + +if __name__ == "__main__": + unittest.main() diff --git a/examples/mcp-server/verify.py b/examples/mcp-server/verify.py new file mode 100644 index 0000000..4dd3d9b --- /dev/null +++ b/examples/mcp-server/verify.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Run the mcp-server contract against the starter or solution.""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("implementation", choices=("starter", "solution")) + parser.add_argument("--expect-failure", action="store_true") + args = parser.parse_args() + + 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=args.expect_failure, + text=args.expect_failure, + ) + + 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 "") + expected_failures = ( + "test_invalid_request_structure", + "test_tools_call_handles_runtime_exception", + "test_tools_call_missing_required_argument", + ) + if not any(failure in output for failure in expected_failures): + print("Starter failed for an unexpected reason:", file=sys.stderr) + print(output, file=sys.stderr) + return 1 + print("Expected MCP boundary regression reproduced: unhandled exceptions and missing schema validations fail.") + return 0 + return result.returncode + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/product-slug/README.md b/examples/product-slug/README.md new file mode 100644 index 0000000..25c8617 --- /dev/null +++ b/examples/product-slug/README.md @@ -0,0 +1,35 @@ +--- +id: product-slug-example +type: example +title: Three-minute AI-coding change +summary: Reproduce a Python text-boundary bug, make a bounded fix, and verify it with the standard library. +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-02 +--- + +# Three-minute AI-coding change + +This exercise shows the full evidence chain: a contract, a failing example, a +bounded implementation, and an automated check. It needs only Python 3.11+. + +```bash +python examples/product-slug/verify.py starter --expect-failure +python examples/product-slug/verify.py solution +``` + +The first command must report an expected failure; the second must pass four +tests. Then give [TASK.md](TASK.md) to a coding agent, ask it to edit only +`starter/product_slug.py`, and run: + +```bash +python examples/product-slug/verify.py starter +``` + +Compare the patch with `solution/product_slug.py`. The goal is not to reproduce +the same syntax; it is to satisfy the same contract with a small, readable diff. + +--- + +See this example on [flypython.com](https://flypython.com/examples/product-slug): the browsable contract, step-by-step walkthrough, and current review state. diff --git a/examples/product-slug/README_cn.md b/examples/product-slug/README_cn.md new file mode 100644 index 0000000..b94e143 --- /dev/null +++ b/examples/product-slug/README_cn.md @@ -0,0 +1,34 @@ +--- +id: product-slug-example +type: example +title: 3 分钟完成一次 AI Coding 修改 +summary: 复现 Python 文本边界 Bug,完成范围明确的修复,并只用标准库验证结果。 +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-02 +--- + +# 3 分钟完成一次 AI Coding 修改 + +这个练习展示完整证据链:任务契约、稳定失败、边界明确的实现和自动检查。只需要 +Python 3.11+。 + +```bash +python examples/product-slug/verify.py starter --expect-failure +python examples/product-slug/verify.py solution +``` + +第一条命令应报告“预期失败”,第二条命令应通过 4 项测试。然后把 [TASK_cn.md](TASK_cn.md) +交给 coding agent,要求它只修改 `starter/product_slug.py`,最后运行: + +```bash +python examples/product-slug/verify.py starter +``` + +可以和 `solution/product_slug.py` 对照。目标不是复制相同语法,而是用小而可读的 diff +满足同一份契约。 + +--- + +在 [flypython.com](https://flypython.com/examples/zh/product-slug) 查看本示例的在线版本:可浏览的任务契约、分步说明与每个示例的当前审核状态。 diff --git a/examples/product-slug/TASK.md b/examples/product-slug/TASK.md new file mode 100644 index 0000000..5a99a4b --- /dev/null +++ b/examples/product-slug/TASK.md @@ -0,0 +1,12 @@ +# Task contract: normalize a product slug + +Change only `starter/product_slug.py`. + +- `normalize_product_slug(name)` returns a lowercase ASCII slug. +- A run of whitespace, `_`, or `-` becomes one `-`. +- Other punctuation is removed without creating duplicate separators. +- Leading and trailing separators are removed. +- An empty result raises `ValueError`. +- Do not add dependencies or change tests. + +Done means `python examples/product-slug/verify.py starter` exits successfully. diff --git a/examples/product-slug/TASK_cn.md b/examples/product-slug/TASK_cn.md new file mode 100644 index 0000000..fb6afe3 --- /dev/null +++ b/examples/product-slug/TASK_cn.md @@ -0,0 +1,12 @@ +# 任务契约:规范化产品 slug + +只修改 `starter/product_slug.py`。 + +- `normalize_product_slug(name)` 返回小写 ASCII slug; +- 连续空白、`_` 或 `-` 变成一个 `-`; +- 其他标点被移除,不能产生重复分隔符; +- 去掉开头和结尾的分隔符; +- 结果为空时抛出 `ValueError`; +- 不增加依赖,不修改测试。 + +完成标准:`python examples/product-slug/verify.py starter` 成功退出。 diff --git a/examples/product-slug/solution/product_slug.py b/examples/product-slug/solution/product_slug.py new file mode 100644 index 0000000..e356456 --- /dev/null +++ b/examples/product-slug/solution/product_slug.py @@ -0,0 +1,13 @@ +import re + + +def normalize_product_slug(name: str) -> str: + """Return a normalized ASCII URL identifier for a product name.""" + + normalized = name.strip().lower() + normalized = re.sub(r"[^a-z0-9\s_-]", "", normalized) + normalized = re.sub(r"[\s_-]+", "-", normalized) + normalized = normalized.strip("-") + if not normalized: + raise ValueError("product name must contain an ASCII letter or digit") + return normalized diff --git a/examples/product-slug/starter/product_slug.py b/examples/product-slug/starter/product_slug.py new file mode 100644 index 0000000..21d830c --- /dev/null +++ b/examples/product-slug/starter/product_slug.py @@ -0,0 +1,4 @@ +def normalize_product_slug(name: str) -> str: + """Return a URL-like identifier for a product name.""" + + return name.lower().replace(" ", "-") diff --git a/examples/product-slug/tests/test_product_slug.py b/examples/product-slug/tests/test_product_slug.py new file mode 100644 index 0000000..e47c092 --- /dev/null +++ b/examples/product-slug/tests/test_product_slug.py @@ -0,0 +1,26 @@ +import unittest + +from product_slug import normalize_product_slug + + +class NormalizeProductSlugTests(unittest.TestCase): + def test_lowercases_and_joins_words(self) -> None: + self.assertEqual(normalize_product_slug("Good Python Product"), "good-python-product") + + def test_collapses_mixed_separators(self) -> None: + self.assertEqual(normalize_product_slug(" API__Agent---Kit "), "api-agent-kit") + + def test_removes_punctuation(self) -> None: + self.assertEqual(normalize_product_slug("MCP: Tools!"), "mcp-tools") + + def test_punctuation_does_not_duplicate_separators(self) -> None: + self.assertEqual(normalize_product_slug("Hello , World"), "hello-world") + self.assertEqual(normalize_product_slug("Kit! (deluxe)"), "kit-deluxe") + + def test_rejects_an_empty_result(self) -> None: + with self.assertRaises(ValueError): + normalize_product_slug(" -- !! ") + + +if __name__ == "__main__": + unittest.main() diff --git a/examples/product-slug/verify.py b/examples/product-slug/verify.py new file mode 100644 index 0000000..897e0f5 --- /dev/null +++ b/examples/product-slug/verify.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Run the product-slug contract against the starter or solution.""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("implementation", choices=("starter", "solution")) + parser.add_argument("--expect-failure", action="store_true") + args = parser.parse_args() + + 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=args.expect_failure, + text=args.expect_failure, + ) + + 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 "") + expected_failures = ( + "FAIL: test_collapses_mixed_separators", + "FAIL: test_rejects_an_empty_result", + "FAIL: test_removes_punctuation", + "FAIL: test_punctuation_does_not_duplicate_separators", + ) + if not all(failure in output for failure in expected_failures): + print("Starter failed for an unexpected reason:", file=sys.stderr) + print(output, file=sys.stderr) + return 1 + print("Expected regression reproduced: 4 boundary cases fail.") + return 0 + return result.returncode + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/pydantic-validation/README.md b/examples/pydantic-validation/README.md new file mode 100644 index 0000000..25636b0 --- /dev/null +++ b/examples/pydantic-validation/README.md @@ -0,0 +1,39 @@ +--- +id: example-pydantic-validation +type: example +title: Validate Untrusted Boundary Payloads +summary: Parse, sanitize, and validate messy external payloads with field normalization and structured error contracts. +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-02 +--- + +# Validate Untrusted Boundary Payloads + +This exercise is standard-library only by design: you hand-roll the normalization +and validation mechanics that a schema library such as Pydantic automates, so the +boundary rules stay visible. In production, reach for Pydantic — see the catalog's +[Pydantic documentation](https://pydantic.dev/docs/validation/latest/get-started/) entry. + +External payloads from webhooks, mobile clients, and LLM tool calls are untrusted. +They frequently arrive with mixed camelCase and snake_case keys, dirty currency strings, +or invalid enum states. A production boundary validator must sanitize valid variations +and return structured errors without crashing. + +```bash +python examples/pydantic-validation/verify.py starter --expect-failure +python examples/pydantic-validation/verify.py solution +``` + +The first command reports the expected failures from the naive starter. +The second verifies the robust solution. Give [TASK.md](TASK.md) to a coding agent, +ask it to fix only `starter/validator.py`, and run: + +```bash +python examples/pydantic-validation/verify.py starter +``` + +--- + +See this example on [flypython.com](https://flypython.com/examples/pydantic-validation): the browsable contract, step-by-step walkthrough, and current review state. diff --git a/examples/pydantic-validation/README_cn.md b/examples/pydantic-validation/README_cn.md new file mode 100644 index 0000000..13b2e15 --- /dev/null +++ b/examples/pydantic-validation/README_cn.md @@ -0,0 +1,36 @@ +--- +id: example-pydantic-validation +type: example +title: 验证不可信边界请求体 +summary: 使用字段规范化与结构化错误契约,清洗并验证混乱的外部数据载荷。 +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-02 +--- + +# 验证不可信边界请求体 + +本练习刻意只使用标准库:亲手实现 Pydantic 等校验库自动完成的规范化与验证机制, +让边界规则清晰可见。生产环境中请直接使用 Pydantic——见目录中收录的 +[Pydantic 官方文档](https://pydantic.dev/docs/validation/latest/get-started/)。 + +来自 Webhook、客户端或 LLM 工具调用的数据是不可信的。它们经常携带驼峰与下划线混杂的键名、 +带符号的金额字符串或非法的枚举状态。生产级边界验证器必须在容忍合法变体的同时清洗数据,并在 +遇到非法输入时返回明确的结构化错误,绝不能抛出未捕获的 500 异常崩溃。 + +```bash +python examples/pydantic-validation/verify.py starter --expect-failure +python examples/pydantic-validation/verify.py solution +``` + +第一条命令展示朴素 starter 的预期失败;第二条命令验证健壮的 solution。 +把 [TASK_cn.md](TASK_cn.md) 提交给 coding agent,让其仅修改 `starter/validator.py` 并运行: + +```bash +python examples/pydantic-validation/verify.py starter +``` + +--- + +在 [flypython.com](https://flypython.com/examples/zh/pydantic-validation) 查看本示例的在线版本:可浏览的任务契约、分步说明与每个示例的当前审核状态。 diff --git a/examples/pydantic-validation/TASK.md b/examples/pydantic-validation/TASK.md new file mode 100644 index 0000000..e0f10d2 --- /dev/null +++ b/examples/pydantic-validation/TASK.md @@ -0,0 +1,14 @@ +# Task contract: validate untrusted transaction payload + +Change only `starter/validator.py`. + +- Implement `validate_transaction_payload(raw: dict) -> tuple[bool, dict]`. +- Return `(True, sanitized_dict)` on success, or `(False, errors_dict)` on failure. +- Key normalization: + - Support both `userId` and `user_id` -> output normalized `user_id: int`. + - Support both `amount` as float/int or dirty currency string (e.g. `"$120.50"` or `" 120.50 "`) -> output `amount: float` rounded to 2 decimal places. Amount must be > 0. + - `status` must be one of `{"pending", "completed", "failed"}` (case-insensitive) -> output lowercase string. +- If payload is missing required fields or has invalid values, populate `errors_dict` mapping field name to error message. +- Do not raise unhandled exceptions on malformed input. + +Done means `python examples/pydantic-validation/verify.py starter` exits successfully. diff --git a/examples/pydantic-validation/TASK_cn.md b/examples/pydantic-validation/TASK_cn.md new file mode 100644 index 0000000..2634502 --- /dev/null +++ b/examples/pydantic-validation/TASK_cn.md @@ -0,0 +1,14 @@ +# 任务契约:验证不可信交易请求体 + +仅修改 `starter/validator.py`。 + +- 实现 `validate_transaction_payload(raw: dict) -> tuple[bool, dict]`。 +- 成功时返回 `(True, sanitized_dict)`,校验失败时返回 `(False, errors_dict)`。 +- 字段规范化契约: + - 同时支持 `userId` 与 `user_id` -> 输出规范化字段 `user_id: int`。 + - 支持 `amount` 为数字或带符号字符串(如 `"$120.50"`、`" 120.50 "`)-> 输出保留两位小数的 `amount: float`,且数值必须大于 0。 + - `status` 必须为 `{"pending", "completed", "failed"}` 之一(不区分大小写)-> 输出全小写字符串。 +- 缺少必填字段或格式非法时,在 `errors_dict` 中填充以字段名为键、错误描述为值的映射。 +- 遇到非法数据时不得抛出未捕获异常崩溃。 + +完成标准:`python examples/pydantic-validation/verify.py starter` 成功退出。 diff --git a/examples/pydantic-validation/solution/validator.py b/examples/pydantic-validation/solution/validator.py new file mode 100644 index 0000000..02b88ec --- /dev/null +++ b/examples/pydantic-validation/solution/validator.py @@ -0,0 +1,73 @@ +"""Robust transaction payload sanitizer and validator with structured error reporting.""" + +from __future__ import annotations + +import re +from typing import Any + +ALLOWED_STATUSES = {"pending", "completed", "failed"} +CLEAN_CURRENCY_RE = re.compile(r"^[$\s]*([0-9]+(?:\.[0-9]+)?)[$\s]*$") + + +def validate_transaction_payload(raw: Any) -> tuple[bool, dict[str, Any]]: + if not isinstance(raw, dict): + return False, {"_schema": "payload must be a JSON object"} + + errors: dict[str, str] = {} + sanitized: dict[str, Any] = {} + + # 1. user_id (support user_id or userId) + raw_user_id = raw.get("user_id", raw.get("userId")) + if raw_user_id is None: + errors["user_id"] = "field is required" + else: + try: + val = int(raw_user_id) + if val <= 0: + errors["user_id"] = "must be a positive integer" + else: + sanitized["user_id"] = val + except (ValueError, TypeError): + errors["user_id"] = "must be a valid integer" + + # 2. amount (support numeric or dirty string "$120.50") + raw_amount = raw.get("amount") + if raw_amount is None: + errors["amount"] = "field is required" + elif isinstance(raw_amount, (int, float)): + if raw_amount <= 0: + errors["amount"] = "must be greater than 0" + else: + sanitized["amount"] = round(float(raw_amount), 2) + elif isinstance(raw_amount, str): + match = CLEAN_CURRENCY_RE.match(raw_amount.strip()) + if match: + try: + parsed = float(match.group(1)) + if parsed <= 0: + errors["amount"] = "must be greater than 0" + else: + sanitized["amount"] = round(parsed, 2) + except ValueError: + errors["amount"] = "must be a valid numeric amount" + else: + errors["amount"] = "must be a valid numeric amount" + else: + errors["amount"] = "must be a valid numeric amount" + + # 3. status (case-insensitive enum) + raw_status = raw.get("status") + if raw_status is None: + errors["status"] = "field is required" + elif isinstance(raw_status, str): + lowered = raw_status.strip().lower() + if lowered not in ALLOWED_STATUSES: + errors["status"] = f"must be one of {sorted(ALLOWED_STATUSES)}" + else: + sanitized["status"] = lowered + else: + errors["status"] = f"must be one of {sorted(ALLOWED_STATUSES)}" + + if errors: + return False, errors + return True, sanitized diff --git a/examples/pydantic-validation/starter/validator.py b/examples/pydantic-validation/starter/validator.py new file mode 100644 index 0000000..b77b711 --- /dev/null +++ b/examples/pydantic-validation/starter/validator.py @@ -0,0 +1,21 @@ +"""Naive implementation with unhandled KeyError, dirty string errors, and crash on invalid status.""" + +from __future__ import annotations + +from typing import Any + + +def validate_transaction_payload(raw: Any) -> tuple[bool, dict[str, Any]]: + # Naive access will crash on non-dict or camelCase keys + user_id = int(raw["user_id"]) + amount = float(raw["amount"]) + status = raw["status"] + + if amount <= 0: + return False, {"amount": "must be greater than 0"} + + return True, { + "user_id": user_id, + "amount": amount, + "status": status, + } diff --git a/examples/pydantic-validation/tests/test_validator.py b/examples/pydantic-validation/tests/test_validator.py new file mode 100644 index 0000000..ad81652 --- /dev/null +++ b/examples/pydantic-validation/tests/test_validator.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import unittest + +from validator import validate_transaction_payload + + +class TestTransactionValidator(unittest.TestCase): + def test_valid_snake_case_payload(self) -> None: + ok, res = validate_transaction_payload({ + "user_id": 42, + "amount": 99.95, + "status": "completed", + }) + self.assertTrue(ok) + self.assertEqual(res, {"user_id": 42, "amount": 99.95, "status": "completed"}) + + def test_handles_camel_case_and_dirty_amount(self) -> None: + ok, res = validate_transaction_payload({ + "userId": "101", + "amount": " $120.50 ", + "status": "PENDING", + }) + self.assertTrue(ok) + self.assertEqual(res, {"user_id": 101, "amount": 120.50, "status": "pending"}) + + def test_rejects_missing_fields_without_crashing(self) -> None: + ok, errors = validate_transaction_payload({}) + self.assertFalse(ok) + self.assertIn("user_id", errors) + self.assertIn("amount", errors) + self.assertIn("status", errors) + + def test_rejects_invalid_amount_and_status(self) -> None: + ok, errors = validate_transaction_payload({ + "user_id": 1, + "amount": -50.0, + "status": "unknown_state", + }) + self.assertFalse(ok) + self.assertIn("amount", errors) + self.assertIn("status", errors) + + +if __name__ == "__main__": + unittest.main() diff --git a/examples/pydantic-validation/verify.py b/examples/pydantic-validation/verify.py new file mode 100644 index 0000000..e9eb2f4 --- /dev/null +++ b/examples/pydantic-validation/verify.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Run the pydantic-validation contract against the starter or solution.""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("implementation", choices=("starter", "solution")) + parser.add_argument("--expect-failure", action="store_true") + args = parser.parse_args() + + 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=args.expect_failure, + text=args.expect_failure, + ) + + 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 "") + expected_failures = ( + "test_handles_camel_case_and_dirty_amount", + "test_rejects_missing_fields_without_crashing", + ) + if not any(failure in output for failure in expected_failures): + print("Starter failed for an unexpected reason:", file=sys.stderr) + print(output, file=sys.stderr) + return 1 + print("Expected boundary regression reproduced: camelCase/dirty amounts and missing fields fail.") + return 0 + return result.returncode + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/structured-pipeline/README.md b/examples/structured-pipeline/README.md new file mode 100644 index 0000000..0caacce --- /dev/null +++ b/examples/structured-pipeline/README.md @@ -0,0 +1,31 @@ +--- +id: structured-pipeline-example +type: example +title: Resilient batch data pipeline +summary: Build an isolated, bounded data batch processing pipeline with error recovery using the standard library. +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-02 +--- + +# Resilient batch data pipeline + +This exercise demonstrates handling batches of semi-structured input safely: isolating failures, validating field types, and generating structured summaries without crashing. It needs only Python 3.11+. + +```bash +python examples/structured-pipeline/verify.py starter --expect-failure +python examples/structured-pipeline/verify.py solution +``` + +The first command must report expected failure (unhandled exceptions, missing batch guardrails); the second must pass all tests. +Then provide [TASK.md](TASK.md) to your coding agent (Cursor, Windsurf, Claude Code) and ask it to fix `starter/pipeline.py`. +Verify with: + +```bash +python examples/structured-pipeline/verify.py starter +``` + +--- + +See this example on [flypython.com](https://flypython.com/examples/structured-pipeline): the browsable contract, step-by-step walkthrough, and current review state. diff --git a/examples/structured-pipeline/README_cn.md b/examples/structured-pipeline/README_cn.md new file mode 100644 index 0000000..4eb6dc6 --- /dev/null +++ b/examples/structured-pipeline/README_cn.md @@ -0,0 +1,31 @@ +--- +id: structured-pipeline-example +type: example +title: 鲁棒批处理数据流水线 +summary: 使用标准库实现具备错误隔离、类型校验与批处理统计的鲁棒数据流水线。 +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-02 +--- + +# 鲁棒批处理数据流水线 + +本练习演示如何安全处理半结构化批量数据:隔离坏数据避免级联崩溃、严格校验输入字段、产出结构化汇总报告。仅依赖 Python 3.11+ 标准库。 + +```bash +python examples/structured-pipeline/verify.py starter --expect-failure +python examples/structured-pipeline/verify.py solution +``` + +第一条命令应报告“预期失败”(未处理异常、缺少批处理护栏);第二条命令应全部通过。 +随后将 [TASK_cn.md](TASK_cn.md) 提供给 Coding Agent(Cursor、Windsurf、Claude Code 等),要求其修复 `starter/pipeline.py`。 +使用以下命令验证: + +```bash +python examples/structured-pipeline/verify.py starter +``` + +--- + +在 [flypython.com](https://flypython.com/examples/zh/structured-pipeline) 查看本示例的在线版本:可浏览的任务契约、分步说明与每个示例的当前审核状态。 diff --git a/examples/structured-pipeline/TASK.md b/examples/structured-pipeline/TASK.md new file mode 100644 index 0000000..e580acf --- /dev/null +++ b/examples/structured-pipeline/TASK.md @@ -0,0 +1,13 @@ +# Task contract: build a resilient structured CLI pipeline with exit codes and error handling + +Change only `starter/pipeline.py`. + +- `run_pipeline(items: list[dict], batch_size: int = 2) -> dict` processes records in batches. +- Each record must be a dict containing non-empty `'id'` (str) and positive `'value'` (int or float). +- Valid items must be transformed into `{'id': item['id'], 'processed_value': round(item['value'] * 1.1, 2)}`. +- Invalid items must not crash the batch; they must be collected in an `'errors'` list with `{'id': item.get('id', 'unknown'), 'reason': str(error)}`. +- The function must return a summary dict: `{'total': int, 'successful': int, 'failed': int, 'results': list[dict], 'errors': list[dict]}`. +- If `batch_size < 1`, raise `ValueError`. +- Do not add external dependencies; use only Python standard library. + +Done means `python examples/structured-pipeline/verify.py starter` exits successfully. diff --git a/examples/structured-pipeline/TASK_cn.md b/examples/structured-pipeline/TASK_cn.md new file mode 100644 index 0000000..9a2e2f5 --- /dev/null +++ b/examples/structured-pipeline/TASK_cn.md @@ -0,0 +1,13 @@ +# 任务契约:构建鲁棒的结构化批处理流水线(带错误隔离与统计) + +仅修改 `starter/pipeline.py`。 + +- `run_pipeline(items: list[dict], batch_size: int = 2) -> dict` 分批处理输入记录。 +- 传入的每条记录必须是包含非空字符串 `'id'` 以及正数(int 或 float)`'value'` 的字典。 +- 合法记录应被转换为 `{'id': item['id'], 'processed_value': round(item['value'] * 1.1, 2)}`。 +- 非法记录绝不能导致整批中断崩溃,必须被收集进 `'errors'` 列表,格式为 `{'id': item.get('id', 'unknown'), 'reason': str(error)}`。 +- 函数必须返回统一统计字典:`{'total': int, 'successful': int, 'failed': int, 'results': list[dict], 'errors': list[dict]}`。 +- 若 `batch_size < 1`,必须抛出 `ValueError`。 +- 不引入外部依赖,仅使用 Python 标准库。 + +完成标准:`python examples/structured-pipeline/verify.py starter` 成功退出。 diff --git a/examples/structured-pipeline/solution/pipeline.py b/examples/structured-pipeline/solution/pipeline.py new file mode 100644 index 0000000..f9ee6ae --- /dev/null +++ b/examples/structured-pipeline/solution/pipeline.py @@ -0,0 +1,47 @@ +from __future__ import annotations + + +def run_pipeline(items: list[dict], batch_size: int = 2) -> dict: + """Robust pipeline implementation with isolation and input boundaries.""" + if batch_size < 1: + raise ValueError("batch_size must be >= 1") + + results = [] + errors = [] + + for start in range(0, len(items), batch_size): + batch = items[start:start + batch_size] + for item in batch: + try: + if not isinstance(item, dict): + raise TypeError("Record must be a dictionary") + + raw_id = item.get("id") + if not isinstance(raw_id, str) or not raw_id.strip(): + raise ValueError("Record 'id' must be a non-empty string") + + value = item.get("value") + if not isinstance(value, (int, float)) or isinstance(value, bool): + raise TypeError("Record 'value' must be a numeric int or float") + + if value <= 0: + raise ValueError("Record 'value' must be positive") + + results.append({ + "id": raw_id, + "processed_value": round(value * 1.1, 2), + }) + except Exception as exc: + item_id = item.get("id", "unknown") if isinstance(item, dict) else "unknown" + errors.append({ + "id": str(item_id) if item_id else "unknown", + "reason": str(exc), + }) + + return { + "total": len(items), + "successful": len(results), + "failed": len(errors), + "results": results, + "errors": errors, + } diff --git a/examples/structured-pipeline/starter/pipeline.py b/examples/structured-pipeline/starter/pipeline.py new file mode 100644 index 0000000..22a8bf9 --- /dev/null +++ b/examples/structured-pipeline/starter/pipeline.py @@ -0,0 +1,20 @@ +from __future__ import annotations + + +def run_pipeline(items: list[dict], batch_size: int = 2) -> dict: + """Starter implementation: fragile, does not isolate batch errors, crashes on invalid item.""" + # Bug: ignores batch_size guardrails, directly iterates without error catching or type verification + results = [] + for item in items: + # Will crash if id is missing or value is non-positive or wrong type + results.append({ + "id": item["id"], + "processed_value": round(item["value"] * 1.1, 2), + }) + return { + "total": len(items), + "successful": len(results), + "failed": 0, + "results": results, + "errors": [], + } diff --git a/examples/structured-pipeline/tests/test_pipeline.py b/examples/structured-pipeline/tests/test_pipeline.py new file mode 100644 index 0000000..f9595ec --- /dev/null +++ b/examples/structured-pipeline/tests/test_pipeline.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import unittest + +from pipeline import run_pipeline + + +class TestStructuredPipeline(unittest.TestCase): + def test_all_valid_records(self) -> None: + items = [ + {"id": "item-1", "value": 10}, + {"id": "item-2", "value": 20.5}, + ] + result = run_pipeline(items, batch_size=2) + self.assertEqual(result["total"], 2) + self.assertEqual(result["successful"], 2) + self.assertEqual(result["failed"], 0) + self.assertEqual(result["errors"], []) + self.assertEqual( + result["results"], + [ + {"id": "item-1", "processed_value": 11.0}, + {"id": "item-2", "processed_value": 22.55}, + ], + ) + + def test_invalid_batch_size_raises(self) -> None: + with self.assertRaises(ValueError): + run_pipeline([], batch_size=0) + + def test_isolates_and_records_malformed_records(self) -> None: + items = [ + {"id": "valid-1", "value": 100}, + {"id": "", "value": 50}, # empty id + {"id": "bad-val", "value": -5}, # non-positive + {"id": "bad-type", "value": "hundred"}, # string instead of number + {"id": "valid-2", "value": 50}, + ] + result = run_pipeline(items, batch_size=2) + self.assertEqual(result["total"], 5) + self.assertEqual(result["successful"], 2) + self.assertEqual(result["failed"], 3) + self.assertEqual(len(result["errors"]), 3) + failed_ids = [err["id"] for err in result["errors"]] + self.assertIn("bad-val", failed_ids) + self.assertIn("bad-type", failed_ids) + + def test_empty_input(self) -> None: + result = run_pipeline([], batch_size=10) + self.assertEqual(result["total"], 0) + self.assertEqual(result["successful"], 0) + self.assertEqual(result["failed"], 0) + self.assertEqual(result["results"], []) + self.assertEqual(result["errors"], []) + + +if __name__ == "__main__": + unittest.main() diff --git a/examples/structured-pipeline/verify.py b/examples/structured-pipeline/verify.py new file mode 100644 index 0000000..6316d31 --- /dev/null +++ b/examples/structured-pipeline/verify.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Run the structured-pipeline contract against starter or solution.""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("implementation", choices=("starter", "solution")) + parser.add_argument("--expect-failure", action="store_true") + args = parser.parse_args() + + 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=args.expect_failure, + text=args.expect_failure, + ) + + 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 "") + expected_failures = ( + "test_invalid_batch_size_raises", + "test_isolates_and_records_malformed_records", + ) + if not any(failure in output for failure in expected_failures): + print("Starter failed for an unexpected reason:", file=sys.stderr) + print(output, file=sys.stderr) + return 1 + print("Expected batch pipeline regression reproduced: unhandled malformed items and invalid batch size fail.") + return 0 + return result.returncode + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/expand/1.py b/expand/1.py deleted file mode 100644 index 3239a25..0000000 --- a/expand/1.py +++ /dev/null @@ -1,54 +0,0 @@ -#a = '19' -#print "please input a int number in 1-100" -#x = raw_input() -#if a != x: -# print "sorry you are wrong!\ninput again:" -# x = raw_input() -#else -# print "you are right" -#raw_input() - -from random import randint - -def printNum(): - - sInput = raw_input(u"请输入你猜的数字(1-100):") - - try: - nInput = int(sInput) - except (ValueError,TypeError),diag: - print str(diag) - - if(nInput < 1 or nInput > 100): - print u"你数字不在范围之内,请重新输入" - - return nInput - -def main(): - - nValue = randint(1,100) - nInput = printNum() - nTotal = 1 - - while(nValue!= nInput): - - if (nValue > nInput): - print u"你猜的数小了" - - elif (nValue < nInput): - print u"你猜的数大了" - - nTotal += 1 - nInput=printNum() - - print u"恭喜你猜对了" - print u"你直到猜对 共猜了%d次" % nTotal - - if nTotal < 10: - print u"恭喜,你的成绩超过了平均水平" - else: - print u"很遗憾,你成绩没达到平均水平" - - -if __name__ == "__main__": - main() diff --git a/expand/2.py b/expand/2.py deleted file mode 100644 index 8f1d16f..0000000 --- a/expand/2.py +++ /dev/null @@ -1,27 +0,0 @@ -#s = raw_input() -#i = 0 -#for i in s: -# print i -#raw_input() - -#扩展成在字符串中把特定字符替换成规定字符 -import re -def printReplace(srcStr,word): - for letter in srcStr: - if letter == word: - srcStr = srcStr.replace(letter,'!') #字符串不可更改 - return srcStr - -def printReplace_re(srcStr,word): - srcStr=re.sub(word,'!',srcStr) - return srcStr - -def main(): - srcStr = raw_input(u"请输入一个字符串:") - destStr=printReplace_re(srcStr,'i') - print destStr - - -if __name__ == "__main__": - main() - diff --git a/guides/README.md b/guides/README.md new file mode 100644 index 0000000..77a6d37 --- /dev/null +++ b/guides/README.md @@ -0,0 +1,16 @@ +# Guides + +Guides explain the engineering decisions behind reliable Python products. Start +with the outcome you need: + +- [Use Python well with AI Coding](ai-coding/workflow.md): give a coding agent enough context, constrain the change, test behavior, and review side effects. +- [Context budgeting and bounded tasks](ai-coding/context-budget.md): maximize agent accuracy with strict attention budgeting, clear contracts, and automated verification. +- [Build a Python product that can be changed safely](python-engineering/product-quality.md): turn a script into a product with contracts, boundaries, observability, and a release path. +- [Modern Python typing in practice](python-engineering/modern-typing.md): use Protocol, TypedDict, generics, and static type checking as machine-enforced contracts. +- [Reliable async Python patterns](python-engineering/async-patterns.md): avoid event loop blocking, leverage TaskGroup, and manage concurrency with Semaphore. +- [Migrate a Python MCP server to 2026-07-28](mcp/migrate-2026-07-28.md): remove the initialize handshake, adopt the input_required round-trip, and drop deprecated features. + +For task-sized instructions, use the [playbooks](../playbooks/README.md). For +code you can run immediately, use the [examples](../examples/README.md). + +[中文索引](README_cn.md) diff --git a/guides/README_cn.md b/guides/README_cn.md new file mode 100644 index 0000000..ad2aa84 --- /dev/null +++ b/guides/README_cn.md @@ -0,0 +1,14 @@ +# 工程与方法指南 + +指南解释了构建可靠 Python 产品背后的工程决策。从你当前需要的目标开始: + +- [用 Python 和 AI Coding 做出好产品](ai-coding/workflow_cn.md):给 coding agent 充足上下文、限制修改边界、测试行为并审查副作用。 +- [Agent 上下文预算与精准任务设计](ai-coding/context-budget_cn.md):通过注意力预算、严格契约与自动化验证最大化 Agent 编码准确率。 +- [把 Python 变成可以安全修改的产品](python-engineering/product-quality_cn.md):用契约、边界、可观测性和发布路径把脚本变成可靠产品。 +- [现代 Python 类型系统实战](python-engineering/modern-typing_cn.md):使用 Protocol、TypedDict、泛型与静态检查构建机器可验证契约。 +- [异步 Python 可靠模式与避坑指南](python-engineering/async-patterns_cn.md):杜绝阻塞事件循环,掌握 TaskGroup 与 Semaphore 并发控制。 +- [将 Python MCP 服务端迁移到 2026-07-28 规范](mcp/migrate-2026-07-28_cn.md):移除 initialize 握手、采用 input_required 多轮交互并放弃废弃特性。 + +需要可操作的任务步骤,请查看 [Playbook](../playbooks/README_cn.md)。需要可立即运行的代码,请查看 [可运行示例](../examples/README_cn.md)。 + +[English index](README.md) diff --git a/guides/ai-coding/context-budget.md b/guides/ai-coding/context-budget.md new file mode 100644 index 0000000..e4d5889 --- /dev/null +++ b/guides/ai-coding/context-budget.md @@ -0,0 +1,47 @@ +--- +id: ai-coding-context-budget +type: guide +title: Context Budgeting and Bounded Tasks for Coding Agents +summary: Maximize coding agent accuracy by controlling context size, writing explicit task contracts, and enforcing automated verification loops. +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-02 +--- + +# Context Budgeting and Bounded Tasks for Coding Agents + +Providing an entire repository dump to a coding agent increases noise, induces +hallucinations, and dilutes attention. Effective AI coding relies on tight context +budgets and bounded contracts. + +## 1. The context window is an attention budget + +Do not feed hundreds of unrelated files into the agent prompt. Provide only: +1. The specific file to modify; +2. The public interface signatures of direct callers/callees; +3. The automated test file defining the desired behavior. + +## 2. Express requirements as machine-checkable contracts + +Natural language instructions like “make the API cleaner” lead to unpredictable rewrites. +Instead, specify: +- Input types and output formats; +- Permitted dependencies and standard library constraints; +- Failure cases and explicit exception types; +- The exact verification command to run. + +## 3. Fast feedback with local tooling + +Ensure the agent can run local feedback loops using fast tools such as +[uv documentation](https://docs.astral.sh/uv/) and [pytest documentation](https://docs.pytest.org/en/stable/). +A sub-second test loop allows the agent to iterate and fix errors autonomously before human review. + +## 4. Review diffs for unintended side effects + +Always inspect git diffs to ensure the agent did not delete unrelated comments, +introduce unpinned dependencies, or modify shared global state. + +--- + +Continue this topic on [flypython.com](https://flypython.com/learn/ai-coding-context-budget): the guided reading path, prerequisites, related checklists, and current review dates. diff --git a/guides/ai-coding/context-budget_cn.md b/guides/ai-coding/context-budget_cn.md new file mode 100644 index 0000000..634caa3 --- /dev/null +++ b/guides/ai-coding/context-budget_cn.md @@ -0,0 +1,44 @@ +--- +id: ai-coding-context-budget +type: guide +title: Coding Agent 上下文预算与精准任务设计 +summary: 通过控制上下文体积、编写明确任务契约与建立自动化验证闭环,最大化 Agent 编码准确率。 +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-02 +--- + +# Coding Agent 上下文预算与精准任务设计 + +直接向 Coding Agent 丢入整个仓库代码会带来大量噪声,稀释注意力并增加模型幻觉。 +高效的 AI Coding 依赖于严格的上下文预算与边界明确的任务契约。 + +## 1. 上下文窗口是注意力预算 + +避免一次性将数十个无关文件塞入 Agent 提示词。仅提供: +1. 待修改的目标代码文件; +2. 直接调用方/被调用方的公共接口签名; +3. 定义预期行为的自动化测试用例。 + +## 2. 将需求表达为机器可验证的契约 + +诸如“优化这段 API”之类的模糊自然语言会导致 Agent 进行不可控的重构。应当明确指定: +- 输入类型与输出结构; +- 允许的依赖范围与标准库约束; +- 必须覆盖的边界异常与错误码; +- 最终判断完成的验证命令。 + +## 3. 利用本地工具建立亚秒级反馈闭环 + +确保 Agent 能借助现代工具链(如 [uv documentation](https://docs.astral.sh/uv/) 与 +[pytest documentation](https://docs.pytest.org/en/stable/))在本地快速执行测试。 +极速的反馈闭环能够让 Agent 在人工审查前自主修复语法与逻辑缺陷。 + +## 4. 严格审查 Git Diff 的副作用 + +在合并前始终审查代码差异,确保 Agent 没有意外删除重要注释、引入未经审查的依赖或改动全局共享状态。 + +--- + +在 [flypython.com](https://flypython.com/learn/zh/ai-coding-context-budget) 继续这个主题:配套的导读路径、前置条件、相关清单与最新审核日期。 diff --git a/guides/ai-coding/workflow.md b/guides/ai-coding/workflow.md new file mode 100644 index 0000000..776f944 --- /dev/null +++ b/guides/ai-coding/workflow.md @@ -0,0 +1,174 @@ +--- +id: python-ai-coding-workflow +type: guide +title: Use Python Well with AI Coding +summary: A contract-first workflow for using coding agents to make small, testable, and safe Python changes. +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-02 +--- + +# Use Python Well with AI Coding + +An AI coding agent can produce syntax and boilerplate quickly, but it does not +automatically understand project boundaries, the real runtime, user risk, or the +definition of done. Good AI coding is not about generating more code. It is +about turning work into small changes that are understandable, verifiable, and +safe to reverse. + +Using AI coding and building an AI agent are different things. The first is a +development method; the second is only one kind of product you might build. The +same reliable workflow applies whether the outcome is an API, an automation +script, a data tool, or an agent system. + +## 1. Give the task a contract + +Before asking a coding agent to modify code, state: + +- Outcome: what a user or system will be able to accomplish. +- Current behavior: what happens now. +- Inputs and outputs: formats, boundaries, and error forms. +- Scope: what may change and what must remain untouched. +- Acceptance: the tests, commands, or real actions that must pass. +- Authority: whether network access, dependency installation, commits, pushes, + or deployments are allowed. + +Reusable task template: + +```text +Outcome: +Current behavior: +Expected behavior: +Allowed changes: +Out of scope: +Acceptance checks: +Commit/push/deploy authority: +``` + +An instruction such as “improve this” invites an oversized change. A concrete +contract lets the agent execute without guessing product decisions. + +## 2. Require repository inspection first + +A reliable Python change starts by checking: + +1. `AGENTS.md`, `README.md`, and contribution rules. +2. The Git branch, worktree, and remote state. +3. `pyproject.toml`, the Python version, and dependency locks. +4. The implementation and tests closest to the requested behavior. +5. The real entry point: CLI, API route, job, or browser flow. + +Do not let an agent infer implemented behavior from a README, TODO, or filename. +Documentation, code, tests, and runtime evidence must agree. + +## 3. Make the Python environment reproducible + +AI-generated code is useful only inside a reproducible environment: + +- Pin the Python version. +- Use an isolated virtual environment. +- Declare direct dependencies and lock the full dependency graph. +- Do not rely on packages installed globally on one machine. +- Install and test once in a clean environment. + +See the official [uv documentation](https://docs.astral.sh/uv/) for a modern +workflow and the [Python venv documentation](https://docs.python.org/3/library/venv.html) +for the underlying environment behavior. + +## 4. Make the smallest verifiable change + +Ask the coding agent to work at this granularity: + +1. Identify one missing or failing behavior. +2. Define the test or observable acceptance point. +3. Change the smallest relevant set of files. +4. Run focused tests. +5. Run the complete affected test scope. +6. Check the diff for unrelated edits. + +If a change cannot be summarized in one sentence, it can usually be split +again. Avoid combining an architecture refactor, dependency upgrade, copy +rewrite, and production release in one step. + +## 5. Constrain generated code with types, validation, and tests + +Python's dynamic nature supports fast work, but it also makes it easy to produce +plausible code with unclear boundaries. Prefer: + +- [Python typing](https://typing.python.org/en/latest/) for function and module + contracts. +- [Pydantic](https://pydantic.dev/docs/validation/latest/get-started/) for + untrusted inputs. +- [pytest](https://docs.pytest.org/en/stable/) for successful, failing, and edge + behavior. + +Tests should cover normal input, missing or malformed input, timeouts, external +failures, insufficient permission, and side effects that must not occur. + +A green test suite proves only that the checked behavior passed. It does not +prove that the requirement was correct or replace runtime acceptance. + +## 6. Verify the real entry point + +Run what the user will actually use: + +- CLI: inspect exit status, standard output, and standard error. +- API: inspect requests, responses, status codes, timeouts, and validation. +- Web: use a real browser and inspect DOM, interaction, requests, and console. +- Automation: use harmless input and check retries, recovery, and idempotency. +- Agent: inspect tool arguments, structured output, permissions, traces, and + failure boundaries. + +Useful primary references include [HTTPX](https://www.python-httpx.org/), +[FastAPI](https://fastapi.tiangolo.com/), and +[Playwright for Python](https://playwright.dev/python/docs/intro). + +## 7. Review side effects and authority separately + +AI-generated Python often touches files, shells, browsers, networks, databases, +or model tools. Before accepting the change, confirm: + +- File targets are precise and cannot overwrite broad user data. +- `subprocess` avoids unnecessary `shell=True` execution. +- Network calls have timeouts, bounded retries, and destination controls. +- Logs and errors do not expose credentials or private data. +- Database changes are auditable and recoverable. +- Agent tools use the least authority and request necessary confirmation. + +See the standard-library documentation for +[subprocess](https://docs.python.org/3/library/subprocess.html) and +[pathlib](https://docs.python.org/3/library/pathlib.html). + +## 8. Report evidence, not only completion + +A complete handoff states: + +- What changed and what did not. +- Which tests and acceptance actions actually ran. +- Which outcomes were verified and which remain unknown. +- Whether the work was committed, pushed, or deployed. +- The exact version that can be checked independently. + +Recommended definition of done: + +```text +[ ] The worktree contains only in-scope changes +[ ] Type, format, and unit checks pass +[ ] Failure paths and boundary inputs were exercised +[ ] The real CLI, API, or browser entry point was verified +[ ] File, network, credential, and database side effects were reviewed +[ ] Documentation matches current implementation +[ ] Commit, remote, and deployment versions can be compared exactly +``` + +## Next step + +Use the repository README to choose primary sources for Python foundations, +Web/API work, automation, or AI-agent development. The catalog helps you choose +reliable context; this workflow helps you decide whether an AI-generated change +is safe to accept. + +--- + +Continue this topic on [flypython.com](https://flypython.com/learn/ai-coding-workflow): the guided reading path, prerequisites, related checklists, and current review dates. diff --git a/guides/ai-coding/workflow_cn.md b/guides/ai-coding/workflow_cn.md new file mode 100644 index 0000000..3beca34 --- /dev/null +++ b/guides/ai-coding/workflow_cn.md @@ -0,0 +1,167 @@ +--- +id: python-ai-coding-workflow +type: guide +title: 用 AI Coding 写好 Python +summary: 一套契约优先的工作方法,用 coding agent 完成小而可测、能够安全交付的 Python 修改。 +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-02 +--- + +# 用 AI Coding 写好 Python + +AI coding agent 可以快速生成语法和样板代码,但它不会自动理解项目边界、真实运行环境、 +用户风险或完成标准。高质量 AI Coding 的核心不是写出更多代码,而是把工作拆成可理解、 +可验证、可回退的小改动。 + +“使用 AI Coding”与“开发 AI Agent”是两件事:前者是开发方式,后者只是可能构建的一类 +产品。无论最终开发 API、自动化脚本、数据工具还是 Agent,都应使用同一套可靠工作循环。 + +## 1. 先给任务建立契约 + +在让 coding agent 修改代码前,至少写清: + +- 目标:用户或系统最终能完成什么; +- 当前行为:现在具体发生了什么; +- 输入与输出:数据格式、边界和错误形式; +- 约束:允许修改什么,明确不修改什么; +- 验收:要运行哪些测试、命令或真实操作; +- 权限:是否允许联网、安装依赖、提交、推送或部署。 + +可直接复用下面的任务模板: + +```text +目标: +当前行为: +期望行为: +允许修改: +禁止修改: +验收方式: +提交/推送/部署权限: +``` + +模糊的“优化一下”通常会得到范围过大的修改;明确契约才能让 AI 承担执行,而不是替你 +猜测产品决定。 + +## 2. 要求 AI 先读取仓库 + +一个可靠的 Python 修改应先检查: + +1. `AGENTS.md`、`README.md` 和贡献规则; +2. 当前 Git 分支、工作树和远程状态; +3. `pyproject.toml`、Python 版本和依赖锁文件; +4. 与目标最接近的实现和测试; +5. 真实入口,例如 CLI、API route、任务脚本或浏览器流程。 + +不要让 AI 仅凭 README、TODO 或文件名推断实现已经存在。文档、代码、测试和真实运行结果 +需要相互印证。 + +## 3. 固定 Python 环境 + +AI 生成的代码只有在可复现环境中才有意义: + +- 固定 Python 版本; +- 使用隔离虚拟环境; +- 声明直接依赖并锁定完整依赖树; +- 不把本机全局包当成项目依赖; +- 在干净环境中至少完成一次安装和测试。 + +现代项目可以参考 [uv 官方文档](https://docs.astral.sh/uv/);虚拟环境的底层行为见 +[Python venv 文档](https://docs.python.org/3/library/venv.html)。 + +## 4. 每次只实现最小可验证改动 + +让 coding agent 遵循下面的粒度: + +1. 找到一个明确失败或缺失行为; +2. 先确定测试或验收观察点; +3. 修改最少的相关文件; +4. 运行局部测试; +5. 再运行受影响范围的完整测试; +6. 检查 diff 中是否混入无关修改。 + +一个改动如果无法用一句话说明,通常还可以继续拆小。避免让 AI 同时重构架构、升级依赖、 +修改文案并发布生产环境。 + +## 5. 用类型、验证和测试约束生成结果 + +Python 的动态特性适合快速开发,也容易让 AI 生成“看起来合理但边界不清”的代码。 +优先使用: + +- [Python typing](https://typing.python.org/en/latest/)表达函数和模块契约; +- [Pydantic](https://pydantic.dev/docs/validation/latest/get-started/)验证不可信输入; +- [pytest](https://docs.pytest.org/en/stable/)验证成功、失败和边界行为。 + +测试至少覆盖: + +- 正常输入; +- 空值、缺失字段和错误类型; +- 超时、重试和外部服务失败; +- 权限不足和不可恢复错误; +- 不应发生的文件、网络或数据库副作用。 + +绿色测试只能证明被检查的行为通过,不能证明需求正确,也不能代替真实运行验收。 + +## 6. 验证真实入口 + +完成代码后,运行用户真正会使用的入口: + +- CLI:执行真实命令并检查退出码、标准输出和错误输出; +- API:检查请求、响应、状态码、超时和验证错误; +- Web:使用真实浏览器检查 DOM、交互、网络请求和控制台; +- 自动化:使用无害输入检查重复运行、失败恢复和幂等性; +- Agent:检查工具参数、结构化输出、权限、轨迹和失败边界。 + +HTTP 客户端可参考 [HTTPX](https://www.python-httpx.org/),API 边界可参考 +[FastAPI](https://fastapi.tiangolo.com/),浏览器流程可参考 +[Playwright for Python](https://playwright.dev/python/docs/intro)。 + +## 7. 单独检查副作用和权限 + +AI 生成的 Python 经常会操作文件、Shell、浏览器、网络、数据库或模型工具。接受修改前 +逐项确认: + +- 文件目标是否精确,是否可能覆盖用户数据; +- `subprocess` 是否避免不必要的 `shell=True`; +- 网络请求是否有超时、重试上限和目标限制; +- 日志和错误信息是否暴露凭据或私人数据; +- 数据库修改是否可审计、可回滚; +- Agent 工具是否使用最小权限并要求必要确认。 + +相关标准库行为见 +[subprocess 文档](https://docs.python.org/3/library/subprocess.html)和 +[pathlib 文档](https://docs.python.org/3/library/pathlib.html)。 + +## 8. 交付时报告证据,而不是只说“完成” + +一次完整交付至少说明: + +- 修改了什么; +- 没有修改什么; +- 实际运行了哪些测试和验收; +- 哪些结果已经验证; +- 哪些结果仍然未知; +- 是否已经提交、推送或部署,以及对应的精确版本。 + +推荐完成标准: + +```text +[ ] 工作树只包含本次范围内的修改 +[ ] 类型、格式和单元测试通过 +[ ] 失败路径和边界输入已检查 +[ ] 真实 CLI/API/浏览器入口已验证 +[ ] 文件、网络、凭据和数据库副作用已检查 +[ ] 文档与当前实现一致 +[ ] 提交、远程和部署版本可以精确核对 +``` + +## 下一步 + +根据正在开发的系统,从仓库 README 中继续选择 Python 基础、Web/API、自动化或 AI Agent +的一手资料。资源目录帮助你选择可靠上下文;这套工作循环帮助你判断 AI 生成的修改是否 +真的可以接受。 + +--- + +在 [flypython.com](https://flypython.com/learn/zh/ai-coding-workflow) 继续这个主题:配套的导读路径、前置条件、相关清单与最新审核日期。 diff --git a/guides/mcp/migrate-2026-07-28.md b/guides/mcp/migrate-2026-07-28.md new file mode 100644 index 0000000..9f327f6 --- /dev/null +++ b/guides/mcp/migrate-2026-07-28.md @@ -0,0 +1,134 @@ +--- +id: mcp-2026-07-28-migration +type: guide +title: Migrate a Python MCP server to the 2026-07-28 specification +summary: Update a Python MCP server for the stateless 2026-07-28 specification by removing the initialize handshake, adopting the input_required round-trip, dropping deprecated features, and hardening authorization. +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-06 +--- + +# Migrate a Python MCP server to the 2026-07-28 specification + +The 2026-07-28 Model Context Protocol specification is the largest release since remote +MCP: it removes the initialize handshake entirely, replaces server-initiated requests +with a multi-round-trip `input_required` flow, and deprecates roots, sampling, and +logging. MCP is now stewarded by the Agentic AI Foundation under the Linux Foundation, +and the Python SDK shipped alongside the specification. + +You are done migrating when your server answers `tools/list` and `tools/call` with no +session state of any kind, no tool depends on the deprecated server-initiated requests, +and your authorization path satisfies the new issuer rules. + +## What changed in 2026-07-28 + +- **Stateless-first (SEP-2575, SEP-2567).** The `initialize`/`initialized` exchange and + the `Mcp-Session-Id` header are retired. Every request self-describes with protocol + version, client identity, and capabilities through `_meta`. An optional + `server/discover` RPC exists for clients that want capabilities up front. +- **Multi-round-trip replaces server-initiated requests (SEP-2322).** + `elicitation/create`, `sampling/createMessage`, and `roots/list` no longer hold open + streams. A tool that needs client input returns a result with + `resultType: "input_required"` plus the requests that need answers; the client retries + the original call with the answers attached in `inputResponses`. +- **Per-request version and routing headers (SEP-2243).** `MCP-Protocol-Version` + travels with every request (`2026-07-28`), and Streamable HTTP requests must include + `Mcp-Method` and `Mcp-Name` so infrastructure can route without parsing JSON bodies. +- **Authorization hardening.** Authorization servers must return the `iss` parameter per + RFC 9207 (SEP-2468), clients must set `application_type` during dynamic registration + (SEP-837), and client credentials are issuer-bound — never reused across authorization + servers (SEP-2352). +- **Response caching (SEP-2549).** Responses from `tools/list`, `prompts/list`, + `resources/list`, and `resources/read` carry `ttlMs` and `cacheScope`. +- **Tasks restructured (SEP-2663).** Tasks moved into the + `io.modelcontextprotocol/tasks` extension with poll-based `tasks/get`, a new + `tasks/update`, and change notifications moved to a `subscriptions/listen` stream. +- **Deprecations with a minimum twelve-month window.** Roots, sampling, and logging are + deprecated (SEP-2577), the legacy HTTP+SSE transport has a one-year offramp, and + Dynamic Client Registration is replaced by Client ID Metadata Documents (CIMD). + +## Migration checklist + +1. **Delete the handshake.** Remove `initialize` and `notifications/initialized` + handling and every `Mcp-Session-Id` lookup. A request that still sends `initialize` + should fail as an unknown method, not be negotiated. +2. **Make each request self-sufficient.** Anything your server previously remembered + from initialize — client capabilities, protocol version, identity — must now be read + per-request from `_meta`, or fetched through `server/discover` when a client opts in. +3. **Replace elicitation with the input_required round-trip.** A tool that used to call + `elicitation/create` now returns `resultType: "input_required"` with the questions, + and completes when the client retries with `inputResponses`. +4. **Stop building on deprecated primitives.** Roots, sampling, and logging still work + during the deprecation window, but new code should not depend on them. +5. **Upgrade the official Python SDK.** The TypeScript, Python, Go, and C# SDKs shipped + with the specification; the RC-to-final window was roughly ten weeks, so older SDK + versions predate the breaking changes. +6. **Update transports.** Drop the legacy HTTP+SSE transport on its one-year offramp and + emit the required `Mcp-Method` and `Mcp-Name` headers on Streamable HTTP. +7. **Check the authorization path.** Validate `iss` per RFC 9207, set `application_type` + during registration, and keep one credential per issuer. +8. **Adopt caching metadata.** Annotate list responses with `ttlMs` and `cacheScope` so + stateless infrastructure can cache them safely. + +## The input_required flow in code + +The runnable reference lives in +[examples/mcp-server](../../examples/mcp-server/README.md). The essential shape: + +```python +class InputRequired(Exception): + def __init__(self, requests): + super().__init__("tool execution needs additional client input") + self.requests = requests + + +def delete_resource(args, input_responses=None): + answer = str((input_responses or {}).get("confirm", "")).strip().lower() + if answer != "yes": + raise InputRequired([{"id": "confirm", "prompt": "Type 'yes' to confirm deletion."}]) + return "deleted" +``` + +The server catches `InputRequired` before its generic exception handler and returns: + +```json +{ + "result": { + "resultType": "input_required", + "requests": [{"id": "confirm", "prompt": "Type 'yes' to confirm deletion."}], + "content": [{"type": "text", "text": "Additional client input is required before this tool can finish."}] + } +} +``` + +The client retries the identical `tools/call` with +`"inputResponses": {"confirm": "yes"}`. Because the flow is retry-based, an unconfirmed +call never mutates anything — the same property that makes idempotent automation safe. + +Verify the complete behavior, including JSON-RPC error codes and the removed +handshake: + +```bash +python examples/mcp-server/verify.py starter --expect-failure +python examples/mcp-server/verify.py solution +``` + +## What this guide does not cover + +This guide works from the published release notes and the shipped SDKs. Field-level wire +schemas for `_meta`, the `requests` entries, and `cacheScope` values live in the +specification and SDK types — consult them before shipping, and prefer the official +Python SDK over hand-rolled dispatch in production. Authorization deployment details +— issuer discovery, credential storage, and CIMD rollout — are also out of scope here. + +## Sources + +- [The 2026-07-28 MCP specification release](https://blog.modelcontextprotocol.io/posts/2026-07-28/) +- [2026-07-28 release candidate and breaking-change overview](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/) +- [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) +- [MCP under the Agentic AI Foundation](https://aaif.io/projects/model-context-protocol) + +--- + +Continue this topic on [flypython.com](https://flypython.com/learn/mcp-2026-07-28-migration): the guided reading path, prerequisites, related checklists, and current review dates. diff --git a/guides/mcp/migrate-2026-07-28_cn.md b/guides/mcp/migrate-2026-07-28_cn.md new file mode 100644 index 0000000..0eed52e --- /dev/null +++ b/guides/mcp/migrate-2026-07-28_cn.md @@ -0,0 +1,127 @@ +--- +id: mcp-2026-07-28-migration +type: guide +title: 将 Python MCP 服务端迁移到 2026-07-28 规范 +summary: 面向 2026-07-28 无状态规范更新 Python MCP 服务端,移除 initialize 握手、采用 input_required 多轮交互、放弃废弃特性并强化授权。 +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-06 +--- + +# 将 Python MCP 服务端迁移到 2026-07-28 规范 + +2026-07-28 的 Model Context Protocol 规范是 remote MCP 之后最大的一次发布:它彻底 +移除了 initialize 握手,用 `input_required` 多轮交互取代服务端主动发起的请求,并 +废弃了 roots、sampling 和 logging。MCP 现由 Linux 基金会旗下的 Agentic AI +Foundation 管理,Python SDK 已随规范同步发布。 + +迁移完成的标准:你的服务端在完全不依赖任何会话状态的情况下响应 `tools/list` 与 +`tools/call`,没有任何工具依赖已废弃的服务端主动请求,且授权链路满足新的发行方 +(issuer)规则。 + +## 2026-07-28 改了什么 + +- **无状态优先(SEP-2575、SEP-2567)。** `initialize`/`initialized` 交互与 + `Mcp-Session-Id` 头被移除。每个请求通过 `_meta` 自描述协议版本、客户端身份与 + 能力;需要提前获取能力的客户端可以使用可选的 `server/discover` RPC。 +- **多轮交互取代服务端主动请求(SEP-2322)。** `elicitation/create`、 + `sampling/createMessage` 和 `roots/list` 不再保持长连接。需要客户端输入的工具 + 返回带 `resultType: "input_required"` 的结果以及待回答的请求;客户端携带 + `inputResponses` 重试原始调用。 +- **按请求传递版本与路由头(SEP-2243)。** `MCP-Protocol-Version` 随每个请求传递 + (值为 `2026-07-28`),Streamable HTTP 请求必须携带 `Mcp-Method` 和 `Mcp-Name`, + 让基础设施无需解析 JSON 请求体即可路由。 +- **授权强化。** 授权服务器必须按 RFC 9207 返回 `iss` 参数(SEP-2468),客户端在 + 动态注册时必须设置 `application_type`(SEP-837),客户端凭据与发行方绑定——绝不 + 能跨授权服务器复用(SEP-2352)。 +- **响应缓存(SEP-2549)。** `tools/list`、`prompts/list`、`resources/list` 和 + `resources/read` 的响应携带 `ttlMs` 与 `cacheScope`。 +- **Tasks 重构(SEP-2663)。** Tasks 移入 `io.modelcontextprotocol/tasks` 扩展, + 采用轮询式 `tasks/get` 与新增的 `tasks/update`,变更通知迁移到 + `subscriptions/listen` 流。 +- **至少十二个月窗口的废弃项。** Roots、sampling 和 logging 被废弃 + (SEP-2577);传统 HTTP+SSE 传输有一年过渡期;动态客户端注册(DCR)由客户端 + ID 元数据文档(CIMD)取代。 + +## 迁移清单 + +1. **删除握手。** 移除 `initialize` 与 `notifications/initialized` 的处理,以及所有 + `Mcp-Session-Id` 查找。仍然发送 `initialize` 的请求应按未知方法失败,而不是被 + 协商。 +2. **让每个请求自洽。** 服务端以前从 initialize 记住的一切——客户端能力、协议版 + 本、身份——现在必须按请求从 `_meta` 读取,或在客户端主动选择时通过 + `server/discover` 获取。 +3. **用 input_required 交互取代 elicitation。** 过去调用 `elicitation/create` 的工 + 具现在返回带提问的 `resultType: "input_required"`,并在客户端携带 + `inputResponses` 重试时完成。 +4. **不要在废弃原语上新建代码。** Roots、sampling 和 logging 在废弃窗口内仍然可 + 用,但新代码不应再依赖它们。 +5. **升级官方 Python SDK。** TypeScript、Python、Go 和 C# SDK 已随规范发布;从 RC + 到正式版大约有十周窗口,更早的 SDK 版本早于这些破坏性变更。 +6. **更新传输层。** 在一年过渡期内下线传统 HTTP+SSE 传输,并在 Streamable HTTP 上 + 输出必需的 `Mcp-Method` 与 `Mcp-Name` 头。 +7. **检查授权链路。** 按 RFC 9207 校验 `iss`,注册时设置 `application_type`,每个 + 发行方使用独立凭据。 +8. **采用缓存元数据。** 为列表响应标注 `ttlMs` 与 `cacheScope`,让无状态基础设施 + 可以安全缓存。 + +## input_required 流程代码 + +可运行的完整参考在 [examples/mcp-server](../../examples/mcp-server/README_cn.md)。 +核心形状如下: + +```python +class InputRequired(Exception): + def __init__(self, requests): + super().__init__("tool execution needs additional client input") + self.requests = requests + + +def delete_resource(args, input_responses=None): + answer = str((input_responses or {}).get("confirm", "")).strip().lower() + if answer != "yes": + raise InputRequired([{"id": "confirm", "prompt": "Type 'yes' to confirm deletion."}]) + return "deleted" +``` + +服务端在通用异常处理之前捕获 `InputRequired`,返回: + +```json +{ + "result": { + "resultType": "input_required", + "requests": [{"id": "confirm", "prompt": "Type 'yes' to confirm deletion."}], + "content": [{"type": "text", "text": "Additional client input is required before this tool can finish."}] + } +} +``` + +客户端携带 `"inputResponses": {"confirm": "yes"}` 重试完全相同的 `tools/call`。因为 +整个流程基于重试,未确认的调用不会产生任何副作用——这与幂等自动化的安全性质完全 +一致。 + +验证完整行为,包括 JSON-RPC 错误码与被移除的握手: + +```bash +python examples/mcp-server/verify.py starter --expect-failure +python examples/mcp-server/verify.py solution +``` + +## 本指南不覆盖的内容 + +本指南基于已发布的发布公告与随规范交付的 SDK。`_meta`、`requests` 条目和 +`cacheScope` 取值的字段级线缆协议请以规范与 SDK 类型定义为准,上线前务必核对;生 +产环境优先使用官方 Python SDK 而不是手写分发逻辑。授权的部署细节——发行方发现、 +凭据存储与 CIMD 的推进节奏——同样不在本指南范围内。 + +## 来源 + +- [2026-07-28 MCP 规范发布公告](https://blog.modelcontextprotocol.io/posts/2026-07-28/) +- [2026-07-28 RC 与破坏性变更概览](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/) +- [Anthropic:将 MCP 捐给 Agentic AI Foundation](https://www.anthropic.com/news/donating-the-model-context-protocol-and-establishing-of-the-agentic-ai-foundation) +- [Agentic AI Foundation 下的 MCP](https://aaif.io/projects/model-context-protocol) + +--- + +在 [flypython.com](https://flypython.com/learn/zh/mcp-2026-07-28-migration) 继续这个主题:配套的导读路径、前置条件、相关清单与最新审核日期。 diff --git a/guides/python-engineering/async-patterns.md b/guides/python-engineering/async-patterns.md new file mode 100644 index 0000000..c374ecd --- /dev/null +++ b/guides/python-engineering/async-patterns.md @@ -0,0 +1,61 @@ +--- +id: python-async-patterns +type: guide +title: Reliable Async Python Patterns and Pitfalls +summary: Build resilient concurrent Python applications by avoiding common async antipatterns and managing task lifecycles. +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-02 +--- + +# Reliable Async Python Patterns and Pitfalls + +Asynchronous Python (`asyncio`) offers high throughput for IO-bound applications, +such as web services built with [FastAPI documentation](https://fastapi.tiangolo.com/) or +batch pipelines using [HTTPX documentation](https://www.python-httpx.org/). +However, AI coding agents frequently introduce subtle concurrency bugs. + +## 1. Never invoke blocking IO inside the event loop + +Calling synchronous filesystem or network methods (such as `time.sleep` or standard +sync file reads) blocks the entire event loop, starving other concurrent coroutines. +Offload unavoidable blocking operations to worker threads via `asyncio.to_thread()`: + +```python +import asyncio + +def blocking_io_task(): ... + +async def handle_request(): + result = await asyncio.to_thread(blocking_io_task) +``` + +## 2. Prefer TaskGroup over bare gather + +In Python 3.11+, use `asyncio.TaskGroup` for structured concurrency instead of `asyncio.gather`. +`TaskGroup` guarantees that if any child task raises an unhandled exception, all sibling tasks +are immediately cancelled and cleaned up, preventing orphan runaway tasks. + +```python +import asyncio + +async def main(): + async with asyncio.TaskGroup() as tg: + task1 = tg.create_task(fetch_user(1)) + task2 = tg.create_task(fetch_orders(1)) +``` + +## 3. Bound concurrency with Semaphore + +Never launch unbounded coroutines on large datasets. Always wrap concurrent operations +in an `asyncio.Semaphore` to cap open file descriptors and avoid rate-limiting triggers. + +## 4. Handle cancellation and cleanup + +Always use `try...finally` blocks or async context managers to release database connections, +network sessions, and lock resources when an async operation is cancelled. + +--- + +Continue this topic on [flypython.com](https://flypython.com/learn/reliable-async-patterns): the guided reading path, prerequisites, related checklists, and current review dates. diff --git a/guides/python-engineering/async-patterns_cn.md b/guides/python-engineering/async-patterns_cn.md new file mode 100644 index 0000000..c0e018f --- /dev/null +++ b/guides/python-engineering/async-patterns_cn.md @@ -0,0 +1,60 @@ +--- +id: python-async-patterns +type: guide +title: 异步 Python 可靠模式与避坑指南 +summary: 通过避免常见异步反模式与规范管理任务生命周期,构建高韧性的并发 Python 应用。 +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-02 +--- + +# 异步 Python 可靠模式与避坑指南 + +异步 Python (`asyncio`) 为 IO 密集型应用提供了极高的并发吞吐量,例如基于 +[FastAPI documentation](https://fastapi.tiangolo.com/) 构建的 Web 服务或基于 +[HTTPX documentation](https://www.python-httpx.org/) 的数据管道。 +但在 AI 辅助编码中,Agent 容易生成隐蔽的异步并发缺陷。 + +## 1. 严禁在事件循环中调用阻塞 IO + +在协程中直接调用同步阻塞操作(如 `time.sleep` 或标准同步文件读取)会冻结整个事件循环, +导致其他所有并发协程饥饿停滞。必须通过 `asyncio.to_thread()` 将无法异步化的操作委托至后台线程池: + +```python +import asyncio + +def blocking_io_task(): ... + +async def handle_request(): + result = await asyncio.to_thread(blocking_io_task) +``` + +## 2. 优先使用 TaskGroup 替代裸 gather + +在 Python 3.11+ 中,推荐使用 `asyncio.TaskGroup` 实现结构化并发。相比 `asyncio.gather`, +`TaskGroup` 保证当其中任何一个子任务发生未捕获异常时,会自动取消并清理其余同级任务, +彻底杜绝孤儿悬挂任务泄露。 + +```python +import asyncio + +async def main(): + async with asyncio.TaskGroup() as tg: + task1 = tg.create_task(fetch_user(1)) + task2 = tg.create_task(fetch_orders(1)) +``` + +## 3. 使用 Semaphore 限制最大并发 + +严禁对大规模数据集直接发起无界并发。始终使用 `asyncio.Semaphore` 限制并发上限, +防止打满系统文件描述符或触发下游服务 429 限流风暴。 + +## 4. 完备处理协程取消与资源释放 + +在协程被取消(CancelledError)时,必须通过 `try...finally` 或异步上下文管理器 +确保数据库连接、HTTP 会话及锁资源被正确释放。 + +--- + +在 [flypython.com](https://flypython.com/learn/zh/reliable-async-patterns) 继续这个主题:配套的导读路径、前置条件、相关清单与最新审核日期。 diff --git a/guides/python-engineering/modern-typing.md b/guides/python-engineering/modern-typing.md new file mode 100644 index 0000000..d2fc581 --- /dev/null +++ b/guides/python-engineering/modern-typing.md @@ -0,0 +1,65 @@ +--- +id: python-modern-typing +type: guide +title: Modern Python Typing in Practice +summary: Use explicit Python types as machine-checkable contracts for reliable AI-assisted engineering. +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-02 +--- + +# Modern Python Typing in Practice + +In the AI-coding era, type hints are not cosmetic annotations. They serve as +machine-readable boundary contracts that constrain LLM code generation and catch +regressions before execution. + +## 1. Structural subtyping with Protocol + +Avoid rigid inheritance hierarchies for test doubles and service adapters. +Use `typing.Protocol` to define lightweight behavioral interfaces: + +```python +from typing import Protocol + +class DataRepository(Protocol): + def get_by_id(self, item_id: str) -> dict | None: ... + def save(self, item_id: str, data: dict) -> None: ... +``` + +A mock class or real database driver satisfies `DataRepository` without subclassing, +making unit tests fast and decoupling domain logic from storage implementations. + +## 2. Strong boundary contracts with TypedDict and Pydantic + +When dealing with untrusted JSON from external APIs, use `typing.TypedDict` for internal +shapes and [Pydantic documentation](https://pydantic.dev/docs/validation/latest/get-started/) +models for boundary deserialization and validation. + +```python +from typing import TypedDict + +class UserProfile(TypedDict): + user_id: int + username: str + is_active: bool +``` + +## 3. Generic types for reusable containers + +Use `typing.TypeVar` or modern PEP 695 generics (`class Container[T]: ...` in Python 3.12+) +to retain type safety through caches, queues, and API wrappers rather than falling back to `Any`. + +## 4. Static verification in development and CI + +Type annotations provide value only when enforced. Run static type checkers like +[Mypy documentation](https://mypy.readthedocs.io/en/stable/) or Pyright in your CI pipeline, +and configure pre-commit hooks to block untyped public APIs. + +Refer to the official [Python typing documentation](https://typing.python.org/en/latest/) +for authoritative reference on Python's type system specifications. + +--- + +Continue this topic on [flypython.com](https://flypython.com/learn/modern-python-typing): the guided reading path, prerequisites, related checklists, and current review dates. diff --git a/guides/python-engineering/modern-typing_cn.md b/guides/python-engineering/modern-typing_cn.md new file mode 100644 index 0000000..756b290 --- /dev/null +++ b/guides/python-engineering/modern-typing_cn.md @@ -0,0 +1,60 @@ +--- +id: python-modern-typing +type: guide +title: 现代 Python 类型系统实战 +summary: 将明确的 Python 类型作为机器可读的契约,指导可靠的 AI 辅助编程与工程交付。 +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-02 +--- + +# 现代 Python 类型系统实战 + +在 AI Coding 时代,类型注解不是可有可无的装饰。它们是机器可解析的边界契约,能够有效约束大模型的 +代码生成范围,并在代码运行前拦截逻辑倒退。 + +## 1. 使用 Protocol 实现结构化子类型(鸭子类型契约) + +避免为测试桩或服务适配器构建繁重的类继承层级。使用 `typing.Protocol` 定义轻量级的行为接口: + +```python +from typing import Protocol + +class DataRepository(Protocol): + def get_by_id(self, item_id: str) -> dict | None: ... + def save(self, item_id: str, data: dict) -> None: ... +``` + +无论是测试用的 Mock 对象还是真实的数据库适配器,只要签名匹配即可满足 `DataRepository` 契约, +使单元测试运行极快,并解耦业务逻辑与存储实现。 + +## 2. 使用 TypedDict 与 Pydantic 建立严格边界契约 + +处理来自外部不可信的 JSON 数据时,内部使用 `typing.TypedDict` 描述确定性结构,在系统网络边界处 +使用 [Pydantic documentation](https://pydantic.dev/docs/validation/latest/get-started/) 进行反序列化和严格校验。 + +```python +from typing import TypedDict + +class UserProfile(TypedDict): + user_id: int + username: str + is_active: bool +``` + +## 3. 使用泛型保留容器与管道的类型信息 + +使用 `typing.TypeVar` 或现代 Python 3.12+ 泛型语法(如 `class Container[T]: ...`),确保在缓存层、 +任务队列与 API 包装器中保持类型连续性,避免退化为无约束的 `Any`。 + +## 4. 在开发与 CI 流程中进行静态验证 + +类型注解只有在被强制检查时才具备确定性价值。在 CI 流水线中运行 [Mypy documentation](https://mypy.readthedocs.io/en/stable/) +或 Pyright 等静态类型检查器,并配置 pre-commit 钩子拦截未标注类型的公共 API。 + +更多类型系统权威规范可参考官方 [Python typing documentation](https://typing.python.org/en/latest/)。 + +--- + +在 [flypython.com](https://flypython.com/learn/zh/modern-python-typing) 继续这个主题:配套的导读路径、前置条件、相关清单与最新审核日期。 diff --git a/guides/python-engineering/product-quality.md b/guides/python-engineering/product-quality.md new file mode 100644 index 0000000..34a735f --- /dev/null +++ b/guides/python-engineering/product-quality.md @@ -0,0 +1,72 @@ +--- +id: python-product-quality +type: guide +title: Build a Python Product That Can Be Changed Safely +summary: A practical quality model for moving from a useful script to an operable Python product. +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-02 +--- + +# Build a Python Product That Can Be Changed Safely + +A good Python product is not defined by framework choice or code volume. It +solves a clear user problem, behaves predictably at its boundaries, and can be +changed without gambling with user data or production behavior. + +## 1. Begin with one user outcome + +Write a concrete sentence: “Given this input, this user can obtain this result.” +Turn it into acceptance examples before choosing a framework. If the result +cannot be observed, the product requirement is not yet testable. + +## 2. Make boundaries explicit + +Keep domain logic independent from HTTP, databases, files, model providers, and +other APIs. Validate untrusted input at those boundaries, return stable error +forms, and set timeouts for every network call. + +## 3. Design failure as carefully as success + +Decide which operations may be retried, which must be idempotent, and which need +human confirmation. Never hide partial failure. Preserve enough context for a +user or operator to recover without guessing. + +## 4. Test the contract at several levels + +Use fast unit tests for domain rules, integration tests for boundaries, and a +small number of end-to-end checks for the real user path. A green test suite is +evidence for only the behavior it actually exercises. + +## 5. Make operation visible + +Use structured logs, request or job identifiers, useful health checks, and +metrics tied to user outcomes. Do not log secrets, raw credentials, or sensitive +payloads. An operator should be able to answer what failed, for whom, and where. + +## 6. Release a reversible change + +Pin runtime and dependencies, document configuration, separate schema changes +from application rollout when needed, and define rollback before deployment. +Verify the deployed behavior through the real interface, not only build output. + +## 7. Treat AI, agents, skills, MCP, and APIs as boundaries + +Model output is untrusted input. Give tools the minimum permissions they need, +validate structured output, cap time and cost, record tool calls, and require +human approval for irreversible actions. A Skill or MCP server improves reuse; +it does not remove the need for authentication, authorization, tests, and audit. + +## Definition of done + +- A user outcome and its failure behavior are documented. +- Boundary inputs and outputs are typed and validated. +- The main behavior and recovery path have tests. +- Logs and health signals answer actionable questions without leaking secrets. +- The release and rollback commands are known. +- The production behavior has been checked through the real user path. + +--- + +Continue this topic on [flypython.com](https://flypython.com/learn/python-product-quality): the guided reading path, prerequisites, related checklists, and current review dates. diff --git a/guides/python-engineering/product-quality_cn.md b/guides/python-engineering/product-quality_cn.md new file mode 100644 index 0000000..7dbcd13 --- /dev/null +++ b/guides/python-engineering/product-quality_cn.md @@ -0,0 +1,64 @@ +--- +id: python-product-quality +type: guide +title: 构建能够持续修改的 Python 产品 +summary: 从有用脚本走向可运行、可维护 Python 产品的一套实用质量模型。 +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-02 +--- + +# 构建能够持续修改的 Python 产品 + +好的 Python 产品不取决于框架选择或代码数量。它要解决清楚的用户问题,在系统边界上 +行为稳定,并且能持续修改,而不是拿用户数据和生产环境碰运气。 + +## 1. 从一个用户结果开始 + +先写一句具体的话:“给定这个输入,这类用户可以得到这个结果。”在选择框架前把它变成 +验收示例。如果结果无法观察,产品要求就还不能测试。 + +## 2. 明确系统边界 + +让领域逻辑独立于 HTTP、数据库、文件、模型供应商和其他 API。在边界验证不可信输入, +返回稳定的错误结构,并给每次网络调用设置超时。 + +## 3. 像设计成功一样设计失败 + +提前决定哪些操作可以重试、哪些必须幂等、哪些需要人工确认。不要隐藏部分失败,要保留 +足够上下文,让用户或运维人员不必猜测就能恢复。 + +## 4. 分层验证契约 + +用快速单元测试覆盖领域规则,用集成测试覆盖边界,用少量端到端测试覆盖真实用户路径。 +测试全绿只证明被实际执行的行为,不能证明没有覆盖到的部分。 + +## 5. 让运行状态可见 + +使用结构化日志、请求或任务 ID、有意义的健康检查,以及与用户结果相关的指标。不要记录 +密钥、原始凭据或敏感数据。发生失败时,应能回答哪里失败、影响谁、如何恢复。 + +## 6. 发布可回退的修改 + +固定运行时和依赖,记录配置;必要时把数据结构变更和应用发布拆开;部署前先定义回退方式。 +最后要通过真实用户接口验证线上行为,而不是只相信构建或部署输出。 + +## 7. 把 AI、Agent、Skill、MCP 和 API 都当作边界 + +模型输出也是不可信输入。工具只获得完成任务所需的最小权限;结构化输出必须验证;时间和 +成本需要上限;工具调用要可追踪;不可逆操作应要求人工确认。Skill 或 MCP server 提高了 +复用能力,但不能替代认证、授权、测试与审计。 + +## 完成标准 + +- 用户结果和失败行为已经记录; +- 边界输入输出有类型并经过验证; +- 主要行为和恢复路径都有测试; +- 日志和健康信号能回答行动问题且不泄漏秘密; +- 已知发布与回退命令; +- 已通过真实用户路径检查生产行为。 + +--- + +在 [flypython.com](https://flypython.com/learn/zh/python-product-quality) 继续这个主题:配套的导读路径、前置条件、相关清单与最新审核日期。 diff --git a/llms.txt b/llms.txt new file mode 100644 index 0000000..e9d7ebc --- /dev/null +++ b/llms.txt @@ -0,0 +1,49 @@ +# FlyPython Repository Index for LLMs and AI Agents + +> FlyPython provides production-tested Python engineering playbooks, challenge courses with objective verification and per-checkpoint claim codes, verifiable test-driven starter examples, task contracts, and agent IDE rules. + +## Quick API and Automation Commands + +- Run full test suite with uv: `uv run 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` +- Discover Radar review candidates: `python tools/radar_scan.py --dry-run` +- Structured Content Manifest: `content-manifest.json` +- Full curated catalog data: `catalog.json` +- Project Radar data: `radar.json` + +## Machine-Readable Directories + +- `catalog/resources/`: YAML files representing verified libraries, tools, and platforms. +- `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/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/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). + - `courses/da-report/`: Turn a verified EDA summary JSON into a rendered report with stdlib-only code and deterministic verification (EN+ZH). +- `examples/`: Reproducible test projects with failing starters and verified solutions: + - `examples/product-slug/`: String normalization edge cases and regex safety. + - `examples/pydantic-validation/`: Model boundaries and custom validators. + - `examples/mcp-server/`: JSON-RPC 2.0 Model Context Protocol tool server with schema validation. + - `examples/async-fetcher/`: Async concurrency control with Semaphore and backoff retry. + - `examples/structured-pipeline/`: Batch data pipeline with error isolation and schema checking. +- `templates/`: + - `templates/AGENT_RULES.example.md`: Universal rule set for Cursor (.cursorrules), Windsurf, and Copilot. + - `templates/AGENT_RULES.example_cn.md`: Universal rule set in Chinese. + - `templates/AGENTS.example.md`: Repository instruction policy for coding agents. + - `templates/AGENTS.example_cn.md`: Repository instruction policy in Chinese. + - `templates/TASK_CONTRACT.md`: Scoped specification template for coding agents. + - `templates/IMPLEMENTATION_PLAN.md`: Implementation plan template. + - `templates/CODE_REVIEW.md`: Code change review template. + - `templates/VERIFICATION.md`: Evidence record for reproducible tests. + +## Official Portal + +- Official Website: https://flypython.com +- Website LLMs Index: https://flypython.com/llms.txt +- Complete Text Knowledge Dump: https://flypython.com/llms-full.txt diff --git a/paths/README.md b/paths/README.md new file mode 100644 index 0000000..21af3b5 --- /dev/null +++ b/paths/README.md @@ -0,0 +1,31 @@ +# Learning paths + +Paths sequence challenge courses into badge routes. Each path lives in its +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), + 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 + optional extra credit (10 points) — not required for the badge. +- [Data analysis with an agent](data-analysis/) — clean, explore, visualize, + and report on a real dataset. Modules: the `da-eda`, `da-visualization`, + and `da-report` courses plus an end-to-end capstone (200 points). + +## Conventions + +- `path.json` is the contract: module order, course references, points, and + badge definitions; `optional: true` marks extra-credit modules that do not + count toward the badge. `python tools/verify_paths.py` checks every path + against the courses that actually exist in `courses/`. +- Module documents (`modules/*.md`) always ship with a `_cn.md` pair in the + same change. +- Route capstones carry their own `TASK.md`/`TASK_cn.md`, starter inputs, a + reviewed `solution/`, and a `verify.py` that gates the route badge. +- Points come only from checkpoints verified by `verify.py` and recorded + with claim codes; badges are self-reported local evidence, never + certificates. + +Continue the guided experience on [flypython.com](https://flypython.com/). diff --git a/paths/README_cn.md b/paths/README_cn.md new file mode 100644 index 0000000..d1356f0 --- /dev/null +++ b/paths/README_cn.md @@ -0,0 +1,27 @@ +# 学习路线 + +学习路线把挑战课程串成徽章路线。每条路线一个文件夹,包含机器可校验的 +`path.json` 契约与双语的模块、挑战文档。 + +- [Agent 工具基础](foundation/)——驱动一门编码 Agent(Claude Code、 + Codex、Cursor、DeepSeek Harness、Kimi Code、ZCode 六选一),写出单一 + 真源规则文件,并亲手验证 Agent 的产出。模块:六选一工具课、规则与 + 验证两门课,以及一个双 Agent 路线挑战(200 分)。入门总览模块为 + 可选加分项(10 分)——不计入徽章。 +- [用 Agent 做数据分析](data-analysis/)——对真实数据集完成清洗、探索、 + 可视化与报告。模块:`da-eda`、`da-visualization`、`da-report` 三门课程, + 外加一个端到端综合项目(200 分)。 + +## 约定 + +- `path.json` 是契约:模块顺序、课程引用、积分与徽章定义; + `optional: true` 标记不计入徽章的加分模块。 + `python tools/verify_paths.py` 会把每条路线与 `courses/` 中实际存在的 + 课程对照校验。 +- 模块文档(`modules/*.md`)必须在同一次变更中附带 `_cn.md` 配对。 +- 路线综合项目自带 `TASK.md`/`TASK_cn.md`、starter 输入、审核过的 + `solution/` 与守卫路线徽章的 `verify.py`。 +- 积分只来自 `verify.py` 判卷通过并经认领码记录的检查点;徽章是 + 自我报告的本地证据,绝不是证书。 + +引导式体验可继续在 [flypython.com](https://flypython.com/) 上进行。 diff --git a/paths/data-analysis/capstone/TASK.md b/paths/data-analysis/capstone/TASK.md new file mode 100644 index 0000000..537eefc --- /dev/null +++ b/paths/data-analysis/capstone/TASK.md @@ -0,0 +1,33 @@ +# Capstone task: verified analysis of a real dataset + +**Points:** 200 · **Badge requirement:** Data Analysis Agent + +End to end, with your agent as the tool: take +`dataset/orders.csv` (303 messy rows) through clean → explore → report. + +## Required output + +Your pipeline (any structure you choose) must write: + +- `out/results.json` with keys: + - `rows_total`, `duplicates_removed`, `rows_clean` + - `total_revenue` (2 decimals), `avg_order_value` (2 decimals), + `total_quantity` + - `revenue_by_region` — `{region: total}` at 2 decimals + - `top_category` — category with the highest total revenue + - `first_date`, `last_date` — ISO dates +- `out/report.md` containing the sections `## Data quality`, + `## Findings`, `## Appendix` and stating the verified total revenue. + +## Data quality rules + +Drop exact duplicates; drop rows with unparseable `amount`, unparseable +`date`, or blank `region`. Everything else stays. + +## Verification + +`python verify.py` checks `out/` against ground truth (tolerance 0.01) +and prints the capstone claim code on success. Reference implementation: +`solution/capstone.py` — look at it only after your own attempt. + +Self-reported evidence, never a certificate. diff --git a/paths/data-analysis/capstone/TASK_cn.md b/paths/data-analysis/capstone/TASK_cn.md new file mode 100644 index 0000000..bfeb789 --- /dev/null +++ b/paths/data-analysis/capstone/TASK_cn.md @@ -0,0 +1,32 @@ +# 综合项目任务:真实数据集的验证分析 + +**积分:** 200 · **徽章要求:** 数据分析 Agent + +端到端跑一遍,Agent 是你的工具:把 `dataset/orders.csv` +(303 行脏数据)走完 清洗 → 探索 → 报告。 + +## 要求的产出 + +你的流水线(结构自定)必须写出: + +- `out/results.json`,包含键: + - `rows_total`、`duplicates_removed`、`rows_clean` + - `total_revenue`(两位小数)、`avg_order_value`(两位小数)、 + `total_quantity` + - `revenue_by_region`——`{region: 总额}`,两位小数 + - `top_category`——总收入最高的品类 + - `first_date`、`last_date`——ISO 日期 +- `out/report.md`,包含 `## Data quality`、`## Findings`、 + `## Appendix` 三节,并写出已验证的总收入数字。 + +## 数据质量规则 + +删除完全重复行;删除 `amount` 不可解析、`date` 不可解析、或 +`region` 为空的行。其余保留。 + +## 验证 + +`python verify.py` 用真值校验 `out/`(容差 0.01),通过时打印综合 +项目认领码。参考实现:`solution/capstone.py`——自己尝试过之后再看。 + +自我报告的证据,绝非证书。 diff --git a/paths/data-analysis/capstone/dataset/orders.csv b/paths/data-analysis/capstone/dataset/orders.csv new file mode 100644 index 0000000..fe65e72 --- /dev/null +++ b/paths/data-analysis/capstone/dataset/orders.csv @@ -0,0 +1,304 @@ +order_id,date,region,category,amount,quantity +R00001,2026-05-27,West,Clothing,460.69,1 +R00002,2026-06-17,East,Clothing,656.12,4 +R00003,2026-06-04,East,Books,296.66,3 +R00004,2026-05-22,South,Books,630.7,2 +R00005,2026-06-02,East,Clothing,382.17,8 +R00006,2026-05-03,West,Books,167.69,6 +R00007,2026-05-16,West,Electronics,700.16,3 +R00008,2026-05-19,North,Home,,3 +R00009,2026-05-31,West,Sports,87.85,8 +R00010,2026-05-22,West,Clothing,285.1,5 +R00011,2026-05-31,North,Books,743.32,7 +R00012,2026-05-05,North,Clothing,631.96,8 +R00013,2026-05-18,East,Sports,513.72,5 +R00014,2026-06-15,South,Sports,300.61,1 +R00015,2026-05-24,South,Clothing,349.23,6 +R00016,2026-05-21,West,Home,618.13,7 +R00017,2026-05-09,South,Books,376.5,6 +R00018,2026-05-08,East,Books,590.64,2 +R00019,2026-06-14,West,Home,90.52,8 +R00020,2026-06-04,South,Sports,163.13,8 +R00021,2026-05-21,North,Sports,118.97,2 +R00022,2026-06-05,East,Sports,324.64,6 +R00023,2026-06-22,South,Books,442.72,3 +R00024,2026-06-07,East,Clothing,453.16,3 +R00025,2026-05-16,North,Clothing,330.92,1 +R00026,2026-05-03,East,Home,258.95,2 +R00027,2026-06-15,South,Home,446.3,3 +R00028,2026-05-06,West,Home,41.0,3 +R00029,2026-05-05,East,Electronics,145.94,2 +R00030,2026-06-08,West,Home,571.3,6 +R00031,2026-05-14,West,Home,671.28,7 +R00032,2026-05-27,South,Home,635.21,1 +R00033,2026-05-27,West,Sports,636.65,3 +R00034,2026-06-20,South,Electronics,726.72,6 +R00035,2026-05-06,South,Sports,37.04,5 +R00036,2026-06-26,East,Home,237.05,4 +R00037,2026-05-21,South,Clothing,767.45,1 +R00038,2026-05-28,West,Clothing,116.71,3 +R00039,2026-05-29,East,Clothing,487.24,4 +R00040,2026-05-23,West,Home,296.46,4 +R00041,2026-06-18,South,Home,208.84,4 +R00042,2026-06-18,North,Electronics,434.54,1 +R00043,2026-06-19,West,Clothing,302.57,3 +R00044,2026-06-01,West,Home,364.04,7 +R00045,2026-05-27,South,Sports,error,6 +R00046,2026-05-27,South,Electronics,664.9,7 +R00047,2026-06-14,East,Clothing,62.42,4 +R00048,2026-06-16,East,Books,181.82,7 +R00049,2026-05-13,East,Electronics,319.59,2 +R00050,2026-05-23,East,Home,157.29,1 +R00051,2026-06-02,South,Books,254.17,1 +R00052,2026-05-16,North,Home,168.78,2 +R00053,2026-06-29,North,Books,489.13,1 +R00054,2026-05-25,South,Electronics,578.67,4 +R00055,2026-06-14,West,Clothing,371.55,7 +R00056,2026-05-19,North,Clothing,39.66,1 +R00057,2026-06-29,North,Sports,757.96,5 +R00058,2026-05-07,East,Electronics,670.6,2 +R00059,2026-05-19,West,Electronics,339.74,6 +R00060,2026-05-20,West,Clothing,20.86,7 +R00061,2026-05-26,East,Clothing,543.27,5 +R00062,2026-05-19,South,Books,270.54,5 +R00063,2026-05-31,West,Clothing,321.09,4 +R00064,2026-06-06,North,Electronics,355.75,1 +R00065,2026-05-02,East,Sports,390.96,1 +R00066,2026-05-08,East,Sports,75.43,4 +R00067,2026-05-03,South,Electronics,65.39,2 +R00068,2026-06-14,East,Sports,72.23,7 +R00069,2026-05-25,West,Clothing,73.16,6 +R00070,2026-05-20,East,Clothing,472.0,1 +R00071,2026-05-30,West,Books,49.37,8 +R00072,2026-05-18,South,Home,131.27,4 +R00073,2026-06-07,East,Clothing,15.9,1 +R00074,2026-06-09,East,Sports,123.22,2 +R00075,2026-05-23,West,Electronics,151.16,7 +R00076,2026-06-19,South,Home,361.83,3 +R00077,2026-06-22,North,Books,116.39,7 +R00078,2026-06-01,North,Home,799.12,7 +R00079,2026-05-16,East,Clothing,547.27,6 +R00080,2026-06-13,South,Sports,458.43,4 +R00081,2026-05-04,North,Clothing,462.13,5 +R00082,2026-05-22,South,Clothing,428.47,5 +R00083,2026-06-09,East,Sports,421.72,3 +R00084,2026-05-04,North,Books,561.72,3 +R00085,2026-06-24,South,Home,252.73,1 +R00086,2026-05-02,East,Clothing,353.6,7 +R00087,2026-06-26,South,Books,284.88,6 +R00088,2026-06-19,East,Sports,259.29,3 +R00089,2026-05-27,East,Electronics,,2 +R00090,2026-06-25,South,Electronics,325.81,8 +R00091,2026-05-27,East,Books,253.36,5 +R00092,2026-06-16,South,Clothing,43.61,1 +R00093,2026-06-29,West,Sports,196.87,8 +R00094,2026-05-05,East,Electronics,172.98,7 +R00095,2026-06-27,West,Electronics,59.0,6 +R00096,2026-06-18,East,Home,143.7,1 +R00097,2026-06-08,West,Clothing,591.51,6 +R00098,2026-06-07,West,Home,521.48,4 +R00099,2026-05-04,West,Clothing,798.18,6 +R00100,2026-05-10,East,Clothing,276.26,2 +R00101,2026-06-07,North,Electronics,440.31,2 +R00102,2026-05-02,South,Sports,500.4,8 +R00103,2026-05-23,East,Clothing,612.94,5 +R00104,2026-06-20,North,Clothing,157.03,2 +R00105,2026-06-10,East,Sports,719.13,6 +R00106,2026-05-18,West,Clothing,429.05,1 +R00107,2026-06-19,West,Books,153.78,3 +R00108,2026-05-19,East,Electronics,232.05,2 +R00109,2026-06-11,North,Sports,195.13,6 +R00110,2026-05-23,West,Electronics,102.51,7 +R00111,2026-06-30,East,Home,389.92,3 +R00112,2026-06-24,South,Home,781.14,1 +R00113,2026-06-25,North,Clothing,206.51,7 +R00114,2026-06-21,East,Sports,283.77,8 +R00115,2026-05-09,East,Electronics,73.19,3 +R00116,2026-06-01,West,Electronics,236.1,1 +R00117,2026-06-15,South,Sports,443.52,8 +R00118,2026-06-13,West,Sports,135.32,1 +R00119,2026-06-18,South,Clothing,296.56,5 +R00120,2026-05-08,South,Books,547.0,3 +R00121,2026-13-45,East,Books,85.4,6 +R00122,2026-05-16,East,Electronics,512.45,5 +R00123,2026-05-24,East,Electronics,443.98,4 +R00124,2026-05-31,North,Electronics,494.62,1 +R00125,2026-06-20,South,Sports,394.06,1 +R00126,2026-05-13,South,Books,150.7,7 +R00127,2026-05-06,North,Electronics,546.35,2 +R00128,2026-06-10,South,Home,378.54,4 +R00129,2026-06-21,North,Clothing,113.15,4 +R00130,2026-06-09,South,Books,546.15,5 +R00131,2026-05-21,East,Home,172.09,1 +R00132,2026-05-06,South,Clothing,30.91,1 +R00133,2026-05-09,East,Sports,786.8,5 +R00134,2026-06-01,North,Home,322.08,6 +R00135,2026-06-03,West,Electronics,741.16,7 +R00136,2026-06-13,West,Sports,287.95,6 +R00137,2026-05-28,East,Electronics,704.39,6 +R00138,2026-06-04,South,Books,415.15,6 +R00139,2026-06-06,East,Home,641.92,7 +R00140,2026-05-17,East,Clothing,315.25,1 +R00141,2026-05-19,East,Books,357.1,7 +R00142,2026-05-01,South,Home,50.35,1 +R00143,2026-05-26,South,Home,646.31,3 +R00144,2026-06-06,South,Books,176.46,7 +R00145,2026-06-02,South,Clothing,517.1,6 +R00146,2026-06-18,West,Electronics,512.78,4 +R00147,2026-06-20,South,Sports,29.79,5 +R00148,2026-06-07,East,Clothing,67.05,8 +R00149,2026-06-06,East,Home,433.71,7 +R00150,2026-05-03,East,Clothing,298.32,8 +R00151,2026-05-07,West,Clothing,798.22,7 +R00152,2026-06-02,North,Home,330.15,8 +R00153,2026-06-17,East,Books,186.53,4 +R00154,2026-06-25,North,Home,544.3,4 +R00155,2026-06-11,East,Clothing,176.94,2 +R00156,2026-05-15,North,Home,546.97,5 +R00157,2026-05-14,North,Electronics,689.12,3 +R00158,2026-06-30,South,Books,366.6,1 +R00159,2026-05-17,East,Books,515.21,2 +R00160,2026-06-03,West,Home,114.38,1 +R00161,2026-05-10,South,Electronics,105.68,2 +R00162,2026-05-27,South,Home,687.53,3 +R00163,2026-06-20,North,Books,377.69,4 +R00164,2026-06-10,North,Electronics,473.48,3 +R00165,2026-05-24,West,Home,269.07,6 +R00166,2026-05-17,West,Electronics,673.72,2 +R00167,2026-06-19,North,Clothing,266.79,4 +R00168,2026-05-29,South,Sports,206.3,3 +R00169,2026-05-29,East,Electronics,597.49,3 +R00170,2026-06-06,West,Clothing,268.43,4 +R00171,2026-05-10,East,Clothing,239.11,3 +R00172,2026-05-18,West,Books,583.79,2 +R00173,2026-05-23,East,Home,168.76,2 +R00174,2026-06-17,West,Clothing,249.4,7 +R00175,2026-05-08,West,Sports,546.69,5 +R00176,2026-05-03,East,Sports,202.03,6 +R00177,2026-06-08,North,Electronics,30.45,3 +R00178,2026-05-29,West,Electronics,110.32,2 +R00179,2026-05-26,South,Sports,591.74,7 +R00180,2026-05-03,North,Electronics,724.66,1 +R00181,2026-05-28,East,Clothing,84.88,2 +R00182,2026-05-05,North,Electronics,518.74,2 +R00183,2026-05-10,South,Clothing,330.54,4 +R00184,2026-06-11,East,Clothing,724.02,8 +R00185,2026-05-26,South,Sports,585.35,4 +R00186,2026-05-19,East,Clothing,135.82,1 +R00187,2026-05-31,West,Sports,543.84,5 +R00188,2026-06-22,North,Electronics,27.11,8 +R00189,2026-05-26,West,Sports,236.38,7 +R00190,2026-06-08,East,Home,297.72,6 +R00191,2026-05-15,West,Electronics,223.3,7 +R00192,2026-05-31,West,Electronics,374.32,4 +R00193,2026-05-03,North,Home,296.44,6 +R00194,2026-06-24,South,Home,463.56,6 +R00195,2026-06-06,West,Home,690.94,7 +R00196,2026-06-21,West,Books,139.36,1 +R00197,2026-06-22,East,Sports,267.76,5 +R00198,2026-06-11,North,Clothing,783.12,3 +R00199,2026-06-26,East,Books,656.92,5 +R00200,2026-05-02,South,Electronics,,6 +R00201,2026-06-14,North,Home,561.19,4 +R00202,2026-06-14,West,Home,439.27,8 +R00203,2026-06-15,West,Sports,699.88,7 +R00204,2026-06-16,West,Home,94.9,5 +R00205,2026-05-01,South,Sports,68.64,1 +R00206,2026-06-11,West,Clothing,62.95,8 +R00207,2026-05-09,South,Books,553.9,2 +R00208,2026-06-24,East,Clothing,177.5,7 +R00209,2026-06-09,North,Books,112.22,6 +R00210,2026-06-29,West,Clothing,100.89,1 +R00211,2026-05-16,East,Sports,502.62,7 +R00212,2026-06-21,West,Electronics,533.98,1 +R00213,2026-05-19,East,Sports,33.78,2 +R00214,2026-05-15,West,Electronics,93.7,3 +R00215,2026-05-20,North,Home,548.85,7 +R00216,2026-05-26,North,Home,603.2,4 +R00217,2026-06-18,East,Clothing,690.38,7 +R00218,2026-06-18,South,Clothing,17.12,1 +R00219,2026-06-23,East,Sports,367.23,4 +R00220,2026-06-26,South,Home,637.95,1 +R00221,2026-06-17,North,Sports,245.47,4 +R00222,2026-06-09,West,Sports,606.49,6 +R00223,2026-05-01,South,Sports,430.45,4 +R00224,2026-05-30,West,Electronics,743.58,2 +R00225,2026-06-18,West,Books,133.67,5 +R00226,2026-05-24,East,Electronics,547.31,4 +R00227,2026-05-15,East,Sports,473.84,4 +R00228,2026-06-29,West,Books,309.31,1 +R00229,2026-06-27,East,Sports,287.19,1 +R00230,2026-05-22,West,Books,46.24,6 +R00231,2026-05-01,West,Home,360.48,1 +R00232,2026-05-18,East,Home,469.22,8 +R00233,2026-05-23,South,Electronics,104.8,4 +R00234,2026-05-22,South,Books,339.57,5 +R00235,2026-05-18,East,Books,319.96,3 +R00236,2026-06-10,East,Electronics,662.98,3 +R00237,2026-05-29,West,Electronics,296.9,8 +R00238,2026-05-29,East,Home,563.86,6 +R00239,2026-05-22,North,Books,26.96,5 +R00240,2026-05-30,West,Sports,393.79,5 +R00241,2026-06-12,West,Home,223.34,7 +R00242,2026-06-17,East,Electronics,500.94,1 +R00243,2026-06-26,West,Books,299.25,5 +R00244,2026-05-05,West,Sports,548.23,5 +R00245,2026-06-03,South,Books,730.69,2 +R00246,2026-06-26,North,Clothing,465.81,5 +R00247,2026-05-27,North,Home,32.19,8 +R00248,2026-05-19,West,Clothing,619.43,8 +R00249,2026-05-26,South,Clothing,156.77,3 +R00250,2026-06-14,North,Home,484.8,6 +R00251,2026-05-02,,Books,513.02,3 +R00252,2026-06-07,South,Books,177.47,2 +R00253,2026-06-27,East,Clothing,262.63,1 +R00254,2026-06-20,East,Books,777.34,8 +R00255,2026-06-23,South,Clothing,19.05,2 +R00256,2026-05-12,North,Clothing,351.25,3 +R00257,2026-06-11,East,Home,275.41,8 +R00258,2026-06-09,West,Sports,275.82,2 +R00259,2026-05-01,East,Clothing,492.81,5 +R00260,2026-05-11,North,Home,753.32,3 +R00261,2026-06-08,North,Electronics,256.21,7 +R00262,2026-06-28,East,Books,429.65,7 +R00263,2026-06-02,South,Electronics,592.76,3 +R00264,2026-06-20,North,Sports,343.22,7 +R00265,2026-05-15,West,Home,72.36,5 +R00266,2026-05-03,North,Electronics,660.62,1 +R00267,2026-06-25,East,Books,397.67,4 +R00268,2026-05-29,East,Clothing,764.49,3 +R00269,2026-06-22,North,Electronics,342.66,6 +R00270,2026-05-21,North,Electronics,369.71,2 +R00271,2026-05-06,South,Clothing,798.56,3 +R00272,2026-06-08,South,Electronics,400.73,3 +R00273,2026-05-08,East,Sports,386.9,5 +R00274,2026-05-04,West,Clothing,205.2,4 +R00275,2026-06-21,South,Electronics,280.81,6 +R00276,2026-05-30,South,Sports,716.89,7 +R00277,2026-06-11,East,Home,497.85,5 +R00278,2026-06-27,South,Sports,799.66,7 +R00279,2026-06-23,West,Electronics,709.32,3 +R00280,2026-05-24,East,Clothing,777.5,8 +R00281,2026-06-11,West,Home,207.42,8 +R00282,2026-06-22,East,Books,690.42,8 +R00283,2026-06-09,North,Sports,644.62,8 +R00284,2026-05-26,South,Home,645.47,6 +R00285,2026-05-11,East,Clothing,34.35,8 +R00286,2026-06-20,East,Books,284.87,8 +R00287,2026-05-03,East,Sports,388.56,1 +R00288,2026-05-26,East,Books,73.61,8 +R00289,2026-05-04,East,Clothing,477.44,6 +R00290,2026-06-23,North,Clothing,331.19,2 +R00291,2026-05-25,South,Sports,397.77,7 +R00292,2026-05-15,South,Electronics,627.25,6 +R00293,2026-06-24,East,Electronics,266.63,5 +R00294,2026-05-12,South,Sports,131.99,5 +R00295,2026-05-01,South,Sports,748.64,3 +R00296,2026-05-17,South,Books,597.78,5 +R00297,2026-06-25,North,Home,626.09,1 +R00298,2026-05-26,South,Sports,254.27,5 +R00299,2026-05-03,West,Clothing,107.8,2 +R00300,2026-05-26,North,Sports,147.51,5 +R00031,2026-05-14,West,Home,671.28,7 +R00031,2026-05-14,West,Home,671.28,7 +R00151,2026-05-07,West,Clothing,798.22,7 diff --git a/paths/data-analysis/capstone/requirements.txt b/paths/data-analysis/capstone/requirements.txt new file mode 100644 index 0000000..5728f51 --- /dev/null +++ b/paths/data-analysis/capstone/requirements.txt @@ -0,0 +1 @@ +pandas==2.3.3 diff --git a/paths/data-analysis/capstone/solution/capstone.py b/paths/data-analysis/capstone/solution/capstone.py new file mode 100644 index 0000000..c343c70 --- /dev/null +++ b/paths/data-analysis/capstone/solution/capstone.py @@ -0,0 +1,82 @@ +"""Capstone reference pipeline — reviewed solution. + +Reads dataset/orders.csv and writes out/results.json + out/report.md. +See TASK.md for the contract. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pandas as pd + +ROOT = Path(__file__).resolve().parent.parent +DATASET = ROOT / "dataset" / "orders.csv" +OUT_DIR = ROOT / "out" + + +def load_and_clean(path): + df = pd.read_csv(path) + stats = {"rows_total": int(len(df)), + "duplicates_removed": int(df.duplicated().sum())} + df = df.drop_duplicates().copy() + df["amount"] = pd.to_numeric(df["amount"], errors="coerce") + df["date"] = pd.to_datetime(df["date"], errors="coerce") + df["region"] = df["region"].astype("string").str.strip() + df.loc[df["region"].isin(["", "nan", "NaN", "<NA>"]), "region"] = pd.NA + clean = df.dropna(subset=["amount", "date", "region"]) + stats["rows_removed"] = stats["rows_total"] - stats["duplicates_removed"] - len(clean) + stats["rows_clean"] = int(len(clean)) + return clean, stats + + +def summarize(clean): + by_region = clean.groupby("region")["amount"].sum().round(2) + by_cat = clean.groupby("category")["amount"].sum() + return { + "total_revenue": round(float(clean["amount"].sum()), 2), + "avg_order_value": round(float(clean["amount"].mean()), 2), + "total_quantity": int(clean["quantity"].sum()), + "revenue_by_region": {str(k): float(v) for k, v in by_region.items()}, + "top_category": str(by_cat.idxmax()), + "first_date": clean["date"].min().date().isoformat(), + "last_date": clean["date"].max().date().isoformat(), + } + + +def render_report(results): + lines = [ + "# Capstone analysis report", + "", + "## Data quality", + f"{results['rows_clean']} of {results['rows_total']} rows survived " + f"cleaning ({results['duplicates_removed']} duplicates, " + f"{results['rows_removed']} invalid).", + "", + "## Findings", + f"Total revenue {results['total_revenue']:.2f} over " + f"{results['total_quantity']} units; top category " + f"{results['top_category']}; period {results['first_date']} to " + f"{results['last_date']}.", + "", + "## Appendix", + "Numbers verified against the dataset by verify.py; prose is not verified.", + "", + ] + return "\n".join(lines) + + +def main(argv=None): + clean, stats = load_and_clean(DATASET) + results = {**stats, **summarize(clean)} + OUT_DIR.mkdir(parents=True, exist_ok=True) + (OUT_DIR / "results.json").write_text( + json.dumps(results, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + (OUT_DIR / "report.md").write_text(render_report(results), encoding="utf-8") + print(f"rows_clean={results['rows_clean']} total_revenue={results['total_revenue']}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/paths/data-analysis/capstone/verify.py b/paths/data-analysis/capstone/verify.py new file mode 100644 index 0000000..4e66f6c --- /dev/null +++ b/paths/data-analysis/capstone/verify.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Verify the data-analysis capstone output. + +Checks out/results.json against dataset ground truth (tolerance 0.01) +and out/report.md for the required sections. Prints the capstone claim +code on success — self-reported evidence for flypython.com, never a +certificate. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent +OUT_DIR = ROOT / "out" + +CHALLENGE_ID = "capstone-data-analysis" +COURSE_SALT = "f1a2b3c4d5e6f7a8" + +EXPECTED = { + "rows_total": 303, + "duplicates_removed": 3, + "rows_clean": 294, + "total_revenue": 110217.74, + "avg_order_value": 374.89, + "total_quantity": 1286, + "top_category": "Clothing", + "first_date": "2026-05-01", + "last_date": "2026-06-30", +} +EXPECTED_REGIONS = {"East": 34031.39, "North": 22001.64, + "South": 28675.58, "West": 25509.13} +REQUIRED_SECTIONS = ("## Data quality", "## Findings", "## Appendix") + + +def _claim_code(): + digest = hashlib.sha256( + (CHALLENGE_ID + ":capstone:" + COURSE_SALT).encode("utf-8") + ).digest() + return base64.b32encode(digest).decode("ascii")[:8] + + +def _close(actual, expected, tol=0.01): + try: + return abs(float(actual) - float(expected)) <= tol + except (TypeError, ValueError): + return False + + +def main() -> int: + problems = [] + results_path = OUT_DIR / "results.json" + report_path = OUT_DIR / "report.md" + + if not results_path.exists(): + problems.append("missing out/results.json") + else: + try: + results = json.loads(results_path.read_text(encoding="utf-8")) + except ValueError: + problems.append("out/results.json is not valid JSON") + results = None + if results is not None: + for key, expected in EXPECTED.items(): + actual = results.get(key) + if isinstance(expected, float): + ok = _close(actual, expected) + else: + ok = actual == expected + if not ok: + problems.append(f"results.{key}: expected {expected!r}, got {actual!r}") + regions = results.get("revenue_by_region") or {} + for region, expected in EXPECTED_REGIONS.items(): + if not _close(regions.get(region), expected): + problems.append( + f"revenue_by_region.{region}: expected {expected}, " + f"got {regions.get(region)!r}") + + if not report_path.exists(): + problems.append("missing out/report.md") + else: + text = report_path.read_text(encoding="utf-8") + for section in REQUIRED_SECTIONS: + if section not in text: + problems.append(f"report.md missing section {section!r}") + if "110217.74" not in text: + problems.append("report.md does not state the verified total revenue") + + if problems: + for problem in problems: + print(f"FAIL {problem}", file=sys.stderr) + return 1 + + print(f"capstone verified — claim code: {_claim_code()}") + print("\u4e2d\u6587\uff1a\u6bd5\u4e1a\u9879\u76ee\u5df2\u901a\u8fc7\uff0c\u8ba4\u9886\u7801\u89c1\u4e0a\uff1b\u63d0\u4ea4\u5230 flypython.com\u3002") + print("Self-reported evidence for flypython.com, never a certificate.") + print("\u8ba4\u9886\u7801\u662f\u81ea\u6211\u62a5\u544a\u7684\u8bc1\u636e\uff0c\u4e0d\u662f\u8bc1\u4e66\u3002") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/paths/data-analysis/modules/capstone.md b/paths/data-analysis/modules/capstone.md new file mode 100644 index 0000000..f644788 --- /dev/null +++ b/paths/data-analysis/modules/capstone.md @@ -0,0 +1,37 @@ +--- +id: path-da-capstone +type: path +title: "Capstone: verified analysis of a real dataset" +summary: Data Analysis path capstone — end-to-end clean, explore, and report on orders.csv with verify.py checking the numbers. +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +--- + +# Capstone: verified analysis of a real dataset + +**Points:** 200 · **Type:** project · **Evidence:** objective (`verify.py`) + +The final challenge of the Data Analysis path. Everything the path taught — +loading messy exports, cleaning with counted removals, summarizing with +checkable numbers, reporting with traceable metrics — applied end to end +on `capstone/dataset/orders.csv`. + +## What to do + +Work in `paths/data-analysis/capstone/`. The contract is `TASK.md` +(`TASK_cn.md` for 中文): your pipeline writes `out/results.json` with the +specified keys and `out/report.md` with the required sections, then +`python verify.py` checks the numbers against ground truth and prints the +capstone claim code. + +Use your agent for the boring parts — parsing, aggregation, rendering — +and keep the contract in front of it. The solution folder exists for +maintainer review; do not copy it for your first attempt. + +## Checkpoint + +`python verify.py` exits 0 → claim code recorded against the Data +Analysis path badge (200 points). Self-reported evidence, never a +certificate. diff --git a/paths/data-analysis/modules/capstone_cn.md b/paths/data-analysis/modules/capstone_cn.md new file mode 100644 index 0000000..0e0e7ef --- /dev/null +++ b/paths/data-analysis/modules/capstone_cn.md @@ -0,0 +1,33 @@ +--- +id: path-da-capstone +type: path +title: "综合项目:真实数据集的验证分析" +summary: 数据分析路线综合项目——在 orders.csv 上端到端完成清洗、探索与报告,verify.py 校验数字。 +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-12 +--- + +# 综合项目:真实数据集的验证分析 + +**积分:** 200 · **类型:** 项目 · **证据:** 客观验证(`verify.py`) + +数据分析路线的收官挑战。本路线教过的一切——读脏导出、按计数清洗、 +产出可核查数字、写出可回溯指标的报告——在 +`capstone/dataset/orders.csv` 上端到端用一遍。 + +## 怎么做 + +在 `paths/data-analysis/capstone/` 下工作。契约见 `TASK.md` +(中文 `TASK_cn.md`):你的流水线写出含规定键的 `out/results.json` +和含必需章节的 `out/report.md`,然后 `python verify.py` 用真值校验 +数字并打印综合项目认领码。 + +让 Agent 干繁琐的活——解析、聚合、渲染——并把契约摆在它面前。 +solution 文件夹供维护者审核;第一次尝试不要抄它。 + +## 检查点 + +`python verify.py` 退出 0 → 认领码计入数据分析路线徽章(200 分)。 +自我报告的证据,绝非证书。 diff --git a/paths/data-analysis/path.json b/paths/data-analysis/path.json new file mode 100644 index 0000000..4c0082c --- /dev/null +++ b/paths/data-analysis/path.json @@ -0,0 +1,66 @@ +{ + "id": "data-analysis", + "slug": "data-analysis", + "status": "live", + "title_en": "Data analysis with agents", + "title_zh": "用 Agent 做数据分析", + "summary_en": "From a messy export to a verified analysis report: clean, explore, visualize, and report on a real dataset with your agent.", + "summary_zh": "从混乱的数据导出到一份经过验证的分析报告:用你的 Agent 完成清洗、探索、可视化与报告。", + "audience_en": "Beginners who read data in Excel and want their agent to do real analysis.", + "audience_zh": "会在 Excel 里看数据、想让 Agent 完成真正分析初学者。", + "outcome_en": "You can take a raw dataset end to end - clean, explore, visualize, report - with every number verified.", + "outcome_zh": "你能端到端处理一份原始数据——清洗、探索、可视化、报告——每个数字都经过验证。", + "badge": { + "id": "data-analysis-agent", + "name_en": "Data Analysis Agent", + "name_zh": "数据分析 Agent" + }, + "modules": [ + { + "order": 0, + "id": "m0", + "kind": "course", + "course": "hands-on-python-with-claude-code", + "title_en": "Start: environment and first verification", + "title_zh": "起步:环境与第一次验证", + "points": 100 + }, + { + "order": 1, + "id": "m1", + "kind": "course", + "course": "da-eda", + "title_en": "Exploratory data analysis", + "title_zh": "探索性数据分析", + "points": 100 + }, + { + "order": 2, + "id": "m2", + "kind": "course", + "course": "da-visualization", + "title_en": "Data visualization", + "title_zh": "数据可视化", + "points": 100 + }, + { + "order": 3, + "id": "m3", + "kind": "course", + "course": "da-report", + "title_en": "From analysis to report", + "title_zh": "从分析到报告", + "points": 100 + }, + { + "order": 4, + "id": "capstone", + "kind": "module", + "ref": "modules/capstone.md", + "title_en": "Capstone: verified analysis of a real dataset", + "title_zh": "综合项目:真实数据集的验证分析", + "points": 200, + "kind_label": "project" + } + ] +} diff --git a/paths/foundation/modules/m0-orientation.md b/paths/foundation/modules/m0-orientation.md new file mode 100644 index 0000000..42e4f0d --- /dev/null +++ b/paths/foundation/modules/m0-orientation.md @@ -0,0 +1,64 @@ +--- +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. +lang: en-US +content_version: 2 +status: reviewed +reviewed_on: 2026-09-13 +--- + +# Module 0: The agent tool landscape + +**Points:** 10 · **Type:** orientation · **Evidence:** self-attested + +Before the hands-on courses, get your bearings: what the tools are, what they +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 +you want to work: + +| Tool | Where it runs | What it's good at | +| --- | --- | --- | +| **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 | +| **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 | + +They differ in interface, not in fundamentals. Every one of them: reads your +files, follows written instructions, edits code, runs commands, and — this is +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 +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 + (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." +3. Watch what it does: which files it creates, which commands it runs, + what it reports back. +4. Ask it: "how do you know it works?" — notice whether it *shows evidence* + or just *says it worked*. That gap is what the rest of this path is about. + +## Checkpoint + +You have finished this module when you can answer, in your own words: + +- Which agent did you install, and how do you start it? +- What did it do when you asked for the date script — and what evidence did + it give you that the script ran? + +Self-attested checkpoint: when you can answer both, record it — module M0 of +the Foundation path is worth 10 points. Honest self-reporting is the rule of +this platform: nobody checks your answer, but nothing here is a certificate. diff --git a/paths/foundation/modules/m0-orientation_cn.md b/paths/foundation/modules/m0-orientation_cn.md new file mode 100644 index 0000000..e2a6c3d --- /dev/null +++ b/paths/foundation/modules/m0-orientation_cn.md @@ -0,0 +1,55 @@ +--- +id: path-foundation-m0 +type: path +title: Agent 工具生态总览 +summary: 基础路线模块 0——六个 Agentic 编程工具生态导览;按你的工作环境选一个,并观察它的证据习惯。 +lang: zh-CN +content_version: 2 +status: reviewed +reviewed_on: 2026-09-13 +--- + +# 模块 0:Agent 工具生态总览 + +**积分:** 10 · **类型:** 入门导览 · **证据:** 自我声明 + +在动手课程之前先搞清楚地形:有哪些工具、它们的共性是什么、你先驱动哪一个。 + +## 生态概览 + +六个 Agentic 编程工具承载本路线的实战课程。按你想工作的位置选: + +| 工具 | 运行位置 | 擅长什么 | +| --- | --- | --- | +| **Claude Code** | 终端 | 长链路多步任务,指令遵循强,读 `CLAUDE.md`/`AGENTS.md` | +| **Codex(桌面应用)** | 桌面应用 | 带审批与 diff 审查的任务线程,读 `AGENTS.md` | +| **Cursor** | IDE | 编辑器内 Agent/Ask/Plan 模式,`.cursor/rules` 项目规则 | +| **DeepSeek Harness** | Python 框架 | 插件组装的 Agent(`cordis.yml`),可审计的只增轨迹 | +| **Kimi Code** | 终端 | 内置 `explore`/`plan`/`coder` 子代理,读、规划、改码分道 | +| **ZCode** | 桌面应用 | 长程 Goal 模式、任务列表、工作区 Git 状态、远程查岗 | + +它们的差别在界面,不在本质。每一个都:读你的文件、遵循书面指令、改代码、 +跑命令,以及——这是重点——**可能以一种看起来对的方式出错**。所以这条 +路线把大量精力放在规则和验证上,而不是提示词上。 + +**按环境选:** 住终端 → Claude Code 或 Kimi Code;住图形界面 → Codex +应用、Cursor 或 ZCode;想自己组装并审计 Agent 循环 → DeepSeek Harness。 +起步只需要**一个**;路线末尾的挑战会要求第二个不同的工具。 + +## 第一个任务(不写代码) + +1. 从六个工具里挑**一个**,按官方文档安装(课程页有链接)。 +2. 在一个空文件夹里打开它,让它「写一个打印当前日期的 Python 脚本并运行」。 +3. 观察它的行为:创建了哪些文件、跑了哪些命令、回报了什么。 +4. 问它:「你怎么知道它能跑?」——注意它是*给出证据*还是只*说跑过了*。 + 这个差距就是本路线剩下的内容要讲的。 + +## 检查点 + +能用自己的话回答下面两题,本模块即完成: + +- 你装了哪个 Agent,怎么启动它? +- 你让它写日期脚本时它做了什么——它给出的是什么证据? + +自我声明检查点:能答出两题即可认领——这是基础路线的模块 M0,值 10 分。 +诚实自我报告是本站规则:没人检查你的答案,但这里也没有任何证书。 diff --git a/paths/foundation/modules/route-challenge.md b/paths/foundation/modules/route-challenge.md new file mode 100644 index 0000000..3fbd20f --- /dev/null +++ b/paths/foundation/modules/route-challenge.md @@ -0,0 +1,60 @@ +--- +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. +lang: en-US +content_version: 2 +status: reviewed +reviewed_on: 2026-09-13 +--- + +# Route challenge: dual-agent, double-verified + +**Points:** 200 · **Type:** project · **Evidence:** objective (`verify.py`) + +The final challenge of the Foundation path: prove you can drive **two +different** agents through the same task — and verify both results yourself. + +## The task + +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`, +`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: + +1. **Round one — agent A.** In a fresh folder, write a `TASK.md` + describing the contract (inputs, outputs, edge cases, done = tests pass), + drive your first agent to implement it, and add a `verify.py`-style + check that passes on the result. +2. **Round two — agent B.** Same task, fresh folder, same `TASK.md`. + Drive a *different* agent to implement it independently — do not copy + round one's code. +3. **Double verification.** Run your objective check against **both** + implementations. Record what differed: structure, edge-case handling, + test coverage, anything that surprised you. + +## What to record + +- Both folders kept intact (they are your evidence). +- A short note: which agent's output needed fewer corrections, and *why you + believe that* — this is the skeptical-review habit from the + `verifying-ai-generated-code` course applied across tools. + +## Checkpoint + +Complete when: + +- Both implementations exist and your objective check passes on each. +- Your comparison note names at least one concrete difference with evidence + (a file, a test, a command output). +- The two agents were genuinely different tools — not two sessions of the + same one. + +The claim for this challenge is recorded against the Foundation path badge — +200 points toward **Agent User**. Self-reported evidence, never a certificate. diff --git a/paths/foundation/modules/route-challenge_cn.md b/paths/foundation/modules/route-challenge_cn.md new file mode 100644 index 0000000..70fdf2a --- /dev/null +++ b/paths/foundation/modules/route-challenge_cn.md @@ -0,0 +1,56 @@ +--- +id: path-foundation-challenge +type: path +title: "路线挑战:双 Agent、双重验证" +summary: 基础路线收官——把同一个任务分别交给六个路线 Agent 中任选的两个,并亲手验证两份产出。 +lang: zh-CN +content_version: 2 +status: reviewed +reviewed_on: 2026-09-13 +--- + +# 路线挑战:双 Agent、双重验证 + +**积分:** 200 · **类型:** 项目 · **证据:** 客观(`verify.py`) + +基础路线的最后一关:证明你能驱动**两个不同**的 Agent 完成同一个任务—— +并且亲手验证两份结果。 + +## 任务 + +挑一个你真正在乎的小任务(建议:「一个统计文件行数、词数、字节数的 +CLI」,或复用路线课程里的任一场景皮肤: +`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`、`verifying-ai-generated-code`)。 + +从本路线教授的六个 Agent 工具中任选**两个**(Claude Code、Codex 应用、 +Cursor、DeepSeek Harness、Kimi Code、ZCode),然后: + +1. **第一轮——Agent A。** 在全新文件夹里写一份描述契约的 `TASK.md` + (输入、输出、边界情况、完成 = 测试通过),驱动第一个 Agent 实现, + 并加一个 `verify.py` 式的检查能在结果上通过。 +2. **第二轮——Agent B。** 同一个任务、新文件夹、同一份 `TASK.md`。 + 驱动一个*不同*的 Agent 独立实现——不要抄第一轮的代码。 +3. **双重验证。** 对**两份**实现都跑你的客观检查。记录差异:结构、 + 边界情况处理、测试覆盖、任何让你意外的地方。 + +## 要记录什么 + +- 两个文件夹都原样保留(它们就是你的证据)。 +- 一段短笔记:哪个 Agent 的产出需要的修正更少,以及*你为什么这么 + 认为*——这是 `verifying-ai-generated-code` 课程里怀疑式评审习惯 + 的跨工具应用。 + +## 检查点 + +满足以下条件即完成: + +- 两份实现都存在,且你的客观检查在各自上通过。 +- 你的对比笔记点名了至少一处有证据的具体差异(一个文件、一个测试、 + 一段命令输出)。 +- 两个 Agent 确实是不同的工具——不是同一个工具的两个会话。 + +本挑战的认领记入基础路线徽章——为 **Agent 使用者** 贡献 200 分。 +自我报告的证据,从来不是证书。 diff --git a/paths/foundation/path.json b/paths/foundation/path.json new file mode 100644 index 0000000..9d026f5 --- /dev/null +++ b/paths/foundation/path.json @@ -0,0 +1,131 @@ +{ + "id": "foundation", + "slug": "foundation", + "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 任选),给它们好规则,并验证它们产出的一切。", + "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.", + "outcome_zh": "你能独立驱动两种不同的编码 Agent 完成小任务,写出单一真源规则文件,并亲手验证 Agent 的产出。", + "badge": { + "id": "agent-user", + "name_en": "Agent User", + "name_zh": "Agent 使用者" + }, + "modules": [ + { + "order": 0, + "id": "m1", + "kind": "course", + "course": "hands-on-python-with-claude-code", + "title_en": "Claude Code hands-on", + "title_zh": "Claude Code 实战", + "points": 100, + "branch": "first-agent", + "branch_label_en": "pick your first agent", + "branch_label_zh": "选你的第一个 Agent" + }, + { + "order": 1, + "id": "m2", + "kind": "course", + "course": "hands-on-with-openai-codex", + "title_en": "Codex app hands-on", + "title_zh": "Codex 应用实战", + "points": 100, + "branch": "first-agent", + "branch_label_en": "pick your first agent", + "branch_label_zh": "选你的第一个 Agent" + }, + { + "order": 2, + "id": "m3a", + "kind": "course", + "course": "hands-on-with-cursor", + "title_en": "Cursor hands-on", + "title_zh": "Cursor 实战", + "points": 100, + "branch": "first-agent", + "branch_label_en": "pick your first agent", + "branch_label_zh": "选你的第一个 Agent" + }, + { + "order": 3, + "id": "m3b", + "kind": "course", + "course": "hands-on-with-deepseek-harness", + "title_en": "DeepSeek Harness hands-on", + "title_zh": "DeepSeek Harness 实战", + "points": 100, + "branch": "first-agent", + "branch_label_en": "pick your first agent", + "branch_label_zh": "选你的第一个 Agent" + }, + { + "order": 4, + "id": "m3c", + "kind": "course", + "course": "hands-on-with-kimi-code", + "title_en": "Kimi Code hands-on", + "title_zh": "Kimi Code 实战", + "points": 100, + "branch": "first-agent", + "branch_label_en": "pick your first agent", + "branch_label_zh": "选你的第一个 Agent" + }, + { + "order": 5, + "id": "m3d", + "kind": "course", + "course": "hands-on-with-zcode", + "title_en": "ZCode hands-on", + "title_zh": "ZCode 实战", + "points": 100, + "branch": "first-agent", + "branch_label_en": "pick your first agent", + "branch_label_zh": "选你的第一个 Agent" + }, + { + "order": 6, + "id": "m3", + "kind": "course", + "course": "agent-rules-single-source", + "title_en": "One source of truth for agent rules", + "title_zh": "Agent 规则的单一真源", + "points": 100 + }, + { + "order": 7, + "id": "m4", + "kind": "course", + "course": "verifying-ai-generated-code", + "title_en": "Verifying AI-generated code", + "title_zh": "验证 AI 生成的代码", + "points": 100 + }, + { + "order": 8, + "id": "route-challenge", + "kind": "module", + "ref": "modules/route-challenge.md", + "title_en": "Route challenge: dual-agent, double-verified", + "title_zh": "路线挑战:双 Agent、双重验证", + "points": 200, + "kind_label": "project" + }, + { + "order": 9, + "id": "m0", + "kind": "module", + "ref": "modules/m0-orientation.md", + "title_en": "The agent tool landscape", + "title_zh": "Agent 工具生态总览", + "points": 10, + "kind_label": "orientation", + "optional": true + } + ] +} diff --git a/playbooks/README.md b/playbooks/README.md new file mode 100644 index 0000000..1679ffb --- /dev/null +++ b/playbooks/README.md @@ -0,0 +1,17 @@ +# Playbooks + +Playbooks turn recurring Python work into reviewable steps with an explicit +definition of done. + +| Task | Use it when | Proof of completion | +| --- | --- | --- | +| [Fix a bug with a regression test](fix-a-bug/README.md) | Behavior is wrong or has regressed | Reproduction fails before the fix and passes after it | +| [Add or change an API](add-an-api/README.md) | A public HTTP or library contract changes | Contract, errors, tests, and compatibility are verified | +| [Integrate an external API](integrate-an-external-api/README.md) | Python calls a third-party service or model | Timeouts, failures, credentials, and test doubles are covered | +| [Upgrade dependencies](upgrade-dependencies/README.md) | Runtime or packages need updating | Lockfile, tests, advisories, and runtime smoke check pass | +| [Ship a release](ship-a-release/README.md) | A package or service is ready to publish | Artifact, changelog, deployment, and rollback are verified | +| [Write deterministic evals](write-llm-evals/README.md) | Changing LLM prompts, models, or tool-calling agents | Golden datasets and deterministic assertions pass without regressions | +| [Set up structured logging](setup-structured-logging/README.md) | Moving from scripts to production services | Structured JSON, trace ID propagation, and redaction are tested | +| [Manage database migrations](manage-database-migrations/README.md) | Modifying SQL schemas or ORM models | Upgrade, downgrade, backfill safety, and zero-downtime compatibility pass | + +[中文索引](README_cn.md) diff --git a/playbooks/README_cn.md b/playbooks/README_cn.md new file mode 100644 index 0000000..b75d9e3 --- /dev/null +++ b/playbooks/README_cn.md @@ -0,0 +1,16 @@ +# 任务 Playbook + +Playbook 把反复出现的 Python 工程任务变成带明确完成标准的、可审查的步骤。 + +| 任务 | 适用场景 | 完成证据 | +| --- | --- | --- | +| [用回归测试修复 Bug](fix-a-bug/README_cn.md) | 线上行为错误或出现功能倒退 | 复现脚本在修复前失败,修复后通过 | +| [增加或修改 API](add-an-api/README_cn.md) | 公开 HTTP 接口或库契约发生变化 | 契约、错误码、测试用例和向后兼容性完成验证 | +| [集成外部 API](integrate-an-external-api/README_cn.md) | Python 调用第三方服务或模型接口 | 超时、网络故障、鉴权和测试桩全部覆盖 | +| [升级依赖](upgrade-dependencies/README_cn.md) | 运行环境或第三方依赖包需要更新 | 锁文件、单元测试、安全通告和冒烟测试全部通过 | +| [发布上线](ship-a-release/README_cn.md) | 构建产物或服务准备发布 | 产物构建、更新日志、部署和回退方案完成验证 | +| [编写确定性评测](write-llm-evals/README_cn.md) | 变更 LLM 提示词、替换模型或更新 Agent 工具 | 黄金测试集与结构化断言验证无性能/准确率倒退 | +| [搭建结构化日志](setup-structured-logging/README_cn.md) | 从本地脚本走向生产级服务 | JSON 格式、Trace ID 上下文贯穿与脱敏通过验证 | +| [管理数据库架构迁移](manage-database-migrations/README_cn.md) | 修改 SQL 数据库表结构或 ORM 模型 | 升级、回滚、数据回填与零停机平滑发布完成验证 | + +[English index](README.md) diff --git a/playbooks/add-an-api/README.md b/playbooks/add-an-api/README.md new file mode 100644 index 0000000..d90f0a6 --- /dev/null +++ b/playbooks/add-an-api/README.md @@ -0,0 +1,28 @@ +--- +id: add-a-python-api +type: playbook +title: Add or Change a Python API +summary: Define the public contract first, then implement and verify success, error, and compatibility behavior. +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-02 +--- + +# Add or Change a Python API + +1. Write request, response, status, authentication, idempotency, and error + contracts before selecting implementation details. +2. Separate domain logic from transport code; validate data at the boundary. +3. Add contract tests for one success, each meaningful failure, permissions, + and malformed input. Test generated schemas when clients depend on them. +4. Decide whether the change is additive, deprecating, or breaking. Document a + migration path for every breaking change. +5. Apply explicit timeouts and cancellation to downstream calls. Do not expose + provider errors or secrets directly to clients. +6. Verify the API through the same network boundary a real client uses, then + inspect logs and state changes. + +--- + +Continue this task on [flypython.com](https://flypython.com/resources#add-or-change-a-python-api): the full playbook directory, the guide behind each checklist, and the reviewed source catalog. diff --git a/playbooks/add-an-api/README_cn.md b/playbooks/add-an-api/README_cn.md new file mode 100644 index 0000000..9b4f4cf --- /dev/null +++ b/playbooks/add-an-api/README_cn.md @@ -0,0 +1,23 @@ +--- +id: add-a-python-api +type: playbook +title: 增加或修改 Python API +summary: 先定义公共契约,再实现并验证成功、错误和兼容性行为。 +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-02 +--- + +# 增加或修改 Python API + +1. 先写清请求、响应、状态码、认证、幂等和错误契约,再选择实现细节; +2. 把领域逻辑与传输层分开,在边界验证数据; +3. 为成功、主要失败、权限和畸形输入补充契约测试;客户端依赖 schema 时也要测试它; +4. 判断修改属于新增、弃用还是破坏性变化,并为每项破坏性变化说明迁移路径; +5. 给下游调用设置明确超时与取消,不向客户端直接暴露供应商错误或秘密; +6. 通过真实客户端使用的网络边界验证 API,再检查日志与状态变化。 + +--- + +在 [flypython.com 资源页](https://flypython.com/resources#add-or-change-a-python-api)继续这个任务(该页为英文,锚点直达本清单):完整 Playbook 目录、每份清单背后的指南与经过审核的资料目录。 diff --git a/playbooks/fix-a-bug/README.md b/playbooks/fix-a-bug/README.md new file mode 100644 index 0000000..66ed975 --- /dev/null +++ b/playbooks/fix-a-bug/README.md @@ -0,0 +1,33 @@ +--- +id: fix-a-python-bug +type: playbook +title: Fix a Python Bug with a Regression Test +summary: Reproduce the behavior, constrain the cause, make the smallest fix, and prove the regression stays fixed. +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-02 +--- + +# Fix a Python Bug with a Regression Test + +1. Record the observed behavior, expected behavior, smallest input that fails, + and affected user path in a [task contract](../../templates/TASK_CONTRACT.md). +2. Add one test that fails for the reported reason. If it does not fail before + the change, it is not yet regression evidence. +3. Ask the coding agent to inspect the call path and propose the smallest + plausible cause. Do not authorize an unrelated refactor. +4. Change only the behavior required by the contract. Preserve public error + forms unless the contract explicitly changes them. +5. Run the new test, the nearest test suite, then the full deterministic suite. +6. Review the diff for widened scope, hidden exception handling, new network or + filesystem effects, and missing edge cases. +7. Re-run the original user path and record commands and results in the + [verification template](../../templates/VERIFICATION.md). + +Practice the complete loop with the +[product slug example](../../examples/product-slug/README.md). + +--- + +Continue this task on [flypython.com](https://flypython.com/resources#fix-a-bug-with-regression-test): the full playbook directory, the guide behind each checklist, and the reviewed source catalog. diff --git a/playbooks/fix-a-bug/README_cn.md b/playbooks/fix-a-bug/README_cn.md new file mode 100644 index 0000000..09ecbf3 --- /dev/null +++ b/playbooks/fix-a-bug/README_cn.md @@ -0,0 +1,27 @@ +--- +id: fix-a-python-bug +type: playbook +title: 用回归测试修复 Python Bug +summary: 复现行为、缩小原因、完成最小修复,并证明回归问题不会再次出现。 +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-02 +--- + +# 用回归测试修复 Python Bug + +1. 在[任务契约](../../templates/TASK_CONTRACT_cn.md)中记录实际行为、预期行为、最小失败输入 + 和受影响的用户路径; +2. 先增加一个能因所报告问题而失败的测试;如果修改前没有失败,它还不能构成回归证据; +3. 让 coding agent 检查调用路径并提出范围最小的可能原因,不授权无关重构; +4. 只修改契约要求的行为,除非契约明确变更,否则保持公共错误形式稳定; +5. 依次运行新测试、最近的测试套件、完整确定性测试; +6. 审查 diff 是否扩大范围、隐藏异常、新增网络或文件副作用、遗漏边界情况; +7. 重跑原始用户路径,并在[验证记录](../../templates/VERIFICATION_cn.md)中写下命令与结果。 + +可以用 [product slug 示例](../../examples/product-slug/README_cn.md)完成一次完整练习。 + +--- + +在 [flypython.com 资源页](https://flypython.com/resources#fix-a-bug-with-regression-test)继续这个任务(该页为英文,锚点直达本清单):完整 Playbook 目录、每份清单背后的指南与经过审核的资料目录。 diff --git a/playbooks/integrate-an-external-api/README.md b/playbooks/integrate-an-external-api/README.md new file mode 100644 index 0000000..e192cad --- /dev/null +++ b/playbooks/integrate-an-external-api/README.md @@ -0,0 +1,28 @@ +--- +id: integrate-an-external-api +type: playbook +title: Integrate an External API +summary: Wrap a third-party API behind a typed boundary with safe credentials, timeouts, retries, and deterministic tests. +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-02 +--- + +# Integrate an External API + +1. Document the provider, endpoint, data sent, credential owner, cost limit, + rate limit, and allowed side effects. +2. Put provider code behind a small typed interface. Validate every response; + model output is untrusted data too. +3. Keep credentials outside source control and redact them from logs and errors. +4. Set connect and response timeouts. Retry only transient, idempotent work with + bounded backoff; never retry an irreversible action blindly. +5. Test with a deterministic fake transport, including timeout, malformed data, + rate limit, and partial failure. Keep live tests opt-in. +6. Verify cost, latency, logs, and the user-visible fallback in a controlled + environment before enabling production traffic. + +--- + +Continue this task on [flypython.com](https://flypython.com/resources#integrate-an-external-api): the full playbook directory, the guide behind each checklist, and the reviewed source catalog. diff --git a/playbooks/integrate-an-external-api/README_cn.md b/playbooks/integrate-an-external-api/README_cn.md new file mode 100644 index 0000000..4d9f573 --- /dev/null +++ b/playbooks/integrate-an-external-api/README_cn.md @@ -0,0 +1,23 @@ +--- +id: integrate-an-external-api +type: playbook +title: 接入外部 API +summary: 用类型边界封装第三方 API,并安全处理凭据、超时、重试和确定性测试。 +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-02 +--- + +# 接入外部 API + +1. 记录供应商、端点、发送的数据、凭据负责人、成本上限、速率限制和允许的副作用; +2. 用小而明确的类型接口封装供应商代码,并验证所有响应;模型输出同样是不可信数据; +3. 凭据不得进入源码,日志和错误信息必须脱敏; +4. 分别设置连接和响应超时;只对暂时性、幂等操作做有上限的退避重试; +5. 使用确定性的假传输测试超时、畸形数据、限流和部分失败,真实联网测试保持显式选择; +6. 在受控环境检查成本、延迟、日志和用户可见的降级行为,再开放生产流量。 + +--- + +在 [flypython.com 资源页](https://flypython.com/resources#integrate-an-external-api)继续这个任务(该页为英文,锚点直达本清单):完整 Playbook 目录、每份清单背后的指南与经过审核的资料目录。 diff --git a/playbooks/manage-database-migrations/README.md b/playbooks/manage-database-migrations/README.md new file mode 100644 index 0000000..a3221ac --- /dev/null +++ b/playbooks/manage-database-migrations/README.md @@ -0,0 +1,29 @@ +--- +id: manage-database-migrations +type: playbook +title: Manage Database Schema Migrations +summary: Plan, execute, and verify reversible database migrations with zero-downtime compatibility and rollback safety. +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-02 +--- + +# Manage Database Schema Migrations + +1. Separate schema migration changes from application code deployments whenever + introducing non-additive column or table alterations. +2. Follow expand-and-contract: add new nullable columns or tables first, write to + both old and new fields, backfill historical data in batches, then drop old columns. +3. Verify migration script autogeneration with Alembic; inspect every generated SQL + statement for destructive operations (`DROP COLUMN`, table locks, or unindexed constraints). +4. Implement and test both `upgrade()` and `downgrade()` functions against a clean + local test database before submitting the change. +5. Apply explicit statement timeouts during migration runs to prevent locking production + tables under heavy concurrent workloads. +6. Verify application behavior with both old and new schema versions active to guarantee + zero-downtime rolling deployment safety. + +--- + +Continue this task on [flypython.com](https://flypython.com/resources#manage-database-migrations): the full playbook directory, the guide behind each checklist, and the reviewed source catalog. diff --git a/playbooks/manage-database-migrations/README_cn.md b/playbooks/manage-database-migrations/README_cn.md new file mode 100644 index 0000000..67b7aeb --- /dev/null +++ b/playbooks/manage-database-migrations/README_cn.md @@ -0,0 +1,23 @@ +--- +id: manage-database-migrations +type: playbook +title: 管理数据库架构迁移 +summary: 规划、执行并验证具备零停机兼容性与回滚安全性的可逆数据库迁移。 +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-02 +--- + +# 管理数据库架构迁移 + +1. 当引入非增量(破坏性)列或表变更时,必须将数据库架构迁移与应用代码部署解耦分步进行。 +2. 遵循“扩展与收缩(Expand and Contract)”模式:先添加允许为空的新列或新表,实现双写,批量回填历史数据,最后废弃清理旧列。 +3. 仔细审查 Alembic 自动生成的迁移脚本;逐行检查生成的 SQL 语句,排查破坏性操作(如直接 `DROP COLUMN`、长事务锁表或未索引外键约束)。 +4. 在提交代码前,在本地干净的测试数据库上同时验证 `upgrade()` 升级与 `downgrade()` 回退逻辑。 +5. 在迁移执行脚本中配置显式语句超时(Statement Timeout),防止在生产高并发环境下长时间阻塞表级读写。 +6. 验证新旧两版应用代码在过渡期数据库结构下的共存表现,确保平滑滚动升级零停机。 + +--- + +在 [flypython.com 资源页](https://flypython.com/resources#manage-database-migrations)继续这个任务(该页为英文,锚点直达本清单):完整 Playbook 目录、每份清单背后的指南与经过审核的资料目录。 diff --git a/playbooks/setup-structured-logging/README.md b/playbooks/setup-structured-logging/README.md new file mode 100644 index 0000000..3c7517f --- /dev/null +++ b/playbooks/setup-structured-logging/README.md @@ -0,0 +1,29 @@ +--- +id: setup-structured-logging +type: playbook +title: Set Up Production Structured Logging +summary: Configure structured JSON logging with context propagation, sensitive data redaction, and environment isolation. +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-02 +--- + +# Set Up Production Structured Logging + +1. Choose JSON-formatted structured output for production and colorized readable + text for local development; avoid ad-hoc `print()` and unstructured string concatenation. +2. Propagate a unique `request_id` or `trace_id` through every incoming HTTP request, + background job, and downstream service call using Python `contextvars`. +3. Standardize log event schemas: include timestamp (ISO 8601), log level, logger name, + event name, trace identifiers, and structured payload fields. +4. Implement automatic redaction filters for sensitive fields (e.g. `password`, `token`, + `authorization`, `api_key`, and personally identifiable information). +5. Capture full exception stack traces with `exc_info=True` only at service boundaries; + do not log redundant raw tracebacks across multiple nested catch blocks. +6. Verify log generation under test by asserting structured dictionary fields + rather than brittle substring matches. + +--- + +Continue this task on [flypython.com](https://flypython.com/resources#setup-structured-logging): the full playbook directory, the guide behind each checklist, and the reviewed source catalog. diff --git a/playbooks/setup-structured-logging/README_cn.md b/playbooks/setup-structured-logging/README_cn.md new file mode 100644 index 0000000..b42c615 --- /dev/null +++ b/playbooks/setup-structured-logging/README_cn.md @@ -0,0 +1,23 @@ +--- +id: setup-structured-logging +type: playbook +title: 搭建生产级结构化日志 +summary: 配置具备上下文贯穿、敏感数据脱敏与环境隔离的 JSON 结构化日志体系。 +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-02 +--- + +# 搭建生产级结构化日志 + +1. 生产环境统一采用 JSON 格式化输出,本地开发环境采用带色彩的高可读文本;避免随手写 `print()` 或无结构的字符串拼接。 +2. 通过 Python 标准库 `contextvars` 将全局唯一的 `request_id` 或 `trace_id` 贯穿所有 HTTP 请求、后台任务与下游远程调用。 +3. 规范日志事件数据结构:统一包含 ISO 8601 时间戳、日志级别、Logger 名称、事件标识、Trace ID 及结构化上下文键值对。 +4. 在日志处理器中配置敏感字段自动脱敏过滤器(覆盖 `password`、`token`、`authorization`、`api_key` 及用户个人隐私信息)。 +5. 仅在服务边界处使用 `exc_info=True` 捕获完整异常堆栈;禁止在多层嵌套的 `try...except` 中重复记录冗余的原始错误堆栈。 +6. 在自动化测试中针对日志结构字典字段进行确切断言,而非脆弱的非结构化子串正则匹配。 + +--- + +在 [flypython.com 资源页](https://flypython.com/resources#setup-structured-logging)继续这个任务(该页为英文,锚点直达本清单):完整 Playbook 目录、每份清单背后的指南与经过审核的资料目录。 diff --git a/playbooks/ship-a-release/README.md b/playbooks/ship-a-release/README.md new file mode 100644 index 0000000..96b5409 --- /dev/null +++ b/playbooks/ship-a-release/README.md @@ -0,0 +1,26 @@ +--- +id: ship-a-python-release +type: playbook +title: Ship a Python Release +summary: Produce a traceable package or service release and verify the real deployed behavior and rollback path. +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-02 +--- + +# Ship a Python Release + +1. Select the exact commit; confirm tests, version, changelog, migrations, + configuration, and compatibility from that commit. +2. Build artifacts once in a clean environment and inspect their contents. +3. Publish or deploy with least-privilege credentials. Record artifact digest, + deployment identity, and configuration version. +4. Verify installation or the production user path, not merely command success. +5. Check health, logs, data changes, and critical integrations. +6. If acceptance fails, stop rollout and use the documented rollback. Announce + availability only after the verified artifact is reachable. + +--- + +Continue this task on [flypython.com](https://flypython.com/resources#ship-a-python-release): the full playbook directory, the guide behind each checklist, and the reviewed source catalog. diff --git a/playbooks/ship-a-release/README_cn.md b/playbooks/ship-a-release/README_cn.md new file mode 100644 index 0000000..d8bc074 --- /dev/null +++ b/playbooks/ship-a-release/README_cn.md @@ -0,0 +1,23 @@ +--- +id: ship-a-python-release +type: playbook +title: 发布 Python 版本 +summary: 交付可追踪的包或服务版本,并验证真实线上行为与回退路径。 +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-02 +--- + +# 发布 Python 版本 + +1. 选择准确 commit,并从该版本确认测试、版本号、变更日志、迁移、配置和兼容性; +2. 在干净环境一次性构建产物,并检查产物内容; +3. 用最小权限凭据发布或部署,记录产物摘要、部署标识和配置版本; +4. 验证安装过程或线上真实用户路径,而不是只相信命令成功; +5. 检查健康状态、日志、数据变化和关键集成; +6. 验收失败就停止发布并按文档回退,只有可访问的目标产物验证通过后才宣布可用。 + +--- + +在 [flypython.com 资源页](https://flypython.com/resources#ship-a-python-release)继续这个任务(该页为英文,锚点直达本清单):完整 Playbook 目录、每份清单背后的指南与经过审核的资料目录。 diff --git a/playbooks/upgrade-dependencies/README.md b/playbooks/upgrade-dependencies/README.md new file mode 100644 index 0000000..10ebb87 --- /dev/null +++ b/playbooks/upgrade-dependencies/README.md @@ -0,0 +1,26 @@ +--- +id: upgrade-python-dependencies +type: playbook +title: Upgrade Python Dependencies +summary: Upgrade dependencies in a bounded change with lockfile, compatibility, security, and runtime evidence. +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-02 +--- + +# Upgrade Python Dependencies + +1. Define the package range, reason, supported Python versions, and rollback. +2. Read upstream release notes and security advisories; identify breaking or + deprecated behavior before changing the lockfile. +3. Update only the intended direct dependencies and review transitive changes. +4. Run formatting, types, tests, build, and a real runtime smoke check. +5. Review dependency provenance, install scripts, licenses, artifact size, and + newly requested permissions. +6. Record the resolved versions and user-visible impact. Keep unrelated upgrades + out of the same change. + +--- + +Continue this task on [flypython.com](https://flypython.com/resources#upgrade-python-dependencies): the full playbook directory, the guide behind each checklist, and the reviewed source catalog. diff --git a/playbooks/upgrade-dependencies/README_cn.md b/playbooks/upgrade-dependencies/README_cn.md new file mode 100644 index 0000000..effc2a8 --- /dev/null +++ b/playbooks/upgrade-dependencies/README_cn.md @@ -0,0 +1,23 @@ +--- +id: upgrade-python-dependencies +type: playbook +title: 升级 Python 依赖 +summary: 在边界明确的修改中,用锁文件、兼容性、安全和运行证据完成依赖升级。 +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-02 +--- + +# 升级 Python 依赖 + +1. 定义包范围、升级原因、支持的 Python 版本和回退方式; +2. 先阅读上游发布说明与安全公告,识别破坏性或弃用行为,再修改锁文件; +3. 只更新目标直接依赖,并审查所有传递依赖变化; +4. 运行格式、类型、测试、构建和真实运行 smoke check; +5. 审查依赖来源、安装脚本、许可证、产物大小和新增权限; +6. 记录最终版本与用户影响,不把无关升级混入同一个修改。 + +--- + +在 [flypython.com 资源页](https://flypython.com/resources#upgrade-python-dependencies)继续这个任务(该页为英文,锚点直达本清单):完整 Playbook 目录、每份清单背后的指南与经过审核的资料目录。 diff --git a/playbooks/write-llm-evals/README.md b/playbooks/write-llm-evals/README.md new file mode 100644 index 0000000..3a168a2 --- /dev/null +++ b/playbooks/write-llm-evals/README.md @@ -0,0 +1,29 @@ +--- +id: write-llm-evals +type: playbook +title: Write Deterministic Evals for LLMs and Agents +summary: Build regression test suites for LLM prompt changes and tool-calling agents with golden datasets and schema assertions. +lang: en-US +content_version: 1 +status: reviewed +reviewed_on: 2026-09-02 +--- + +# Write Deterministic Evals for LLMs and Agents + +1. Curate a versioned golden dataset of real user inputs covering happy paths, + adversarial prompts, ambiguous edge cases, and known regressions. +2. Define deterministic boundary assertions before adding semantic judges: + validate JSON Schema conformity, required fields, and disallowed tokens. +3. Test tool-calling parameters against strict type contracts; assert that + tool selections match expected capabilities without hallucinated arguments. +4. Separate cheap local unit tests from live model evaluations. Use recorded + fixture responses for fast CI runs and run live evals on scheduled batches. +5. Record benchmark pass rates, token counts, and latency before and after + any prompt or model migration. Never ship prompt changes without comparing diffs. +6. Guard against flaky evals by establishing tolerance thresholds and isolating + temperature/seed parameters during regression checks. + +--- + +Continue this task on [flypython.com](https://flypython.com/resources#write-llm-evals): the full playbook directory, the guide behind each checklist, and the reviewed source catalog. diff --git a/playbooks/write-llm-evals/README_cn.md b/playbooks/write-llm-evals/README_cn.md new file mode 100644 index 0000000..d3d297b --- /dev/null +++ b/playbooks/write-llm-evals/README_cn.md @@ -0,0 +1,23 @@ +--- +id: write-llm-evals +type: playbook +title: 为大模型与 Agent 编写确定性评测 +summary: 结合黄金测试集与 Schema 断言,为 Prompt 调整与工具调用 Agent 构建防劣化回归测试套件。 +lang: zh-CN +content_version: 1 +status: reviewed +reviewed_on: 2026-09-02 +--- + +# 为大模型与 Agent 编写确定性评测 + +1. 沉淀带版本管理的真实用户输入黄金测试集(Golden Dataset),覆盖标准流程、对抗性输入、歧义用例及已知历史 Bug。 +2. 在引入大模型语义评分前,优先建立确定性边界断言:校验 JSON Schema 契约合法性、必填字段完整性及禁用敏感词。 +3. 针对工具调用入参进行严格类型断言;验证模型选择的工具与参数完全符合预定义的工具契约,杜绝幻觉参数。 +4. 将轻量本地单元测试与在线模型评测解耦。在快速 CI 中使用录制的 Mock 响应,在线评测采用定时批处理运行。 +5. 在变更 Prompt 或迁移模型前后,记录评测通过率、Token 消耗及延迟基线。严禁在没有指标对比证据的情况下直接发布 Prompt 变更。 +6. 固定测试时的 temperature 与随机种子,设置可量化的容差阈值,防止偶发波动干扰回归结论。 + +--- + +在 [flypython.com 资源页](https://flypython.com/resources#write-llm-evals)继续这个任务(该页为英文,锚点直达本清单):完整 Playbook 目录、每份清单背后的指南与经过审核的资料目录。 diff --git a/primer/10/card.py b/primer/10/card.py deleted file mode 100644 index e6bac33..0000000 --- a/primer/10/card.py +++ /dev/null @@ -1,33 +0,0 @@ -def safe_float(obj): - try: - retval = float(obj) - except (ValueError,TypeError),diag: - retval = str(diag) - return retval - -def main(): - log = open("cardlog.txt","w") - try: - ccfile = open("carddata.txt","r") - except IOError,e: - log.write("no txns this \n") - log.close() - return - txns = ccfile.readlines() - ccfile.close() - total = 0.00 - log.write("account log:\n") - - for eachTxn in txns: - result = safe_float(eachTxn) - if isinstance(result,float): - total += result - log.write("data ....processed\n") - else: - log.write("ignored: %s" % result) - print "$%.2f (new balance)" % (total) - log.close() - -if __name__ == '__main__': - print "run" - main() diff --git a/primer/10/myexc.py b/primer/10/myexc.py deleted file mode 100644 index 0e3245f..0000000 --- a/primer/10/myexc.py +++ /dev/null @@ -1,104 +0,0 @@ - -import os,socket,errno,types,tempfile - -class NetworkError(IOError): - pass - -class FileError(IOError): - pass - -def updArgs(args,newarg=None): - if isinstance(args,IOError): - myargs =[] - myargs.extend([arg for arg in args]) - else: - myargs = list(args) - - if newarg: - myargs.append(newarg) - - return tuple(myargs) - -def fileAargs(file,mode,agrs): - if args[0] == errno.EACCES and \ - 'access' in dir(os): - perms = '' - perms = {'r':os.R_OK,'w':os.W_OK,'x':os.X_OK} - pkeys = permd.keys() - pkeys.sort() - pkeys.reverse() - - for eachPerm in 'rwx': - if os.access(file,permd[eachPerm]): - perms += eachPerm - else: - perms += '-' - - if isinstance(args,IOError): - myargs = [] - myargs.extend([arg for arg in args]) - else: - myargs = list(args) - - myargs[1] = "'%s' %s (perms:'%s')" % (mode,myargs[1],perms) - - myargs.append(args.filename) - - else: - myargs = args - return tuple(myargs) - -def myconnnect(sock,host,port): - try: - sock.connect((hosy,port)) - except socket.error,args: - myargs = updArgs(args) - if len(myargs) == 1: - myargs = (errno.ENXIO,myargs[0]) - - raise NetworkError,\ - updArgs(myargs,host +":" + str(port)) -def myopen(file,mode ="r"): - try: - fo = open(file,mode) - except IOError,args: - raise FileError,fileArgs(file,mode,args) - return fo - -def testfile(): - file = mkdtemp() - f = open(file,"w") - f.close() - - for eachTest in ((0,"r"),(0100,"r"), \ - (0400,"w"),(0500,"w")): - try: - os.chmod(file,eachTest[0]) - f=myopen(file,eachTest[1]) - - except FileError,args: - print "%s: %s" % \ - (args.__class__.__name__,args) - else: - print file,"open ok..perm ignored" - f.close() - os.chmod(file,0777) - os.unlink(file) -def testnet(): - s = socket.socket(socket.AF_INET.socket.SOCK_STREAM) - for eachHost in ("deli","www"): - try: - myconnect(s,"deli",8080) - except NetworkError,args: - print "%s:%s" %\ - (args.__class__.__name__,args) -if __name__ == "__main__": - testfile() - testnet() - - - - - - - diff --git a/primer/11/Gui.py b/primer/11/Gui.py deleted file mode 100644 index 6bff1f6..0000000 --- a/primer/11/Gui.py +++ /dev/null @@ -1,17 +0,0 @@ - -from functools import partial -import Tkinter - -root=Tkinter.Tk() -MyButton = partial(Tkinter.Button,root, - fg="white",bg="blue") -b1= MyButton(text="button 1") -b2=MyButton(text="button 2") -#qb=MyButton(text="QUIT",bg="red", - # command=root.quit) - -b1.pack() -b2.pack() -#qb.pack(file=Tkinter.X,expand= True) -root.title("PFAs!") -root.mainloop() diff --git a/primer/11/deco.py b/primer/11/deco.py deleted file mode 100644 index 21d7389..0000000 --- a/primer/11/deco.py +++ /dev/null @@ -1,22 +0,0 @@ - -from time import ctime,sleep - -def tsfunc(func): - def wrappedFunc(): - print "[%s] %s() called" % \ - (ctime(),func.__name__) - return func() - return wrappendFunc - -@tsfunc -def foo(): - pass - -foo() -sleep(4) - -for i in range(2): - sleep(1) - foo() - - diff --git a/primer/11/easyMath.py b/primer/11/easyMath.py deleted file mode 100644 index e14d53b..0000000 --- a/primer/11/easyMath.py +++ /dev/null @@ -1,40 +0,0 @@ - -from operator import add,sub -from random import randint,choice - -ops ={"+":add,"-":sub} -MAXTRIES = 2 - -def doprob(): - op = choice('+-') - nums = [randint(1,10) for i in range(2)] - nums.sort(reverse = True) - ans = ops[op](*nums) - pr="%d %s %d =" %(nums[0],op,nums[1]) - oops =0 - while True: - try: - if int(raw_input(pr)) == ans: - print "corrent" - break - if oops == MAXTRIES: - print "answer \n %s%d" %(pr,ans) - else: - print "incorrent...try again" - oops += 1 - except (KeyboardInterrupt, EOFError,ValueError): - print "invalid input ... try again" - -def main(): - while True: - doprob() - try: - opt= raw_input("Again?[y]".lower()) - if opt and opt[0] == "n": - break - except (KeyboardInterrupt,EOFError): - break -if __name__ == "__main__": - main() - - diff --git a/primer/11/grabWeb.py b/primer/11/grabWeb.py deleted file mode 100644 index 5b3de06..0000000 --- a/primer/11/grabWeb.py +++ /dev/null @@ -1,26 +0,0 @@ - -from urllib import urlretrieve - -def firstNoBlank(lines): - for eachLine in lines: - if not eachLine.strip(): - continue - else: - return eachLine - -def firstLast(webpage): - f = open(webpage) - lines= f.readlines() - f.close() - print firstNoBlank(lines),lines.reverse() - print firstNoBlank(lines), - -def download(url = "http://www.cqupt.edu.cn",process=firstLast): - try: - retval = urlretrieve(url)[0] - except IOError: - retval = None - if retval: - process(retval) -if __name__ == "__main__": - download() diff --git a/primer/11/numconv.py b/primer/11/numconv.py deleted file mode 100644 index 63eb282..0000000 --- a/primer/11/numconv.py +++ /dev/null @@ -1,8 +0,0 @@ - -def convert(func,seq): - return[func (eachNum ) for eachNum in seq] - -myseq=(123,45.67,-6.2e8,99999999L) -print convert(int,myseq) -print convert(long,myseq) -print convert(float,myseq) diff --git a/primer/11/odd.py b/primer/11/odd.py deleted file mode 100644 index 2a37d6a..0000000 --- a/primer/11/odd.py +++ /dev/null @@ -1,10 +0,0 @@ - -from random import randint - -#def odd(n): -# return n%2 - -allNums =[] -for eachNum in range(9): - allNums.append(randint(1,99)) -print[n for n in allNums if n%2] diff --git a/primer/11/testit.py b/primer/11/testit.py deleted file mode 100644 index ad5037e..0000000 --- a/primer/11/testit.py +++ /dev/null @@ -1,26 +0,0 @@ - -def testit(func,*nkwargs,**kwargs): - try: - retval = func(*nkwargs,**kwargs) - result = (True,retval) - except Exception,diag: - result = (False,str(diag)) - return result - -def test(): - funcs =(int,long,float) - vals = (1234,12.34,"1234","12.34") - - for eachFunc in funcs: - print "_"*20 - for eachVal in vals: - retval = testit(eachFunc,eachVal) - if retval[0]: - print "%s(%s)=" % \ - (eachFunc.__name__,"eachVal"),retval[1] - else: - print "%s(%s)=FAILED:" %\ - (eachFunc.__name__,"eachVal"),retval[1] - -if __name__ == "__main__": - test() diff --git a/primer/12/hot.py b/primer/12/hot.py deleted file mode 100644 index e695791..0000000 --- a/primer/12/hot.py +++ /dev/null @@ -1,10 +0,0 @@ -class HotelRoomCalc(object): - 'hotel room rate calculator' - def __init__(self,rt,sales=0.085,rm=0.1): - self.salesTax = sales - self.roomTax = rm - self.roomRate = rt - - def calcTotal(self,days = 1): - daily = round((self.roomRate *(1+self.roomTax + self.salesTax)),2) - return float(days)*daily diff --git a/primer/13/NumStr.py b/primer/13/NumStr.py deleted file mode 100644 index 986062b..0000000 --- a/primer/13/NumStr.py +++ /dev/null @@ -1,27 +0,0 @@ -class NumStr(object): - def __init__(self,num=0,string=''): - self.__num = num - self.__string = string - - def __str__(self): - return "[%d :: %r]" % (self.__num,self.__string) - __repr__ = __str__ - - def __add__(self,other): - if isinstance(other,NumStr): - return self.__class__(self.__num + \ - other.__num,self.__string+other.__string) - else: - return TypeError,"type error" - def __mul__(self,num): - if isinstance(num,int): - return self.__class__(self.__num *num,self.__string *num) - else: - raise TypeError,"__num__ error" - def __nonzero__(self): - return self.__num or len(self.__string) - def __norm_cval(self,cmpres): - return cmp(cmpres,0) - def __cmp__(self,other): - return self.__norm_cval(cmp(self.__num,other.__num)) + \ - self.__norm_cval(cmp(self.__string,other.__string)) diff --git a/primer/13/Rand.py b/primer/13/Rand.py deleted file mode 100644 index 1b56d0d..0000000 --- a/primer/13/Rand.py +++ /dev/null @@ -1,10 +0,0 @@ - -from random import choice - -class Rand(object): - def _init_(self,seq): - self.data = seq - def _iter_(self): - return self - def next(self): - return choice(self.data) diff --git a/primer/13/file.py b/primer/13/file.py deleted file mode 100644 index 8830909..0000000 --- a/primer/13/file.py +++ /dev/null @@ -1,34 +0,0 @@ -import os -import pickle - -class File(object): - saved=[] - def __init__(self,name=None): - self.name = name - def __get__(self,obj,typ=None): - if self.name not in File.saved: - raise AttributeError,"%r used before assignment " % self.name - try: - f = open(self.name,"r") - val = pickle.load(f) - f.close() - return val - except (pickle.UnpicklingError,IOError,EOFError,AttributeError,\ - ImportError,IndexError),e: - raise AttributeError,"could not read %r:%s" % (self.name,e) - def __set__(self,obj,val): - f = open(self.name,"w") - try: - pickle.dump(val,f) - File.saved.append(self.name) - except (TypeError,pickle.PicklingError),e: - raise AttributeError,"could not pickle %r " % self.name - finally: - f.close() - - def __delete__(self,obj): - try: - os.unlink(self.name) - File.saved.remove(self.name) - except (OSError,ValueError),e: - pass diff --git a/primer/13/meta.py b/primer/13/meta.py deleted file mode 100644 index 243a253..0000000 --- a/primer/13/meta.py +++ /dev/null @@ -1,28 +0,0 @@ -from warnings import warn - -class ReqStr(type): - def __init__(cls,name,bases,attrd): - super(ReqStr,cls).__init__(name,bases,attrd) - if "__str__" not in attrd: - raise TypeError("class overring __str__") - if "__repr__" not in attrd: - warn("class suggests __repr__",stacklevel = 3) -print "define ReqStr (meta)class \n" - -class Foo(object): - __metaclass__ = ReqStr - - def __str__(self): - return "instance of class",self.__class__.__name__ -print "defined Foo class\n" - -class Bar(object): - __metaclass__ = ReqStr - - def __str__(self): - return self.__class__.__name__ -print "defined Bar class\n " - -class FooBar(object): - __metaclass__ = ReqStr -print "defined FooBar class \n" diff --git a/primer/13/roundFloat.py b/primer/13/roundFloat.py deleted file mode 100644 index 06cf9bf..0000000 --- a/primer/13/roundFloat.py +++ /dev/null @@ -1,9 +0,0 @@ -class RoundFloat(object): - def __init__(self,val): - assert isinstance(val,float),"Value must be a float" - self.value = round(val,2) - - def __str__(self): - return "%.2f" %self.value - __repr__ = __str__ - diff --git a/primer/13/time.py b/primer/13/time.py deleted file mode 100644 index de514b5..0000000 --- a/primer/13/time.py +++ /dev/null @@ -1,28 +0,0 @@ - -from time import time,ctime - -class Time(object): - def __init__(self,obj): - self.__data = obj; - self.__ctime = self.__mtime =self.__atime=time() - def get(self): - self.__atime = time() - return self.__data - def gettimeval(self,t_type): - if not isinstance(t_type,str) or t_type[0] not in "cma": - raise TypeError," arg c m a" - return getattr(self,"_%s__%stime" % (self.__class__.__name__,t_type[0])) - def gettimestr(self,t_type): - return ctime(self.gettimeval(t_type)) - def set(self,obj): - self.__data = obj; - self.__mtime = self.__atime= time() - def __repr__(self): - self.__atime = time() - return 'self.__data' - def __str__(self): - self.__atime = time() - return str(self.__data) - def __getatt__(self,attr): - self.__atime = time() - return getattr(self.__data,attr) diff --git a/primer/13/time60.py b/primer/13/time60.py deleted file mode 100644 index a61ca1e..0000000 --- a/primer/13/time60.py +++ /dev/null @@ -1,14 +0,0 @@ -class Time60(object): - def __init__(self,hr,min): - self.hr = hr - self.min = min - def __str__(self): - return "%d:%d" %(self.hr,self.min) - __repr__ = __str__ - - def __add__(self,other): - return self.__class__(self.hr + other.hr,self.min+other.min) - def __iadd__(self,other): - self.hr += other.hr - self.min += other.min - return self diff --git a/primer/16/sockCli.py b/primer/16/sockCli.py deleted file mode 100644 index f97acee..0000000 --- a/primer/16/sockCli.py +++ /dev/null @@ -1,20 +0,0 @@ -from socket import * - -HOST ="localhost" -PORT = 8888 -BUFSIZ = 1024 -ADDR = (HOST,PORT) - -while True: - tcpCliSock = socket(AF_INET,SOCK_STREAM) - tcpCliSock.connect(ADDR) - data=raw_input(">") - - if not data: - break - tcpCliSock.send("%s\r\n" % data) - data = tcpCliSock.recv(BUFSIZ) - if not data: - break - print data.strip() - tcpCliSock.close() diff --git a/primer/16/sockServ.py b/primer/16/sockServ.py deleted file mode 100644 index 9e4e38f..0000000 --- a/primer/16/sockServ.py +++ /dev/null @@ -1,17 +0,0 @@ -from SocketServer import (TCPServer as TCP, - StreamRequestHandler as SRH) -from time import ctime - -HOST='' -PORT=8888 -ADDR=(HOST,PORT) - -class MyRequestHandler(SRH): - def handle(self): - print "......connected from :",self.client_address - self.wfile.write('[%s] %s' % - (ctime(),self.rfile.readline())) - -tcpSer = TCP(ADDR,MyRequestHandler) -print "waiting for connection..." -tcpSer.serve_forever() diff --git a/primer/16/tcpclient.py b/primer/16/tcpclient.py deleted file mode 100644 index b346fc6..0000000 --- a/primer/16/tcpclient.py +++ /dev/null @@ -1,21 +0,0 @@ -from socket import * - -HOST = "localhost" -PORT = 21567 -BUFSIZ = 1024 -ADDR=(HOST,PORT) - -tcpCliSock = socket(AF_INET,SOCK_STREAM) -tcpCliSock.connect(ADDR) - -while True: - data = raw_input(">") - if not data: - break - tcpCliSock.send(data) - data = tcpCliSock.recv(BUFSIZ) - if not data: - break - print data - -tcpCliSock.close() diff --git a/primer/16/tcpserver.py b/primer/16/tcpserver.py deleted file mode 100644 index 688b0aa..0000000 --- a/primer/16/tcpserver.py +++ /dev/null @@ -1,26 +0,0 @@ - -from socket import * -from time import ctime - -HOST ='' -PORT=21567 -BUFSIZ = 1024 -ADDR =(HOST,PORT) - -tcpSerSock = socket(AF_INET,SOCK_STREAM) -tcpSerSock.bind(ADDR) -tcpSerSock.listen(5) - -while True: - print "waiting from connection..." - tcpCliSock,addr = tcpSerSock.accept() - print "...connected from :",addr - - while True: - data = tcpCliSock.recv(BUFSIZ) - if not data: - break - tcpCliSock.send('[%s] %s' %(ctime(),data)) - - tcpCliSock.close() -tcpSerSock.close() diff --git a/primer/16/udpCli.py b/primer/16/udpCli.py deleted file mode 100644 index 9a502f3..0000000 --- a/primer/16/udpCli.py +++ /dev/null @@ -1,19 +0,0 @@ -from socket import * - -HOST="localhost" -PORT=21567 -BUFSIZ = 1024 -ADDR= (HOST,PORT) - -udpCliSock= socket(AF_INET,SOCK_DGRAM) - -while True: - data = raw_input(">") - if not data: - break - udpCliSock.sendto(data,ADDR) - data,ADDR= udpCliSock.recvfrom(BUFSIZ) - if not data: - break - print data -udpCliSock.close() diff --git a/primer/16/udpSer.py b/primer/16/udpSer.py deleted file mode 100644 index bf7eabb..0000000 --- a/primer/16/udpSer.py +++ /dev/null @@ -1,17 +0,0 @@ -from socket import * -from time import ctime - -HOST ='' -PORT=21567 -BUFSIZ=1024 -ADDR =(HOST,PORT) - -udpSerSock = socket(AF_INET,SOCK_DGRAM) -udpSerSock.bind(ADDR) - -while True: - print "waiting for message..." - data,addr = udpSerSock.recvfrom(BUFSIZ) - udpSerSock.sendto('[%s] %s' %(ctime(),data),addr) - print "...received from and returned to :",addr -udpSerSock.close() diff --git a/primer/18/sleep.py b/primer/18/sleep.py deleted file mode 100644 index 65c80cd..0000000 --- a/primer/18/sleep.py +++ /dev/null @@ -1,26 +0,0 @@ -from time import sleep,ctime -import thread - -loops =[4,2,11] -def loop(nloop,nsec,lock): - print " start loop:",nloop,"at",ctime() - sleep(nsec) - print "loop :",nloop, "done at:",ctime() - -def main(): - print "start at:", ctime() - locks = [] - nloops = range(len(loops)) - - for i in nloops: - lock = thread.allocate_lock() - lock.acquire() - locks.append(lock) - for i in nloops: - thread.start_new_thread(loop,(i,loops[i],locks[i])) - for i in nloops: - while locks[i].locked():pass - - print " all done at:" ,ctime() -if __name__ == "__main__": - main() diff --git a/primer/18/threading.py b/primer/18/threading.py deleted file mode 100644 index 367fcf3..0000000 --- a/primer/18/threading.py +++ /dev/null @@ -1,37 +0,0 @@ -import threading -from time import sleep,ctime - -loops = [4,2] - -class ThreadFunc(object): - def __init__(self,func,args,name=""): - self.name = name - self.name = func - self.args = args - def __call__(self): - apply(self.func,slef.args) - - def loop(nloop,nsec): - print "start loop:",nloop,"at:",ctime() - sleep(nsec) - print "loop",nloop,"done at:",ctime() - - def main(): - print "starting at:",ctime() - threads = [] - nloops = range(len(loops)) - #创建线程 - for i in nloops: - t = threading.Thread(target = loop,args=(i,loops[i])) - threads.append(t); - #启动线程 - for i in nloops: - threads[i].start() - #等待线程 - for i in nloops: - threads[i].join() - print "all DONE at:",ctime() - -if __name__ == "__main__": - main() - diff --git a/primer/19/label.py b/primer/19/label.py deleted file mode 100644 index cec761f..0000000 --- a/primer/19/label.py +++ /dev/null @@ -1,11 +0,0 @@ -import Tkinter - -top = Tkinter.Tk() - -label = Tkinter.Label(top,text="input") -label.pack() - -button = Tkinter.Button(top,text="QIUT",command=top.quit,bg="red",fg="white") -button.pack(fill=Tkinter.X,expand=1) - -Tkinter.mainloop() diff --git a/primer/2/2_10.py b/primer/2/2_10.py deleted file mode 100644 index d2dec79..0000000 --- a/primer/2/2_10.py +++ /dev/null @@ -1,10 +0,0 @@ -a = 19 -print "please input a int number in 1-100" -x = raw_input() -while x != a: - print "sorry error: \n input again:" - x = raw_input() -if x == a: - print "you are right" - break -raw_input() diff --git a/primer/2/2_11.py b/primer/2/2_11.py deleted file mode 100644 index 5c3877d..0000000 --- a/primer/2/2_11.py +++ /dev/null @@ -1,25 +0,0 @@ -print "Menu: input the choice\n" -print "s: sum" -print "a: avg" -print "x: exit" -aa =[1,2,3,4] -while True: - ch = raw_input() - if ch == 's': - i = 0 - Sum = 0 - print "you choice sum:\n" - for i in aa: - Sum += i - print Sum - if ch == 'a': - print "you choice avg:\n" - i = 0 - Sum1 = 0 - for i in aa: - Sum1 += i - avg = float(Sum1)/(len(aa)) - print avg - if ch == 'x': - break - diff --git a/primer/2/2_2.py b/primer/2/2_2.py deleted file mode 100644 index 5d71d06..0000000 --- a/primer/2/2_2.py +++ /dev/null @@ -1,9 +0,0 @@ -a = '19' -print "please input a int number in 1-100" -x = raw_input() -if a != x: - print "sorry you are wrong!\ninput again:" - x = raw_input() -else - print "you are right" -raw_input() diff --git a/primer/2/2_7.py b/primer/2/2_7.py deleted file mode 100644 index 8405c4b..0000000 --- a/primer/2/2_7.py +++ /dev/null @@ -1,5 +0,0 @@ -s = raw_input() -i = 0 -for i in s: - print i -raw_input() diff --git a/primer/2/2_8.py b/primer/2/2_8.py deleted file mode 100644 index 7ea33df..0000000 --- a/primer/2/2_8.py +++ /dev/null @@ -1,7 +0,0 @@ -s=[1,2,3,4,5,6] -i = 0 -t = 0 -for i in s: - t += i -print float(t)/len(s) -raw_input() diff --git a/primer/20/Cralwer.py b/primer/20/Cralwer.py deleted file mode 100644 index 3afa5d2..0000000 --- a/primer/20/Cralwer.py +++ /dev/null @@ -1,114 +0,0 @@ -#!/usr/bin/env python - -from sys import argv -from os import makedirs,unlink,sep -from os.path import dirname,exists,isdir,splitext -from string import replace,find,lower -from htmllib import HTMLParser -from urllib import urlretrieve -from urlparse import urlparse,urljoin -from formatter import DumbWriter,AbstractFormatter -from cStringIO import StringIO - -class Retriever(object):#下载网页类 - - def __init__(self,url): - self.url = url - self.file = self.filename(url) - - def filename(self,url,deffile ="index.htm"): - parsedurl = urlparse(url,"http:",0) #解析路径 - path = parsedurl[1] + parsedurl[2] - ext = splitext(path) - if ext[1] == "": #如果没有文件,使用默认 - if path[-1] == "/": - path += deffile - else: - path += "/" + deffile - ldir = dirname(path) #本地目录 - if sep != "/": - ldir = replace(ldir,"/",sep) - if not isdir(ldir): #如果没有目录,创建一个 - if exists(ldir):unlink(ldir) - makedirs(ldir) - return path - - def download(self):# 下载网页 - try: - retval = urlretrieve(self.url,self.file) - except IOError: - retval = ('***Error: invalid URL: "%s"' % self.url,) - return retval - - def parseAndGetLinks(self): #解析HTML,保存链接 - self.parser = HTMLParser(AbstractFormatter(DumbWriter(StringIO()))) - self.parser.feed(open(self.file).read()) - self.parser.close() - return self.parser.anchorlist - - -class Crawler(object): #管理类,管理整个爬行过程 - - count = 0 #下载网页计数器 - - def __init__ (self,url): - self.q = [url] #链接队列 - self.seen = [] #已下载 - self.dom = urlparse(url)[1] #判断链接是否为主链接的子域名 - - def getPage(self,url): #下载网页 - r = Retriever(url) - retval = r.download() - if retval[0] == "*": #错误,不解析 - print retval,"--- skipping parse" - return - Crawler.count += 1 - print '\n(',Crawler.count,')' - print "URL:",url - print "FILE:",retval[0] - self.seen.append(url) - - links = r.parseAndGetLinks() #得到链接 - for eachLink in links: - if eachLink[:4] != "http" and find(eachLink,"://") == -1: - eachLink = urljoin(url,eachLink) - print "* ",eachLink - - if find(lower(eachLink),"mailto:") != -1: #过滤邮箱链接 - print "--- discarded,mailto link" - continue - - if eachLink not in self.seen: - if find(eachLink,self.dom) == -1: - print "---discarded,not in domain" - else: - if eachLink not in self.q: - self.q.append(eachLink) - print "---new,add to Q" - else: - print "---discarded,already in Q" - else: - print "---discarded, arlready processed" - - def go(self): #在队列里处理链接,启动 - while self.q: - url=self.q.pop() - self.getPage(url) - -def main(): - if len(argv) > 1: - url = argv[1] - - else: - try: - url = raw_input("Enter starting URL:") - except (KeyboardInterrupt,EOFError): - url = "" - - if not url: return - robot = Crawler(url) - robot.go() - -if __name__ == "__main__": - main() - diff --git a/primer/3/makeFile.py b/primer/3/makeFile.py deleted file mode 100644 index bee369a..0000000 --- a/primer/3/makeFile.py +++ /dev/null @@ -1,14 +0,0 @@ -import os - -filename=raw_input("filename:") -if os.path.exists(filename): - print "file exist" - exit() - - -fd=open(filename,'r') -fd.writelines(all) -fd.close() - -print "done" - diff --git a/primer/3/makeTextFile.py b/primer/3/makeTextFile.py deleted file mode 100644 index 6969f8f..0000000 --- a/primer/3/makeTextFile.py +++ /dev/null @@ -1,32 +0,0 @@ - -'makeTextFile.py -- create text file' - -import os -ls = os.linesep - -#get filename -while True: - fname = raw_input() - if os.path.exists(fname): - print "ERROR: '%s' already exists " %fname - else: - break - -#get file content lines -all = [] -print "\n Enter lines:('.' by itself to quit)\n" - -#loop until user terminates input -while True: - entry = raw_input('>') - if entry == '.': - break - else: - all.append(entry) - -#write lines to file with proper line-ending -fobj = open(fname,'w') -fobj.writelines(['%s%s' %(x,ls) for x in all]) -fobj.close() -print 'DONE!' - diff --git a/primer/3/readTextFile.py b/primer/3/readTextFile.py deleted file mode 100644 index 980d3e0..0000000 --- a/primer/3/readTextFile.py +++ /dev/null @@ -1,17 +0,0 @@ - -'readTextFile.py -- read and display text file' - -#get filename -fname = raw_input("enter the filename:") -print - -#attempt to open file for reading -try: - fobj = open(fname,'r') -except IOError,e: - print "*** file open error",e -else: - #display contents to the screen - for echoLine in fobj: - print echoLine; -fobj.close() diff --git a/primer/4/display.py b/primer/4/display.py deleted file mode 100644 index e50f368..0000000 --- a/primer/4/display.py +++ /dev/null @@ -1,11 +0,0 @@ -def displayType(num): - print num, 'is', - if isinstance(num,(int,long,float,complex)): - print "a number of type:",type(num).__name__ - else: - print "not a number at all" - -displayType(2) -displayType(-1.9) -displayType(-1+1.9j) -displayType('xcxx') diff --git a/primer/6/idcheck.py b/primer/6/idcheck.py deleted file mode 100644 index b6d6220..0000000 --- a/primer/6/idcheck.py +++ /dev/null @@ -1,20 +0,0 @@ -import string - -alphas = string.letters +'_' -nums = string.digits - -print "Welcom to the indetifter Checker V1.0" -print "Test must be at least 2 Chars long" -myinput = raw_input("Iddentifter to test:\n") - -if len(myinput) > 1: - if myinput[0] not in alphas: - print "invald: first symbol must be alphbetic" - else: - for otherChar in myinput[1:]: - if alphas not in alphas + nums: - print "invalid:remaining symbols must be alphanumeric" - break - else: - print "ok as an identifier" -raw_input() diff --git a/primer/6/queue.py b/primer/6/queue.py deleted file mode 100644 index 7eb364d..0000000 --- a/primer/6/queue.py +++ /dev/null @@ -1,38 +0,0 @@ - -queue = [] -def enQ(): - queue.append(raw_input("enter new string:").strip()) -def deQ(): - if len(queue) == 0: - print "empty queue\n" - else: - print 'Removed![',`queue.pop()`,']' -def viewQ(): - print queue - -CMDS = {'e': enQ,'d':deQ,'v':viewQ} -def showmenu(): - pr=""" -(E)nqueue -(D)enqueue -(V)iew -(Q)uit - - enter choice: """ - while True: - while True: - try: - choice = raw_input(pr).strip()[0].lower() - except (EOFError,KeyboardInterpt,IndexError): - choice = "q" - print "\nYou picked:[%s]" % choice - if choice not in "devq": - print "error,try again" - else: - break - if choice == "q": - break - CMDS[choice]() -if __name__ == '__main__': - showmenu() - diff --git a/primer/6/stack.py b/primer/6/stack.py deleted file mode 100644 index 60dc053..0000000 --- a/primer/6/stack.py +++ /dev/null @@ -1,43 +0,0 @@ -stack = [] - -def pushit(): - stack.append(raw_input("enter a string:").strip()) - -def popit(): - if len(stack) == 0: - print "stack is empty!\n" - else: - print 'Removed![',`stack.pop()`,']' - -def viewstack(): - print stack - -CMDS ={'u': pushit,'o':popit,'v':viewstack} - -def showmenu(): - pr = """ - p(U)sh - p(O)p - (V)iew - (Q)uit - - enter the choice: """ - while True: - while True: - try: - choice = raw_input(pr).strip()[0].lower() - except (EOFError,KeyboardInterrupt,IndexError): - choice ="q" - - print "\nYou picked:[%s]" % choice - if choice not in "uovq": - print "Invalid option,try again" - else: - break - - if choice == "q": - break - CMDS[choice]() -if __name__ == "__main__": - showmenu() - diff --git a/primer/6/unicode.txt b/primer/6/unicode.txt deleted file mode 100644 index f3e8683..0000000 --- a/primer/6/unicode.txt +++ /dev/null @@ -1 +0,0 @@ -hhhh diff --git a/primer/6/unicodeFile.py b/primer/6/unicodeFile.py deleted file mode 100644 index 0752725..0000000 --- a/primer/6/unicodeFile.py +++ /dev/null @@ -1,20 +0,0 @@ -''' -An example for unicode string -''' - -CODEC = "utf-8" -FILE = "unicode.txt" - -hello = u"hhhh\n" -byte = hello.encode(CODEC) -f = open(FILE,"w") -f.write(byte) -f.close() - -f = open(FILE,"r") -byte = f.read() -f.close() - -hello = byte.decode(CODEC) -print hello -raw_input() diff --git a/primer/7/userpw.py b/primer/7/userpw.py deleted file mode 100644 index ebe358e..0000000 --- a/primer/7/userpw.py +++ /dev/null @@ -1,53 +0,0 @@ -db = {} - -def newuser(): - prompt = "login desired:" - while True: - name = raw_input(prompt) - if db.has_key(name): - prompt = "name taken,try again: " - continue - else: - break - pwd = raw_input("passwd:") - db[name]= pwd - print "regeisted oK!\n" - -def olduser(): - name = raw_input("login:") - pwd = raw_input("passwd:") - passwd = db.get(name) - if passwd == pwd: - print "welcome back," ,name - else: - print "login incorrect" - -def showmenu(): - prompt = """ - (n) new user login - (l) exiting user login - (q) quit - enter choice : """ - - done = False - while not done: - chosen = False - while not chosen: - try: - choice = raw_input(prompt).strip()[0].lower() - except(EOFError,KeyboardInterrupt): - choice = "q" - print "\n you picked [%s]" % choice - if choice not in "nlq": - print "invalid option, try again" - else: - chosen = True - - if choice == "q": done = True - if choice == "n": newuser() - if choice == "l": olduser() - -if __name__ == "__main__": - showmenu() - - diff --git a/primer/9/ospathex.py b/primer/9/ospathex.py deleted file mode 100644 index a333211..0000000 --- a/primer/9/ospathex.py +++ /dev/null @@ -1,38 +0,0 @@ - -import os -for tmpdir in("/tmp"): - if os.path.isdir(tmpdir): - break - else: - print "no temp dir available" - tmpdir = "" -if tmpdir: - os.chdir(tmpdir) - cwd = os.getcwd() - print " current tmp dir" - print cwd - - print "create example dir" - os.mkdir("example") - os.chdir("example") - cwd = os.getcwd() - print "new work dir" - print cwd - print "list the dir" - print os.listdir(cwd) - - print "create test file" - fobj = open("test","w") - fobj.write("xxg\n") - fobj.write("111111") - fobj.close() - - print "update the list dir" - print os.listdir(cwd) - - print "rename the file" - os.rename("test","xxg.txt") - print os.listdir(cwd) - - - diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2e9a0a4 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,49 @@ +[project] +name = "flypython" +version = "0.1.1" +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", +] + +[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", + "pandas>=2.3,<2.4", + "matplotlib>=3.10,<3.11", +] + +[tool.pytest.ini_options] +addopts = "-q" +testpaths = ["tests"] +pythonpath = ["."] + +[tool.ruff] +target-version = "py311" +line-length = 88 + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "SIM"] +ignore = ["E501"] + +[tool.ruff.lint.per-file-ignores] +# 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/radar.json b/radar.json new file mode 100644 index 0000000..5a74686 --- /dev/null +++ b/radar.json @@ -0,0 +1,194 @@ +{ + "$schema": "./schema/radar-v1.schema.json", + "schema_version": 1, + "projects": [ + { + "id": "fastapi", + "repo": "fastapi/fastapi", + "url": "https://github.com/fastapi/fastapi", + "category": "web-apis", + "status": "stable", + "first_seen": "2026-09-02", + "reviewed_on": "2026-09-02", + "license": "MIT", + "evidence": { + "last_release": "Active release train; verified against the repository releases page at review time.", + "release_cadence": "Regular minor releases; security fixes ship promptly.", + "maintenance": "Large maintainer team and contributor base; de facto standard for typed ASGI services." + }, + "ai_familiarity": "high", + "alternatives": [ + "flask", + "django-ninja", + "litestar" + ], + "rationale_en": "Production-standard ASGI framework with automatic OpenAPI docs, Pydantic validation, and dependency injection.", + "rationale_zh": "生产级 ASGI 框架,自带 OpenAPI 文档、Pydantic 校验与依赖注入。", + "when_not_to_use_en": "For non-HTTP services, or teams standardized on Django's bundled ORM/admin stack.", + "when_not_to_use_zh": "非 HTTP 服务,或已围绕 Django 自带 ORM 与 admin 体系标准化的 团队。", + "risk_en": "Ensure background tasks handle errors properly and use async endpoints responsibly; blocking calls inside async routes degrade the whole service.", + "risk_zh": "需正确处理后台任务异常、审慎使用 async 端点;在 async 路由中执行阻塞 调用会拖垮整个服务。" + }, + { + "id": "instructor", + "repo": "567-labs/instructor", + "url": "https://github.com/567-labs/instructor", + "category": "ai-tools", + "status": "stable", + "first_seen": "2026-09-02", + "reviewed_on": "2026-09-02", + "license": "MIT", + "evidence": { + "last_release": "Active release train; verified against the repository releases page at review time.", + "release_cadence": "Regular releases tracking provider API changes.", + "maintenance": "Actively maintained with broad provider integration coverage." + }, + "ai_familiarity": "medium", + "alternatives": [ + "openai structured outputs", + "outlines" + ], + "rationale_en": "Production standard for extracting structured JSON from LLMs using Pydantic models with retry validation.", + "rationale_zh": "从 LLM 提取结构化 JSON 的事实标准,基于 Pydantic 模型并带重试 校验。", + "when_not_to_use_en": "When your provider already enforces structured outputs natively and you need nothing beyond it.", + "when_not_to_use_zh": "所用提供商已原生强制结构化输出、且不需要更多能力时。", + "risk_en": "Requires API keys for the target LLM providers; retry loops add latency and token cost.", + "risk_zh": "需要目标 LLM 提供商的 API Key;重试循环会增加延迟与 token 成本。" + }, + { + "id": "marimo", + "repo": "marimo-team/marimo", + "url": "https://github.com/marimo-team/marimo", + "category": "notebooks", + "status": "rising", + "first_seen": "2026-09-02", + "reviewed_on": "2026-09-02", + "license": "Apache-2.0", + "evidence": { + "last_release": "Active release train; verified against the repository releases page at review time.", + "release_cadence": "Frequent releases while the product matures.", + "maintenance": "Actively maintained by the marimo team with responsive issue triage." + }, + "ai_familiarity": "low", + "alternatives": [ + "jupyter", + "quarto" + ], + "rationale_en": "Reactive, pure-Python notebook stored as standard executable .py files with deterministic state execution.", + "rationale_zh": "响应式纯 Python 笔记本,以标准可执行 .py 文件保存,状态执行具有 确定性。", + "when_not_to_use_en": "When your workflow depends on Jupyter-only extensions, or kernel-state debugging is central to your process.", + "when_not_to_use_zh": "工作流依赖 Jupyter 专属扩展,或内核状态调试是核心流程时。", + "risk_en": "Requires a modern browser environment and replaces the Jupyter workflow rather than extending it.", + "risk_zh": "需要现代浏览器环境;它是 Jupyter 工作流的替代而非扩展。" + }, + { + "id": "polars", + "repo": "pola-rs/polars", + "url": "https://github.com/pola-rs/polars", + "category": "data-pipelines", + "status": "stable", + "first_seen": "2026-09-02", + "reviewed_on": "2026-09-02", + "license": "MIT", + "evidence": { + "last_release": "Active release train; verified against the repository releases page at review time.", + "release_cadence": "Frequent releases; performance-focused changelogs.", + "maintenance": "Actively maintained by the Polars team and a growing contributor base." + }, + "ai_familiarity": "medium", + "alternatives": [ + "pandas", + "duckdb", + "dask" + ], + "rationale_en": "High-performance DataFrame library built in Rust on Apache Arrow with lazy query optimization.", + "rationale_zh": "高性能 DataFrame 库,基于 Rust 与 Apache Arrow,支持惰性查询 优化。", + "when_not_to_use_en": "When your pipeline leans on the pandas ecosystem (accessors, narrow libraries) or you need index-heavy semantics.", + "when_not_to_use_zh": "管线依赖 pandas 生态(accessor、小众库)或需要重度索引语义时。", + "risk_en": "API differs from pandas and memory layout is columnar; budget migration time rather than assuming drop-in parity.", + "risk_zh": "API 与 pandas 不同、内存为列式布局;迁移需要预留时间,不能当作 直接替换。" + }, + { + "id": "pydantic-ai", + "repo": "pydantic/pydantic-ai", + "url": "https://github.com/pydantic/pydantic-ai", + "category": "ai-agents", + "status": "rising", + "first_seen": "2026-09-02", + "reviewed_on": "2026-09-02", + "license": "MIT", + "evidence": { + "last_release": "Active pre-1.0 release train; verified against the repository releases page at review time.", + "release_cadence": "Frequent releases while the API surface evolves.", + "maintenance": "Maintained by the Pydantic team; expect breaking changes before 1.0." + }, + "ai_familiarity": "low", + "alternatives": [ + "openai-agents-sdk", + "langgraph" + ], + "rationale_en": "Model-agnostic agent framework prioritizing type-safe structured outputs, dependency injection, and testability.", + "rationale_zh": "模型无关的 Agent 框架,优先保证类型安全的结构化输出、依赖注入与 可测试性。", + "when_not_to_use_en": "When you need a stable long-lived API surface today, or heavy multi-agent orchestration features.", + "when_not_to_use_zh": "需要当下就稳定的长周期 API,或重度多 Agent 编排能力时。", + "risk_en": "Rapidly evolving API surface; pin minor versions and re-run your evals on every upgrade.", + "risk_zh": "API 演进迅速;固定小版本,每次升级后重跑评测。" + }, + { + "id": "ruff", + "repo": "astral-sh/ruff", + "url": "https://github.com/astral-sh/ruff", + "category": "code-quality", + "status": "stable", + "first_seen": "2026-09-02", + "reviewed_on": "2026-09-02", + "license": "MIT", + "evidence": { + "last_release": "Active release train; verified against the repository releases page at review time.", + "release_cadence": "Frequent releases with annotated changelogs.", + "maintenance": "Actively maintained by Astral; broad adoption across major projects." + }, + "ai_familiarity": "high", + "alternatives": [ + "flake8", + "black", + "isort", + "pylint" + ], + "rationale_en": "10-100x faster linter and formatter that unifies Flake8, Black, isort, and pyupgrade rules in a single configuration.", + "rationale_zh": "快 10–100 倍的 linter 与格式化工具,把 Flake8、Black、isort、 pyupgrade 规则统一到一份配置里。", + "when_not_to_use_en": "When a project depends on plugin ecosystems (e.g. Flake8 plugins) that have no Ruff equivalent yet.", + "when_not_to_use_zh": "项目依赖尚无 Ruff 等价实现的插件生态(如 Flake8 插件)时。", + "risk_en": "Drop-in Black compatibility; rare syntax-parsing differences surface on unusual code bases.", + "risk_zh": "与 Black 基本兼容;在非常规代码上偶见语法解析差异。" + }, + { + "id": "uv", + "repo": "astral-sh/uv", + "url": "https://github.com/astral-sh/uv", + "category": "tooling-packaging", + "status": "stable", + "first_seen": "2026-09-02", + "reviewed_on": "2026-09-02", + "license": "MIT OR Apache-2.0", + "evidence": { + "last_release": "Active release train; verified against the repository releases page at review time.", + "release_cadence": "Frequent point releases, often multiple per month.", + "maintenance": "Actively maintained by Astral with a public roadmap and rapid issue triage." + }, + "ai_familiarity": "medium", + "alternatives": [ + "pip", + "pip-tools", + "pyenv", + "poetry" + ], + "rationale_en": "Extremely fast Rust-based package and project manager that replaces pip, pip-tools, venv, and pyenv with lockfile determinism.", + "rationale_zh": "极快的 Rust 实现的包与项目管理器,以锁文件确定性取代 pip、 pip-tools、venv 与 pyenv。", + "when_not_to_use_en": "When you must pin an existing pip-tools or Poetry workflow, or in air-gapped environments without wheel mirrors.", + "when_not_to_use_zh": "必须沿用既有 pip-tools 或 Poetry 工作流时,或缺少 wheel 镜像的离线环境中。", + "risk_en": "Actively maintained by Astral; relies on prebuilt binary wheels, so supply-chain review applies on upgrade.", + "risk_zh": "由 Astral 积极维护;依赖预编译二进制 wheel,升级时需做供应链审查。" + } + ] +} diff --git a/requirements-dev.lock.txt b/requirements-dev.lock.txt new file mode 100644 index 0000000..9d5b0a2 --- /dev/null +++ b/requirements-dev.lock.txt @@ -0,0 +1,24 @@ +-r requirements.lock.txt +attrs==26.1.0 +iniconfig==2.3.0 +jsonschema==4.26.0 +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 +kiwisolver==1.5.1 +matplotlib==3.10.9 +numpy==2.5.3 +pandas==2.3.3 +pillow==12.3.0 +pyparsing==3.3.2 +python-dateutil==2.9.0.post0 +pytz==2026.3.post1 +six==1.17.0 +tzdata==2026.3 diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..2137822 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,5 @@ +-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 new file mode 100644 index 0000000..bbdff30 --- /dev/null +++ b/requirements.lock.txt @@ -0,0 +1,6 @@ +PyYAML==6.0.3 +certifi==2026.7.22 +charset-normalizer==3.5.1 +idna==3.19 +requests==2.32.5 +urllib3==2.6.3 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4d2c6a5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +PyYAML==6.0.3 +requests==2.32.5 +urllib3==2.6.3 diff --git a/schema/catalog-v1.schema.json b/schema/catalog-v1.schema.json new file mode 100644 index 0000000..0f0dde2 --- /dev/null +++ b/schema/catalog-v1.schema.json @@ -0,0 +1,160 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "FlyPython catalog export", + "type": "object", + "additionalProperties": false, + "required": ["$schema", "schema_version", "catalog", "paths", "resources"], + "properties": { + "$schema": { + "const": "./schema/catalog-v1.schema.json" + }, + "schema_version": { + "const": 1 + }, + "catalog": { + "type": "object", + "additionalProperties": false, + "required": ["reviewed_on", "status"], + "properties": { + "reviewed_on": { + "type": "string", + "format": "date" + }, + "status": { + "const": "active" + } + } + }, + "paths": { + "type": "array", + "minItems": 4, + "maxItems": 4, + "items": { + "$ref": "#/$defs/path" + } + }, + "resources": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/resource" + } + } + }, + "$defs": { + "localizedPath": { + "type": "string", + "enum": ["foundations", "web-apis", "automation", "ai-agents"] + }, + "path": { + "type": "object", + "additionalProperties": false, + "required": ["id", "order", "title_en", "title_zh", "summary_en", "summary_zh"], + "properties": { + "id": { + "$ref": "#/$defs/localizedPath" + }, + "order": { + "type": "integer", + "minimum": 1 + }, + "title_en": { + "type": "string", + "minLength": 1 + }, + "title_zh": { + "type": "string", + "minLength": 1 + }, + "summary_en": { + "type": "string", + "minLength": 1 + }, + "summary_zh": { + "type": "string", + "minLength": 1 + } + } + }, + "resource": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "path", + "title", + "url", + "source_type", + "level", + "language", + "why_en", + "why_zh", + "reviewed_on", + "status", + "requires_key", + "risk", + "featured", + "order" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "path": { + "$ref": "#/$defs/localizedPath" + }, + "title": { + "type": "string", + "minLength": 1 + }, + "url": { + "type": "string", + "format": "uri", + "pattern": "^https://" + }, + "source_type": { + "type": "string", + "enum": ["official-docs", "official-standard", "official-project"] + }, + "level": { + "type": "string", + "enum": ["beginner", "intermediate", "advanced", "all-levels"] + }, + "language": { + "type": "string", + "enum": ["en", "zh", "multilingual"] + }, + "why_en": { + "type": "string", + "minLength": 1 + }, + "why_zh": { + "type": "string", + "minLength": 1 + }, + "reviewed_on": { + "type": "string", + "format": "date" + }, + "status": { + "const": "active" + }, + "requires_key": { + "type": "boolean" + }, + "risk": { + "type": "string", + "enum": ["low", "medium"] + }, + "featured": { + "type": "boolean" + }, + "order": { + "type": "integer", + "minimum": 1 + } + } + } + } +} diff --git a/schema/content-manifest-v1.schema.json b/schema/content-manifest-v1.schema.json new file mode 100644 index 0000000..30d40e1 --- /dev/null +++ b/schema/content-manifest-v1.schema.json @@ -0,0 +1,43 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://flypython.com/schemas/content-manifest-v1.schema.json", + "title": "FlyPython first-party content manifest", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "documents"], + "properties": { + "schema_version": {"const": 1}, + "documents": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "type", "content_version", "status", "reviewed_on", "locales"], + "properties": { + "id": {"type": "string", "pattern": "^[a-z0-9][a-z0-9-]+$"}, + "type": {"enum": ["guide", "playbook", "example", "course", "path"]}, + "content_version": {"type": "integer", "minimum": 1}, + "status": {"const": "reviewed"}, + "reviewed_on": {"type": "string", "format": "date"}, + "locales": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["lang", "path", "title", "summary", "sha256"], + "properties": { + "lang": {"enum": ["en-US", "zh-CN"]}, + "path": {"type": "string"}, + "title": {"type": "string", "minLength": 1}, + "summary": {"type": "string", "minLength": 1}, + "sha256": {"type": "string", "pattern": "^[a-f0-9]{64}$"} + } + } + } + } + } + } + } +} diff --git a/schema/radar-v1.schema.json b/schema/radar-v1.schema.json new file mode 100644 index 0000000..e431927 --- /dev/null +++ b/schema/radar-v1.schema.json @@ -0,0 +1,117 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "FlyPython Project Radar export", + "type": "object", + "additionalProperties": false, + "required": ["$schema", "schema_version", "projects"], + "properties": { + "$schema": { + "const": "./schema/radar-v1.schema.json" + }, + "schema_version": { + "const": 1 + }, + "projects": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "repo", + "url", + "category", + "status", + "first_seen", + "reviewed_on", + "license", + "evidence", + "ai_familiarity", + "alternatives", + "rationale_en", + "rationale_zh", + "when_not_to_use_en", + "when_not_to_use_zh", + "risk_en", + "risk_zh" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "repo": { + "type": "string", + "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$" + }, + "url": { + "type": "string", + "format": "uri", + "pattern": "^https://" + }, + "category": { + "type": "string", + "enum": [ + "tooling-packaging", + "code-quality", + "web-apis", + "ai-agents", + "ai-tools", + "data-pipelines", + "notebooks" + ] + }, + "status": { + "type": "string", + "enum": [ + "new", + "rising", + "stable", + "major-update", + "experimental", + "archived" + ] + }, + "first_seen": { + "type": "string", + "format": "date" + }, + "reviewed_on": { + "type": "string", + "format": "date" + }, + "license": { + "type": "string", + "minLength": 1 + }, + "evidence": { + "type": "object", + "additionalProperties": false, + "required": ["last_release", "release_cadence", "maintenance"], + "properties": { + "last_release": {"type": "string", "minLength": 1}, + "release_cadence": {"type": "string", "minLength": 1}, + "maintenance": {"type": "string", "minLength": 1} + } + }, + "ai_familiarity": { + "type": "string", + "enum": ["low", "medium", "high"] + }, + "alternatives": { + "type": "array", + "items": {"type": "string", "minLength": 1}, + "uniqueItems": true + }, + "rationale_en": {"type": "string", "minLength": 1}, + "rationale_zh": {"type": "string", "minLength": 1}, + "when_not_to_use_en": {"type": "string", "minLength": 1}, + "when_not_to_use_zh": {"type": "string", "minLength": 1}, + "risk_en": {"type": "string", "minLength": 1}, + "risk_zh": {"type": "string", "minLength": 1} + } + } + } + } +} diff --git a/templates/AGENTS.example.md b/templates/AGENTS.example.md new file mode 100644 index 0000000..bea8afc --- /dev/null +++ b/templates/AGENTS.example.md @@ -0,0 +1,10 @@ +# Repository instructions for coding agents + +- Inspect the repository and existing tests before proposing a change. +- State assumptions and keep edits within the task contract. +- Never expose credentials or add network access without authorization. +- Prefer the smallest change that proves the requested behavior. +- Add or update tests for behavior, not implementation trivia. +- Preserve unrelated work in a dirty working tree. +- Run deterministic checks and report exactly what remains unverified. +- Do not commit, push, publish, deploy, or delete data unless explicitly asked. diff --git a/templates/AGENTS.example_cn.md b/templates/AGENTS.example_cn.md new file mode 100644 index 0000000..f9797c6 --- /dev/null +++ b/templates/AGENTS.example_cn.md @@ -0,0 +1,10 @@ +# Agent 仓库操作守则 (AGENTS.md) + +- 在提议或修改代码之前,先检查代码库现状与现有测试。 +- 明确假设前提,改动严格收敛在给定的任务契约范围内。 +- 未经明确授权,绝不泄露敏感凭据或引入未经许可的网络请求。 +- 优先采用能够证明预期行为的最小改动方案。 +- 针对业务行为添加或更新测试,而非针对实现琐碎细节写断言。 +- 保护工作区中与当前任务无关的未提交改动。 +- 运行确定性检查,并向用户准确汇报哪些项目仍未被验证。 +- 除非用户明确要求,否则不要擅自执行 commit、push、发布、部署或删除数据。 diff --git a/templates/AGENT_RULES.example.md b/templates/AGENT_RULES.example.md new file mode 100644 index 0000000..48a2515 --- /dev/null +++ b/templates/AGENT_RULES.example.md @@ -0,0 +1,16 @@ +# AI Coding Agent Rules (Cursor / Windsurf / Copilot / Claude) + +## Core Working Rules + +1. **Explicit Boundaries**: Do not modify files outside the specific task scope. +2. **Contract First**: Read existing type definitions, schemas, and test assertions before proposing changes. +3. **No Phantom Code**: Every code modification must be backed by an executable test or verifier command. +4. **Preserve Integrity**: Do not delete unrelated comments, docstrings, or formatting unless explicitly requested. +5. **Standard Tooling**: Prefer modern standard tooling (Python 3.11+, uv, pytest, ruff, mypy). + +## Change Verification Protocol + +Before declaring a task done: +1. Run local tests: `pytest` +2. Run static analysis: `ruff check` and `mypy` +3. Verify git diff: Ensure zero unintended side effects or modified global state. diff --git a/templates/AGENT_RULES.example_cn.md b/templates/AGENT_RULES.example_cn.md new file mode 100644 index 0000000..1b3cbd5 --- /dev/null +++ b/templates/AGENT_RULES.example_cn.md @@ -0,0 +1,16 @@ +# AI 编程智能体规则模板 (Cursor / Windsurf / Copilot / Claude) + +## 核心工作守则 + +1. **清晰边界 (Explicit Boundaries)**:严禁修改任务范围之外的无关文件。 +2. **契约优先 (Contract First)**:在动手写代码前,先阅读既有类型定义、数据契约与测试断言。 +3. **拒绝幻觉代码 (No Phantom Code)**:每一次代码改动,都必须有明确可执行的测试或验证命令支撑。 +4. **保持完整规范 (Preserve Integrity)**:除非明确要求,不要删除无关的注释、文档字符串或格式。 +5. **现代工程栈 (Standard Tooling)**:优先推荐现代化标准工具链(Python 3.11+、uv、pytest、ruff、mypy)。 + +## 改动交付验证流程 + +在声明任务完成前,必须依次执行: +1. 运行本地测试:`pytest` +2. 运行静态检查:`ruff check` 与 `mypy` +3. 检查变更影响:通过 `git diff` 确认无任何预期外的全局副作用。 diff --git a/templates/API_SPEC.md b/templates/API_SPEC.md new file mode 100644 index 0000000..e92e37d --- /dev/null +++ b/templates/API_SPEC.md @@ -0,0 +1,41 @@ +# Public API Specification + +- **Endpoint**: `POST /v1/transactions` +- **Authentication**: `Bearer <api_key>` +- **Idempotency**: Supported via `Idempotency-Key` header + +## Request Contract + +```json +{ + "user_id": 101, + "amount": 99.95, + "currency": "USD", + "status": "pending" +} +``` + +## Response Contract (200 OK) + +```json +{ + "transaction_id": "txn_8f7b2c", + "user_id": 101, + "amount": 99.95, + "currency": "USD", + "status": "pending", + "created_at": "2026-09-02T12:00:00Z" +} +``` + +## Error Contract (4xx / 5xx) + +```json +{ + "error": { + "code": "INVALID_PARAM", + "message": "amount must be greater than 0", + "field": "amount" + } +} +``` diff --git a/templates/API_SPEC_cn.md b/templates/API_SPEC_cn.md new file mode 100644 index 0000000..924f2af --- /dev/null +++ b/templates/API_SPEC_cn.md @@ -0,0 +1,41 @@ +# 公开接口契约规范(API Specification) + +- **接口路径**:`POST /v1/transactions` +- **鉴权方式**:`Bearer <api_key>` +- **幂等机制**:通过 `Idempotency-Key` 请求头支持 + +## 请求结构契约(Request Body) + +```json +{ + "user_id": 101, + "amount": 99.95, + "currency": "USD", + "status": "pending" +} +``` + +## 成功响应契约(200 OK) + +```json +{ + "transaction_id": "txn_8f7b2c", + "user_id": 101, + "amount": 99.95, + "currency": "USD", + "status": "pending", + "created_at": "2026-09-02T12:00:00Z" +} +``` + +## 统一错误响应结构(4xx / 5xx) + +```json +{ + "error": { + "code": "INVALID_PARAM", + "message": "amount must be greater than 0", + "field": "amount" + } +} +``` diff --git a/templates/CODE_REVIEW.md b/templates/CODE_REVIEW.md new file mode 100644 index 0000000..973e9e1 --- /dev/null +++ b/templates/CODE_REVIEW.md @@ -0,0 +1,8 @@ +# Code review + +- Does the change satisfy the user contract and preserve unrelated behavior? +- Are boundary inputs, errors, timeouts, and retries explicit? +- Are secrets, permissions, destructive effects, and model/tool calls safe? +- Do tests prove the reported failure, success, and important edge cases? +- Is the diff smaller and clearer than plausible alternatives? +- Was real runtime behavior checked where unit tests are insufficient? diff --git a/templates/CODE_REVIEW_cn.md b/templates/CODE_REVIEW_cn.md new file mode 100644 index 0000000..db14436 --- /dev/null +++ b/templates/CODE_REVIEW_cn.md @@ -0,0 +1,8 @@ +# 代码审查 + +- 改动是否满足用户契约,并保持无关行为不变? +- 边界输入、错误处理、超时与重试是否明确? +- 密钥、权限、破坏性影响以及模型/工具调用是否安全? +- 测试是否证明了所报告的故障、成功路径和关键边界情况? +- 相比其他合理方案,本次 diff 是否更小、更清晰? +- 在单元测试不足以覆盖的场景,是否检查过真实运行时行为? diff --git a/templates/EVAL_RECORD.md b/templates/EVAL_RECORD.md new file mode 100644 index 0000000..7772f66 --- /dev/null +++ b/templates/EVAL_RECORD.md @@ -0,0 +1,21 @@ +# LLM / Agent Evaluation Record + +- **Evaluation ID**: `eval-YYYY-MM-DD-01` +- **Target Model / Version**: `gpt-4o-2024-08-06` / `claude-3-5-sonnet-20241022` +- **Prompt / Commit SHA**: `<git-sha>` +- **Evaluator**: `@maintainer` + +## Benchmark Results + +| Metric | Baseline | Candidate | Delta | Status | +| --- | --- | --- | --- | --- | +| Schema Conformance | 98.5% | 100.0% | +1.5% | PASS | +| Exact Match Pass Rate | 84.0% | 88.5% | +4.5% | PASS | +| Average Latency (p95) | 1.42s | 1.18s | -0.24s | PASS | +| Token Cost / 1k req | $1.20 | $0.95 | -$0.25 | PASS | + +## Boundary & Edge Case Regressions + +- [x] Malformed JSON input handling verified +- [x] Prompt injection and system prompt leak attempts blocked +- [x] Empty response fallback verified diff --git a/templates/EVAL_RECORD_cn.md b/templates/EVAL_RECORD_cn.md new file mode 100644 index 0000000..32003e9 --- /dev/null +++ b/templates/EVAL_RECORD_cn.md @@ -0,0 +1,21 @@ +# 大模型 / Agent 评测记录单 + +- **评测编号**:`eval-YYYY-MM-DD-01` +- **目标模型 / 版本**:`gpt-4o-2024-08-06` / `claude-3-5-sonnet-20241022` +- **提示词版本 / Commit SHA**:`<git-sha>` +- **评测人**:`@maintainer` + +## 基准测试结果 + +| 指标维度 | 基线(Baseline) | 候选版本(Candidate) | 变化幅度(Delta) | 结论 | +| --- | --- | --- | --- | --- | +| Schema 契约符合率 | 98.5% | 100.0% | +1.5% | 通过 | +| 确定性匹配通过率 | 84.0% | 88.5% | +4.5% | 通过 | +| 平均延迟(P95) | 1.42s | 1.18s | -0.24s | 通过 | +| 千次请求 Token 成本 | $1.20 | $0.95 | -$0.25 | 通过 | + +## 边界与对抗性测试用例 + +- [x] 非法畸形 JSON 容错与解析回退完成验证 +- [x] 提示词注入攻击与系统 Prompt 防窃取完成验证 +- [x] 空输出回退机制完成验证 diff --git a/templates/IMPLEMENTATION_PLAN.md b/templates/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..8f0bfab --- /dev/null +++ b/templates/IMPLEMENTATION_PLAN.md @@ -0,0 +1,13 @@ +# Implementation plan + +## Repository findings + +## Smallest useful change + +## Files and boundaries + +## Verification sequence + +## Risks and rollback + +## Decisions requiring a human diff --git a/templates/IMPLEMENTATION_PLAN_cn.md b/templates/IMPLEMENTATION_PLAN_cn.md new file mode 100644 index 0000000..8a4d832 --- /dev/null +++ b/templates/IMPLEMENTATION_PLAN_cn.md @@ -0,0 +1,13 @@ +# 实现计划 + +## 仓库现状调查 + +## 最小可用改动 + +## 文件与改动边界 + +## 验证顺序 + +## 风险与回滚 + +## 需要人决策的事项 diff --git a/templates/README.md b/templates/README.md new file mode 100644 index 0000000..d6bd50c --- /dev/null +++ b/templates/README.md @@ -0,0 +1,11 @@ +# Reusable templates + +- [Task contract](TASK_CONTRACT.md) ([中文](TASK_CONTRACT_cn.md)): outcome, boundaries, acceptance, authority. +- [Implementation plan](IMPLEMENTATION_PLAN.md) ([中文](IMPLEMENTATION_PLAN_cn.md)): findings, steps, risks, rollback. +- [Code review](CODE_REVIEW.md) ([中文](CODE_REVIEW_cn.md)): correctness, security, maintainability, evidence. +- [Verification record](VERIFICATION.md) ([中文](VERIFICATION_cn.md)): exact commands and observed results. +- [Agent repository instructions](AGENTS.example.md) ([中文](AGENTS.example_cn.md)): a starting policy for a Python repository using coding agents. +- [Agent IDE rules](AGENT_RULES.example.md) ([中文](AGENT_RULES.example_cn.md)): universal configuration template for Cursor (.cursorrules), Windsurf, and Copilot. +- [LLM evaluation record](EVAL_RECORD.md) ([中文](EVAL_RECORD_cn.md)): structured template for prompt and agent benchmark tracking. +- [API specification](API_SPEC.md) ([中文](API_SPEC_cn.md)): contract definition for REST endpoints and error payloads. +- [`pyproject.toml` example](pyproject.example.toml): a minimal quality baseline using a `src/` layout, pytest, Ruff, and mypy. diff --git a/templates/README_cn.md b/templates/README_cn.md new file mode 100644 index 0000000..1818d47 --- /dev/null +++ b/templates/README_cn.md @@ -0,0 +1,11 @@ +# 可复用工程模板 + +- [任务契约](TASK_CONTRACT_cn.md) ([English](TASK_CONTRACT.md)):目标结果、改动边界、验收标准、决策权限。 +- [实现计划](IMPLEMENTATION_PLAN_cn.md) ([English](IMPLEMENTATION_PLAN.md)):现状调查、实现步骤、风险点与回滚策略。 +- [代码审查](CODE_REVIEW_cn.md) ([English](CODE_REVIEW.md)):正确性、安全性、可维护性与验证凭据。 +- [验证记录](VERIFICATION_cn.md) ([English](VERIFICATION.md)):执行命令与实际观察结果。 +- [Agent 仓库守则](AGENTS.example_cn.md) ([English](AGENTS.example.md)):面向 Coding Agent 的仓库级准则规范。 +- [Agent IDE 规则](AGENT_RULES.example_cn.md) ([English](AGENT_RULES.example.md)):适用于 Cursor (.cursorrules)、Windsurf、Copilot 的通用预设。 +- [大模型评测记录](EVAL_RECORD_cn.md) ([English](EVAL_RECORD.md)):提示词与 Agent benchmark 的结构化评测模版。 +- [API 规范契约](API_SPEC_cn.md) ([English](API_SPEC.md)):REST 接口契约定义与错误 Payload 规范。 +- [`pyproject.toml` 示例](pyproject.example.toml):基于 `src/` 布局、pytest、Ruff 与 mypy 的极简代码质量基线。 diff --git a/templates/TASK_CONTRACT.md b/templates/TASK_CONTRACT.md new file mode 100644 index 0000000..6dfff09 --- /dev/null +++ b/templates/TASK_CONTRACT.md @@ -0,0 +1,11 @@ +# Task contract + +- User outcome: +- Current behavior: +- Expected behavior: +- Inputs and outputs: +- Allowed files or systems: +- Explicitly out of scope: +- Failure and recovery behavior: +- Acceptance commands or user flows: +- Network, dependency, commit, push, and deployment authority: diff --git a/templates/TASK_CONTRACT_cn.md b/templates/TASK_CONTRACT_cn.md new file mode 100644 index 0000000..a125090 --- /dev/null +++ b/templates/TASK_CONTRACT_cn.md @@ -0,0 +1,11 @@ +# 任务契约 + +- 用户结果: +- 当前行为: +- 预期行为: +- 输入与输出: +- 允许修改的文件或系统: +- 明确不做: +- 失败与恢复行为: +- 验收命令或用户流程: +- 联网、依赖、提交、推送和部署权限: diff --git a/templates/VERIFICATION.md b/templates/VERIFICATION.md new file mode 100644 index 0000000..913f950 --- /dev/null +++ b/templates/VERIFICATION.md @@ -0,0 +1,10 @@ +# Verification record + +- Commit or working-tree state: +- Environment and Python version: +- Command or user flow: +- Expected result: +- Observed result: +- Logs, state, or artifact inspected: +- Remaining unverified behavior: +- Rollback check: diff --git a/templates/VERIFICATION_cn.md b/templates/VERIFICATION_cn.md new file mode 100644 index 0000000..d733c06 --- /dev/null +++ b/templates/VERIFICATION_cn.md @@ -0,0 +1,10 @@ +# 验证记录 + +- Commit 或工作树状态: +- 环境与 Python 版本: +- 命令或用户流程: +- 预期结果: +- 实际结果: +- 已检查的日志、状态或产物: +- 尚未验证的行为: +- 回退检查: diff --git a/templates/pyproject.example.toml b/templates/pyproject.example.toml new file mode 100644 index 0000000..43d9fcb --- /dev/null +++ b/templates/pyproject.example.toml @@ -0,0 +1,19 @@ +[project] +name = "example-product" +version = "0.1.0" +requires-python = ">=3.11" + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-q" + +[tool.ruff] +target-version = "py311" +line-length = 88 + +[tool.ruff.lint] +select = ["E", "F", "I", "B", "UP"] + +[tool.mypy] +python_version = "3.11" +strict = true diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..b8bc414 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from datetime import date +from typing import Any + +import pytest + + +@pytest.fixture +def valid_catalog() -> dict[str, Any]: + paths = [] + resources = [] + for order, path_id in enumerate( + ("foundations", "web-apis", "automation", "ai-agents"), start=1 + ): + paths.append( + { + "id": path_id, + "title_en": f"{path_id} title", + "title_zh": f"{path_id} 标题", + "summary_en": f"{path_id} summary", + "summary_zh": f"{path_id} 摘要", + "order": order, + } + ) + resources.append( + { + "id": f"resource-{order}", + "path": path_id, + "title": f"Resource {order}", + "url": f"https://example{order}.com/docs/", + "source_type": "official-docs", + "level": "beginner", + "language": "en", + "why_en": "Primary documentation maintained by the project.", + "why_zh": "由项目维护的官方文档。", + "reviewed_on": date(2026, 8, 31), + "status": "active", + "requires_key": False, + "risk": "low", + "featured": order == 1, + "order": 1, + } + ) + return { + "catalog": { + "reviewed_on": date(2026, 8, 31), + "status": "active", + "paths": paths, + }, + "resources": resources, + } diff --git a/tests/test_ai_coding_content.py b/tests/test_ai_coding_content.py new file mode 100644 index 0000000..ee01898 --- /dev/null +++ b/tests/test_ai_coding_content.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import re +from pathlib import Path + +import yaml + +from tools.catalog import load_catalog + +ROOT = Path(__file__).resolve().parents[1] +# flypython.com links are first-party contextual continuations governed by +# docs/REPO_TO_WEBSITE.md, not catalog references. +FIRST_PARTY_PREFIX = "https://flypython.com" +GUIDES = { + "en-US": ROOT / "guides" / "ai-coding" / "workflow.md", + "zh-CN": ROOT / "guides" / "ai-coding" / "workflow_cn.md", +} + + +def load_guide(path: Path) -> tuple[dict, str]: + content = path.read_text(encoding="utf-8") + assert content.startswith("---\n") + _, front_matter, body = content.split("---\n", maxsplit=2) + return yaml.safe_load(front_matter), body + + +def test_ai_coding_guides_have_matching_identity_and_version() -> None: + loaded = {lang: load_guide(path) for lang, path in GUIDES.items()} + english, _ = loaded["en-US"] + chinese, _ = loaded["zh-CN"] + + assert english["id"] == chinese["id"] == "python-ai-coding-workflow" + assert english["content_version"] == chinese["content_version"] == 1 + assert english["reviewed_on"] == chinese["reviewed_on"] + assert english["lang"] == "en-US" + assert chinese["lang"] == "zh-CN" + + +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) + assert headings == [str(number) for number in range(1, 9)] + + +def test_guide_reference_urls_come_from_reviewed_catalog() -> None: + catalog = load_catalog(ROOT / "catalog") + catalog_urls = {resource["url"] for resource in catalog["resources"]} + + for path in GUIDES.values(): + _, body = load_guide(path) + urls = set(re.findall(r"\]\((https://[^)]+)\)", body)) + assert urls + assert {url for url in urls if not url.startswith(FIRST_PARTY_PREFIX)} <= catalog_urls + + +def test_readmes_link_to_the_matching_ai_coding_guide() -> None: + english = (ROOT / "README.md").read_text(encoding="utf-8") + chinese = (ROOT / "README_cn.md").read_text(encoding="utf-8") + + assert "guides/ai-coding/workflow.md" in english + assert "guides/ai-coding/workflow_cn.md" in chinese diff --git a/tests/test_catalog.py b/tests/test_catalog.py new file mode 100644 index 0000000..d5ab4d3 --- /dev/null +++ b/tests/test_catalog.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +from copy import deepcopy +from datetime import date +from pathlib import Path + +import pytest +import yaml + +from tools.catalog import ( + CatalogLoadError, + canonical_hostname, + load_catalog, + normalize_url, + validate_catalog, +) +from tools.validate_catalog import run + + +def test_valid_catalog_passes(valid_catalog: dict) -> None: + assert validate_catalog(valid_catalog, today=date(2026, 8, 31)) == [] + + +@pytest.mark.parametrize("language", ["en", "zh", "multilingual"]) +def test_supported_language_values_pass(valid_catalog: dict, language: str) -> None: + data = deepcopy(valid_catalog) + data["resources"][0]["language"] = language + assert validate_catalog(data, today=date(2026, 8, 31)) == [] + + +def test_loader_rejects_duplicate_yaml_keys(tmp_path) -> None: + catalog = tmp_path / "resources.yml" + catalog.write_text("catalog: {}\ncatalog: {}\nresources: []\n", encoding="utf-8") + with pytest.raises(CatalogLoadError, match="duplicate key"): + load_catalog(catalog) + + +def test_loader_rejects_non_string_mapping_keys(tmp_path) -> None: + catalog = tmp_path / "resources.yml" + catalog.write_text("? [catalog]\n: {}\nresources: []\n", encoding="utf-8") + with pytest.raises(CatalogLoadError, match="mapping keys must be strings"): + load_catalog(catalog) + + +def test_schema_duplicate_https_date_and_parity_errors(valid_catalog: dict) -> None: + data = deepcopy(valid_catalog) + data["resources"][0]["url"] = "http://example.invalid/docs" + data["resources"][1]["url"] = "https://example.invalid/docs/" + data["resources"][2]["url"] = "https://example.invalid/docs" + data["resources"][2]["reviewed_on"] = "2025-01-01" + data["resources"][3]["why_zh"] = "" + issues = validate_catalog(data, today=date(2026, 8, 31), max_review_age_days=366) + codes = {issue.code for issue in issues} + assert {"https-required", "duplicate-url", "stale-review", "invalid-text"} <= codes + + +def test_resource_ids_and_urls_reject_unsafe_forms(valid_catalog: dict) -> None: + data = deepcopy(valid_catalog) + data["resources"][0]["id"] = "Not A Slug" + data["resources"][0]["url"] = "https://user:secret@example.com/docs" + codes = { + issue.code for issue in validate_catalog(data, today=date(2026, 8, 31)) + } + assert {"invalid-id", "url-credentials"} <= codes + + +def test_path_orders_must_be_consecutive(valid_catalog: dict) -> None: + data = deepcopy(valid_catalog) + data["catalog"]["paths"][-1]["order"] = 5 + + codes = { + issue.code for issue in validate_catalog(data, today=date(2026, 8, 31)) + } + + assert "order-parity" in codes + + +def test_resource_orders_must_be_unique_and_consecutive(valid_catalog: dict) -> None: + data = deepcopy(valid_catalog) + duplicate = deepcopy(data["resources"][0]) + duplicate["id"] = "another-foundation-resource" + duplicate["url"] = "https://another.example.com/docs/" + data["resources"].append(duplicate) + + codes = { + issue.code for issue in validate_catalog(data, today=date(2026, 8, 31)) + } + + assert {"duplicate-resource-order", "resource-order-parity"} <= codes + + +def test_directory_loader_composes_catalog_sources( + tmp_path: Path, valid_catalog: dict +) -> None: + catalog_dir = tmp_path / "catalog" + resources_dir = catalog_dir / "resources" + resources_dir.mkdir(parents=True) + metadata = { + "reviewed_on": valid_catalog["catalog"]["reviewed_on"], + "status": valid_catalog["catalog"]["status"], + } + (catalog_dir / "catalog.yml").write_text( + yaml.safe_dump(metadata, sort_keys=False), encoding="utf-8" + ) + (catalog_dir / "paths.yml").write_text( + yaml.safe_dump(valid_catalog["catalog"]["paths"], sort_keys=False), + encoding="utf-8", + ) + for resource in reversed(valid_catalog["resources"]): + (resources_dir / f"{resource['id']}.yml").write_text( + yaml.safe_dump(resource, sort_keys=False), encoding="utf-8" + ) + + loaded = load_catalog(catalog_dir) + + assert loaded == valid_catalog + + +def test_directory_loader_requires_resource_id_to_match_filename( + tmp_path: Path, valid_catalog: dict +) -> None: + catalog_dir = tmp_path / "catalog" + resources_dir = catalog_dir / "resources" + resources_dir.mkdir(parents=True) + (catalog_dir / "catalog.yml").write_text( + "reviewed_on: 2026-08-31\nstatus: active\n", encoding="utf-8" + ) + (catalog_dir / "paths.yml").write_text("[]\n", encoding="utf-8") + (resources_dir / "wrong-name.yml").write_text( + yaml.safe_dump(valid_catalog["resources"][0], sort_keys=False), + encoding="utf-8", + ) + + with pytest.raises(CatalogLoadError, match="resource id must match filename"): + load_catalog(catalog_dir) + + +@pytest.mark.parametrize( + ("url", "expected"), + [ + ( + "HTTPS://[2001:4860:4860::8888]:443/docs/", + "https://[2001:4860:4860::8888]/docs", + ), + ( + "https://[2001:4860:4860::8888]:8443/docs/", + "https://[2001:4860:4860::8888]:8443/docs", + ), + ], +) +def test_normalize_url_preserves_ipv6_brackets(url: str, expected: str) -> None: + assert normalize_url(url) == expected + + +def test_hostname_canonicalization_handles_idna_and_trailing_dot() -> None: + assert canonical_hostname("BÜCHER.example.") == "xn--bcher-kva.example" + + +def test_validator_detects_idna_equivalent_duplicate_urls(valid_catalog: dict) -> None: + data = deepcopy(valid_catalog) + data["resources"][0]["url"] = "https://bücher.example/docs/" + data["resources"][1]["url"] = "https://xn--bcher-kva.example/docs" + + codes = { + issue.code for issue in validate_catalog(data, today=date(2026, 8, 31)) + } + + assert "duplicate-url" in codes + + +def test_validator_reports_non_string_mapping_keys(valid_catalog: dict) -> None: + data = deepcopy(valid_catalog) + data["catalog"][1] = "unexpected" + + issues = validate_catalog(data, today=date(2026, 8, 31)) + + assert any(issue.code == "invalid-key" for issue in issues) + + +def test_validator_exit_code_for_invalid_catalog(tmp_path) -> None: + catalog = tmp_path / "resources.yml" + catalog.write_text("catalog: {}\nresources: []\n", encoding="utf-8") + assert run(["--catalog", str(catalog)]) == 1 diff --git a/tests/test_check_links.py b/tests/test_check_links.py new file mode 100644 index 0000000..2986a05 --- /dev/null +++ b/tests/test_check_links.py @@ -0,0 +1,677 @@ +from __future__ import annotations + +import json +import threading +from pathlib import Path + +import pytest +import requests + +import tools.check_links as check_links +from tools.check_links import ( + CatalogLink, + LinkChecker, + LinkResult, + PinnedDNSHTTPAdapter, + SafeTargetGuard, + ThreadLocalSessions, + UnsafeTarget, + build_report, + build_session, + classify_status, + exit_code_for_report, + select_links, +) + +PUBLIC_IP = "93.184.216.34" + + +class FakeResponse: + def __init__(self, status_code, url="https://example.com/", history=None, headers=None): + self.status_code = status_code + self.url = url + self.history = history or [] + self.headers = headers or {} + self.closed = False + + def close(self): + self.closed = True + + +class FakeSession: + def __init__(self, head, get=None): + self.head_response = head + self.get_response = get + self.calls = [] + self.closed = False + + def head(self, url, **kwargs): + self.calls.append(("HEAD", url, kwargs)) + return self.head_response + + def get(self, url, **kwargs): + self.calls.append(("GET", url, kwargs)) + return self.get_response + + def close(self): + self.closed = True + + +def guard_for(address=PUBLIC_IP): + return SafeTargetGuard(lambda _host, _port: [address]) + + +def link(): + return CatalogLink("docs", "foundations", "Docs", "https://example.com/docs") + + +@pytest.mark.parametrize( + ("status_code", "expected"), + [ + (200, "working"), + (301, "redirect"), + (403, "review"), + (408, "review"), + (425, "review"), + (429, "review"), + (503, "review"), + (404, "broken"), + ], +) +def test_status_classification(status_code, expected) -> None: + assert classify_status(status_code, redirected=False) == expected + + +def test_redirect_history_is_preserved() -> None: + hop = FakeResponse(301, "https://example.com/old", headers={"Location": "/docs"}) + response = FakeResponse(200, "https://example.com/docs") + + class RedirectSession(FakeSession): + def __init__(self): + super().__init__(hop) + self.responses = iter([hop, response]) + + def head(self, url, **kwargs): + self.calls.append(("HEAD", url, kwargs)) + return next(self.responses) + + session = RedirectSession() + checker = LinkChecker( + guard=guard_for(), session_factory=lambda: session, workers=1, min_interval=0 + ) + redirect_link = CatalogLink( + "docs", "foundations", "Docs", "https://example.com/old" + ) + result = checker.check_one(redirect_link) + assert result.status == "redirect" + assert result.history == [ + {"status_code": 301, "url": "https://example.com/old", "location": "/docs"} + ] + assert hop.closed is True + + +def test_head_failure_falls_back_to_streaming_get_and_confirms_404() -> None: + session = FakeSession(FakeResponse(404), FakeResponse(404)) + checker = LinkChecker( + guard=guard_for(), session_factory=lambda: session, workers=1, min_interval=0 + ) + result = checker.check_one(link()) + assert result.status == "broken" + assert result.method == "GET" + assert session.calls[1][2]["stream"] is True + + +def test_head_redirect_without_location_falls_back_to_get() -> None: + head = FakeResponse(301) + response = FakeResponse(200) + session = FakeSession(head, response) + checker = LinkChecker( + guard=guard_for(), session_factory=lambda: session, workers=1, min_interval=0 + ) + + result = checker.check_one(link()) + + assert result.status == "working" + assert result.method == "GET" + assert head.closed is True + assert response.closed is True + + +def test_get_redirect_without_location_is_fatal() -> None: + head = FakeResponse(301) + response = FakeResponse(301) + session = FakeSession(head, response) + checker = LinkChecker( + guard=guard_for(), session_factory=lambda: session, workers=1, min_interval=0 + ) + + result = checker.check_one(link()) + + assert result.status == "error" + assert "no Location" in (result.error or "") + assert head.closed is True + assert response.closed is True + + +def test_unsupported_redirect_status_is_fatal() -> None: + response = FakeResponse(304) + session = FakeSession(response) + checker = LinkChecker( + guard=guard_for(), session_factory=lambda: session, workers=1, min_interval=0 + ) + + result = checker.check_one(link()) + + assert result.status == "error" + assert "unsupported redirect status 304" in (result.error or "") + assert response.closed is True + + +@pytest.mark.parametrize("status_code", [403, 408, 425, 429, 500, 503]) +def test_transient_and_access_denied_statuses_need_review(status_code) -> None: + head = FakeResponse(status_code) + session = FakeSession(head, FakeResponse(status_code)) + checker = LinkChecker( + guard=guard_for(), + session_factory=lambda: session, + workers=1, + retries=0, + min_interval=0, + ) + assert checker.check_one(link()).status == "review" + assert len(session.calls) == 1 + assert head.closed is True + + +@pytest.mark.parametrize( + "url", + [ + "http://localhost/admin", + "http://127.0.0.1/", + "http://169.254.169.254/latest/meta-data/", + "http://100.100.100.200/latest/meta-data/", + "http://metadata.google.internal/computeMetadata/v1/", + "https://168.63.129.16/", + "https://224.0.0.1/", + "https://[64:ff9b::7f00:1]/", + "https://[::ffff:93.184.216.34]/", + ], +) +def test_literal_local_and_metadata_targets_are_blocked(url) -> None: + with pytest.raises(UnsafeTarget): + SafeTargetGuard().resolve_url(url) + + +def test_dns_resolution_to_private_ip_is_blocked() -> None: + with pytest.raises(UnsafeTarget, match="non-public"): + guard_for("10.0.0.8").resolve_url("https://example.com/") + + +def test_adapter_pins_public_ip_and_preserves_tls_hostname() -> None: + adapter = PinnedDNSHTTPAdapter(guard_for(), max_retries=0) + captured = {} + + class PoolManager: + def connection_from_host(self, **kwargs): + captured.update(kwargs) + return "pool" + + adapter.poolmanager = PoolManager() + request = requests.Request("GET", "https://example.com/path").prepare() + assert adapter.get_connection_with_tls_context(request, True) == "pool" + assert captured["host"] == PUBLIC_IP + assert captured["pool_kwargs"]["server_hostname"] == "example.com" + assert captured["pool_kwargs"]["assert_hostname"] == "example.com" + assert request.headers["Host"] == "example.com" + + +def test_adapter_revalidates_and_blocks_an_unsafe_redirect_hop() -> None: + resolved_hosts = [] + + def resolver(host, _port): + resolved_hosts.append(host) + return [PUBLIC_IP if host == "example.com" else "127.0.0.1"] + + adapter = PinnedDNSHTTPAdapter(SafeTargetGuard(resolver), max_retries=0) + + class PoolManager: + def connection_from_host(self, **_kwargs): + return "pool" + + adapter.poolmanager = PoolManager() + first = requests.Request("GET", "https://example.com/start").prepare() + redirect = requests.Request("GET", "https://internal.example/admin").prepare() + + assert adapter.get_connection_with_tls_context(first, True) == "pool" + with pytest.raises(UnsafeTarget, match="non-public"): + adapter.get_connection_with_tls_context(redirect, True) + assert resolved_hosts == ["example.com", "internal.example"] + + +def test_checker_blocks_https_redirect_downgrade() -> None: + hop = FakeResponse( + 302, + "https://example.com/start", + headers={"Location": "http://example.com/docs"}, + ) + session = FakeSession(hop) + checker = LinkChecker( + guard=guard_for(), session_factory=lambda: session, workers=1, min_interval=0 + ) + + result = checker.check_one(link()) + + assert result.status == "blocked" + assert "may not downgrade" in (result.error or "") + assert hop.closed is True + + +def test_thread_local_sessions_are_not_shared_between_workers() -> None: + created = [] + barrier = threading.Barrier(2) + + def factory(): + value = object() + created.append(value) + return value + + sessions = ThreadLocalSessions(factory) + results = [] + + def worker(): + first = sessions.get() + barrier.wait() + results.append((first, sessions.get())) + + threads = [threading.Thread(target=worker) for _ in range(2)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + assert len(created) == 2 + assert results[0][0] is results[0][1] + assert results[1][0] is results[1][1] + assert results[0][0] is not results[1][0] + + +def test_check_all_closes_worker_sessions() -> None: + sessions = [] + + def factory(): + session = FakeSession(FakeResponse(200)) + sessions.append(session) + return session + + checker = LinkChecker( + guard=guard_for(), session_factory=factory, workers=1, min_interval=0 + ) + assert len(checker.check_all([link()])) == 1 + assert len(sessions) == 1 + assert sessions[0].closed is True + + +def test_mode_selects_internal_external_or_all() -> None: + data = { + "resources": [ + {"id": "internal", "path": "foundations", "title": "I", "url": "/guide/"}, + { + "id": "external", + "path": "foundations", + "title": "E", + "url": "https://docs.python.org/3/", + }, + ] + } + internal = select_links(data, mode="internal", base_url="https://flypython.com/") + external = select_links(data, mode="external", base_url="https://flypython.com/") + all_links = select_links(data, mode="all", base_url="https://flypython.com/") + assert [item.id for item in internal] == ["internal"] + assert [item.id for item in external] == ["external"] + assert len(all_links) == 2 + + +def test_exit_code_fails_every_actionable_result() -> None: + for status in ("review", "broken", "blocked", "error"): + result = LinkResult("id", "path", "Title", "https://example.com", status) + report = build_report(catalog=Path("catalog.yml"), mode="all", results=[result]) + assert exit_code_for_report(report) == 1 + + working = LinkResult( + "id", "path", "Title", "https://example.com", "working" + ) + report = build_report(catalog=Path("catalog.yml"), mode="all", results=[working]) + assert exit_code_for_report(report) == 0 + + +@pytest.mark.parametrize( + "report", + [ + {}, + {"counts": {}}, + {"counts": {"total": 0}}, + { + "counts": { + "working": 1, + "redirect": 0, + "review": 0, + "broken": 0, + "blocked": 0, + "error": 0, + } + }, + { + "counts": { + "working": 1, + "redirect": 0, + "review": 0, + "broken": 0, + "blocked": 0, + "error": 0, + "unknown": 1, + "total": 1, + } + }, + { + "counts": { + "working": 0, + "redirect": 0, + "review": 0, + "broken": 0, + "blocked": 0, + "error": 0, + "total": 1, + } + }, + ], +) +def test_exit_code_rejects_malformed_reports(report: dict) -> None: + assert exit_code_for_report(report) == 2 + + +def test_unknown_result_status_cannot_fail_open() -> None: + result = LinkResult("id", "path", "Title", "https://example.com", "typo") + report = build_report(catalog=Path("catalog.yml"), mode="all", results=[result]) + + assert exit_code_for_report(report) == 2 + + +def test_unexpected_checker_error_is_fatal() -> None: + class BrokenSession(FakeSession): + def head(self, url, **kwargs): + raise RuntimeError("programming defect") + + checker = LinkChecker( + guard=guard_for(), + session_factory=lambda: BrokenSession(FakeResponse(200)), + workers=1, + retries=0, + min_interval=0, + ) + result = checker.check_one(link()) + assert result.status == "error" + assert "programming defect" in (result.error or "") + + +@pytest.mark.parametrize( + "exception", + [ + requests.exceptions.ConnectionError("connection failed"), + requests.exceptions.SSLError("certificate failed"), + requests.exceptions.TooManyRedirects("redirect loop"), + requests.exceptions.InvalidURL("invalid redirect"), + ], +) +def test_terminal_request_failures_are_fatal(exception) -> None: + class BrokenSession(FakeSession): + def head(self, url, **kwargs): + raise exception + + checker = LinkChecker( + guard=guard_for(), + session_factory=lambda: BrokenSession(FakeResponse(200)), + workers=1, + retries=0, + min_interval=0, + ) + + assert checker.check_one(link()).status == "error" + + +def test_timeout_remains_review_needed() -> None: + class SlowSession(FakeSession): + def head(self, url, **kwargs): + raise requests.Timeout("timed out") + + checker = LinkChecker( + guard=guard_for(), + session_factory=lambda: SlowSession(FakeResponse(200)), + workers=1, + retries=0, + min_interval=0, + ) + + assert checker.check_one(link()).status == "review" + + +def test_adapter_transport_and_status_retries_are_disabled() -> None: + session = build_session(guard_for()) + try: + retry = session.get_adapter("https://").max_retries + assert retry.respect_retry_after_header is False + assert retry.backoff_max == 5.0 + assert retry.total == 0 + assert retry.connect == 0 + assert retry.read == 0 + assert retry.status == 0 + assert not retry.status_forcelist + finally: + session.close() + + +def test_status_retry_is_manual_and_closes_each_response() -> None: + first = FakeResponse(503) + second = FakeResponse(200) + + class RetrySession(FakeSession): + def __init__(self): + super().__init__(first) + self.responses = iter([first, second]) + + def head(self, url, **kwargs): + self.calls.append(("HEAD", url, kwargs)) + return next(self.responses) + + session = RetrySession() + checker = LinkChecker( + guard=guard_for(), + session_factory=lambda: session, + workers=1, + retries=1, + backoff_factor=0, + min_interval=0, + ) + + result = checker.check_one(link()) + + assert result.status == "working" + assert len(session.calls) == 2 + assert first.closed is True + assert second.closed is True + + +@pytest.mark.parametrize("retry_after", ["60", "9" * 400]) +def test_large_retry_after_stops_without_get_fallback( + monkeypatch, retry_after: str +) -> None: + response = FakeResponse(429, headers={"Retry-After": retry_after}) + session = FakeSession(response) + checker = LinkChecker( + guard=guard_for(), + session_factory=lambda: session, + workers=1, + retries=2, + min_interval=0, + ) + sleeps = [] + monkeypatch.setattr(check_links.time, "sleep", sleeps.append) + + result = checker.check_one(link()) + + assert result.status == "review" + assert len(session.calls) == 1 + assert sleeps == [] + assert response.closed is True + + +def test_bounded_retry_after_is_honored(monkeypatch) -> None: + first = FakeResponse(429, headers={"Retry-After": "2"}) + second = FakeResponse(200) + + class RetrySession(FakeSession): + def __init__(self): + super().__init__(first) + self.responses = iter([first, second]) + + def head(self, url, **kwargs): + self.calls.append(("HEAD", url, kwargs)) + return next(self.responses) + + session = RetrySession() + checker = LinkChecker( + guard=guard_for(), + session_factory=lambda: session, + workers=1, + retries=1, + min_interval=0, + ) + sleeps = [] + monkeypatch.setattr(check_links.time, "sleep", sleeps.append) + + result = checker.check_one(link()) + + assert result.status == "working" + assert len(session.calls) == 2 + assert sleeps == [2.0] + + +def test_transport_timeout_retry_is_manual() -> None: + response = FakeResponse(200) + + class FlakySession(FakeSession): + def __init__(self): + super().__init__(response) + self.attempt = 0 + + def head(self, url, **kwargs): + self.calls.append(("HEAD", url, kwargs)) + self.attempt += 1 + if self.attempt == 1: + raise requests.Timeout("timed out") + return response + + session = FlakySession() + checker = LinkChecker( + guard=guard_for(), + session_factory=lambda: session, + workers=1, + retries=1, + backoff_factor=0, + min_interval=0, + ) + + result = checker.check_one(link()) + + assert result.status == "working" + assert len(session.calls) == 2 + + +def test_wrapped_timeout_remains_review_needed() -> None: + class WrappedTimeoutSession(FakeSession): + def head(self, url, **kwargs): + raise requests.ConnectionError(TimeoutError("timed out")) + + checker = LinkChecker( + guard=guard_for(), + session_factory=lambda: WrappedTimeoutSession(FakeResponse(200)), + workers=1, + retries=0, + min_interval=0, + ) + + assert checker.check_one(link()).status == "review" + + +def test_responses_close_when_result_processing_fails(monkeypatch) -> None: + head = FakeResponse(404) + response = FakeResponse(200) + session = FakeSession(head, response) + checker = LinkChecker( + guard=guard_for(), session_factory=lambda: session, workers=1, min_interval=0 + ) + + def fail_classification(_status_code, *, redirected): + raise RuntimeError("cannot process response") + + monkeypatch.setattr(check_links, "classify_status", fail_classification) + result = checker.check_one(link()) + + assert result.status == "error" + assert head.closed is True + assert response.closed is True + + +@pytest.mark.parametrize( + ("option", "value"), + [ + ("--timeout", "nan"), + ("--timeout", "inf"), + ("--backoff", "nan"), + ("--min-interval", "inf"), + ], +) +def test_cli_rejects_non_finite_float_arguments(option: str, value: str) -> None: + with pytest.raises(SystemExit, match="2"): + check_links.build_parser().parse_args([option, value]) + + +@pytest.mark.parametrize( + ("status", "status_code", "expected_exit"), + [ + ("review", 408, 1), + ("broken", 404, 1), + ("blocked", None, 1), + ("error", None, 1), + ], +) +def test_cli_writes_json_and_uses_report_exit_code( + monkeypatch, + tmp_path: Path, + valid_catalog: dict, + status: str, + status_code: int | None, + expected_exit: int, +) -> None: + monkeypatch.setattr(check_links, "load_catalog", lambda _path: valid_catalog) + monkeypatch.setattr(check_links, "validate_catalog", lambda _data: []) + result = LinkResult( + "docs", + "foundations", + "Docs", + "https://example.com/docs", + status, + status_code=status_code, + method="GET" if status_code is not None else None, + ) + monkeypatch.setattr( + check_links.LinkChecker, "check_all", lambda _self, _links: [result] + ) + output = tmp_path / f"{status}.json" + + exit_code = check_links.run( + ["--catalog", "ignored.yml", "--output", str(output), "--min-interval", "0"] + ) + + assert exit_code == expected_exit + report = json.loads(output.read_text(encoding="utf-8")) + assert report["counts"][status] == 1 + assert report["results"][0]["status_code"] == status_code diff --git a/tests/test_content_manifest.py b/tests/test_content_manifest.py new file mode 100644 index 0000000..6a9ebe8 --- /dev/null +++ b/tests/test_content_manifest.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from jsonschema import Draft202012Validator, FormatChecker + +from tools.content_manifest import build_manifest + +ROOT = Path(__file__).resolve().parents[1] + + +def test_content_manifest_matches_sources_and_schema() -> None: + generated = build_manifest(ROOT) + committed = json.loads((ROOT / "content-manifest.json").read_text(encoding="utf-8")) + schema = json.loads( + (ROOT / "schema" / "content-manifest-v1.schema.json").read_text(encoding="utf-8") + ) + + assert committed == generated + Draft202012Validator(schema, format_checker=FormatChecker()).validate(committed) + + +def test_each_document_has_a_unique_bilingual_pair() -> None: + manifest = build_manifest(ROOT) + assert manifest["documents"] + assert len({item["id"] for item in manifest["documents"]}) == len(manifest["documents"]) + for item in manifest["documents"]: + assert [locale["lang"] for locale in item["locales"]] == ["en-US", "zh-CN"] diff --git a/tests/test_content_parity.py b/tests/test_content_parity.py new file mode 100644 index 0000000..8a11a4a --- /dev/null +++ b/tests/test_content_parity.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any + +import yaml + +from tools.catalog import load_catalog +from tools.content_manifest import build_manifest + +ROOT = Path(__file__).resolve().parents[1] +# flypython.com links are first-party contextual continuations governed by +# docs/REPO_TO_WEBSITE.md, not catalog references. +FIRST_PARTY_PREFIX = "https://flypython.com" + + +def _extract_frontmatter(path: Path) -> dict[str, Any]: + text = path.read_text(encoding="utf-8") + assert text.startswith("---\n"), f"{path} must start with frontmatter" + parts = text.split("---\n", maxsplit=2) + return yaml.safe_load(parts[1]) + + +def test_content_manifest_builds_without_errors() -> None: + manifest = build_manifest(ROOT) + assert manifest["schema_version"] == 1 + assert len(manifest["documents"]) >= 10 + + +def test_bilingual_guide_urls_are_in_catalog() -> None: + catalog = load_catalog(ROOT / "catalog") + catalog_urls = {resource["url"] for resource in catalog["resources"]} + + for guide_path in ROOT.glob("guides/**/*.md"): + if guide_path.name == "README.md" or guide_path.name == "README_cn.md": + continue + text = guide_path.read_text(encoding="utf-8") + external_urls = set(re.findall(r"\]\((https://[^)]+)\)", text)) + for url in external_urls: + if url.startswith(FIRST_PARTY_PREFIX): + continue + assert url in catalog_urls, ( + f"Guide {guide_path.relative_to(ROOT)} references unreviewed URL: {url}" + ) diff --git a/tests/test_courses.py b/tests/test_courses.py new file mode 100644 index 0000000..d9a93b3 --- /dev/null +++ b/tests/test_courses.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from pathlib import Path + +from tools.content_manifest import build_manifest +from tools.verify_courses import check_bilingual_contract, discover_courses + +ROOT = Path(__file__).resolve().parents[1] + + +def test_courses_directory_is_discovered() -> None: + courses = discover_courses(ROOT) + names = {course.name for course in courses} + assert { + "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", + "verifying-ai-generated-code", + "mcp-server-in-python", + } <= names + + +def test_course_folders_satisfy_the_bilingual_contract() -> None: + for course in discover_courses(ROOT): + assert check_bilingual_contract(course) == [] + + +def test_missing_chinese_pair_is_reported(tmp_path: Path) -> None: + course = tmp_path / "demo-course" + (course / "lessons").mkdir(parents=True) + (course / "lessons" / "L01.md").write_text("# L01\n", encoding="utf-8") + problems = check_bilingual_contract(course) + assert any("without _cn.md pair: L01" in problem for problem in problems) + + +def test_orphan_chinese_lesson_is_reported(tmp_path: Path) -> None: + course = tmp_path / "demo-course" + (course / "lessons").mkdir(parents=True) + (course / "lessons" / "L01.md").write_text("# L01\n", encoding="utf-8") + (course / "lessons" / "L01_cn.md").write_text("# L01\n", encoding="utf-8") + (course / "lessons" / "L02_cn.md").write_text("# L02\n", encoding="utf-8") + problems = check_bilingual_contract(course) + assert any("without English pair: L02" in problem for problem in problems) + + +def test_course_documents_enter_the_content_manifest() -> None: + manifest = build_manifest(ROOT) + course_documents = [ + document + for document in manifest["documents"] + if document["type"] == "course" + ] + ids = {document["id"] for document in course_documents} + expected_courses = { + "course-claude-code", + "course-codex-cli", + "course-cursor", + "course-deepseek-harness", + "course-kimi-code", + "course-zcode", + "course-agent-rules", + "course-verify-ship", + "course-mcp-tools", + } + assert expected_courses <= ids + for prefix in expected_courses: + for lesson in range(1, 6): + assert f"{prefix}-l0{lesson}" in ids + for document in course_documents: + langs = {locale["lang"] for locale in document["locales"]} + assert langs == {"en-US", "zh-CN"} + + +def test_course_manifest_schema_allows_course_type() -> None: + import json + + schema = json.loads( + (ROOT / "schema" / "content-manifest-v1.schema.json").read_text( + encoding="utf-8" + ) + ) + enum = schema["properties"]["documents"]["items"]["properties"]["type"][ + "enum" + ] + assert "course" in enum diff --git a/tests/test_examples.py b/tests/test_examples.py new file mode 100644 index 0000000..e100011 --- /dev/null +++ b/tests/test_examples.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import pytest + +from tools.verify_examples import discover_example_verifiers + +ROOT = Path(__file__).resolve().parents[1] +EXAMPLE_VERIFIERS = discover_example_verifiers(ROOT) + + +@pytest.mark.parametrize("verify_path", EXAMPLE_VERIFIERS, ids=lambda p: p.parent.name) +def test_example_has_failing_starter_and_passing_solution(verify_path: Path) -> None: + subprocess.run( + [sys.executable, str(verify_path), "starter", "--expect-failure"], + check=True, + ) + subprocess.run([sys.executable, str(verify_path), "solution"], check=True) diff --git a/tests/test_export_catalog.py b/tests/test_export_catalog.py new file mode 100644 index 0000000..e3293d5 --- /dev/null +++ b/tests/test_export_catalog.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from tools.export_catalog import build_export, build_radar_export, render, run + +ROOT = Path(__file__).resolve().parents[1] + + +def test_export_has_stable_public_contract(valid_catalog: dict) -> None: + export = build_export(valid_catalog) + + assert export["schema_version"] == 1 + assert export["catalog"] == { + "reviewed_on": "2026-08-31", + "status": "active", + } + assert export["paths"] == valid_catalog["catalog"]["paths"] + assert export["resources"][0]["reviewed_on"] == "2026-08-31" + + +def test_checked_in_export_matches_catalog_sources() -> None: + assert run(["--check"]) == 0 + assert run(["--check", "--target", "both"]) == 0 + + +def test_catalog_schema_is_valid_json() -> None: + schema = json.loads( + (ROOT / "schema" / "catalog-v1.schema.json").read_text(encoding="utf-8") + ) + + assert schema["$schema"] == "https://json-schema.org/draft/2020-12/schema" + assert schema["properties"]["schema_version"]["const"] == 1 + + +def test_render_export_is_deterministic(valid_catalog: dict) -> None: + assert render(build_export(valid_catalog)) == render(build_export(valid_catalog)) + + +def test_radar_export_lists_reviewed_projects_sorted() -> None: + projects = [ + {"id": "zeta", "reviewed_on": "2026-09-02"}, + {"id": "alpha", "reviewed_on": "2026-09-02"}, + ] + export = build_radar_export(projects) + assert export["$schema"] == "./schema/radar-v1.schema.json" + assert [p["id"] for p in export["projects"]] == ["alpha", "zeta"] + + +def test_radar_schema_declares_lifecycle_and_familiarity() -> None: + schema = json.loads( + (ROOT / "schema" / "radar-v1.schema.json").read_text(encoding="utf-8") + ) + project = schema["properties"]["projects"]["items"] + assert set(project["properties"]["status"]["enum"]) == { + "new", + "rising", + "stable", + "major-update", + "experimental", + "archived", + } + assert set(project["properties"]["ai_familiarity"]["enum"]) == { + "low", + "medium", + "high", + } diff --git a/tests/test_markdown_links.py b/tests/test_markdown_links.py new file mode 100644 index 0000000..a214dbd --- /dev/null +++ b/tests/test_markdown_links.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +LINK = re.compile(r"\[[^]]+\]\(([^)]+)\)") + + +def test_relative_markdown_links_resolve() -> None: + missing: list[str] = [] + for document in ROOT.rglob("*.md"): + if any(part.startswith(".") for part in document.relative_to(ROOT).parts): + continue + content = document.read_text(encoding="utf-8") + for target in LINK.findall(content): + path_text = target.split("#", maxsplit=1)[0] + if not path_text or "://" in path_text or path_text.startswith("mailto:"): + continue + destination = (document.parent / path_text).resolve() + if not destination.exists() or (destination != ROOT and ROOT not in destination.parents): + missing.append(f"{document.relative_to(ROOT)} -> {target}") + assert not missing, "broken relative Markdown links:\n" + "\n".join(missing) diff --git a/tests/test_radar.py b/tests/test_radar.py new file mode 100644 index 0000000..403fdca --- /dev/null +++ b/tests/test_radar.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +from datetime import date +from typing import Any + +import pytest + +from tools.catalog import ( + CatalogLoadError, + load_radar, + validate_radar, +) + + +def make_project(**overrides: Any) -> dict[str, Any]: + project: dict[str, Any] = { + "id": "sample-project", + "repo": "owner/sample-project", + "url": "https://github.com/owner/sample-project", + "category": "code-quality", + "status": "rising", + "first_seen": date(2026, 9, 2), + "reviewed_on": date(2026, 9, 2), + "license": "MIT", + "evidence": { + "last_release": "1.0.0", + "release_cadence": "monthly", + "maintenance": "active team", + }, + "ai_familiarity": "medium", + "alternatives": ["sample-other"], + "rationale_en": "Does one thing well with evidence.", + "rationale_zh": "专注做好一件事,有证据支撑。", + "when_not_to_use_en": "When you need the ecosystem of sample-other.", + "when_not_to_use_zh": "需要 sample-other 生态时。", + "risk_en": "Fast-moving API.", + "risk_zh": "API 变化较快。", + } + project.update(overrides) + return project + + +def test_valid_project_passes() -> None: + assert validate_radar([make_project()], today=date(2026, 9, 12)) == [] + + +@pytest.mark.parametrize( + "field,value,code", + [ + ("status", "trending", "invalid-enum"), + ("category", "misc", "invalid-enum"), + ("ai_familiarity", "unknown", "invalid-enum"), + ("repo", "not-a-repo", "invalid-repo"), + ("url", "http://insecure.example/repo", "https-required"), + ("id", "Bad_ID", "invalid-id"), + ("rationale_zh", "", "invalid-text"), + ], +) +def test_invalid_fields_are_reported(field: str, value: Any, code: str) -> None: + issues = validate_radar( + [make_project(**{field: value})], today=date(2026, 9, 12) + ) + assert any(issue.code == code for issue in issues), issues + + +def test_unknown_fields_are_rejected() -> None: + issues = validate_radar( + [make_project(extra="nope")], today=date(2026, 9, 12) + ) + assert any(issue.code == "unknown-field" for issue in issues) + + +def test_review_date_cannot_predate_first_seen() -> None: + issues = validate_radar( + [make_project(first_seen=date(2026, 9, 10))], + today=date(2026, 9, 12), + ) + assert any(issue.code == "date-parity" for issue in issues) + + +def test_stale_review_is_reported() -> None: + issues = validate_radar( + [make_project()], + today=date(2027, 9, 12), + max_review_age_days=366, + ) + assert any(issue.code == "stale-review" for issue in issues) + + +def test_duplicate_ids_are_reported() -> None: + issues = validate_radar( + [make_project(), make_project()], today=date(2026, 9, 12) + ) + assert any(issue.code == "duplicate-id" for issue in issues) + + +def test_evidence_requires_all_three_fields() -> None: + evidence = {"last_release": "1.0.0", "release_cadence": "monthly"} + issues = validate_radar( + [make_project(evidence=evidence)], today=date(2026, 9, 12) + ) + assert any( + issue.code == "missing-field" and issue.location.endswith("evidence") + for issue in issues + ) + + +def test_committed_radar_sources_validate_and_ids_match_filenames( + tmp_path: Any, +) -> None: + import shutil + from pathlib import Path + + source = Path(__file__).resolve().parents[1] / "catalog" / "projects" + target = tmp_path / "projects" + shutil.copytree(source, target) + projects = load_radar(target) + assert len(projects) >= 7 + issues = validate_radar(projects, today=date(2026, 9, 12)) + assert issues == [] + + +def test_loader_rejects_id_filename_mismatch(tmp_path: Any) -> None: + (tmp_path / "wrong-name.yml").write_text( + "id: something-else\n", encoding="utf-8" + ) + with pytest.raises(CatalogLoadError): + load_radar(tmp_path) diff --git a/tests/test_radar_scan.py b/tests/test_radar_scan.py new file mode 100644 index 0000000..06be5ec --- /dev/null +++ b/tests/test_radar_scan.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +from datetime import date + +import pytest + +from tools.radar_scan import ( + FORBIDDEN_CANDIDATE_KEYS, + build_document, + merge_candidates, + parse_github_search, + parse_hn_search, + parse_pypi_updates, +) + + +def test_github_payload_maps_to_discovery_facts_only() -> None: + payload = { + "items": [ + { + "html_url": "https://github.com/owner/repo", + "stargazers_count": 42, + "license": {"spdx_id": "MIT"}, + "description": "A tool that should never be copied", + } + ] + } + candidates = parse_github_search(payload, date(2026, 9, 12)) + assert candidates == [ + { + "source": "github-search", + "url": "https://github.com/owner/repo", + "first_seen": "2026-09-12", + "stars": 42, + "license": "MIT", + } + ] + + +def test_pypi_feed_parses_latest_release() -> None: + document = ( + '<?xml version="1.0"?><rss><channel>' + "<item><link>https://pypi.org/project/thing/</link>" + "<title>thing 2.1.0" + "https://example.com/not-pypi" + "skip 1.0" + "" + ) + candidates = parse_pypi_updates(document, date(2026, 9, 12)) + assert candidates == [ + { + "source": "pypi-updates", + "url": "https://pypi.org/project/thing", + "first_seen": "2026-09-12", + "latest_release": "2.1.0", + } + ] + + +def test_hn_payload_keeps_urls_and_points_only() -> None: + payload = { + "hits": [ + {"url": "https://github.com/owner/repo", "points": 7, "title": "show hn"}, + {"url": "javascript:void(0)", "points": 1}, + ] + } + candidates = parse_hn_search(payload, date(2026, 9, 12)) + assert candidates == [ + { + "source": "hn-latest", + "url": "https://github.com/owner/repo", + "first_seen": "2026-09-12", + "points": 7, + } + ] + + +def test_merge_drops_reviewed_urls_and_preserves_first_seen() -> None: + reviewed = {"https://github.com/owner/reviewed"} + fresh = [ + { + "source": "github-search", + "url": "https://github.com/owner/repo", + "first_seen": "2026-09-12", + }, + { + "source": "github-search", + "url": "https://github.com/owner/reviewed", + "first_seen": "2026-09-12", + }, + ] + previous = [ + { + "source": "github-search", + "url": "https://github.com/owner/repo", + "first_seen": "2026-08-01", + } + ] + merged = merge_candidates(fresh, previous, reviewed, limit=10) + assert len(merged) == 1 + assert merged[0]["first_seen"] == "2026-08-01" + + +def test_merge_enforces_per_source_limit() -> None: + fresh = [ + { + "source": "github-search", + "url": f"https://github.com/owner/repo{i}", + "first_seen": "2026-09-12", + } + for i in range(5) + ] + merged = merge_candidates(fresh, [], set(), limit=2) + assert len(merged) == 2 + + +def test_no_candidate_ever_carries_a_verdict_field() -> None: + poisoned = [ + { + "source": "github-search", + "url": "https://github.com/owner/repo", + "first_seen": "2026-09-12", + "description": "generated text must never survive", + } + ] + with pytest.raises(ValueError): + merge_candidates(poisoned, [], set(), limit=10) + + +def test_document_carries_the_human_review_note() -> None: + document = build_document([]) + assert FORBIDDEN_CANDIDATE_KEYS.isdisjoint(document) + assert "human-authored" in document["note"] diff --git a/tests/test_render_readmes.py b/tests/test_render_readmes.py new file mode 100644 index 0000000..951c777 --- /dev/null +++ b/tests/test_render_readmes.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from copy import deepcopy +from pathlib import Path + +import pytest + +from tools.catalog import load_catalog +from tools.render_readmes import ( + END_MARKER, + START_MARKER, + render_catalog_index, + replace_generated_block, + run, +) + +ROOT = Path(__file__).resolve().parents[1] + + +def test_checked_in_readme_catalog_sections_are_current() -> None: + assert run(["--check"]) == 0 + + +def test_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") + + for resource in catalog["resources"]: + link = f"[{resource['title']}]({resource['url']})" + assert link in english + assert link in chinese + assert resource["why_en"] in english + assert resource["why_zh"] in chinese + + +def test_generated_catalog_escapes_markdown_and_html(valid_catalog: dict) -> None: + data = deepcopy(valid_catalog) + data["resources"][0]["title"] = "Unsafe ] " + + rendered = render_catalog_index(data, lang="en") + + assert "