SDD-028 — B21 Competency Framework
Chỉ đạo chủ dự án 2026-08-29: dựng b21.nemo12.com từ workbook Building 21 Student Competency Framework, để một đứa trẻ mở ra là thấy "giỏi hơn ở việc này nghĩa là làm được thêm gì".
Chỉ đạo 2026-08-30 mở rộng thành bốn khung cho bốn vai, đặt tên theo hệ Nemo12:
| Mã | Tên | Cho ai | Nguồn |
|---|---|---|---|
nemo | Nemo Competency Framework | con | Building 21 Student Competencies |
marlin | Marlin Competency Framework | bố mẹ | chưa có |
dolphin | Dolphin Competency Framework | thầy cô | Building 21 Teacher Competencies |
turtle | Turtle Competency Framework | ban giám hiệu | Building 21 Leadership Competencies |
Cùng chỉ đạo đó: song ngữ, có nút EN/VI, và bản tiếng Việt phải nằm trong DB chứ không dịch ở giao diện.
Khung gốc phát hành theo CC BY-NC-SA 4.0 (Lead Author: Sydney Schaef for reDesign; Contributing Author: Sandra Moumoutjis for Building 21; viết cho South Carolina Department of Education). Giấy phép đó quyết định hai điều của thiết kế này: giữ nguyên văn và ghi nguồn ở mọi trang.
1. Nguyên tắc
- Nguyên văn, không dịch, không viết thêm. Chữ trên trang là chữ TRÍCH từ workbook. Dịch lại 2709 câu "I can ..." chính là soạn nội dung học bằng tay — thứ QG-014 từng cấm (cổng đã gỡ ở SRC-672, nhưng lý do KỸ THUẬT dưới đây vẫn đứng vững) cấm. Chữ của giao diện thì tiếng Việt như mọi app khác (luật ngôn ngữ SRC-604).
- Số bậc không cố định. Phần lớn continuum chạy Level 2 tới Level 12 (6 bậc), World Language chạy thang ACTFL (8 bậc), Personal Development chạy 1 tới 4. Bậc vì thế là dòng dữ liệu có
ordinalvàlabel, không phải năm cột cứng: nhét thành cột là mất dữ liệu ngay ở lĩnh vực đầu tiên không vừa khuôn. - Chỉ báo là đơn vị nhỏ nhất. Một ô trong workbook chứa vài câu "I can ..." cách nhau bằng dòng trắng; mỗi câu là một thứ được chấm đạt / chưa đạt, nên mỗi câu là một dòng trong bảng chứ không phải một đoạn văn trong ô của bậc.
- Nemo12 không chấm ở đây. Trang này không sinh điểm tổng, không xếp hạng, không gắn với learner nào. Người xem tự tick để soi mình, và dấu tick nằm trong
localStoragecủa chính máy họ, không đi đâu cả. - Đọc không cần đăng nhập. Đây là tài liệu mở; dựng cửa đăng nhập trước một tài liệu mở là dựng cửa ở chỗ không có nhà.
2. Đường đi của dữ liệu
workbook .xlsx ──extract.mjs──▶ framework-<khung>.json ─┐
├─gen-seed.mjs─▶ seed-b21.sql ─seed-data─▶ D1
AI Gateway ────translate.mjs──▶ vi-<khung>.json ────────┘Workbook gốc không nằm trong repo: nó là file nguồn của bên thứ ba, và thứ repo cần là bản trích đã kiểm được bằng mắt. extract.mjs in số đếm sau mỗi lần chạy để lần trích sau lệch đi là thấy ngay: nemo 12/42/183/944/2500, dolphin 1/5/34/136/542, turtle 1/6/32/128/550.
Bản dịch nằm ở FILE RIÊNG vi-<khung>.json, tra theo id câu, không trộn vào file trích. Hai nguồn đổi theo hai nhịp khác nhau: trích lại workbook không được xoá bản dịch đã duyệt, và dịch lại không được đụng vào bản gốc.
2b. Một lỗi đã lọt ra production (2026-08-30)
Bản trích đầu tiên đếm 2709 chỉ báo cho khung nemo, đúng 209 câu nhiều hơn sự thật. Bộ trích dừng ở dòng có cột A khác rỗng, vì bản đọc workbook lúc đó (parser tự viết) đọc lệch cột và tưởng dòng giấy phép nằm ở cột A. Thật ra nó nằm ở cột B, nên bộ trích không dừng và nuốt cả khối "CC BY-NC-SA / Lead Author / Sources / Last edited" thành chỉ báo của skill cuối mỗi competency — hiện lên trang như một việc trẻ phải làm được.
Bài học ghi vào bộ trích: điều kiện dừng nay soi cả A lẫn B và khớp tên khối (CC BY, Sources:, Author, Last edited), và mọi lần trích đều in số đếm ra để so.
Seed đi qua workflow seed-data chứ không phải wrangler ... --remote ở máy, theo luật "mọi thứ chạm production đi qua GitHub". File seed dùng INSERT OR REPLACE nên chạy lại là no-op (QG-004).
3. Dữ liệu (migration 0173, dựng lại ở 0174)
b21_frameworks code · name · name_vi · audience_vi · slug · sort_order
b21_areas (framework_code, code) · name · name_vi · slug · sort_order
b21_competencies (framework_code, code) · area_code · name · name_vi · slug
statement · statement_vi · standards · sort_order
b21_skills (framework_code, code) · competency_code · name · name_vi · slug
kind(skill|experience) · guiding_question · guiding_question_vi · standards
xq_crosswalk · myways · portrait · sort_order
b21_levels id ("<khung>:<skill>@<ordinal>") · framework_code · skill_code · ordinal
label · label_vi
b21_indicators id ("<level>#<ordinal>") · level_id · ordinal · statement · statement_viKhoá chính là CẶP (framework_code, code), không phải mã trần. Ba khung đều đánh mã theo tiền tố riêng hôm nay (ELA, TC, LC), nhưng không có gì bảo đảm điều đó mãi đúng; để khoá trần thì khung nạp sau đè lên khung nạp trước mà không báo lỗi gì. Đây là lý do 0174 dựng lại bảng thay vì thêm cột: SQLite không đổi được khoá chính tại chỗ.
Bốn dòng b21_frameworks luôn có mặt, kể cả khung chưa có nội dung: trang chủ phải hiện đủ bốn cửa và nói thẳng cửa nào chưa mở. marlin hiện đang là một cửa như thế.
Cột *_vi để NULL là trạng thái hợp lệ. Bản dịch do máy sinh dần (§7), nên giao diện phải chịu được một câu chưa có bản tiếng Việt và rơi về bản gốc.
kind có hai giá trị vì bản gốc có hai loại dòng: skill có continuum, còn experience (WF.3, WF.5 — thi SAT, hoàn thành kỳ thực tập) là việc phải làm xong, không chia bậc. Bỏ nhóm thứ hai đi thì cây năng lực khuyết mắt xích mà không ai biết là do bản gốc hay do bộ trích.
Khoá là mã của khung (ELA.1.1) chứ không phải id sinh tự động: mã đó ổn định trong bản gốc, nằm trên URL, và nhờ vậy nạp lại seed lần hai không đẻ ra bản sao.
4. API
| Method | Path | Auth |
|---|---|---|
| GET | /v1/b21/frameworks | công khai |
| GET | /v1/b21/frameworks/{framework}/areas | công khai |
| GET | /v1/b21/frameworks/{framework}/competencies/{code} | công khai |
Mọi endpoint trả cả hai bản trong cùng một lần gọi, không nhận tham số ngôn ngữ: người xem gạt nút EN/VI liên tục để đối chiếu, và gọi lại API mỗi lần gạt thì cái nút ấy giật.
Hai route này có tên trong bảng PUBLIC của cổng phủ auth (shared/authCoverage.test.ts) kèm lý do, nên chúng công khai một cách được khai báo, không phải do quên.
/v1/b21/areas trả cây gọn (lĩnh vực · competency · số skill) cho trang chủ, cố ý không kéo theo 2709 chỉ báo. /v1/b21/competencies/{code} trả đủ một competency; nó chạy ba truy vấn phẳng rồi ghép trong bộ nhớ thay vì một JOIN lớn, vì JOIN sẽ nhân bản mọi cột của skill lên vài trăm lần chỉ để D1 trả về nhiều byte hơn cần thiết. Phép ghép đó có test riêng (modules/b21/service.test.ts): gắn nhầm chỉ báo sang bậc khác là loại lỗi trang vẫn hiện đầy đủ và không ai nhận ra.
5. Giao diện (apps/b21)
Vite + React, Design System dùng chung với nemo12.com (nền biển sâu od-*), deploy bằng Workers Static Assets như cả tám app mặt tiền còn lại. Ba tầng, ba trang, mỗi trang một câu hỏi:
| Đường dẫn | Trả lời |
|---|---|
/ | cho ai — bốn khung |
/f/{khung} | đi đâu — lĩnh vực và competency của khung đó |
/f/{khung}/c/{code} | có gì — mỗi skill MỘT DÒNG, các bậc nằm cùng dòng đó |
/f/{khung}/c/{code}/{skill} | thế nào — một skill xem riêng |
/c/{code} của bản đầu tiên tự chuyển về /f/nemo/c/{code}: khi ấy chỉ có một khung, và link đã lưu không được gãy.
Một skill chiếm trọn một dòng; các bậc xếp lưới BA BẬC MỘT HÀNG, và chỉ báo mở ra NGAY TRONG thẻ bậc (chỉ đạo 2026-08-30). Ba chứ không phải xếp hết một hàng vì continuum dài nhất có tám bậc: nhồi tám thẻ vào một hàng thì mỗi thẻ chỉ còn hơn trăm pixel và tên bậc ("Intermediate High") vỡ thành ba dòng. Chỉ báo nằm trong thẻ vì khi chúng xổ ra ở dưới cùng, người bấm ở hàng thứ ba phải nhìn ngược lên mới biết mình đang xem bậc nào.
Trang khung là danh sách thẻ lĩnh vực gấp sẵn: 12 lĩnh vực và 42 competency mở hết ra là một trang cuộn mãi không hết, trong khi người vào đây trước hết cần thấy có những lĩnh vực nào. Mã lĩnh vực ("ELA") đặt ở cuối dòng sau tên: người đọc tìm theo tên, cái mã chỉ dùng khi đối chiếu bản gốc. Khung nào chỉ có một lĩnh vực (dolphin, turtle) thì bày thẳng competency, không bọc thêm một thẻ gấp mà tiêu đề chỉ lặp lại tên khung.
5b. Nút EN/VI
Ở góc trên phải mọi trang, nhớ lựa chọn trong localStorage, mặc định VI. Câu chưa dịch rơi về bản gốc. Cách BÁO chỗ chưa dịch phụ thuộc mức phủ của trang: chưa dịch câu nào thì một dòng ở đầu trang; dịch một phần thì mới dán nhãn "(chưa dịch)" vào từng dòng còn bản gốc. Dán nhãn từng dòng khi cả trang chưa dịch thì nhãn ấy hiện vài trăm lần và không nói thêm được gì.
6. Tự đánh dấu
Dấu tick lưu trong localStorage theo id chỉ báo, một bản duy nhất cho cả trang. Để mỗi khối bậc tự giữ state là mỗi khối có một bản sao riêng của localStorage: tick ở bậc 4 rồi tick ở bậc 2 sẽ ghi đè lên nhau và dấu tick biến mất. Mọi lượt đọc/ghi bọc try/catch — chế độ ẩn danh và trình duyệt chặn site data ném ngay ở dòng truy cập đầu tiên, và một trang tài liệu không được phép trắng vì lý do đó.
7. Máy dịch tiếng Việt
Chạy trong worker api, không phải ở máy: workers/api/src/modules/b21/translate.ts, kích bằng cron */5 * * * *, prompt b21.translate-vi@1 trong sổ prompt (AS-10.2.x).
Vì sao không ai ngồi gõ: không phải vì luật cấm (QG-014 đã gỡ ở SRC-672) mà vì khối lượng — bản dịch cũng là nội dung. Nó phải do model sinh, có log và hoá đơn truy được, rồi người duyệt.
Vì sao trong worker chứ không phải một script. Bản đầu là một script trong scripts/b21/ (đã xoá) gọi AI Gateway bằng CLOUDFLARE_API_TOKEN của repo, chạy qua GitHub Actions. Nó hỏng: gateway trả 401 Unauthorized (code 2009) vì token ấy đủ quyền deploy Worker và chạy D1 nhưng không có scope Workers AI — một scope riêng, và chỉ chủ tài khoản thêm được. Worker thì gọi model bằng binding AI, không cần token nào cả, lại nằm sẵn trong chỗ đã có hạn giờ, log và gateway id.
Nhịp và trần: mỗi lượt cron dịch tối đa 8 lô x 12 mẩu. Hết việc thì hàm trả về ngay ở câu SELECT đầu tiên, không gọi model và không tốn gì — nên cron này để chạy mãi cũng không sinh chi phí sau khi dịch xong. Thứ tự dịch đi từ NGOÀI VÀO TRONG (tên khung, lĩnh vực, competency, skill, rồi mới tới chỉ báo): mỗi lượt cron đều làm trang mục lục tiếng Việt hơn một chút, thay vì hàng nghìn chỉ báo xong trước mà tiêu đề vẫn tiếng Anh.
Model trả về số thứ tự, không trả lại nguyên văn, nên khớp bằng n. Mẩu nào model bỏ sót thì dòng ấy vẫn NULL và lượt cron sau lấy lại; không có gì bị ghi bừa.
7a. Ba lỗi của máy dịch, và cách chặn từng cái
Lượt chạy thật đầu tiên (2026-08-30) là chỗ học được nhiều nhất, nên ghi lại đủ:
| Lỗi | Hình dạng thật | Chặn bằng gì |
|---|---|---|
runPrompt chỉ đọc response dạng chuỗi | TypeError: text.replace is not a function — mọi lượt hỏng trong khi model trả lời đúng | Coerce về chuỗi trong runPrompt, có test giữ cả hai hình dạng |
| Model chèn chữ Hán vào câu tiếng Việt | "Tôi có thể đọc và批 đánh giá..." | isCleanTranslation — CỔNG trong mã, không phải lời dặn |
| Model Viết Hoa Từng Từ theo lối tiêu đề tiếng Anh | "Đọc Phản Biện", "Ngôn Ngữ Và Nghệ Thuật Tiếng Anh" | Prompt v3 dạy bằng ví dụ mẫu, không bằng câu cấm |
| Model dịch cả tên riêng | "Turtle" thành "Rùa" | Prompt v2 liệt kê tên phải giữ |
Hai bài học đáng nhớ hơn cả bốn dòng trên:
- Một lời dặn không phải một cái cổng. Prompt v1 đã cấm chữ Hán bằng lời, và model vẫn chèn ngay lượt đầu. Chỉ khi có
isCleanTranslationtừ chối ghi thì câu hỏng mới thật sự không tới được trang. Câu bị từ chối để NULL và lượt cron sau dịch lại; trả bản gốc tiếng Anh vẫn tử tế hơn một câu tiếng Việt lẫn chữ Hán. - Cấm bằng lời không bằng cho ví dụ. v2 cấm Viết Hoa Từng Từ bằng một câu luật và model vẫn làm — nó bắt chước dạng chữ của bản gốc mạnh hơn là nghe lời. v3 thay câu cấm bằng năm cặp ví dụ đúng/sai và hết ngay.
Cổng cũng từng chặn oan: bản đầu coi "trả lại nguyên văn tiếng Anh" là hỏng, nên nó vứt luôn "NextGen Essentials" — một cái tên chương trình mà giữ nguyên mới đúng. Nay chỉ coi là hỏng khi bản gốc dài như một câu (từ 6 từ trở lên).
7b. Đưa bản dịch về repo
GET /v1/b21/frameworks/{khung}/translations trả toàn bộ bản dịch của một khung, tra theo id. node scripts/b21/pull-translations.mjs kéo chúng về scripts/b21/vi-<khung>.json — chỗ người duyệt đọc diff và sửa. Script không đè câu đã có trong file: câu đã sửa tay trong repo là bản chuẩn, còn D1 chỉ giữ bản máy.
Chiều ngược lại cũng phải an toàn, và đây là chỗ suýt hỏng: seed dùng INSERT OR REPLACE sẽ xoá sạch các cột *_vi mà máy vừa dịch trong D1 nhưng repo chưa kéo về, bắt máy dịch lại từ đầu. gen-seed.mjs vì thế sinh ON CONFLICT ... DO UPDATE với COALESCE(excluded.x, bảng.x) cho MỌI cột *_vi: bản gốc thì ghi đè, còn bản dịch chỉ ghi khi repo thật sự có.
8. Cái chưa làm
| Việc | Vì sao |
|---|---|
| Nối chỉ báo với bằng chứng học của learner | Khung này đang là tài liệu công khai; nối vào learner là một quyết định sản phẩm khác, cần REQ riêng |
| Khung Marlin (cho bố mẹ) | Chưa có bản gốc; Building 21 không phát hành khung cho phụ huynh |
| Người duyệt bản dịch ngay trên trang | Đợt đầu duyệt bằng cách đọc diff vi-<khung>.json; dựng màn duyệt trước khi biết bản dịch hỏng kiểu gì là dựng sớm |
| WF.4 (kỹ năng công nghệ) chưa có bậc | Bản gốc không kèm continuum cho nhóm này; bịa thêm là tự viết nội dung |
| Tìm kiếm toàn khung | Chờ xem người dùng thật đi vào bằng đường nào trước khi thêm ô tìm kiếm |
Trace
REQ-BRD-10→§3-7 (dữ liệu §3 · API §4 · bốn trang và nút EN/VI §5 · tự đánh dấu §6 · máy dịch §7).