From 85a08db82beb108344aeae67de4b9c0ae26c9a02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Minh=20Phi=20Tr=E1=BA=A7n?= Date: Fri, 16 Oct 2026 09:35:26 +0700 Subject: [PATCH] =?UTF-8?q?feat(training):=20measure=20learning=20?= =?UTF-8?q?=E2=80=94=20issue=20forms,=20report=20script,=20README=20=C2=A7?= =?UTF-8?q?8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issue forms: 'Báo cáo lab' (real hours, reading minutes, run-lab.ps1 output) and 'Đo đầu/cuối giai đoạn' (0–2 score per question of the T10 §4 oral set, asked before the stage and at promotion). tools/docs/ learning-report.mjs aggregates issues via gh: lab hours vs T10 §2, reading minutes vs the ⏱ cards, gain per stage, plus a 'needs review' list; field labels come from the forms, estimates and question sets from T10. CI runs it on 16 synthetic issues (5 planted errors) against a committed expected report. README §8 explains when to recalibrate. Co-Authored-By: Claude Opus 5.5 --- .github/ISSUE_TEMPLATE/lab-report.yml | 48 ++++++ .github/ISSUE_TEMPLATE/stage-check.yml | 46 ++++++ .github/workflows/build-and-test.yml | 6 + CHANGELOG.md | 6 + docs/training/README.md | 25 ++- docs/training/T06_BUILD_RELEASE_DEVOPS.md | 7 +- docs/training/T10_LABS_AND_ASSESSMENT.md | 2 +- tools/docs/fixtures/learning-issues.json | 82 ++++++++++ tools/docs/fixtures/learning-report.md | 30 ++++ tools/docs/learning-report.mjs | 186 ++++++++++++++++++++++ tools/docs/package.json | 3 +- 11 files changed, 434 insertions(+), 7 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/lab-report.yml create mode 100644 .github/ISSUE_TEMPLATE/stage-check.yml create mode 100644 tools/docs/fixtures/learning-issues.json create mode 100644 tools/docs/fixtures/learning-report.md create mode 100644 tools/docs/learning-report.mjs diff --git a/.github/ISSUE_TEMPLATE/lab-report.yml b/.github/ISSUE_TEMPLATE/lab-report.yml new file mode 100644 index 0000000..e2286e7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/lab-report.yml @@ -0,0 +1,48 @@ +name: "Báo cáo lab" +description: "Nộp kết quả một lab (T10) kèm thời gian làm THẬT, để hiệu chỉnh các ước lượng của lộ trình." +title: "[Lab] L__ — " +body: + - type: markdown + attributes: + value: | + Một issue cho một lab. Ghi thời gian **thật**, kể cả lúc đọc đề và sửa lỗi; không tính lúc nghỉ. Con số lệch xa ước lượng ở [T10 §2](../blob/main/docs/training/T10_LABS_AND_ASSESSMENT.md#2-danh-sách-bài-lab) là thông tin quý, không phải điểm trừ. + Mentor tổng hợp mọi báo cáo bằng `node tools/docs/learning-report.mjs` (docs/training/README §8). + - type: dropdown + id: lab + attributes: + label: Lab + options: [L01, L02, L03, L04, L05, L06, L07, L08, L09, L10, L11, L12, L13, L14, L15, L16, L17, L18, L19] + validations: + required: true + - type: input + id: hours + attributes: + label: Thời gian làm thực tế (giờ) + description: "Chỉ ghi số, dấu chấm thập phân. Ví dụ: 3.5" + placeholder: "3.5" + validations: + required: true + - type: textarea + id: reading + attributes: + label: Thời gian đọc tài liệu cho lab này (phút) + description: "Mỗi dòng một module, `🎯` nếu chỉ đọc các mục 🎯. Ví dụ: `T01 🎯: 80` hoặc `T02: 95`. Bỏ trống nếu đã đọc từ trước." + placeholder: "T01 🎯: 80" + - type: textarea + id: grader + attributes: + label: Kết quả run-lab.ps1 + description: "Với L02–L06: dán phần kết quả của `.\\labs\\run-lab.ps1 -Lab L0x`." + render: text + - type: textarea + id: report + attributes: + label: Báo cáo + description: "Đã làm gì, quan sát được gì, trả lời câu phản tư của lab (T10 quy tắc 3)." + validations: + required: true + - type: input + id: pr + attributes: + label: Link PR + description: "Nếu lab có PR." diff --git a/.github/ISSUE_TEMPLATE/stage-check.yml b/.github/ISSUE_TEMPLATE/stage-check.yml new file mode 100644 index 0000000..ec49dbc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/stage-check.yml @@ -0,0 +1,46 @@ +name: "Đo đầu/cuối giai đoạn" +description: "Điểm vấn đáp trước khi học và lúc đánh giá lên cấp, cùng một bộ câu, để đo mức tiến bộ." +title: "[Đo] " +body: + - type: markdown + attributes: + value: | + Cùng **một** bộ câu hỏi, làm hai lần: **trước khi** bắt đầu giai đoạn, và **lúc đánh giá lên cấp**. Gấp tài liệu, trả lời bằng lời của mình; mentor chấm từng câu. Bộ câu của từng cấp ghi ở cột *Vấn đáp* của [T10 §4](../blob/main/docs/training/T10_LABS_AND_ASSESSMENT.md#4-đề-đánh-giá-lên-cấp). + Điểm thấp ở lần đầu là điều bình thường: đó là điểm xuất phát. + - type: dropdown + id: stage + attributes: + label: Lên cấp + options: + - "🌰 → 🌱 Fresher" + - "🌱 → 🌿 Junior" + - "🌿 → 🌳 Middle" + - "🌳 → 🏔️ Senior" + validations: + required: true + - type: dropdown + id: when + attributes: + label: Lần đo + options: + - "Trước khi học" + - "Lúc đánh giá lên cấp" + validations: + required: true + - type: textarea + id: scores + attributes: + label: Điểm từng câu + description: "Mỗi dòng một câu: `Q1: 2`. Thang 0–2: 0 = sai hoặc không biết, 1 = đúng một phần, 2 = đúng và giải thích được vì sao. Fresher dùng `C1` … `C10` cho 10 câu của C05 §6." + placeholder: | + Q1: 2 + Q2: 1 + validations: + required: true + - type: input + id: learner + attributes: + label: Học viên + description: "Tên hoặc mã học viên; dùng mã nếu repo công khai. Hai lần đo của cùng một người phải ghi giống nhau." + validations: + required: true diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index e5fb3b5..23229f6 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -115,6 +115,12 @@ jobs: if: ${{ !cancelled() }} run: npm run deck + # Script tổng hợp số đo hiệu quả học (README §8) chạy trên dữ liệu mẫu fixtures/ và phải ra đúng báo cáo mẫu: + # bắt việc đổi bảng thời lượng lab (T10 §2), bộ câu lên cấp (T10 §4) hay mẫu issue mà quên script. + - name: Learning report (sample data) + if: ${{ !cancelled() }} + run: npm run learning + # Code mẫu C01–C05, U01: biên dịch C# 10 / net8.0, chạy, so output (chú thích ). - name: Setup .NET (code samples) if: ${{ !cancelled() }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 22d0a8f..3c812fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added (đo hiệu quả học, GĐ6) +- Hai mẫu issue (`.github/ISSUE_TEMPLATE/`): **Báo cáo lab** (lab, giờ làm thật, phút đọc từng module, kết quả `run-lab.ps1`, báo cáo, link PR) và **Đo đầu/cuối giai đoạn** (cấp, lần đo, điểm 0–2 từng câu, mã học viên). Cả hai qua schema `github-issue-forms` của SchemaStore. +- `tools/docs/learning-report.mjs`: đọc issue bằng `gh`, in giờ làm lab so với T10 §2, phút đọc so với ⏱, mức tăng điểm theo giai đoạn trên đúng bộ vấn đáp của T10 §4, cùng mục "Cần xem lại" cho issue ghi sai dạng. Tên trường đọc từ chính hai mẫu issue, ước lượng và bộ câu đọc từ chính T10. +- CI (job `docs`, bước "Learning report (sample data)", `npm run learning`): chạy script trên 16 issue giả lập ở `tools/docs/fixtures/` (có 5 trường hợp sai cài sẵn), so với báo cáo mẫu đã commit. +- training/README §8 **Đo hiệu quả học**: hai số đo, cách tổng hợp, và khi nào sửa con số của tài liệu (≥ 5 báo cáo lệch khỏi ước lượng; thời gian đọc lệch > 30% ở nhiều module; câu vẫn 0 điểm ở lần đo sau). §6 mentor: hỏi bộ vấn đáp hai lần. T10 quy tắc 3: báo cáo lab bằng mẫu issue, kèm giờ làm thật. Sửa bảng "Bắt đầu từ đâu?" còn ghi lộ trình nhanh ≈ 3,5 giờ (đã là ≈ 4 giờ từ GĐ3). + ### Added (tài liệu — tra theo việc cần làm và sơ đồ, GĐ4 phần 2) - T08 và U03 có bảng **"Tra theo việc cần làm"** ngay trên Mục lục: "bạn cần… → đọc mục nào". Bảng của T08 nối sang cả T01, T05, T07, U01, U03 và R03 (23 việc, kể cả sáu công thức của §12 vốn không hiện ở Mục lục); U03 có 10 việc. - Bốn sơ đồ mermaid mới, ở các module chưa có sơ đồ nào: U02 §1 (chọn bề mặt UI, dạng câu hỏi lần lượt), T08 §5 (nút palette nhờ một lệnh hỏi điểm qua `ICommandLauncher`), T08 §11 (ghi nhận ở `ObjectErased`, xử lý ở `Application.Idle`), T07 §4 (vòng lặp side database cho cả lô file). Mỗi sơ đồ bám đúng bảng hay code ngay cạnh nó. diff --git a/docs/training/README.md b/docs/training/README.md index 359f944..ee0f3f4 100644 --- a/docs/training/README.md +++ b/docs/training/README.md @@ -23,7 +23,7 @@ Bộ tài liệu này dành cho người **kế thừa và phát triển tiếp* | :--- | :--- | :--- | | Chưa từng lập trình | [C00 — Bắt đầu từ đây](C00_START_HERE.md) | Giai đoạn 0 (6 tuần) rồi Giai đoạn 1–3: khoảng 20 tuần | | Biết một ngôn ngữ khác, chưa biết C# | [Bài kiểm tra đầu vào](C00_START_HERE.md#6-bài-kiểm-tra-đầu-vào-bạn-nên-bắt-đầu-từ-đâu) | Thường 2 tuần cho C01–C05, rồi Giai đoạn 1–3 | -| Vững C#, mới với AutoCAD .NET | [Đồ án nhập môn](C05_ASYNC_TESTING_GIT.md#5-đồ-án-nhập-môn-planner-dãy-tủ-điện), rồi [T01](T01_AUTOCAD_NET_FOUNDATIONS.md) | Giai đoạn 1–3: 12–14 tuần ([§3](#3-lộ-trình-đào-tạo)). Cần làm được việc sớm: [lộ trình nhanh](#lộ-trình-nhanh), đọc ≈ 3,5 giờ + lab | +| Vững C#, mới với AutoCAD .NET | [Đồ án nhập môn](C05_ASYNC_TESTING_GIT.md#5-đồ-án-nhập-môn-planner-dãy-tủ-điện), rồi [T01](T01_AUTOCAD_NET_FOUNDATIONS.md) | Giai đoạn 1–3: 12–14 tuần ([§3](#3-lộ-trình-đào-tạo)). Cần làm được việc sớm: [lộ trình nhanh](#lộ-trình-nhanh), đọc ≈ 4 giờ + lab | | Đã làm AutoCAD .NET, mới vào dự án | [T02](T02_ARCHITECTURE_DEEP_DIVE.md) → [R05 — Golden Sample](../reference/R05_GOLDEN_SAMPLE.md) → [T08](T08_RECIPES_EXTENDING.md) | Đọc theo thứ tự đó; gặp sự cố thì tra [T03](T03_PITFALLS_CASEBOOK.md) | | Cần làm giao diện: palette, hộp thoại, xem trước | [U00 — Nhánh UI](U00_UI_START_HERE.md), sau [T01 §5 và §7](T01_AUTOCAD_NET_FOUNDATIONS.md#5-document-lock-và-hai-ngữ-cảnh-thực-thi) | Nhánh UI: 3 tuần | | Mentor | [§6 — Dành cho mentor](#6-dành-cho-mentor) | — | @@ -248,7 +248,7 @@ Nạp `bin\Debug\net48\CadCoreFramework.dll` vào AutoCAD bằng `NETLOAD` hoặ 2. **Review bằng checklist** [T09 §2](T09_SENIOR_PLAYBOOK.md#2-checklist-code-review). Yêu cầu học viên tự review PR của mình bằng checklist trước khi gửi. 3. **Ưu tiên lab "sửa backlog thật"** (L06, L08, L11, L12…). Học viên học sâu hơn khi sửa vấn đề có thật. Các mục đó đã được sửa ở PR #6, nên học viên làm trên tag `v1.0.0` rồi **đối chiếu** với lời giải đã merge ([T10 quy tắc 6](T10_LABS_AND_ASSESSMENT.md#1-quy-tắc-làm-lab)) — chấm cả lời giải lẫn chất lượng phần so sánh. Lab L02–L06 có bộ chấm chạy trên AutoCAD thật trong [`labs/`](../../labs/README.md): bảo học viên dán kết quả của `run-lab.ps1` vào báo cáo lab. Kết quả xanh mới là phần máy kiểm được; bảng quan sát, câu phản tư, ESC và đường cao su vẫn do bạn chấm. 4. **Bắt buộc tái hiện trước khi sửa.** Mọi PR sửa lỗi phải mô tả cách tái hiện, như các mục trong T03. -5. **Đánh giá lên cấp** theo [T10 §4](T10_LABS_AND_ASSESSMENT.md#4-đề-đánh-giá-lên-cấp): phần vấn đáp nặng hơn phần trắc nghiệm. +5. **Đánh giá lên cấp** theo [T10 §4](T10_LABS_AND_ASSESSMENT.md#4-đề-đánh-giá-lên-cấp): phần vấn đáp nặng hơn phần trắc nghiệm. Hỏi đúng bộ vấn đáp đó **hai lần**: một lần trước khi học viên bắt đầu giai đoạn, một lần lúc đánh giá; ghi điểm bằng mẫu issue "Đo đầu/cuối giai đoạn" ([§8](#8-đo-hiệu-quả-học)). 6. **Với học viên Giai đoạn 0:** gặp 30 phút mỗi tuần, kiểm đúng "đầu ra" của tuần ở [C00 §2](C00_START_HERE.md#2-lộ-trình-6-tuần). Bắt học viên **gõ lại** ví dụ, không copy. Bài tập C01–C04 và ViewModel của U01-E1 có test chấm sẵn trong [`exercises/`](../../exercises/README.md): test xanh là điều kiện **cần**, chưa đủ. Chọn một bài, bảo học viên giải thích từng dòng. Chấm đồ án nhập môn bằng một bộ **test ẩn** do bạn viết (các giá trị biên, nguyên văn thông điệp lỗi — [C05 §5](C05_ASYNC_TESTING_GIT.md#5-đồ-án-nhập-môn-planner-dãy-tủ-điện)), và đừng công bố lời giải. 7. **Với nhánh UI:** chấm bằng **ảnh** palette ở cả hai theme và bằng **smoke trong AutoCAD**, không chỉ đọc code. Những bước chỉ AutoCAD có giao diện mới kiểm được có danh sách ở cuối [U02](U02_UI_IN_AUTOCAD.md#trạng-thái-kiểm-chứng) và [U03](U03_DRAWING_ON_UI.md#trạng-thái-kiểm-chứng): yêu cầu học viên ghi kết quả của chúng vào mô tả PR. Lab L18 và bài tập U02-E1 (B-31…B-33) làm trên commit `632a6c1` rồi đối chiếu với PR #11, như các lab sửa backlog khác ở mục 3. @@ -297,3 +297,24 @@ Nạp `bin\Debug\net48\CadCoreFramework.dll` vào AutoCAD bằng `NETLOAD` hoặ | Bài tập chấm tự động (`exercises/`, 2026-10-15) | 14 bài, 521 test. Lời giải mẫu (giống đáp án trong tài liệu) qua cả 521 test; code khởi đầu đỏ ở cả 14 bài (513 test đỏ, 8 test xanh là các test kiểm phần đúng sẵn có, như luật cũ của `PanelSpec`). Đối chứng với lỗi cài sẵn (một bài có sẵn lời giải nên test xanh hết, lời giải mẫu in sai một chữ, file bài không có test, lớp test không có bài, lời giải không biên dịch): cả 5 đều bị bắt | | Lab chấm trên AutoCAD (`labs/`, 2026-10-16) | 20 kiểm tra cho L02–L06, chạy trên accoreconsole của AutoCAD 2024. Lời giải mẫu đạt cả 20. Code khởi đầu đỏ ở 17 kiểm tra của L02–L05 phần 1; 3 kiểm tra framework (L05 B-17, L06) xanh trên `main` vì lỗi đã sửa ở PR #6, còn trên `v1.0.0` (qua `-FrameworkRoot`) thì đỏ, sửa xong thì xanh. Đối chứng với 16 bài làm sai mà trông hợp lý (đếm bằng `GetObject`, đếm theo `br.Name`, mở `LayerTable` `ForWrite`, đếm ModelSpace thay cho CurrentSpace, `Commit` ở TN1, `ForWrite` ở TN2, chỉ đọc thuộc tính ở TN3, `LockDocument` ở TN4, hai `Execute` nối tiếp ở TN5, không đổi màu ở TN6, không đổi UCS → WCS, bản thô có đổi hệ, prompt bên trong `Execute`, hỏi thừa một điểm, filter thiếu `` `*U* ``, không lọc lại theo tên thật): cả 16 đều bị bắt, đúng kiểm tra, đúng lý do. Bản vẽ mẫu lưu ra DWG 2018 rồi chấm lại trên chính file đó: đạt. Nhánh `[SKIP]` (máy không có bộ dynamic block mẫu) thử bằng cách tạm đổi tên file mẫu mà bộ dựng bản vẽ đi tìm. Trên AutoCAD 2024 có giao diện (2026-10-16, bản build lời giải mẫu, mở `lab-sample.dwg`): NETLOAD DLL lab được; `TRAIN_DUMP` in đúng layer và số đếm; `TRAIN_DOORS` tìm đủ 3 DOOR, 2 cái tên `*U…`; với UCS xoay 30°, `TRAIN_LINE2P` có đường cao su, vẽ đúng chỗ click; ESC ở điểm thứ hai thì không vẽ gì | | Bộ thẻ ôn (`deck/`, 2026-10-16) | 437 thẻ sinh từ tài liệu; mọi dòng đủ 4 cột (mặt trước, mặt sau, tag, GUID), không trùng GUID. CI kiểm file khớp tài liệu: sửa một đáp án mà không sinh lại thì đỏ (đã thử). Lần chạy đầu bắt được hai khối ✅ lệch số câu/đáp án (U01 §8, T09), đã sửa. Chạy được cả trong dự án sinh từ template (file đổi tên theo tên dự án). **Chưa thử** nhập vào Anki trên máy kiểm chứng: định dạng (các dòng `#separator`, `#html`, `#deck`, `#columns`, `#tags column`, `#guid column`) theo tài liệu nhập văn bản của Anki 2.1.55 trở lên | +| Đo hiệu quả học (§8, 2026-10-16) | Hai mẫu issue qua schema `github-issue-forms` của SchemaStore (một bản sai cài sẵn bị bắt). Script tổng hợp chạy trên 16 issue giả lập: số liệu tính tay khớp (L02 trung vị 3,5 giờ lệch 17%; Junior 21,9% → 84,4%), 5 trường hợp sai cài sẵn đều vào mục "Cần xem lại". Đối chứng âm của bước CI: đổi thời lượng L02 ở T10 §2, hay đổi tên một trường của mẫu issue, thì CI đỏ. **Chưa có** số liệu từ học viên thật | + +--- + +## 8. Đo hiệu quả học + +Các con số của lộ trình là **ước lượng**: dòng ⏱ tính từ số từ (100 từ/phút), cột Thời lượng của [T10 §2](T10_LABS_AND_ASSESSMENT.md#2-danh-sách-bài-lab) lấy từ kinh nghiệm. Muốn biết lộ trình có giúp học nhanh và chắc thật không, phải đo trên học viên thật. Hai số đo, ghi bằng hai mẫu issue của repo (New issue): + +| Đo gì | Mẫu issue | Cách làm | Dùng để | +| :--- | :--- | :--- | :--- | +| **Tiến bộ** của mỗi giai đoạn | Đo đầu/cuối giai đoạn | Cùng **một** bộ câu vấn đáp của [T10 §4](T10_LABS_AND_ASSESSMENT.md#4-đề-đánh-giá-lên-cấp) (Fresher: 10 câu của [C05 §6](C05_ASYNC_TESTING_GIT.md#6-đánh-giá-nhập-môn--fresher)), làm hai lần: trước khi bắt đầu giai đoạn và lúc đánh giá lên cấp. Gấp tài liệu; mentor chấm mỗi câu 0, 1 hay 2 | Mức tăng = điểm % lúc lên cấp − điểm % trước khi học | +| **Thời gian thật** | Báo cáo lab | Giờ làm lab, và phút đọc từng module cần cho lab đó | So với cột Thời lượng của T10 §2 và dòng ⏱ của module | + +**Tổng hợp:** `node tools/docs/learning-report.mjs` đọc mọi issue của repo (cần GitHub CLI đã đăng nhập), rồi in ba bảng: giờ làm lab so với ước lượng, phút đọc so với ⏱, mức tăng theo giai đoạn. Cuối báo cáo là mục "Cần xem lại": issue ghi sai dạng, học viên mới có một lần đo, bộ câu thiếu hay thừa câu. Script đọc tên trường từ chính hai mẫu issue, và đọc ước lượng, bộ câu từ chính T10, nên không có số liệu nào phải chép tay. CI chạy nó trên dữ liệu mẫu ([`tools/docs/fixtures/`](../../tools/docs/fixtures/learning-report.md)) để bắt việc đổi T10 hay mẫu issue mà quên script. + +**Khi nào sửa con số của tài liệu:** +- Một lab có từ 5 báo cáo trở lên mà trung vị nằm ngoài khoảng ước lượng: sửa cột Thời lượng ở T10 §2. Nếu đó là L01, L03, L04 hay L07 thì sửa cả tổng của [lộ trình nhanh](#lộ-trình-nhanh). +- Thời gian đọc lệch cùng một chiều, hơn 30%, ở nhiều module: đổi tốc độ đọc (`WORDS_PER_MINUTE` trong [`check-reading-time.mjs`](../../tools/docs/check-reading-time.mjs)). CI sẽ chỉ ra mọi dòng ⏱ cần ghi lại. +- Mức tăng thấp ở một giai đoạn: xem câu nào **vẫn** 0 điểm ở lần đo sau. Đó là chỗ tài liệu hay lab chưa dạy được; sửa tài liệu, không trách học viên. + +Điểm thấp ở lần đo đầu là bình thường: đó là điểm xuất phát, không dùng để xếp hạng. Repo công khai thì ghi mã học viên thay cho tên. diff --git a/docs/training/T06_BUILD_RELEASE_DEVOPS.md b/docs/training/T06_BUILD_RELEASE_DEVOPS.md index ff4a386..3e1993e 100644 --- a/docs/training/T06_BUILD_RELEASE_DEVOPS.md +++ b/docs/training/T06_BUILD_RELEASE_DEVOPS.md @@ -4,7 +4,7 @@ > **Cấp độ:** 🌿 Junior → 🏔️ Senior · **Cần trước:** [T05](T05_TESTING_STRATEGY.md); Git & PR ([C05 §4](C05_ASYNC_TESTING_GIT.md#4-git-và-pull-request)) · **Trong lộ trình:** tuần 13 ([README §3](README.md#3-lộ-trình-đào-tạo)) > -> **⏱ Đọc:** ≈ 35 phút (phần 🎯 ≈ 15 phút) · **Thực hành:** L15 ([T10 §2](T10_LABS_AND_ASSESSMENT.md#2-danh-sách-bài-lab)) · **Nhãn mục:** 🎯 cốt lõi · 🔍 đào sâu · 📖 tra cứu ([README §5](README.md#5-quy-ước-đọc)) +> **⏱ Đọc:** ≈ 35 phút (phần 🎯 ≈ 20 phút) · **Thực hành:** L15 ([T10 §2](T10_LABS_AND_ASSESSMENT.md#2-danh-sách-bài-lab)) · **Nhãn mục:** 🎯 cốt lõi · 🔍 đào sâu · 📖 tra cứu ([README §5](README.md#5-quy-ước-đọc)) Code chạy được trên máy bạn mới là một nửa. Nửa còn lại là **build tái lập được**, **CI chặn được lỗi**, và **bản phát hành đến tay người dùng đúng như bạn nghĩ**. Module này giải thích từng mắt xích, và chỉ ra những mắt xích đã từng đứt ([T03 A7–A12](T03_PITFALLS_CASEBOOK.md#a7-init-templateps1-không-parse-được-trên-windows-powershell-51)). @@ -113,7 +113,7 @@ Runner **không có AutoCAD**. CI chỉ kiểm build + unit test, nên smoke tes Xem trạng thái nhanh: `gh run list --limit 5`. Xem log bước lỗi: `gh run view --log-failed`. -**Job `docs`** (runner `ubuntu-latest`, Node 24, .NET SDK 8) chạy sáu script trong [`tools/docs/`](../../tools/docs/package.json), và báo lỗi ngay trên dòng của PR: +**Job `docs`** (runner `ubuntu-latest`, Node 24, .NET SDK 8) chạy bảy script trong [`tools/docs/`](../../tools/docs/package.json), và báo lỗi ngay trên dòng của PR: | Bước | Kiểm gì | | :--- | :--- | @@ -121,10 +121,11 @@ Xem trạng thái nhanh: `gh run list --limit 5`. Xem log bước lỗi: `gh run | `npm run reading-time` | Mỗi mục `##` của module có nhãn 🎯/🔍/📖, và số phút trên dòng ⏱ khớp số từ thật ([quy ước](README.md#5-quy-ước-đọc)) | | `npm run mermaid` | Mọi sơ đồ mermaid parse được | | `npm run deck` | Bộ thẻ ôn [`docs/training/deck/`](deck/CadCoreFramework-flashcards.txt) khớp với tài liệu (sinh từ các khối ✅, GLOSSARY, T10 §5), và mỗi câu hỏi số N của một khối ✅ có đúng một đáp án số N. Sửa tài liệu xong thì chạy `node tools/docs/build-deck.mjs --write` ([README §7](README.md#7-duy-trì-bộ-tài-liệu)) | +| `npm run learning` | Script tổng hợp số đo hiệu quả học ([README §8](README.md#8-đo-hiệu-quả-học)) chạy trên dữ liệu mẫu `tools/docs/fixtures/` phải ra đúng báo cáo mẫu: đổi bảng thời lượng lab (T10 §2), bộ câu lên cấp (T10 §4) hay mẫu issue thì phải xem lại báo cáo rồi ghi lại bằng `--check --write` | | `npm run snippets` | Code mẫu C01–C05, U01: mỗi khối `csharp` có một chú thích ẩn `` nói cách kiểm. Khối `run` được biên dịch (C# 10, net8.0, cảnh báo là lỗi) và chạy; `run output` so stdout với khối `text` ngay sau; `test` chạy `dotnet test`; `verbatim ` so với file nguồn của dự án ([README §7](README.md#7-duy-trì-bộ-tài-liệu)) | | `npm run exercises` | Bài tập chấm tự động ([`exercises/`](../../exercises/README.md)): lời giải mẫu qua mọi test, code khởi đầu đỏ ở mọi bài | -Chạy cả sáu trên máy trước khi đẩy: `cd tools/docs`, `npm ci` (một lần), rồi `npm run check`. `links`, `reading-time` và `deck` không cần `npm ci`; `snippets` và `exercises` cần .NET SDK 8 trở lên. Dự án tạo bằng template với `IncludeDemos=false` không còn `Demos/`: link vào thư mục đó được bỏ qua (script ghi rõ số link bỏ qua), để CI của dự án mới không đỏ. +Chạy cả bảy trên máy trước khi đẩy: `cd tools/docs`, `npm ci` (một lần), rồi `npm run check`. `links`, `reading-time`, `deck` và `learning` không cần `npm ci`; `snippets` và `exercises` cần .NET SDK 8 trở lên. Dự án tạo bằng template với `IncludeDemos=false` không còn `Demos/`: link vào thư mục đó được bỏ qua (script ghi rõ số link bỏ qua), để CI của dự án mới không đỏ. **Dependabot** ([`.github/dependabot.yml`](../../.github/dependabot.yml)): mỗi thứ Hai mở PR nâng version cho GitHub Actions và NuGet. Các bản nâng được gộp thành tối đa 3 PR: `github-actions`, `plugin-runtime` (package đi theo bundle vào AutoCAD) và `dev-tools` (test, benchmark, ReportGenerator). Mỗi tháng có thêm một PR `docs-tools` cho package npm của `tools/docs`. CI ở trên chạy trên từng PR. Những package bị loại khỏi tự động, và lý do, xem [§7](#7-chính-sách-dependency). diff --git a/docs/training/T10_LABS_AND_ASSESSMENT.md b/docs/training/T10_LABS_AND_ASSESSMENT.md index 7e2a052..53e3448 100644 --- a/docs/training/T10_LABS_AND_ASSESSMENT.md +++ b/docs/training/T10_LABS_AND_ASSESSMENT.md @@ -24,7 +24,7 @@ Học AutoCAD .NET chỉ thật sự diễn ra khi **làm**. Các bài lab ở 1. **Mỗi lab một branch**: `lab/Lxx-`. Lab thí nghiệm (L02, L03) **không merge**. Lab sửa backlog thì mở PR như công việc thật, nhưng **base là branch tạo từ commit cũ** ghi ở quy tắc 6 (`v1.0.0`; riêng B-31…B-33 là `632a6c1`) để mentor review — không merge vào `main`. Lab xây tính năng mới (L07, L09, L13, L14, L17, L19) theo quy trình bình thường. 2. **Definition of Done (DoD)** của từng lab là tiêu chí chấm. Chưa đạt DoD thì lab chưa xong. -3. **Báo cáo lab** trong mô tả PR (hoặc issue "Lab report"): đã làm gì, kết quả thí nghiệm, trả lời câu hỏi phản tư. +3. **Báo cáo lab** bằng mẫu issue **"Báo cáo lab"** của repo (New issue → Báo cáo lab): đã làm gì, kết quả thí nghiệm (và của `run-lab.ps1` nếu có), trả lời câu hỏi phản tư, cùng **số giờ làm thật**. Số giờ thật dùng để hiệu chỉnh cột Thời lượng ở §2 ([README §8](README.md#8-đo-hiệu-quả-học)). Lab có PR thì dán link PR vào issue. 4. **Tái hiện trước, sửa sau.** Với lab sửa lỗi: viết test hoặc bước tái hiện *fail trước* rồi mới sửa. 5. Thử nghiệm sysvar / script thì dùng accoreconsole `/isolate` ([T04 §5.3](T04_DEBUGGING_TROUBLESHOOTING.md#53-thí-nghiệm-an-toàn-với-accoreconsole)). **Không** thay đổi thiết lập AutoCAD thật của máy (`SECURELOAD`…). 6. **Các mục backlog trong lab đã được sửa ở PR #6** ([T09 §3](T09_SENIOR_PLAYBOOK.md#3-backlog-kỹ-thuật-đã-kiểm-chứng)). Làm lab trên branch tạo từ tag **`v1.0.0`** (`git switch -c lab/L08-ten v1.0.0`; riêng L05 phần 2 và L06 thì dùng `git worktree`, xem dưới), tự giải, **rồi mới** đọc lời giải trong PR #6 và ghi vào báo cáo: chỗ bạn làm khác, vì sao, cách nào tốt hơn. Kiểm thử FAIL trên code cũ: L05 phần 2 và L06 có bộ chấm [`labs/`](../../labs/README.md#l05-phần-2-và-l06-làm-trên-v100) chạy thẳng trên code `v1.0.0` (làm trong một `git worktree`, chấm bằng `-FrameworkRoot`). `tests/integration/HeadlessLab/` của `main` thì **không** chép sang `v1.0.0` được: nó cần những file framework mà `v1.0.0` chưa có, và csproj plugin của `v1.0.0` biên dịch nhầm nó vào plugin. Với các lab sửa backlog khác, đọc kiểm thử tương ứng trên `main` (HeadlessLab, `CadCoreFramework.Tests`) rồi viết lại bản của bạn trên branch lab (quy tắc 4). Lab sửa backlog **không merge** vào `main` nữa. **Riêng B-31…B-33** (tìm thấy khi viết nhánh UI, sửa ở **PR #11**): lab L18 và bài tập U02-E1 làm trên commit **`632a6c1`** (`git switch -c lab/L18-ten 632a6c1`, tức `main` ngay trước PR #11), rồi đối chiếu với PR #11 theo cùng cách. diff --git a/tools/docs/fixtures/learning-issues.json b/tools/docs/fixtures/learning-issues.json new file mode 100644 index 0000000..5b2f710 --- /dev/null +++ b/tools/docs/fixtures/learning-issues.json @@ -0,0 +1,82 @@ +[ + { + "number": 1, + "title": "[Lab] L02 — mẫu 1", + "body": "### Lab\n\nL02\n\n### Thời gian làm thực tế (giờ)\n\n3.5\n\n### Thời gian đọc tài liệu cho lab này (phút)\n\nT01 🎯: 80\nT02 🎯: 60\n\n### Kết quả run-lab.ps1\n\n```text\nKết quả: 5 đạt, 0 chưa đạt, 0 bỏ qua.\n```\n\n### Báo cáo\n\nDữ liệu mẫu cho kiểm thử script, không phải báo cáo thật.\n\n### Link PR\n\n_No response_" + }, + { + "number": 2, + "title": "[Lab] L02 — mẫu 2", + "body": "### Lab\n\nL02\n\n### Thời gian làm thực tế (giờ)\n\n2.5\n\n### Thời gian đọc tài liệu cho lab này (phút)\n\nT01 🎯: 70\n\n### Kết quả run-lab.ps1\n\n_No response_\n\n### Báo cáo\n\nDữ liệu mẫu cho kiểm thử script, không phải báo cáo thật.\n\n### Link PR\n\n_No response_" + }, + { + "number": 3, + "title": "[Lab] L02 — mẫu 3", + "body": "### Lab\n\nL02\n\n### Thời gian làm thực tế (giờ)\n\n4\n\n### Thời gian đọc tài liệu cho lab này (phút)\n\nT01 🎯: 95\n\n### Kết quả run-lab.ps1\n\n_No response_\n\n### Báo cáo\n\nDữ liệu mẫu cho kiểm thử script, không phải báo cáo thật.\n\n### Link PR\n\n_No response_" + }, + { + "number": 4, + "title": "[Lab] L02 — mẫu 4", + "body": "### Lab\n\nL02\n\n### Thời gian làm thực tế (giờ)\n\n3\n\n### Thời gian đọc tài liệu cho lab này (phút)\n\n_No response_\n\n### Kết quả run-lab.ps1\n\n_No response_\n\n### Báo cáo\n\nDữ liệu mẫu cho kiểm thử script, không phải báo cáo thật.\n\n### Link PR\n\n_No response_" + }, + { + "number": 5, + "title": "[Lab] L02 — mẫu 5", + "body": "### Lab\n\nL02\n\n### Thời gian làm thực tế (giờ)\n\n3.5\n\n### Thời gian đọc tài liệu cho lab này (phút)\n\nT01: 140\n\n### Kết quả run-lab.ps1\n\n_No response_\n\n### Báo cáo\n\nDữ liệu mẫu cho kiểm thử script, không phải báo cáo thật.\n\n### Link PR\n\n_No response_" + }, + { + "number": 6, + "title": "[Lab] L07 — mẫu 6", + "body": "### Lab\n\nL07\n\n### Thời gian làm thực tế (giờ)\n\n30\n\n### Thời gian đọc tài liệu cho lab này (phút)\n\nT08 🎯: 40\n\n### Kết quả run-lab.ps1\n\n_No response_\n\n### Báo cáo\n\nDữ liệu mẫu cho kiểm thử script, không phải báo cáo thật.\n\n### Link PR\n\n_No response_" + }, + { + "number": 7, + "title": "[Lab] L07 — mẫu 7", + "body": "### Lab\n\nL07\n\n### Thời gian làm thực tế (giờ)\n\n28\n\n### Thời gian đọc tài liệu cho lab này (phút)\n\n_No response_\n\n### Kết quả run-lab.ps1\n\n_No response_\n\n### Báo cáo\n\nDữ liệu mẫu cho kiểm thử script, không phải báo cáo thật.\n\n### Link PR\n\n_No response_" + }, + { + "number": 8, + "title": "[Lab] L09 — mẫu 8", + "body": "### Lab\n\nL09\n\n### Thời gian làm thực tế (giờ)\n\nkhoảng 6\n\n### Thời gian đọc tài liệu cho lab này (phút)\n\n_No response_\n\n### Kết quả run-lab.ps1\n\n_No response_\n\n### Báo cáo\n\nDữ liệu mẫu cho kiểm thử script, không phải báo cáo thật.\n\n### Link PR\n\n_No response_" + }, + { + "number": 9, + "title": "[Lab] L42 — mẫu 9", + "body": "### Lab\n\nL42\n\n### Thời gian làm thực tế (giờ)\n\n2\n\n### Thời gian đọc tài liệu cho lab này (phút)\n\n_No response_\n\n### Kết quả run-lab.ps1\n\n_No response_\n\n### Báo cáo\n\nDữ liệu mẫu cho kiểm thử script, không phải báo cáo thật.\n\n### Link PR\n\n_No response_" + }, + { + "number": 10, + "title": "[Lab] L04 — mẫu 10", + "body": "### Lab\n\nL04\n\n### Thời gian làm thực tế (giờ)\n\n1.5\n\n### Thời gian đọc tài liệu cho lab này (phút)\n\nT01 phần 7: 30\n\n### Kết quả run-lab.ps1\n\n_No response_\n\n### Báo cáo\n\nDữ liệu mẫu cho kiểm thử script, không phải báo cáo thật.\n\n### Link PR\n\n_No response_" + }, + { + "number": 11, + "title": "[Đo] mẫu 11", + "body": "### Lên cấp\n\n🌱 → 🌿 Junior\n\n### Lần đo\n\nTrước khi học\n\n### Điểm từng câu\n\nQ1: 0\nQ2: 1\nQ3: 0\nQ5: 1\nQ7: 0\nQ9: 0\nQ12: 1\nQ21: 0\n\n### Học viên\n\nHV01" + }, + { + "number": 12, + "title": "[Đo] mẫu 12", + "body": "### Lên cấp\n\n🌱 → 🌿 Junior\n\n### Lần đo\n\nLúc đánh giá lên cấp\n\n### Điểm từng câu\n\nQ1: 2\nQ2: 2\nQ3: 1\nQ5: 2\nQ7: 2\nQ9: 1\nQ12: 2\nQ21: 2\n\n### Học viên\n\nHV01" + }, + { + "number": 13, + "title": "[Đo] mẫu 13", + "body": "### Lên cấp\n\n🌱 → 🌿 Junior\n\n### Lần đo\n\nTrước khi học\n\n### Điểm từng câu\n\nQ1: 1\nQ2: 1\nQ3: 0\nQ5: 0\nQ7: 1\nQ9: 0\nQ12: 0\nQ21: 1\n\n### Học viên\n\nHV02" + }, + { + "number": 14, + "title": "[Đo] mẫu 14", + "body": "### Lên cấp\n\n🌱 → 🌿 Junior\n\n### Lần đo\n\nLúc đánh giá lên cấp\n\n### Điểm từng câu\n\nQ1: 2\nQ2: 1\nQ3: 2\nQ5: 2\nQ7: 1\nQ9: 2\nQ12: 2\nQ21: 1\n\n### Học viên\n\nHV02" + }, + { + "number": 15, + "title": "[Đo] mẫu 15", + "body": "### Lên cấp\n\n🌰 → 🌱 Fresher\n\n### Lần đo\n\nTrước khi học\n\n### Điểm từng câu\n\nC1: 1\nC2: 0\nC3: 0\nC4: 1\nC5: 0\nC6: 0\nC7: 0\nC8: 1\nC9: 0\nC10: 0\n\n### Học viên\n\nHV03" + }, + { + "number": 16, + "title": "[Đo] mẫu 16", + "body": "### Lên cấp\n\n🌿 → 🌳 Middle\n\n### Lần đo\n\nTrước khi học\n\n### Điểm từng câu\n\nQ4: 1\nQ6: 1\n\n### Học viên\n\nHV01" + } +] diff --git a/tools/docs/fixtures/learning-report.md b/tools/docs/fixtures/learning-report.md new file mode 100644 index 0000000..cd16621 --- /dev/null +++ b/tools/docs/fixtures/learning-report.md @@ -0,0 +1,30 @@ +## Thời gian làm lab + +| Lab | Số báo cáo | Trung vị thực tế (giờ) | Ước lượng T10 §2 | Nhận xét | +| :--- | ---: | ---: | :--- | :--- | +| L02 | 5 | 3.5 | 3 giờ | lâu hơn ước lượng 17% | +| L04 | 1 | 1.5 | 2 giờ | nhanh hơn ước lượng 25%; chưa đủ 5 báo cáo để sửa T10 §2 | +| L07 | 2 | 29 | 2–3 ngày (16–24 giờ) | lâu hơn ước lượng 21%; chưa đủ 5 báo cáo để sửa T10 §2 | + +## Thời gian đọc + +| Module | Số báo cáo | Trung vị thực tế (phút) | ⏱ trên thẻ | Lệch | +| :--- | ---: | ---: | ---: | ---: | +| T01 🎯 | 3 | 80 | 50 | +60% | +| T01 | 1 | 140 | 80 | +75% | +| T02 🎯 | 1 | 60 | 55 | +9% | +| T08 🎯 | 1 | 40 | 30 | +33% | + +## Tiến bộ theo giai đoạn + +| Lên cấp | Học viên có đủ hai lần đo | Trước khi học (trung bình) | Lúc lên cấp (trung bình) | Mức tăng | +| :--- | ---: | ---: | ---: | ---: | +| 🌱 → 🌿 Junior | 2 | 21.9% | 84.4% | +62.5 điểm % | + +## Cần xem lại + +- #8: thời gian "khoảng 6" không phải số giờ +- #9: lab "L42" không có trong T10 §2 +- #10: dòng đọc "T01 phần 7: 30" không đúng dạng "T01 🎯: 80" +- #16: bộ câu của 🌿 → 🌳 Middle là Q4, Q6, Q8, Q10, Q11, Q13, Q14, Q22, Q23, Q24, Q25; thiếu Q8, Q10, Q11, Q13, Q14, Q22, Q23, Q24, Q25 +- HV03 (🌰 → 🌱 Fresher): mới có một lần đo diff --git a/tools/docs/learning-report.mjs b/tools/docs/learning-report.mjs new file mode 100644 index 0000000..fe086ec --- /dev/null +++ b/tools/docs/learning-report.mjs @@ -0,0 +1,186 @@ +// Tổng hợp số đo hiệu quả học (docs/training/README §8) từ các issue tạo bằng hai mẫu +// .github/ISSUE_TEMPLATE/lab-report.yml ("Báo cáo lab") và stage-check.yml ("Đo đầu/cuối giai đoạn"): +// - giờ làm lab thực tế so với ước lượng ở T10 §2; +// - phút đọc thực tế so với dòng ⏱ của module; +// - điểm trước/sau mỗi giai đoạn, trên đúng bộ câu vấn đáp ở T10 §4. +// Tên trường đọc từ chính hai file mẫu, ước lượng đọc từ chính tài liệu: không chép số liệu nào bằng tay. +// Chạy: node tools/docs/learning-report.mjs (đọc issue của repo bằng `gh issue list`) +// node tools/docs/learning-report.mjs --file issues.json (mảng { number, title, body }) +// node tools/docs/learning-report.mjs --check (CI: dữ liệu mẫu fixtures/ phải cho đúng báo cáo mẫu) +import fs from 'node:fs'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { repoRoot, report, finish } from './lib.mjs'; + +const HOURS_PER_DAY = 8; // "1 ngày" lab = 8 giờ làm việc +const MIN_REPORTS = 5; // ít hơn thì chưa kết luận +const args = process.argv.slice(2); +const opt = name => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : null; }; +const fixtureIn = path.join(repoRoot, 'tools', 'docs', 'fixtures', 'learning-issues.json'); +const fixtureOut = path.join(repoRoot, 'tools', 'docs', 'fixtures', 'learning-report.md'); + +// ─── Tên trường của hai mẫu issue ────────────────────────────────────────── +function formLabels(file) { + const labels = {}; + let id = null; + for (const line of fs.readFileSync(file, 'utf8').split(/\r?\n/)) { + const m = /^\s*id:\s*(\S+)/.exec(line); + if (m) { id = m[1]; continue; } + const l = /^\s*label:\s*(.+?)\s*$/.exec(line); + if (l && id) { labels[id] = l[1].replace(/^"(.*)"$/, '$1'); id = null; } + } + return labels; +} +const templates = path.join(repoRoot, '.github', 'ISSUE_TEMPLATE'); +const LAB = formLabels(path.join(templates, 'lab-report.yml')); +const STAGE = formLabels(path.join(templates, 'stage-check.yml')); + +// ─── Số liệu trong tài liệu ──────────────────────────────────────────────── +const training = path.join(repoRoot, 'docs', 'training'); +const t10 = fs.readFileSync(path.join(training, 'T10_LABS_AND_ASSESSMENT.md'), 'utf8'); + +/** "3 giờ" → [3, 3]; "0.5–1 ngày" → [4, 8]. */ +function hoursRange(text) { + const m = /(\d+(?:\.\d+)?)(?:–(\d+(?:\.\d+)?))?\s*(giờ|ngày)/.exec(text); + if (!m) return null; + const k = m[3] === 'ngày' ? HOURS_PER_DAY : 1; + return [+m[1] * k, +(m[2] ?? m[1]) * k]; +} +const estimates = new Map(); // L02 → { text, range } +for (const line of t10.split(/\r?\n/)) { + const m = /^\| (L\d\d) \| [^|]+ \| [^|]+ \| ([^|]+) \|/.exec(line); + if (m) estimates.set(m[1], { text: m[2].trim(), range: hoursRange(m[2]) }); +} +if (estimates.size === 0) report(path.join(training, 'T10_LABS_AND_ASSESSMENT.md'), 1, 'không đọc được bảng thời lượng lab ở T10 §2'); + +/** Bộ câu vấn đáp của từng lần lên cấp (T10 §4): "Q22–Q25" → Q22…Q25; Fresher → C1…C10 (C05 §6). */ +const questionSets = new Map(); +for (const line of t10.split(/\r?\n/)) { + const m = /^\| (🌰 → 🌱 Fresher|🌱 → 🌿 Junior|🌿 → 🌳 Middle|🌳 → 🏔️ Senior) \|(?:[^|]*\|){2}([^|]*)\|/.exec(line); + if (!m) continue; + if (/C05/.test(m[2])) { questionSets.set(m[1], Array.from({ length: 10 }, (_, i) => `C${i + 1}`)); continue; } + const ids = []; + for (const r of m[2].matchAll(/Q(\d+)(?:–Q(\d+))?/g)) for (let q = +r[1]; q <= +(r[2] ?? r[1]); q++) ids.push(`Q${q}`); + questionSets.set(m[1], ids); +} + +/** Dòng ⏱ của module: { all, core } phút. */ +function readingCard(code) { + const file = fs.readdirSync(training).find(f => f.startsWith(code + '_')); + if (!file) return null; + const card = fs.readFileSync(path.join(training, file), 'utf8').split(/\r?\n/).find(l => l.includes('**⏱ Đọc:**')) ?? ''; + const all = /\*\*⏱ Đọc:\*\* ≈ (\d+) phút/.exec(card), core = /phần 🎯 ≈ (\d+) phút/.exec(card); + return { all: all ? +all[1] : null, core: core ? +core[1] : (all ? +all[1] : null) }; +} + +// ─── Đọc issue ───────────────────────────────────────────────────────────── +/** Thân issue của mẫu form: "### Tên trường\n\ngiá trị". "_No response_" = bỏ trống. */ +function fields(body) { + const out = {}; + const parts = (body ?? '').replace(/\r\n/g, '\n').split(/^### /m).slice(1); + for (const p of parts) { + const nl = p.indexOf('\n'); + const label = (nl < 0 ? p : p.slice(0, nl)).trim(); + const value = (nl < 0 ? '' : p.slice(nl + 1)).trim(); + out[label] = value === '_No response_' ? '' : value.replace(/^```\w*\n?|\n?```$/g, '').trim(); + } + return out; +} + +const check = args.includes('--check'); +let issues; +const file = check ? fixtureIn : opt('--file'); +if (file) issues = JSON.parse(fs.readFileSync(file, 'utf8')); +else { + try { + issues = JSON.parse(execFileSync('gh', ['issue', 'list', '--state', 'all', '--limit', '1000', '--json', 'number,title,body'], { encoding: 'utf8' })); + } catch (e) { + console.error('Không chạy được `gh issue list` (cần GitHub CLI đã đăng nhập). Hoặc dùng --file .'); + process.exit(2); + } +} + +const warnings = []; +const labHours = new Map(), reading = new Map(), stages = new Map(); +for (const issue of issues.slice().sort((a, b) => a.number - b.number)) { + const f = fields(issue.body); + if (LAB.lab in f) { + const lab = f[LAB.lab], hours = Number(f[LAB.hours]); + if (!estimates.has(lab)) { warnings.push(`#${issue.number}: lab "${lab}" không có trong T10 §2`); continue; } + if (!(hours > 0)) { warnings.push(`#${issue.number}: thời gian "${f[LAB.hours]}" không phải số giờ`); continue; } + labHours.set(lab, [...(labHours.get(lab) ?? []), hours]); + for (const line of (f[LAB.reading] ?? '').split('\n')) { + const m = /^\s*([CTU]\d\d)\s*(🎯)?\s*:\s*(\d+(?:\.\d+)?)\s*$/.exec(line); + if (!m) { if (line.trim()) warnings.push(`#${issue.number}: dòng đọc "${line.trim()}" không đúng dạng "T01 🎯: 80"`); continue; } + const key = `${m[1]}${m[2] ? ' 🎯' : ''}`; + reading.set(key, [...(reading.get(key) ?? []), +m[3]]); + } + } else if (STAGE.stage in f) { + const stage = f[STAGE.stage], when = f[STAGE.when], learner = f[STAGE.learner]; + const expected = questionSets.get(stage); + if (!expected) { warnings.push(`#${issue.number}: cấp "${stage}" không có trong T10 §4`); continue; } + const scores = new Map(); + for (const line of (f[STAGE.scores] ?? '').split('\n')) { + const m = /^\s*([QC]\d+)\s*:\s*([012])\s*$/.exec(line); + if (m) scores.set(m[1], +m[2]); + else if (line.trim()) warnings.push(`#${issue.number}: dòng điểm "${line.trim()}" không đúng dạng "Q1: 2" (0–2)`); + } + const missing = expected.filter(q => !scores.has(q)), extra = [...scores.keys()].filter(q => !expected.includes(q)); + if (missing.length || extra.length) { + warnings.push(`#${issue.number}: bộ câu của ${stage} là ${expected.join(', ')}` + + (missing.length ? `; thiếu ${missing.join(', ')}` : '') + (extra.length ? `; thừa ${extra.join(', ')}` : '')); + continue; + } + const percent = 100 * expected.reduce((s, q) => s + scores.get(q), 0) / (2 * expected.length); + const key = `${stage}\u0000${learner}`; + const entry = stages.get(key) ?? { stage, learner }; + entry[when === 'Trước khi học' ? 'pre' : 'post'] = percent; // lần đo sau cùng thắng + stages.set(key, entry); + } +} + +// ─── Báo cáo ─────────────────────────────────────────────────────────────── +const median = xs => { const s = [...xs].sort((a, b) => a - b); const n = s.length; return n % 2 ? s[(n - 1) / 2] : (s[n / 2 - 1] + s[n / 2]) / 2; }; +const num = x => (Math.round(x * 10) / 10).toString(); +const out = []; +out.push('## Thời gian làm lab', '', '| Lab | Số báo cáo | Trung vị thực tế (giờ) | Ước lượng T10 §2 | Nhận xét |', '| :--- | ---: | ---: | :--- | :--- |'); +for (const [lab, xs] of [...labHours].sort()) { + const est = estimates.get(lab), m = median(xs), [lo, hi] = est.range ?? [NaN, NaN]; + let note = m < lo ? `nhanh hơn ước lượng ${Math.round(100 * (lo - m) / lo)}%` : m > hi ? `lâu hơn ước lượng ${Math.round(100 * (m - hi) / hi)}%` : 'trong khoảng ước lượng'; + if (xs.length < MIN_REPORTS) note += `; chưa đủ ${MIN_REPORTS} báo cáo để sửa T10 §2`; + const inHours = lo === hi ? `${num(lo)} giờ` : `${num(lo)}–${num(hi)} giờ`; + out.push(`| ${lab} | ${xs.length} | ${num(m)} | ${est.text}${est.text === inHours ? '' : ` (${inHours})`} | ${note} |`); +} +out.push('', '## Thời gian đọc', '', '| Module | Số báo cáo | Trung vị thực tế (phút) | ⏱ trên thẻ | Lệch |', '| :--- | ---: | ---: | ---: | ---: |'); +for (const [key, xs] of [...reading].sort()) { + const [code, core] = key.split(' '); + const card = readingCard(code), shown = card ? (core ? card.core : card.all) : null; + const m = median(xs); + out.push(`| ${key} | ${xs.length} | ${num(m)} | ${shown ?? '?'} | ${shown ? (m >= shown ? '+' : '') + Math.round(100 * (m - shown) / shown) + '%' : '?'} |`); +} +out.push('', '## Tiến bộ theo giai đoạn', '', '| Lên cấp | Học viên có đủ hai lần đo | Trước khi học (trung bình) | Lúc lên cấp (trung bình) | Mức tăng |', '| :--- | ---: | ---: | ---: | ---: |'); +const byStage = new Map(); +for (const e of stages.values()) { + if (e.pre === undefined || e.post === undefined) { warnings.push(`${e.learner} (${e.stage}): mới có một lần đo`); continue; } + byStage.set(e.stage, [...(byStage.get(e.stage) ?? []), e]); +} +for (const stage of questionSets.keys()) { + const es = byStage.get(stage); + if (!es) continue; + const pre = es.reduce((s, e) => s + e.pre, 0) / es.length, post = es.reduce((s, e) => s + e.post, 0) / es.length; + out.push(`| ${stage} | ${es.length} | ${num(pre)}% | ${num(post)}% | ${post >= pre ? '+' : ''}${num(post - pre)} điểm % |`); +} +if (warnings.length) out.push('', '## Cần xem lại', '', ...warnings.map(w => `- ${w}`)); +const text = out.join('\n') + '\n'; + +if (check) { + const expected = fs.existsSync(fixtureOut) ? fs.readFileSync(fixtureOut, 'utf8').replace(/\r\n/g, '\n') : null; + if (args.includes('--write')) fs.writeFileSync(fixtureOut, text); + else if (expected !== text) { + report(fixtureOut, 1, 'báo cáo từ dữ liệu mẫu khác báo cáo mẫu đã commit (T10 §2/§4, mẫu issue hay script vừa đổi?). ' + + 'Xem bằng "node tools/docs/learning-report.mjs --file tools/docs/fixtures/learning-issues.json"; đúng thì ghi lại bằng "--check --write"'); + } + finish(`báo cáo mẫu: ${issues.length} issue, ${estimates.size} lab có ước lượng, ${questionSets.size} bộ câu lên cấp`); +} else { + process.stdout.write(text); +} diff --git a/tools/docs/package.json b/tools/docs/package.json index a217e72..05d99fc 100644 --- a/tools/docs/package.json +++ b/tools/docs/package.json @@ -13,7 +13,8 @@ "snippets": "node check-snippets.mjs", "exercises": "node check-exercises.mjs", "deck": "node build-deck.mjs", - "check": "node check-links.mjs && node check-reading-time.mjs && node check-mermaid.mjs && node build-deck.mjs && node check-snippets.mjs && node check-exercises.mjs" + "learning": "node learning-report.mjs --check", + "check": "node check-links.mjs && node check-reading-time.mjs && node check-mermaid.mjs && node build-deck.mjs && node learning-report.mjs --check && node check-snippets.mjs && node check-exercises.mjs" }, "devDependencies": { "jsdom": "30.1.2",