SDD-010 · Lab trong Nemo12: lưu trữ, tải động, độ phủ
Một phần của SDD-010.
10. Lab trong Nemo12 — lưu trữ, tải động, độ phủ (SRC-112)
Lab legacy (288 lab, app.chuyenchon.com/lab) chuyển thành một loại Learning Experience trong đường đi của Unit (§9), không phải một app riêng.
Lưu trữ tách đôi (migration 0037) — điểm mấu chốt để hàng nghìn lab không đè client:
| Bảng | Chứa gì | Đọc khi nào |
|---|---|---|
labs | metadata ~200B/dòng: id, node_id, unit, tên, engine, inquiry_only, số câu hỏi | mỗi lần mở Phòng Lab (chỉ metadata của môn đang xem) |
lab_content | nội dung đầy đủ: items, inquiry, viz (~1–3KB) | chỉ khi learner bấm vào đúng lab đó |
GET /v1/learners/{id}/labs trả kèm mảng labs metadata; GET /v1/labs/{labId} mới trả nội dung. Nhờ vậy bundle JS không đổi khi thêm lab — khác hẳn explore/configs.tsx hiện đang nằm trong bundle (một lý do nữa để lab đi bằng DB chứ không phải file .tsx).
Đường trích xuất (scripts/extract-labs.mjs, skill lab-extract): biên dịch chính file lab bằng esbuild với JSX factory giả + stub ESM sinh đúng tên import, rồi đọc literal ITEMS/INQUIRY/CHECKS ở module scope. Không regex mò trong .tsx. Hai cái bẫy đã dính và phải giữ lại trong code: (1) stub phải là ESM có đúng tên import — dùng Proxy CommonJS thì Hl thành undefined và dấu nhấn ~…~ mất âm thầm (text vẫn hiện ra nên không ai thấy sai); (2) lab bespoke không dùng literal INQUIRY mà tách rời CHECKS/OBSERVE — bỏ qua là mất sạch phần kiểm tra hiểu của 15 lab khoa học.
Chất lượng trước khi nạp: scripts/check-labs.mjs chặn lab có câu hỏi không đáp án đúng, lựa chọn rỗng, dấu ~ lẻ cặp, rác reverse-parse. Lỗi thì không nạp.
Mức độ port: engine='picker' là port trọn vẹn (chạy được ngay bằng dữ liệu). Các archetype khác mới có phần predict → observe → check (inquiry_only=1) — UI nói thẳng với learner là phần mô phỏng đang được dựng lại, không giả vờ.
Độ phủ: scripts/audit-labs.mjs sinh lab-coverage đếm từ cây bài học (unit trắng lab mới hiện ra); Coral có tab Lab đọc GET /v1/coral/labs cho cùng bức tranh để biên soạn bổ sung.
10c. Lab kiểu IB, mỗi Unit một cái (SRC-385)
Chủ dự án 2026-08-19: "Mỗi Unit cần có một Lab, thiết kế theo cách tiếp cận của IB. Lab dạng tương tác, học sinh cần phải click hoặc kéo thả, hoặc kéo slider vài bước, cần trả lời 1-2 câu hỏi."
Ba phần bắt buộc, đúng thứ tự IB:
ĐOÁN → NGHỊCH → HỎI
predict viz checks (1 tới 2 câu)Thứ tự này không đổi được, và đây là chỗ bản cũ làm ngược: LabPlayer trước SRC-385 cho xem hình trước rồi mới hỏi "con đoán gì". Đoán sau khi đã nhìn thì learner chỉ chép lại thứ vừa thấy, không bao giờ đoán sai, nên cũng không bao giờ ngạc nhiên. Mà chỗ lệch giữa "con tưởng" và "hoá ra" mới đúng là chỗ học được. Lab cũ chưa có phần đoán thì vẫn vào thẳng phần khám phá.
scripts/seed-labs-ib.mjs chặn ngay lúc nạp nếu thiếu predict, thiếu viz hoặc không có câu hỏi nào: thiếu một phần thì lab thành phim minh hoạ.
Hình đi bằng DỮ LIỆU, không bằng mã (lab_content.viz, kiểu VizSpec ở apps/learn/src/explore/spec.ts). explore/configs.tsx giữ hình bằng hàm label viết tay, chạy tốt với vài chục bài; nhưng "mỗi Unit một lab" nghĩa là 197 lab riêng môn Toán, mà file config đã 2722 dòng NẰM TRONG BUNDLE. Một hàm cho mỗi unit là bundle phình theo số bài học, đúng thứ mà §10 tách lab_content ra để tránh.
Bộ chuyển configFromSpec() dựng lại closure label từ một chuỗi mẫu {tên}. Không có eval và không có hàm trong dữ liệu: chuỗi mẫu chỉ chèn được các đại lượng mà chính archetype tính ra ({sum}, {cmp}, {abs}, {hyp}…). Tên không có thì để nguyên dấu ngoặc, để lỗi soạn nội dung lộ ra trên màn hình thay vì thành undefined.
Cấp Module thì KHÔNG có lab riêng (quyết định 2026-08-19). Một Module là 5 Unit; lab ở đó hoặc lặp lại lab của một Unit, hoặc trừu tượng tới mức không nghịch được cái gì. Chỉ mở lab cấp Module khi có một quan hệ THẬT nằm vắt qua nhiều Unit mà từng Unit riêng lẻ không cho thấy được, và khi đó nó là một lab bình thường gắn vào Unit cuối của Module, không phải một cấp mới trong cây.
Độ phủ (2026-08-19): 100/197 Unit môn Toán có lab, sau hai đợt SRC-385 (14 rồi 20 lab). Thứ tự soạn đi theo thứ tự learner gặp: Unit đầu của các Module đầu trong từng Package.
Cắt quá tay thì mất đề bài (SRC-390). Ngay sau đợt cắt trên, chủ dự án mở lab tiền thừa và nói "đọc và không hiểu gì luôn". Đúng: SRC-389 xoá luôn question của hình picker cho đủ ngân sách, mà câu đó là ĐỀ BÀI ("Đưa 50 nghìn, mua hết 32 nghìn"), nên màn đoán còn ba con số 50/32/18 đứng trần, kèm hai hộp xám rỗng do phần lộ ra bị làm rỗng mà vẫn được dựng khung.
Luật rút ra, giữ về sau: thứ được phép giấu là phần LỘ RA sau khi bấm, không phải bối cảnh trước khi bấm. Ngân sách chữ cắt vào chỗ nói lặp, không cắt vào chỗ nói lần đầu. Kèm hai sửa: chuỗi rỗng thì không dựng hộp, và archetype mới part-whole (một thanh là cả, kéo để cắt phần đã dùng, phần còn lại tự hiện) thay cho ba con số rời của lab tiền thừa. Đó là hình của mọi bài "có T, dùng đi P, còn lại bao nhiêu", nên còn dùng lại được nhiều lần.
Ngân sách 20 từ mỗi màn (SRC-389). Chủ dự án nhìn màn đoán và nói "có quá nhiều thông tin thừa, cần che dấu phần lớn đi, không quá 20 từ trên một màn hình". Màn ấy khi đó nói cùng một ý ba lần: tiêu đề lab, câu blurb, rồi câu hỏi lớn của lab, trước khi tới câu hỏi learner thực sự phải trả lời.
Cắt còn: tiêu đề (4 tới 6 từ) + hình không chữ + câu hỏi đoán + các lựa chọn + nút. Bỏ hẳn blurb_vi và inquiry.question khỏi giao diện (dữ liệu vẫn giữ, objective_vi dùng chúng), bỏ chữ trong hình xem trước, cắt tiền tố "Đoán trước:" ngay tại chỗ hiển thị (stripLead, không phải sửa 34 tệp dữ liệu), và chặn trong check-labs-ib.mjs: câu hỏi đoán quá 15 từ, lựa chọn quá 4 từ, và cả màn quá 24 từ (tiêu đề + câu hỏi + mọi lựa chọn). Ngân sách đếm cả nhãn nút bấm chứ không chỉ câu văn, vì với learner thì chữ nào trên màn cũng là chữ phải đọc. Sau đợt cắt, màn dày chữ nhất còn 24 từ; phần câu văn thuần chỉ còn 13.
Phần quan sát cũng chỉ còn đúng một ý trên màn, điều hướng bằng hai nút icon và dãy chấm. Bản trước hiện dồn các ý đã xem, nên tới ý thứ ba thì màn hình có ba khối chữ liền nhau.
Một mất mát có chủ đích: câu dặn của SRC-379 ("sai càng học được nhiều") không còn chỗ nào trong giao diện. Luật 20 từ và câu dặn ấy không cùng tồn tại được trên một màn, và chỉ đạo mới thắng.
Đoán bằng cách BẤM, không gõ chữ (SRC-388). Bản đầu để một ô chữ "con nghĩ sao". Ô chữ là rào cản thật với lớp 1-3, và ai cũng bỏ trống rồi đi tiếp, nên phần đoán coi như không tồn tại. Nay mỗi lab mang 2 tới 4 lựa chọn ngắn (inquiry.predict_choices); learner bấm, đổi ý thoải mái, rồi bấm Tiếp tục mới sang bước sau, và chưa chọn thì nút còn khoá.
Lựa chọn ở đây không đánh dấu đáp án đúng, và đó là điểm phải giữ: dự đoán chỉ để learner có cái mà đối chiếu với thứ sắp thấy. Chấm đúng sai là biến nó thành câu hỏi thứ ba, và learner sẽ bắt đầu đoán theo kiểu tìm đáp án an toàn thay vì nói thật mình nghĩ gì, tức là mất đúng cái làm cho phần đoán có giá trị.
Màn ĐOÁN cũng phải có hình (SRC-386). Bản đầu để màn đoán chỉ có chữ, và chủ dự án vào lab "Đếm, gộp và tách" rồi nói "không hiểu gì luôn": câu hỏi là "đếm tiếp từ 4 thêm 3 bước thì dừng ở đâu" cho một bạn lớp 2, mà không có trục số trước mắt thì câu đó vô nghĩa. Nhưng không bê nguyên hình khám phá sang được: hình khám phá luôn kèm lời nhận xét, mà lời ấy chính là đáp án. previewConfig() dựng lại đúng hình đó, đúng trạng thái xuất phát, chỉ thay lời nhận xét bằng một câu trung tính và khoá tương tác. Learner thấy sân khấu để mà nghĩ, chưa thấy điều sẽ xảy ra.
Cùng lý do, SRC-386 thêm archetype dots-groups: hai nhóm chấm tròn xếp NỐI TIẾP nhau trên một hàng, kéo để đổi số vật ở nhóm hai. Trục số là thứ trừu tượng, nó đã giả định learner chấp nhận "một bước bằng một đơn vị"; mà với lớp 1-3 thì việc đang học chính là chuyện đó. Cách xếp nối tiếp và dãy số 1..n chạy liền dưới các chấm tự nó nói ra vì sao đếm tiếp được, không cần một dòng chữ nào.
scripts/check-labs-ib.mjs (chạy trong check:docs) chặn lab thiếu phần đoán, thiếu hình, câu hỏi có hai đáp án cùng đúng, chuỗi mẫu gọi tên đại lượng mà archetype không tính ra, hoặc còn em dash. Bộ kiểm này nằm ở scripts/ chứ không phải vitest của app vì nó đọc file, mà workspace learn không có @types/node.
10b. Thư viện Lab (SRC-166)
GET /v1/subjects/{subjectId}/labs + trang /{school}/thu-vien-lab/{môn}: vào xem bất kỳ lab nào ngay, không qua khoá mở của đường đi Unit, không cần làm bài trước.
Hai lý do bắt buộc phải có trang này:
- Đường đi của Unit mở dần theo "Đo cuối" (SRC-119). Luật đó đúng cho việc học tuần tự, nhưng nó chặn mất một nhu cầu khác hẳn: "tôi chỉ muốn xem thử cái lab kia".
- Đo 2026-08-16: 99/225 lab chưa gắn vào bài nào (
node_id IS NULL), mà/labs?subject_id=…lại lọcnode_id IS NOT NULL— nghĩa là gần một nửa số lab đã migrate không xuất hiện ở bất kỳ đâu trong app. Migrate xong mà không có đường chạm tới thì coi như chưa có.
| Môn | Lab | Chạy trọn vẹn | Mới có predict → observe → check |
|---|---|---|---|
| math | 71 | 13 | 58 |
| english | 60 | 57 | 3 |
| vietnamese | 60 | 56 | 4 |
| chemistry · biology · physics | 34 | 9 | 25 |
Thư viện nói thẳng lab nào mới có phần dự đoán/kiểm tra thay vì để learner bấm vào rồi hụt hẫng. Chỉ tải metadata (~200B/lab); nội dung vẫn tải rời khi bấm — thêm hàng nghìn lab nữa cũng không làm nặng trang.
10d. Lab đã migrate phải CHẠM TỚI ĐƯỢC — luật draft và nhóm "Lab khác" (SRC-455, SRC-458)
Đo 2026-08-21 sau câu hỏi của chủ dự án "labs môn Toán đã sang chưa, từ Unit đã tới được lab chưa": 71 lab Toán chuyenchon nằm trong D1 nhưng chỉ 52 live — 19 lab còn lại ở draft từ chính đợt migrate, nội dung đầy đủ (stepper/parametric/picker, 2 câu kiểm mỗi lab) mà không đường nào chạm tới. Đợt sau lộ thêm 20 lab draft Văn + Anh cùng cảnh.
Luật rút ra, áp cho mọi đợt migrate về sau:
draftchỉ được là trạng thái đang soạn, không được là nơi lab đi ngủ. Lab đã có đủ nội dung thì hoặc bật live, hoặc phải có lý do ghi ra; migrate xong mà lab không nằm trong đường đi nào thì coi như chưa migrate (cùng lập luận §10b lý do 2).- Trần "một lab live mỗi Unit" (
idx_labs_one_per_unit, REQ-LAB-08) không được nới để bật thêm lab. Trong nhóm lab trùng Unit: lab chính giữunit, các lab còn lại vẫn live nhưngunit = NULL→ rơi vào nhóm "Lab khác" của Thư viện Lab. Đúng luật SRC-167: lab không gắn bài vẫn phải chạm tới được. Ở đợt Toán, 5 lab đi theo đường này. - Đường từ Unit tới lab không có bước trung gian nào: lab gắn Unit qua cột
unit, bấm từ ô Lab trên Đường học là vào thẳng. Mỗi lab có URL riênglearn.nemo12.com/{school}/labs/{môn}/{id}(REQ-LAB-12, slug SRC-216) nên chia sẻ / bookmark / deep link đều trỏ đúng một lab. - Kết quả đo sau đợt: cây B1–B5 0 Unit thiếu lab (khớp §14).
Đếm dung lượng phải để SQLite đếm. Cột bytes của lab_content tính bằng LENGTH() trong chính câu SQL, không tính bằng JS ở script nạp: hai bên đếm khác nhau trên tiếng Việt (JS đếm đơn vị UTF-16, SQLite đếm ký tự), nên số kiểm toán lệch âm thầm. Audit độ phủ là thứ bắt được lỗi này.
10e. Mặt thẻ trong Thư viện Lab: mỗi lab một con vật biển (SRC-458)
Chủ dự án 2026-08-21 nhìn trang danh sách labs: "nhìn xấu quá, chán quá. Cái nào nhìn cũng như cái nào." Đúng — mọi thẻ khi đó chỉ khác nhau ở dòng chữ tên lab, mà tên lab của cùng một môn thì đọc gần giống nhau, nên trang thành một bức tường chữ.
- Mỗi lab mang một con vật biển riêng, chọn bằng cách băm từ
idtrên bảng 12 emoji — deterministic, nên lab quen mặt thì lần nào mở cũng đúng con ấy. Đây là điểm mấu chốt: hình ngẫu nhiên mỗi lần tải thì không giúp nhận mặt, chỉ thêm nhiễu. - Thẻ dọc, hình trước chữ sau, lưới 4 cột. Giữ nguyên SRC-227 (mặt thẻ chỉ có hình + tên, không nhồi metadata) — phần metadata đọc được nằm ở §15, sau khi learner đã mở lab.
10f. Bỏ ô gõ chữ khỏi phần đoán, màu hình theo token, các bước không đè nhau (SRC-456)
Ba luật giao diện lab, chốt 2026-08-21 và áp ở tầng dùng chung nên ăn cho cả 11 môn:
- Phần đoán chỉ còn CHỌN 1 TRONG 4. LabPlayer bỏ hẳn ô gõ chữ — nối dài SRC-388 từ "nên bấm" thành "không còn cách nào khác". Đã soạn tay 4 phương án cho 191 lab còn bắt gõ (Toán 70 · Văn 51 · Anh 49 · Lý–Hoá–Sinh 21), nguồn ở
scripts/lab-predict-choices.json, nạp bằngseed-lab-predict-choices.mjs, cổng canh nhãn SRC-421 chạy lúc nạp. Nhiễu phải là nhầm lẫn THẬT của học sinh, không phải phương án cho đủ bốn ô. Lab chưa có phương án thì bỏ qua bước đoán, không dựng khung rỗng. Đo lại sau đợt: 0 lab live còn bắt gõ (SRC-458 xác nhận lại trên cả 11 môn). Dự đoán vẫn không chấm đúng/sai — lý do ở §10c. - Màu hình đi bằng token, không bằng hex. Palette DIAGRAM của engine khám phá từng được pha hex cho nền trắng (trục
#5A6B78chìm hẳn trên nền nước sẫm — đúng lỗi REQ-VIS-09). Nay mọi màu hình trỏ về token--fig-*của theme, theo luật SRC-353. Hệ quả cho test:palette.testđo theo tên token, không đo theo hex — đo hex là khoá cứng một theme. - Các bước quan sát XẾP CHỒNG, không đè nhau. Đảo lại SRC-389 (khi đó: mỗi màn đúng một ý, điều hướng bằng chấm) theo chỉ đạo mới: mỗi bước là một card viền riêng, cách nhau rộng, bước mới bổ sung xuống dưới và mang viền sáng để mắt biết nhìn đâu. Giữ được mạch "điều gì lộ ra sau điều gì" mà không bắt learner nhớ lại ý trước.
10g. Tên lab là CHIP, không phải một dòng của đề bài (SRC-477)
Tên lab đứng trần ngay trên câu hỏi thì mắt đọc liền hai dòng thành một cụm — ảnh lab Vật lí: "Thấy hay đoán" + "Câu nào là quan sát?" đọc thành một câu hỏi khó hiểu. Luật: tên lab hiển thị dưới dạng chip viền tròn, mang nhãn LAB và một icon, tách khỏi khối câu hỏi. Chip nói ngay "đây là cái tên", câu hỏi phía dưới mới là thứ phải trả lời. Áp cho mọi lab, mọi môn.