Skip to content

SDD-027 · Nguyên tắc, hình dạng, vòng sinh chấm sửa và đường ra bản thảo ​

Một phần của SDD-027.

1. Nguyên tắc ​

  1. Cấm gõ tay. Nội dung learner đọc không được tạo trực tiếp từ Claude Code; nó phải đi qua workflow và engine lúc chạy (SRC-635; cổng QG-014 đã gỡ ở SRC-672 — nay là khuyến nghị). Việc của phiên Claude Code là xây cái máy, không viết cái chữ.
  2. Máy sinh, máy chấm, người duyệt. Không có đường nào để nội dung máy sinh tự đi vào bảng curriculum_*. Bản thảo nằm ở R2 và bảng content_run_trials; đưa vào D1 là một hành động của người (§7). Learner không bao giờ gặp bài chưa ai đọc.
  3. Điểm là điều kiện CẦN, không phải đủ. Rubric máy chỉ chấm thứ đo được: số lượng, hiện diện, mối nối, vài dấu hiệu văn phong. Đọc "92/100" thành "bài này hay" là đọc sai cái thước. Thang điểm canonical ở course-audit.md (mục rubric Lesson-draft) — sửa thước thì sửa doc đó TRƯỚC, rubric.ts theo sau.
  4. Một đường duy nhất chạm model. callModel trong workers/foundry/src/models.ts, luôn kèm gateway.id (cùng luật AS-10.1.1 của runPrompt bên api) và kèm metadata đối soát (run_id, model_key, step) để log AI Gateway truy được từng lượt tốn gì. Đổi model chỉ là đổi một chuỗi, và mọi lượt chạy nằm chung một bảng log.
  5. Không mặt tiền công khai. nemo12-foundry khai workers_dev: false, không có routes. Đường vào duy nhất là service binding FOUNDRY từ api, sau cổng requireAdmin.
  6. Mọi con số đều có version của cái thước. Mỗi trial stamp prompt_version (lessonSpec.ts) + rubric_version (rubric.ts) vào cả R2 lẫn D1 (migration 0165, QG-010): điểm giữa hai lượt chạy chỉ so được khi cùng cặp version. Đổi nội dung prompt hay tiêu chí rubric = tăng version, không sửa đè.

2. Hình dạng ​

admin console
   ├─ GET  /v1/admin/foundry/runs         (api, requireAdmin — 50 run gần nhất)
   └─ POST /v1/admin/foundry/runs         (api, requireAdmin + audit log mang target_label/model_keys)
        └─ service binding FOUNDRY
             └─ nemo12-foundry  POST /runs   (chốt trần chi phí ngày FOUNDRY_DAILY_USD_CAP)
                  ├─ ghi content_runs (status=running; create hỏng thì đánh failed ngay)
                  └─ LESSON_FORGE.create()        ← Cloudflare Workflow (WF-19)
                        các model chạy SONG SONG, mỗi model một chuỗi step:
                        step sinh:<model>   → callModel → AI Gateway → model (retry thật: 2 lần)
                        step cham:<model>   → rubric máy (đóng băng phán quyết vào lịch sử step)
                        step sua:<model>    → chỉ khi còn chỗ chưa đạt; nhiệt 0.15
                        step cham2:<model>  → chấm bản sửa, chỉ nhận khi THẬT SỰ hơn
                        step luu:<model>    → R2 foundry/<run>/<model>.json (kèm version + bản thô cả hai vòng)
                        step ghi so         → content_run_trials + content_runs
                        (thân run bọc try/catch: chết giữa chừng thì content_runs = failed, không kẹt running)

3. Vì sao Workflows chứ không phải một request dài ​

Mỗi lượt gọi model mất hàng chục giây và có thể hỏng giữa chừng. Workflow lưu kết quả từng bước và chạy tiếp từ chỗ hỏng, nên một lượt sinh đang dở không mất trắng khi model nghẽn hay worker bị đẩy đi. Đó cũng là lý do mỗi lượt gọi model là một step.do riêng: bước đã trả tiền rồi thì không trả lần thứ hai.

Hai ngữ nghĩa của Workflows mà code phải tôn trọng (bài học audit #012):

  • Workflows chỉ retry khi callback THROW. callModel không bao giờ throw (nó trả {ok:false}) nên trong step phải ném lỗi ra thì cấu hình retries mới sống; cạn retry thì try/catch tầng dưới biến lại thành một trial hỏng có ghi nhận, các model khác vẫn chạy tiếp.
  • Code ngoài step.do chạy lại ở mọi lần replay. Phán quyết rẽ nhánh (chấm điểm) phải nằm TRONG step để một lần redeploy đổi rubric giữa chừng không làm replay rẽ khác với điểm đã ghi.

Tên bước có kèm khoá model (sinh:deepseek-chat) vì tên bước chính là khoá lưu kết quả của Workflows — hai model trùng tên bước là dùng chung một kết quả. Các chuỗi step của từng model độc lập hoàn toàn nên chạy song song (Promise.all): wall time là MAX chứ không phải TỔNG.

4. Vòng sinh → chấm → sửa ​

  1. buildLessonMessages dựng prompt từ brief. Brief mang đủ ngữ cảnh unit — key concepts (CD-4.4: concepts của lesson phải chọn từ đây), Misconception Map mã M (CD-9.6), các lesson anh em (không soạn trùng phạm vi) — model không được cho thứ gì thì không tuân được luật ấy, và không vòng sửa nào cứu nổi vì thông tin không tồn tại trong cả hội thoại.
  2. Kết quả phải đạt lessonDraftSchema (zod). Không đạt schema thì coi như không có kết quả — cùng luật AS-10.2.5 bên api. Schema chặn cả mối nối ma (outcome_seq trỏ outcome không tồn tại) và không có check kind reflection (CD-7.4: câu kiểm cuối bài phải máy chấm được).
  3. scoreLesson chấm theo rubric L1-L16 (course-audit.md, thang 100, rubric_version 2), ngưỡng đạt 90. Điểm chết (tiêu chí schema đã bảo đảm) chỉ còn 13/100 — thang v1 để 44/100 điểm chết nên "90" thực chất là "được rớt đúng một tiêu chí".
  4. Còn bất kỳ chỗ chưa đạt nào thì sửa — không chỉ khi dưới ngưỡng: bài 92/100 mang guiding question không phải câu hỏi cũng phải được vá. Gửi đúng danh sách chỗ hỏng cho model (nói cụ thể thì model vá đúng chỗ; nói "làm tốt hơn" thì nó viết lại từ đầu), kèm đủ brief và đủ Luật viết như vòng sinh (WRITING_RULES dùng chung — vòng sửa từng chạy với system prompt một câu nên tái phạm chính những thứ vòng sinh cấm), ở nhiệt 0.15 (sửa là việc càng ít ngẫu nhiên càng tốt).
  5. Bản sửa chỉ được nhận khi nó thật sự hơn: điểm cao hơn, hoặc bản đầu chưa từng đạt schema mà bản sửa đạt. Model hỏng hẳn (cạn retry) thì không chạy vòng sửa — không có bản thảo nào để sửa, chỉ ghi trial lỗi.

5. So model ​

Mỗi lượt chạy đi qua nhiều model trong sổ MODELS và ghi lại cho từng model: điểm, có phải sửa không, thời gian, token vào/ra, tiền ước tính. Câu hỏi cả hệ này sinh ra để trả lời là "cùng một bài, model nào cho điểm cao hơn và tốn bao nhiêu" — nên đó là hai bảng chứ không phải một (migration 0164; cột version + usage_estimated ở 0165).

Ba luật đọc bảng so sánh:

  • Chỉ so điểm giữa các trial cùng cặp (prompt_version, rubric_version) — khác version là khác đề, khác thước.
  • usage_estimated=1 nghĩa là token/tiền của trial đó là số ƯỚC (provider không trả usage, ước 2 ký tự/token cho tiếng Việt) — đừng xếp hạng giá lẫn lộn số đo với số đoán.
  • Giá trong sổ là giá niêm yết, dùng để xếp hạng tương đối. Đối soát chi tiêu thì đọc AI Gateway — mọi lượt gọi mang metadata run_id/model_key/step nên truy được từng đồng.

Trần chi phí: FOUNDRY_DAILY_USD_CAP (vars, mặc định 5 USD niêm yết/24h) chặn ở POST /runs — chốt một-truy-vấn, không phải budget engine; phải nâng có chủ đích trước khi mở sinh Unit/Course.

6. Cái chưa làm ​

ViệcVì sao
Sinh trọn một Unit / CourseLát đầu tiên cố ý là một Lesson: có số liệu thật về chất lượng và giá rồi mới mở rộng. Chọn model trong lúc chưa đo là đoán. Trước khi mở: nâng trần chi phí có chủ đích (§5)
Vòng review bằng model thứ hai (judge)Rubric máy không chấm được câu chữ. Judge là bước kế, sau khi biết rubric máy lọc được bao nhiêu
Nút duyệt trên admin để đổ vào curriculum_*Chưa có gì đáng duyệt cho tới khi đo xong chất lượng; đường tay tạm thời ở §7
Trừ điểm khi trùng nội dung với bài khác trong cùng courseCần đọc cả course, không đọc được trong phạm vi một lượt sinh; brief đã mang danh sách lesson anh em để giảm trùng từ gốc
Cron quét run running quá N giờLưới cuối cho ca cả catch trong workflow lẫn D1 cùng hỏng — hiếm tới mức chưa đáng một cron; thêm khi foundry chạy đủ dày để có run treo thật

7. Đường ra của bản thảo — nút "Duyệt và nạp" (SRC-639) ​

Mắt xích thứ ba của §1 ("máy sinh, máy chấm, người duyệt"), và nó từng là chỗ hở lớn nhất của cả hệ. Bằng chứng ngày 2026-08-28: 16 lượt chạy done cho math-algebra-a1 mà khoá vẫn hiện 0 nội dung — bản thảo nằm ở content_run_trials.draft_json, learner thì đọc curriculum_*, và không có đường nào nối hai chỗ. Xưởng sinh ra một kho bản thảo không bao giờ tới được ai.

POST /drafts/apply (nút Duyệt và nạp trên Coral) lấy bản thảo điểm cao nhất của một lượt, chấm lại schema ngay lúc nạp (schema có thể đã siết từ lúc sinh tới lúc duyệt), rồi ghi vào sáu bảng nội dung của lesson. Xoá-rồi-ghi chứ không upsert theo seq: số phần tử đổi được (3 hiểu lầm thành 4), mà upsert sẽ để lại dòng thừa của bản cũ — đúng kiểu dữ liệu trùng mà cổng chặn của audit-course.mjs sinh ra để bắt.

Hai thứ cố ý KHÔNG nạp:

Không nạpVì sao
materials.url / ref_idBảng có CHECK ép lab phải có ref_id, loại khác phải có url; CD-7.2 cấm bịa link. Model không biết lab nào có thật. Tiêu đề và reflection đã sinh; người gắn nguồn thật rồi nạp riêng. Bịa một ref thì nó chèn êm và chỉ lộ khi learner bấm vào (đợt 2026-08-27 quét ra 64 ref ma trên 4 course đã live)
name_vi của bàiĐổi tên bài là đổi chính thứ đang được đánh địa chỉ — việc của người soạn unit, không phải của một lượt nạp

Máy không tự nạp bao giờ. Route chỉ chạy khi có người bấm, có audit log, và bản thảo mang run_id làm provenance — nên mọi câu chữ trong production truy ngược được về một lượt chạy, đúng điều xưởng nội dung hướng tới (QG-014 cũ, đã gỡ ở SRC-672).

7b. Đường thủ công (vẫn dùng khi cần đưa vào file JSON của course) ​

Người duyệt vẫn có thể đưa bản thảo lên production bằng đường nạp course content sẵn có:

  1. GET /v1/admin/foundry/runs tìm run → GET .../runs/{id} lấy r2_key của trial tốt nhất.
  2. Đọc draft trong object R2, đọc bằng mắt (điểm là điều kiện cần, không phải đủ).
  3. Ghép vào scripts/course-content/<course_id>.json đúng khuôn lesson của course-content-to-sql.mjs, ghi kèm run_id + model_key vào comment của lesson để giữ provenance.
  4. Chạy course-content-to-sql.mjs thành migration → audit-course.mjs ≥90 → commit.

Mục này bị thay thế khi "Nút duyệt trên admin" (§6) thành hình.

8. Bản thảo ở đâu, bản thô ở đâu ​

D1 giữ bản thảo đã chấm (content_run_trials.draft_json, migration 0169): đây là thứ người duyệt đọc và là thứ đường nạp nội dung (§7) cần, nên nó không được phụ thuộc vào R2. Khoảng 6-10 KB mỗi trial — nhỏ, có cấu trúc, đọc mỗi lần mở màn review.

R2 giữ bản THÔ (foundry/<run>/<model>.json): cả lời dẫn, cả hai vòng gọi model. To, đọc hiếm, chỉ cần khi phải truy lại vì sao điểm chấm và bài đọc thấy không khớp.

Phân vai này ra đời từ một sự cố thật ngày 2026-08-28: token CI thiếu quyền R2 nên binding không gắn được, và lúc ấy mới lộ ra rằng bản thảo chỉ tồn tại trong R2 — tức mất R2 là mọi lượt sinh thành công vẫn không lưu được nội dung nào. Nay thiếu R2 thì r2_key ghi rỗng (nhìn thấy được là không có) và không mất một dòng nội dung nào.

Trạng thái binding R2: chưa gắn. Cấp quyền Workers R2 Storage cho CLOUDFLARE_API_TOKEN rồi bỏ comment khối r2_buckets trong workers/foundry/wrangler.jsonc — code và kiểu đã sẵn sàng. Đã thử đường vòng "tạo worker trước, gắn R2 sau" và bác bỏ: wrangler kiểm bucket ở mọi lần deploy có binding R2, không chỉ lúc tạo.

8b. Vòng đời dữ liệu ở R2 ​

Prefix foundry/<run>/<model>.json nằm trong bucket dùng chung nemo12-content. Lifecycle rule foundry-draft-expiry (đặt 2026-08-28 qua wrangler r2 bucket lifecycle add): mọi object dưới foundry/ tự xoá sau 90 ngày — đủ để truy sự cố, không tích rác vô hạn trong bucket đang chứa cả ảnh gia đình và dữ liệu learner.

Lifecycle của R2 chỉ lọc theo prefix + tuổi, KHÔNG phân biệt được bản đã duyệt — nên provenance vĩnh viễn của bản thảo đã lên production KHÔNG nằm ở R2 mà nằm ở git: bước duyệt (§7) ghi run_id + model_key vào scripts/course-content/<course_id>.json, và bản thân nội dung đã duyệt sống trong file đó + migration sinh từ nó. Khi "Nút duyệt trên admin" (§6) thành hình, bước duyệt chép object sang chỗ không hết hạn nếu lúc đó vẫn cần bản thô gốc.