Hệ thống tự sinh và tự nâng chất lượng nội dung
Từ 28.08.2026, đường sản xuất nội dung khoá học hàng loạt là hệ thống tự làm: gọi Cloudflare AI Gateway, tự chấm, tự sửa, và tự chọn việc tiếp theo, không còn chạy từ máy của người soạn. (Nội dung viết tay vẫn được, từ 03.09.2026, xem mục Luật nền ở cuối trang.)
Trước đó mọi thứ chạy qua wrangler dev --remote trên một chiếc laptop. Cách đó có ba chỗ hỏng: chất lượng phụ thuộc vào ai đang ngồi đó, tiến độ dừng khi máy tắt, và không có cách nào so được model này với model kia ngoài cảm giác.
Cùng ngày ra đời, hệ được rà soát đối kháng toàn phần và nâng cấp theo 57 finding, xem Rà soát content-ops 28.08. Trang này mô tả hệ SAU nâng cấp.
Ba Workflow
Đi đúng lối bộ Workflow cho sách đã chạy sẵn trong repo, không phát minh kiến trúc mới.
CourseContentWorkflow
Sinh một artefact, ghi lại model đã dùng và điểm chất lượng của chính artefact đó.
Một artefact một instance, không phải cả khoá một instance: bước sinh ngân hàng câu hỏi tốn 5–8 lượt gọi AI, gói cả khoá vào một instance thì một bài hỏng kéo theo cả khoá chạy lại.
Ba điều giữ cho dòng run đáng tin:
- Run id là
event.instanceId, engine replay lạirun()từ đầu mỗi lần resume, id sinh ngoài step sẽ đổi giá trị và dòng run treorunningvĩnh viễn. - Điểm chấm theo OWNER: finding của bộ kiểm trỏ vào dòng con (câu hỏi, ví dụ, bài viết), nên bước score tra theo
owner_id, chính là concept/unit của job. Bản đầu sotarget_idvới nhau nên mọi run đều được 1.0 bất kể chất lượng. - Score hỏng không làm hỏng run: nội dung đã ghi xong ở bước generate; lỗi lúc chấm chỉ nghĩa là "chưa chấm được" (score NULL).
CourseQualityAuditWorkflow
Chấm bằng mã, không hỏi mô hình.
Vì sao không dùng AI để chấm
Chính mô hình đã sinh ra nội dung này, và một mô hình chấm bài của chính nó thì gần như luôn cho qua. Mọi luật trong bộ kiểm đều đếm được hoặc so khớp được, nên chạy lại bao nhiêu lần cũng ra cùng kết quả, đó là điều kiện để so chất lượng giữa các model, giữa các lần sinh, và theo thời gian.
Lỗi sống lâu hơn một lượt kiểm: thấy lại thì cập nhật last_seen_at chứ không tạo dòng mới, không còn thấy thì đánh dấu đã sửa. Nhờ vậy nhìn được lỗi nào dai dẳng qua nhiều lượt sinh, đó mới là lỗi đáng đi sửa máy sinh, khác hẳn lỗi ngẫu nhiên của một lượt.
Bộ luật khớp qualityRules của chuẩn v2.2, phủ mọi bảng người học chạm vào: hai ngân hàng bài học, thư viện Big Idea, ví dụ, bài viết, outcome cả hai cấp, SCQA đã lưu (kể lại tình huống, thiếu khoảnh khắc ngoặt), ký tự Hán/Nhật/Hàn lạc dòng, thuật ngữ mượn của khoá khác, và một lớp không luật hình thức nào thay được:
Lớp kiểm bằng người học thật (suspect-answer-key)
Câu hỏi có từ 5 lượt trả lời mà quá 60% dồn vào cùng MỘT phương án khác ô đáp án là câu nghi đánh dấu nhầm, đúng loại lỗi từng lọt qua cả bốn lớp lọc lẫn lượt kiểm chéo lúc sinh. Dữ liệu trả lời có sẵn trong D1; bỏ nó là bỏ lớp kiểm rẻ nhất và thật nhất.
CourseOpsOrchestratorWorkflow
Quyết định sinh cái gì tiếp, theo thứ tự ưu tiên cố ý:
- Lỗi chặn đang mở, nội dung sai hại hơn nội dung thiếu. Một câu chấm sai đáp án làm người học chọn đúng mà bị báo sai; một bài chưa có thì họ chỉ chưa học được.
- Năm khoá ưu tiên, lần lượt từng khoá một, vét trọn khoá thứ nhất rồi mới sang khoá sau. Xem Thứ tự sinh.
- Trong một khoá: hàng rào chủ đề trước, rồi lớp thấp nhất còn thiếu, mức 2 trước mức 3, vì mức sau bám vào mức trước để có nền. Đủ mọi lớp của chuẩn, kể cả hai lớp mức 3 bản đầu bỏ quên (thư viện Big Idea, bản tiếng Anh) và bài mất ngân hàng summative.
- Ngoài nhóm ưu tiên: lớp thấp nhất trước, rồi khoá đang hiển thị trước khoá đã ẩn (
legacy_courses.is_active).
Ba luật vận hành giữ cho lượt điều phối sạch:
- Một artefact một suất: nhiều finding cùng trỏ về một concept không chiếm nhiều suất ngân sách, không đẻ hai instance cùng ghi một bảng.
- Id con dựng từ
instanceIdcủa lượt điều phối, không dùngDate.now(), retry tạo lại đúng id cũ nên "already exists" mới thật sự chống trùng. - Finding mồ côi tự đóng: artefact chứa lỗi đã bị sinh lại và đổi id thì finding cũ được đánh
resolved, không chiếm đầu hàng đợi blocker vĩnh viễn.
Chạy theo cron ba tiếng một lượt (41 */3, tách khỏi các job 6 tiếng, trước đây một hàm scheduled chạy cho cả hai cron làm mọi job 6 tiếng dày gấp đôi). Ngân sách 12 artefact, trần cứng 24. Audit chỉ xếp hàng sau khi con đã xong thật (hỏi D1 hai phút một lần, tối đa 30 lần), không chấm nội dung đang ghi dở.
Nhiều model, cùng một cổng: và kiểm chéo
Đang chạy: deepseek-v4-flash (một model duy nhất, chốt 29.08.2026)
Kiểm chéo: deepseek-v4-pro (người kiểm luôn phải KHÁC người ra đề)
Đứng ngoài: llama-8b (để dành cho lượt phụ trợ rẻ tiền)Vì sao một model chứ không xoay vòng
Vòng xoay nhiều model là để so, mà so thì cần thời gian: mỗi loại artefact phải được sinh nhiều lần mới đủ dữ liệu. Trước mắt chạy một mình deepseek-v4-flash cho nhanh và cho nhất quán. Muốn so lại thì thêm phần tử vào mảng ROTATION, không cần sửa gì khác.
Bộ model từng cũ đi một cách âm thầm: nó dựng từ những gì có mặt lúc mới làm và không ai rà lại. Tới 29.08.2026 mới phát hiện hệ vẫn chạy deepseek-r1-distill-qwen-32b, một bản chưng cất 32B, trong khi DeepSeek V4 Pro và V4 Flash đã có sẵn trên cùng một cổng. (Không có "R4" trong danh mục Workers AI; nhánh R chỉ dừng ở bản chưng cất đó.) Vòng xoay còn chứa qwen2.5-coder, một model viết mã, đang được giao viết văn giảng dạy tiếng Việt, lọt vào chỉ vì lúc dựng danh mục có nó. Bài học: rà lại danh mục model theo lịch, không thì hệ tự tụt hậu mà không báo gì.
Mọi lượt gọi đi qua AI Gateway, nên đổi model là đổi một chuỗi chứ không phải đổi hạ tầng. Orchestrator xoay vòng model khi không bị ép, model đi xuyên xuống từng lượt callAI (bản đầu ghi model vào sổ nhưng không truyền xuống máy sinh, cột so sánh model vì thế toàn dữ liệu bịa), và cột score trong course_content_runs cho biết model nào làm tốt hơn ở loại artefact nào.
Kiểm đáp án là kiểm chéo: người kiểm luôn là model KHÁC người ra đề (VERIFY_PARTNER), kể cả khi chỉ chạy một model để sinh, khâu kiểm vẫn giao cho model khác, vì một model tự chấm bài của chính nó thì gần như luôn cho qua, chạy temperature 0, kiểm bằng chính mình thì nghiêng về đúng cách đọc đã sinh ra lỗi, còn kiểm ở nhiệt cao thì bất đồng vì nhiễu chứ không vì đề sai. Luật loại giữ nguyên: chỉ bỏ câu khi người kiểm bất đồng hai lượt liên tiếp và cùng chỉ về một phương án.
Các lượt chấm (Performance Task, reflection) chạy temperature 0.2, cùng một bài nộp phải ra cùng một mức, và tắt log gateway vì prompt chứa chữ của người học.
Ba trần chặn ở mỗi lượt gọi model
Cloudflare Workflows không tự cắt lượt gọi treo, đã có lượt generate đứng "đang chạy" hơn năm phút, và ở chế độ dồn lực cả hàng việc đứng lại phía sau. Ba hàng rào đặt ở callAI, cửa duy nhất mọi lượt gọi đi qua:
| Trần | Giá trị | Vì sao |
|---|---|---|
| Thời gian | timeoutFor(): sàn 120 s, nâng theo token tới 300 s; 600 s cho model lý luận | Thà hỏng nhanh, vào sổ, thử lại, còn hơn treo cả hàng việc |
| Token một lượt | tokenBudget(): thường tới 12.000, lý luận tới 64.000 (thay hằng số MAX_TOKENS_CEILING = 6000 cũ) | Lượt gọi lớn là lượt lâu nhất và hay treo nhất; xem bảng chi tiết ở mục Ba bước vá bên dưới |
| Số từ mỗi đoạn | maxWords = 300 (engine sinh văn) | Dặn ở cuối lời dặn hệ thống, câu cuối là câu model bám sát nhất |
Thời gian mỗi lượt được đo và ghi vào content_prompt_log (duration_ms, outcome), kể cả khi hỏng, lượt treo mới là lượt đáng đo nhất.
Trần token đi đôi với trần schema: nới schema mà không nới max_tokens thì JSON bị cắt giữa chừng và hỏng cả lượt. Đó cũng là lý do chặn độ dài ở mức đoạn văn chứ không cắt max_tokens xuống thấp, artefact nhiều phần (ba bài viết + chín ví dụ một lượt) sẽ hỏng.
Đọc prompt THẬT đã gửi đi
GET /ops/prompts/live?engine=<tên> trả về nguyên văn chuỗi đã tới model (5 lượt gần nhất mỗi engine), gồm cả phần bơm vào lúc chạy: hàng rào chủ đề của khoá, dàn nhân vật, bối cảnh, danh sách khái niệm đã dùng. Màn hình: tab Lời dặn máy ở content-review.
Khác hẳn /ops/prompts, vốn chỉ là khuôn viết trong mã và chỉ khai ra được 5 trong 12 engine. Đọc khuôn rồi đi tối ưu là đang tối ưu một thứ không tồn tại.
Một lượt gọi gửi đi ba phần, và cả ba đều được ghi lại:
| Phần | Là gì | Vì sao phải xem |
|---|---|---|
system | Giọng chung + trần độ dài | Chỗ đặt nguyên tắc bắt buộc |
user | Dữ liệu bài + hàng rào chủ đề + câu đã dùng | Chiếm phần lớn độ dài; đây là chỗ hay phình |
response_format.json_schema | Khuôn ép cấu trúc trả về | Model phớt lờ khuôn này là lỗi hay gặp nhất, lượt sinh hỏng với "thiếu trường" mà nhìn system/user không hiểu vì sao |
Xem ở hai chỗ:
- Ngay tại lượt chạy,
content-ops/<slug>, mở một dòng workflow → "Xem prompt đã gửi sang model". Khớp theo khoảng thời gian của lượt chạy đó (callAInằm dưới ba tầng hàm và không biết mình phục vụ lượt nào; luồnrun_idxuyên cả chục chữ ký hàm chỉ để hiển thị thì không đáng). Hai lượt chồng nhau trên cùng một khoá thì có thể lẫn. - Theo engine, tab Lời dặn máy ở
content-review, 5 lượt gần nhất mỗi engine.
Lượt gọi mang chữ của người học (log: false) không được ghi, bảng này không được thành kho lưu thứ cấp dữ liệu cá nhân.
Model ngoài Cloudflare
Model trên Workers AI không tôn trọng khuôn JSON đủ chắc cho đường ống này, nên hệ gọi được cả OpenAI, Google và Anthropic, qua chính AI Gateway đang dùng, để giữ nguyên một chỗ xem log, một chỗ đếm chi phí, một chỗ đặt giới hạn.
Mỗi nhà cung cấp ép cấu trúc một kiểu khác nhau, và đây là phần khó thật sự (providers.ts):
| Nhà cung cấp | Cách ép cấu trúc | Bẫy |
|---|---|---|
| OpenAI | response_format.json_schema với strict: true | Đòi mọi object có additionalProperties: false và mọi trường nằm trong required |
generationConfig.responseSchema | Chỉ hiểu một tập nhỏ từ khoá; gặp từ lạ là lỗi 400 chứ không bỏ qua | |
| Anthropic | Bắt buộc gọi một tool có input_schema chính là khuôn cần | Cấu trúc thành ràng buộc của API chứ không còn là lời dặn, chắc nhất trong ba |
Chuỗi mặc định chạy hoàn toàn trên Workers AI, không cần nạp khoá API nào:
FALLBACK_CHAIN = deepseek-v4-pro → deepseek-v4-flash → llama-70bPro đứng trước Flash vì nó là model lý luận đầy đủ, khả năng tôn trọng khuôn JSON cao hơn.
Model ngoài vẫn gọi được nếu có khoá (wrangler secret put ANTHROPIC_API_KEY…), nhưng không nằm trong chuỗi mặc định: nạp khoá là việc thủ công, và một hệ đòi thao tác tay mới chạy được thì không phải hệ. Muốn dùng thì thêm vào đầu FALLBACK_CHAIN.
Đếm token: vào, ra, và phần "nghĩ"
Mỗi lượt gọi ghi lại số token, kể cả lượt hỏng, vì lượt hỏng vẫn tốn đúng số tiền đó và đó là loại lượt hay bị quên khỏi sổ chi phí nhất.
| Cột | Vì sao cần tách riêng |
|---|---|
input_tokens | Prompt phình lên theo thời gian mà không ai để ý |
output_tokens | Chạm trần là dấu hiệu nội dung bị cắt |
reasoning_tokens | Nằm trong output nhưng người đọc không thấy chữ nào của nó, không tách thì "ra 6.000 token mà nội dung rỗng" là câu đố không lời giải |
total_tokens | Đơn vị tính tiền duy nhất |
Sổ chi phí tách khỏi sổ prompt. content_prompt_log chỉ giữ 5 lượt gần nhất mỗi engine (mỗi dòng vài nghìn chữ). Để số token nằm chung ở đó thì cứ mỗi lượt sinh mới là một mẩu chi phí bị xoá vĩnh viễn, và "tổng token của khoá này" thành con số chỉ đúng cho vài lượt cuối. Nên content_token_usage là bảng riêng, chỉ chứa số, không chứa chữ, giữ được rất lâu, sổ chi phí không cộng dồn được từ đầu thì không phải sổ chi phí.
Bảng tổng hiện ở cuối khối prompt trong content-ops/<slug>, tách theo model, kèm cột token đốt vào lượt hỏng.
Chỉ khoá đang hoạt động
courseLevels() chỉ trả về khoá có legacy_courses.is_active = 1. Cả màn hình vận hành lẫn máy điều phối đều đọc từ đó, nên một khoá bị tắt là biến mất khỏi cả hai cùng lúc, không có cảnh giao diện lọc mà máy sinh vẫn âm thầm rải ngân sách lên khoá không ai định dạy.
Phân biệt hai cờ, vì chúng khác nhau:
courses.status='active', khoá chưa bị xoá. Đây là cờ dữ liệu.legacy_courses.is_active, khoá có mặt trước người học. Đây là quyết định sản phẩm, và là cờ máy sinh bám theo.
Muốn máy sinh thôi đụng tới một khoá thì tắt khoá đó ở màn hình Courses, không cần sửa mã hay sửa cấu hình máy sinh. Cần xem cả khoá đã ẩn thì gọi courseLevels(db, { includeHidden: true }).
Chạy bù cho đủ: hàng việc giữ KẾ HOẠCH, Workflows lo CHẠY
Câu hỏi khi dựng phần này là: tự viết một máy xếp lịch (bảng + cron quét), hay dùng Cloudflare Workflows? Câu trả lời là cả hai, nhưng mỗi bên đúng một việc.
Workflows lo chạy. Cron quét bảng thì phải tự viết lại thử-lại, lùi-thời-gian, chạy-đúng-một-lần, và sống sót qua sập máy giữa chừng. Workflows đã có sẵn cả bốn, và đã được kiểm chứng bởi nhiều người hơn số người từng đọc mã của mình. Viết lại chúng là viết lại một thứ khó, đã có, và dễ sai ở đúng những chỗ không ai thử.
Bảng lo kế hoạch. Nhưng Workflows không trả lời được "còn bao nhiêu việc, thứ tự nào, cái nào đang kẹt", một instance là hộp đen cho tới khi xong. Máy điều phối thường cũng vậy: nó tính lại kế hoạch ở mỗi lượt và chỉ chọn vài việc theo ngân sách, nên kế hoạch của nó không bao giờ tồn tại thành hình. content_backfill_queue là chỗ kế hoạch nằm xuống: viết ra hết một lần, rồi rút dần cho tới cạn.
POST /ops/backfill/plan liệt kê chính xác artefact còn thiếu, xếp vào hàng
POST /ops/backfill/run CourseBackfillWorkflow rút hàng, tự nối vòng tới khi cạn
GET /ops/backfill còn bao nhiêu, theo loại nào, cái nào kẹt
POST /ops/backfill/stop dọn sạch hàng chờ, vòng sau không nối nữaChuỗi tự cạn. Mỗi instance rút vài việc rồi nối sang instance mới, thay vì chạy hết trong một lần: một instance sống mãi là một instance không ai dừng được, và cũng là chỗ mọi lỗi tích lại. Hết việc thì dừng hẳn, đó là điều kiện để cơ chế này không thành một cái vòi chảy mãi.
Hai bộ não không giành cùng một hàng việc. Máy điều phối bỏ qua mọi target đang nằm trong hàng bù. Không có luật đó thì artefact bị sinh hai lần, hai lượt ghi đè nhau, và tiền trả gấp đôi cho một kết quả.
Thứ tự trong hàng là thứ tự lớp: hàng rào chủ đề → đích đến → đặc tả → mở bài → học được → chấm được. Sinh bài viết khi unit chưa có Big Idea thì bài viết không biết mình đang phục vụ ý gì.
Và hàng chỉ nhận việc của khoá đang hoạt động và đã có chất liệu nền, cùng hai hàng rào với mọi đường khác.
Tự bật dần: bậc thang mở rộng theo tỉ lệ thành công thật
Không phải một nút "bật tất cả". Bật tất cả khi máy sinh còn hỏng là cách nhanh nhất để đốt tiền và làm bẩn dữ liệu trên diện rộng. Mà chờ người bấm thì lại phụ thuộc vào việc có ai đang ngồi ở máy, đúng cái phụ thuộc mà cả hệ này sinh ra để bỏ đi.
Nên phạm vi là một bậc thang, và máy điều phối tự đi lên hay đi xuống:
| Bậc | Phạm vi |
|---|---|
| 0 | Dừng hẳn |
| 1 | Một khoá |
| 2 | Ba khoá |
| 3 | Năm khoá |
| 4 | Mọi khoá đang hiển thị |
Luật đổi bậc: cần ít nhất 6 lượt trong 24 giờ mới dám đổi, đổi bậc theo hai ba lượt là để một lần may rủi quyết định phạm vi của cả hệ. Từ 80% lượt xong trở lên thì lên một bậc; dưới 50% thì xuống một bậc; dưới 20% khi đang ở bậc 1 thì dừng hẳn và báo ra màn hình, đó là lúc phải đi sửa máy sinh, không phải lúc chạy tiếp.
Bật: POST /ops/ramp với {"enabled": true, "step": 1}. Đọc trạng thái: GET /ops/ramp.
Bậc thang định nghĩa ở API, không viết cứng trong giao diện, giao diện và máy điều phối phải đọc cùng một định nghĩa, không thì một bên đổi mà bên kia hiện số cũ.
Trần token và trần thời gian, chia theo loại model
Model thường: 12.000 token · 120 giây
Model lý luận: 64.000 token · 600 giây (deepseek-v4-*, deepseek-r1, qwen-coder)Hai con số này phải sửa cùng nhau: nới trần token mà không nới trần thời gian thì lượt gọi bị cắt ngang giữa chừng, và triệu chứng trông y hệt như model hỏng.
Đọc câu trả lời một cách chịu đựng được
JSON.parse thẳng là giả định model trả về đúng một chuỗi JSON sạch. Giả định đó sai với đủ kiểu model, và mỗi kiểu sai lại làm hỏng cả lượt sinh dù nội dung bên trong hoàn toàn dùng được:
| Kiểu trả lời | Ai hay làm |
|---|---|
Chèn khối <think>…</think> trước JSON | Model lý luận (nhánh DeepSeek R/V) |
Bọc trong hàng rào ```json | Rất nhiều model |
Kèm một câu dẫn trước dấu { | Model không được ép cấu trúc chặt |
Bọc trong lớp vỏ một khoá {"result":{…}} | Model tự thêm vỏ |
coerceReply() bóc bốn lớp vỏ đó trước khi đưa cho zod. Nó không bịa nội dung, chỉ bóc vỏ, và phép bóc vỏ một-khoá chỉ chạy khi lớp vỏ không có trường nào khuôn đòi mà ruột thì có, nên không thể ăn nhầm vào một kết quả hợp lệ. Bóc hết vẫn không khớp thì để zod báo hỏng như cũ, và raw_reply giữ nguyên văn để biết vì sao.
Luật nền: ai viết cũng được (từ 03.09.2026): nhưng vào repo trước, D1 sau
Luật đã đổi ngày 03.09.2026
Bản trước của mục này cấm agent tự sinh nội dung khoá học. Không còn cấm. Agent, người soạn hay engine, ai viết cũng được, và viết thẳng vào migration INSERT INTO course_* cũng được. CLAUDE.md gốc repo là nơi luật này được ghi chính thức; trang này giải thích cái mất và cái giữ.
Luật cũ tồn tại vì ba lý do, cả ba đều là chuyện đã xảy ra thật. Gỡ luật không làm ba lý do đó biến mất, nó chuyển chúng từ hàng rào thành việc phải tự nhớ khi viết tay:
- Nội dung không đi qua engine thì không lớp kiểm nào chạm tới. Đường ống engine có bốn lớp lọc câu hỏi, lượt kiểm chéo đáp án bằng model khác, các assert văn phong, rồi
auditCoursechấm lại sau khi lưu. Nội dung viết tay đi vòng qua tất cả, nên chạyPOST /ops/audit/:slugsau khi chèn, đó là lớp kiểm duy nhất còn với tới được. - Nó không để lại dấu vết ở
course_content_runs. Bảng đó là cơ sở để so chất lượng giữa các model. Một artefact viết tay là một lỗ vĩnh viễn trong dữ liệu đó, chấp nhận được, nhưng biểu đồ so model sẽ có khoảng trống. - Nó không tái tạo được. Chạy lại workflow không sinh lại nó. Nguồn sự thật duy nhất là file trong git, nên nội dung viết tay phải nằm trong repo (migration hoặc BookPackage JSON), không phải chỉ trong D1, và không
wrangler d1 executechèn từ máy.
Vẫn giữ: sửa engine và prompt là đường tốt hơn cho nội dung hàng loạt; viết tay hợp với nội dung của một cộng đồng cụ thể, số lượng ít, cần đúng giọng ngành (ví dụ sáu migration 20260903d–20260903i). Chất liệu nền (course_seed) vẫn do người soạn viết, xem course-seed. Tool Plane vẫn không có tool ghi nội dung: một tool ghi thẳng D1 phá đúng hàng rào "repo trước, D1 sau".
Ba chế độ chạy
| Chế độ | Hành vi |
|---|---|
paused | Không sinh gì cả. Cron vẫn nổ nhưng thoát ngay. |
scheduled | Nhịp thường: cron ba tiếng một lượt. |
burst | Dồn lực: xong một lượt nối ngay lượt sau, tới khi hết việc thì tự về scheduled. |
Ba tiếng vốn là con số chọn dè chừng lúc mới dựng (sợ hết hạn mức AI Gateway), không phải con số đo được, nên nó vừa quá chậm để ngồi xem kết quả, vừa không có cách nào bảo "chạy cho xong đi".
Chỗ chống lãng phí nằm ngay trong chế độ dồn lực
Khi bộ xếp việc trả về rỗng, mọi khoá đã đủ dữ liệu, chuỗi dừng hẳn và tự chuyển về nhịp thường, ghi lại lý do. Máy tự biết khi nào xong, không cần ai nhớ tắt. Không có vế này thì "chạy liên tục" nghĩa là chạy mãi.
Hai chi tiết giữ cho nó không tự hại mình:
- Chờ con xong thật, không ngủ mù. Bản đầu ngủ đúng 65 phút mỗi vòng vì đó là trần xấu nhất của một artefact. Phần lớn artefact xong trong vài phút, nên cả hệ bị ghim ở tốc độ của trường hợp tệ nhất. Nay hỏi D1 hai phút một lần (tối đa 30 lần).
- Cron ba tiếng vẫn nổ trong lúc dồn lực. Không chặn thì nó mở thêm một chuỗi song song, hai orchestrator giành cùng hàng việc và sinh trùng. Nay chuỗi có nhịp đập (
heartbeat_at): cron nhường khi chuỗi còn đập, và nhận lại việc khi nhịp tắt quá 90 phút, đó cũng là đường tự phục hồi duy nhất.
Thứ tự sinh: lần lượt theo khoá, metadata đi trước
Năm khoá đứng đầu danh sách tokyo đi trước và đi trọn: vét hết việc của khoá thứ nhất rồi mới sang khoá thứ hai. Rải đều ngân sách cho mười sáu khoá thì sau một tuần có mười sáu khoá dở dang và không khoá nào học được, mà người học gặp đúng một khoá, không gặp cái trung bình.
Danh sách ưu tiên mặc định lấy từ chính thứ tự sản phẩm đã chọn (legacy_courses.sort_order, chỉ tính khoá đang hiển thị), nên đổi thứ tự khoá trên tokyo là máy sinh đi theo, không phải sửa mã. Muốn đè thì đặt danh sách tường minh qua POST /ops/priority.
Trong mỗi khoá, thứ tự các chặng là:
| # | Chặng | Sinh ra gì |
|---|---|---|
| 1 | Đích đến | course_competency_outcomes: 3-6 Course Outcome, mỗi cái một năng lực ở một mức của thang bốn bậc, kèm bằng chứng nhìn thấy được |
| 2 | Người học | course_learner_fit: chân dung, pains, jobs to be done, needs, và ai KHÔNG hợp |
| 3 | Hàng rào chủ đề | course_metadata: lĩnh vực, những gì khoá này KHÔNG dạy, thuật ngữ của khoá khác không được mượn, bối cảnh đặt ví dụ, giọng văn |
| 4 | Mức 2 · Đặc tả | Big Idea, Essential Question, outcome, Performance Task |
| 5 | Mức 3 · Mở bài | Câu chuyện SCQA, thư viện Big Idea, bản tiếng Anh |
| 6 | Mức 4 · Học được | Ba bài viết, chín ví dụ, hai ngân hàng câu hỏi |
| 7 | Mức 5 · Chấm được | Rubric bốn mức của Performance Task |
Ba thứ cấp khoá đi trước, mỗi lượt một thứ
Khoá thiếu bất kỳ thứ nào trong ba thứ đầu thì lượt đó chỉ sinh đúng thứ đang thiếu rồi chuyển sang khoá khác.
Thứ tự không tuỳ tiện: chân dung người học bám vào đích đến (người hợp với khoá là người mà những năng lực này chữa được nỗi đau của họ), nên nó không thể đi trước; còn hàng rào chủ đề đứng thứ ba vì nó là thứ mọi prompt cấp unit/bài đọc vào. Mọi artefact còn lại phải đợi hàng rào dựng xong ở lượt sau, sinh nội dung trước khi có rào là sinh không rào, và đó chính là nguồn của những unit từng phải reseed vì mượn khái niệm của khoá khác.
Treo chỗ sinh mãi không đạt
Có những chỗ sinh đi sinh lại vẫn hỏng: không phải xui một lượt mà là máy sinh chưa làm được ca đó. Sinh tiếp chỉ đốt tiền và đẩy việc thật ra khỏi hàng đợi.
Hai lớp chặn:
- Trần theo ngày: một artefact đã sinh ≥3 lần trong 24 giờ thì nhường suất cho chỗ khác trong lượt đó.
- Treo hẳn: hỏng ≥3 lần trong 7 ngày (tính từ lần bỏ treo gần nhất) thì vào sổ
content_ops_quarantinevà thôi đụng tới, kèm lỗi cuối cùng. Nó hiện lên đầu màn hình điều hành vì đây là việc sửa thuật toán và prompt, không phải việc sửa từng bài. Sửa xong thì bấm "Bỏ treo".
Chỉ đếm những lần hỏng sau lần bỏ treo gần nhất, không thì vừa bỏ treo xong là bị chính lịch sử cũ treo lại ngay.
Vòng phản hồi của người soạn
Mọi lớp kiểm ở trên đều đo được bằng mã: lộ đáp án, hỏi vòng tròn, sai độ dài, đáp án đánh dấu nhầm. Cái chúng không đo được là "bài này dạy đúng nhưng nhạt", "ví dụ không giống đời thật", "lời dặn đang bắt mô hình viết sai giọng". Chỉ người đọc mới thấy, và trước nay không có chỗ nào để cái thấy đó chảy ngược vào máy.
Trang Đọc & nhận xét nội dung mở hai thứ ra:
- Lời dặn máy (
GET /ops/prompts), chính những đoạn chữ mà chất lượng nội dung phần lớn nằm ở đó. Người soạn không đọc TypeScript, nhưng đọc được lời dặn. Một số prompt còn viết nội tuyến trong hàm sinh nên chưa hiện được toàn văn; chúng vẫn nhận xét được và màn hình chỉ rõ chỗ cần mở trong mã. - Nội dung đã sinh (
GET /ops/content/:slug), SCQA, bài viết, ví dụ, câu hỏi kèm đáp án đúng, đọc thẳng không phải đi qua giao diện học.
Mỗi mẩu đều gắn được một nhận xét kèm một trong ba phán quyết:
| Phán quyết | Nghĩa |
|---|---|
reject | Không đạt, sinh lại chỗ này |
improve | Chưa tới mức bỏ nhưng cần khá hơn |
praise | Giữ lối này (tín hiệu quý: nó nói máy đang làm đúng ở đâu) |
ContentReviewSynthesisWorkflow gom các nhận xét đang mở theo engine, vì điều đáng sửa là điều lặp lại qua nhiều nhận xét, một nhận xét lẻ thường là chuyện của riêng một bài. Với mỗi nhóm nó viết ra pattern (điều lặp lại) và proposal (sửa gì trong lời dặn hoặc trong mã kiểm), rồi xếp hàng sinh lại những artefact bị reject, kể cả artefact đang bị treo, vì nhận xét của người thắng cái trần hỏng-ba-lần.
Vòng này cố ý dừng ở "đề xuất"
Hệ thống không tự viết lại prompt. Nhận xét của người là dữ liệu để người kỹ sư quyết định; một máy tự sửa lời dặn của chính nó thì không ai kiểm được nó đã đổi gì, và lần sinh sau không truy được vì đâu mà đổi giọng.
Ranh giới: máy tổng hợp và sinh lại; người sửa lời dặn.
Năm endpoint vận hành
Cả cụm /ops/* đứng sau Cloudflare Access (danh tính admin portal, như /api/mentor), đây là cổng vận hành tiêu tiền AI, không phải cổng người học. budget có trần cứng 24, model phải là key thật của MODELS.
| Endpoint | Việc |
|---|---|
GET /ops/dashboard | Trang tổng quan: chế độ, tiến độ từng khoá, việc đang chạy, sổ treo. Cố ý không trả dòng thời gian / so model / xu hướng |
GET /ops/course/:slug | Tất cả về một khoá: năm chặng, trình tự đã sinh, lỗi theo luật, model, xu hướng, sổ treo |
POST /ops/run | Bật một lượt điều phối. Nhận course_slugs, budget (1–24), model |
POST /ops/mode | Đổi chế độ chạy. Bật burst thì chạy ngay, không đợi cron |
POST /ops/priority | Đổi danh sách khoá ưu tiên. Mảng rỗng = tự lấy N khoá đứng đầu danh sách tokyo |
POST /ops/audit/:slug | Kiểm chất lượng một khoá ngay |
POST /ops/quarantine/:id/clear | Bỏ treo một artefact sau khi máy sinh đã được sửa |
GET /ops/prompts | Danh mục lời dặn máy, để người soạn đọc được |
GET /ops/content/:slug | Nội dung thật của một khoá, để người soạn đọc và nhận xét |
POST /ops/review-notes | Ghi một nhận xét (reject / improve / praise) |
POST /ops/review-synthesis | Gom nhận xét thành đề xuất sửa máy sinh, và sinh lại chỗ bị reject |
GET /ops/models | So chất lượng giữa các model theo loại artefact |
GET /ops/findings | Lỗi đang mở, kèm owner_kind/owner_id, xếp theo mức độ nặng |
GET /ops/quality-trend | Chuỗi điểm course_quality_reports theo khoá, trả lời "hệ đang tốt lên hay xấu đi" |
Ba màn hình trong admin
| Màn hình | Đường dẫn | Để làm gì |
|---|---|---|
| Máy sinh nội dung | /content/content-ops | Bốn câu hỏi thôi: máy đang chạy không, còn bao nhiêu việc, có gì đang treo, khoá nào tới lượt |
| Chi tiết một khoá | /content/content-ops/:slug | Từng artefact một kèm trạng thái và đầu vào, lịch sử workflow đã chạy, lỗi theo luật, model nào hợp khoá này, xu hướng, sổ treo của riêng khoá |
| Đọc & nhận xét nội dung | /content/content-review | Đọc lời dặn máy và nội dung đã sinh, ghi nhận xét, chạy lượt tổng hợp phản hồi |
Vì sao tách trang tổng quan và trang chi tiết
Bản đầu dồn tất cả vào một trang: ba mươi tư khoá × năm chặng × dòng thời gian × bảng run × so model × biểu đồ. Không ai đọc nổi một màn hình như thế, và thứ đáng nhìn nhất, đang chạy gì, có gì kẹt, chìm mất giữa những thứ chỉ xem khi đi sâu.
Nay trang tổng quan chỉ trả lời bốn câu hỏi rồi dẫn vào từng khoá; mọi chi tiết sống ở trang của khoá đó. Payload cũng nhẹ theo: /ops/dashboard bỏ hẳn dòng thời gian, so model và xu hướng, chúng chuyển sang /ops/course/:slug.
Trang chi tiết gấp lại được, và mặc định gấp phần dài
Chỉ đạo 30.08: trang khoá dài tới mức phải cuộn qua vài màn hình mới tới thứ mình cần. Nay mọi cụm đều gấp mở được, và cụm nào dài thì mặc định gấp:
| Cụm | Mặc định | Vì sao |
|---|---|---|
| Khoá này là gì | mở, hai vùng con là hai ngăn chiếm cả dòng | Hai cột cạnh nhau bắt mắt chạy zic zắc giữa hai luồng chữ dài không liên quan nhau |
| Năm chặng | mở | Đây là thứ người ta vào trang để xem |
| Lịch sử workflow | gấp | Ba mươi ba lượt chạy dài hơn cả phần còn lại của trang cộng lại |
| Lỗi đang mở, theo luật | gấp | Số lỗi đã nằm trên thanh bấm, nên gấp vẫn trả lời được câu hỏi thường gặp nhất |
| Model nào hợp khoá này | gấp | Chỉ xem khi đang so model, không phải mỗi lần vào trang |
Hai mẩu chú giải của bảng chặng (ý nghĩa màu ô, mẹo bấm vào ô) chuyển vào popover sau dấu ? cạnh tiêu đề: chúng hữu ích đúng lần đầu, còn lại là hai dòng mắt phải bước qua mọi lần vào trang.
Một chi tiết dễ làm hỏng: hai ô lọc của lịch sử (cũ trước, chỉ lượt hỏng) phải nằm trong phần mở ra, không nằm trên thanh bấm. Đặt một <label> vào trong AccordionTrigger thì bấm vào nó sẽ gấp cả cụm lại thay vì đổi bộ lọc.
Cả ba nằm trong cụm Content & Learning → Courses & Docs, sau Cloudflare Access như mọi trang admin khác. Màn hình tự làm mới 10 giây khi đang có việc chạy, chúng tồn tại để ngồi xem máy chạy. Nút EN/VI ở góc trên phải đổi ngôn ngữ cho cả cụm; lựa chọn giữ trong localStorage nên mọi trang đang mở đổi theo.
Từng artefact một, và đầu vào tạo ra nó
Đếm theo chặng chỉ nói "còn 24 cái nữa", nó không nói cái nào, và nhất là không nói vì sao một cái chưa sinh được. Trang chi tiết vì thế đọc thẳng từng đơn vị: mỗi unit và mỗi bài là một ô vuông, xếp theo loại artefact.
| Màu ô | Nghĩa |
|---|---|
| Xanh đặc | Đã có |
| Đỏ sẫm (nhấp nháy) | Đang sinh ngay lúc này |
| Trống | Chưa làm |
| Vàng | Đang treo, sinh mãi không đạt |
Bấm vào một ô mở popover ghi rõ đầu vào nào tạo ra artefact đó, mỗi đầu vào kèm dấu ✓ hoặc ✗ theo tình trạng thật của khoá. Đây là chỗ trả lời câu "vì sao cái này vẫn trống": thư viện Big Idea của một unit chưa sinh được vì unit đó chưa có Big Idea; ngân hàng câu hỏi của một bài chưa sinh được vì bài chưa có hiểu lầm phổ biến để làm phương án nhiễu.
Trang chi tiết khoá dựng bằng Accordion (shadcn/Radix, ở packages/admin-shared/src/components/ui/accordion.tsx) chứ không tự gấp/mở bằng useState. Gấp/mở tự viết thì mỗi trang một kiểu, và phần bàn phím cùng ARIA gần như luôn bị bỏ quên.
Ba tầng, mặc định gấp hết:
- Unit, vạch tiến độ và số
14/17nằm trong nút bấm nên vẫn đọc được khi đang gấp; quét cả khoá bằng mắt rồi chỉ mở đúng unit cần. - Bài, nhãn mang sẵn số phần còn thiếu, tức lý do duy nhất để mở bài đó ra.
- Từng artefact một dòng. Trước đây các chấm xếp ngang nên mắt phải dò tìm chấm nào chưa xanh; xếp dọc thì cột trạng thái thẳng hàng, thiếu chỗ nào nhìn ra ngay.
Lịch sử workflow cũng ba tầng: lượt chạy → (các bước | prompt đã gửi). Tách prompt thành ngăn riêng vì mở một lượt chạy ra mà đổ luôn cả nghìn chữ prompt vào mặt người đọc thì phần "đang kẹt ở bước nào" bị chôn mất.
Popover xếp theo thứ tự việc trước, thông tin sau: nút Tạo nội dung và trạng thái nằm ngay trên cùng, đó là thứ người ta mở popover ra để làm, không phải thứ đọc xong mới thấy ở đáy. Phần thân chia hai cột: bên trái là thứ artefact này phải có (các phần nhỏ, kèm số đã có/cần), bên phải là thứ nó cần mới sinh được (đầu vào ✓/✗) và thứ nó sinh ra. Hai câu hỏi đó luôn được đọc cùng lúc, thiếu đầu vào nào thì phần nào bên trái còn trống.
Hàng rào chủ đề của khoá xuất hiện trong danh sách đầu vào của gần như mọi artefact, đúng như nó thật sự được dùng.
Cùng một bộ artefact đọc được theo hai khung nhìn, đổi bằng nút ngay cạnh tiêu đề:
| Khung nhìn | Xếp theo | Trả lời câu |
|---|---|---|
| Theo Unit (mặc định) | Unit → artefact của riêng Unit → từng bài → artefact của bài | Unit này đã đủ chưa, bài nào trong nó còn thiếu gì |
| Theo chặng | Chặng → loại artefact → mọi unit/bài | Cả khoá đang đứng ở chặng nào |
Người soạn nghĩ theo cây Unit, máy điều phối nghĩ theo chặng. Hai khung nhìn dựng từ cùng một object artefact nên không thể lệch nhau.
Lịch sử workflow
Mục Lịch sử workflow đã chạy gộp cả lượt sinh lẫn lượt kiểm vào một mạch thời gian: CourseContentWorkflow (kèm model, điểm, lỗi) và CourseQualityAuditWorkflow (kèm số lỗi chặn và cảnh báo), mỗi dòng có instance_id để tra ngược trong Cloudflare. Mặc định xếp cũ nhất trước vì đó là cách đọc ra trình tự; lọc được chỉ xem lượt hỏng.
Nhìn riêng lượt sinh thì không thấy được "sinh xong rồi có ai chấm lại không", mà đó mới là vòng khép kín của hệ.
Khác hẳn các endpoint /authoring/*: những endpoint đó chạy một lượt sinh ngay trong request và chỉ bật khi TEST_MODE, tức phải có người ngồi chạy từ máy. Các endpoint ở đây chỉ xếp hàng một workflow rồi trả về ngay.
Mắt xích giữ chất lượng ở NHIỀU nơi
Triết lý: một lớp kiểm là một lớp thua được, nên lớp nào cũng có lớp đứng sau lưng.
| Lớp | Ở đâu | Bắt gì |
|---|---|---|
| Schema (zod, trần đi đôi maxTokens) | lúc sinh | JSON cụt, trường thiếu, độ dài lệch |
| Bốn lớp lọc câu hỏi | lúc sinh | lộ đáp án, hỏi vòng tròn, bốn phương án cùng khuôn, lời giải đá đáp án |
| Kiểm chéo đáp án (model khác, temp 0, hai lần bất đồng) | lúc sinh | ô đúng đánh dấu nhầm |
Assert văn phong (chung một cài đặt với audit, content-rules.ts) | lúc sinh | SCQA kể lại, thiếu ngoặt, tên giả lập, đứt câu, ký tự lạ |
| Audit bằng mã, mọi bảng, mọi cấp | sau khi lưu, mỗi lượt cron | mọi luật trên với nội dung ĐÃ lưu (kể cả nội dung sinh trước khi luật ra đời) |
| Dữ liệu người học thật | sau khi có ≥5 lượt làm | đáp án nhầm lọt qua tất cả các lớp trên |
| Sới so bản (A/B) | người đọc, bầu mù từng cặp | khoảng giữa "không phạm luật" và "hay", thứ không luật nào đếm được |
| Bản đồ thước đo ↔ yêu cầu | người vận hành | luật không trỏ về đâu, yêu cầu không ai gác, luật không chạy ở đâu |
quality-trend | người vận hành | chất lượng đi lên hay đi xuống theo thời gian |
Hai luật nền chạy xuyên suốt: hàng rào chủ đề của course_metadata (lĩnh vực, anti-scope, thuật ngữ cấm mượn, giọng văn, bối cảnh riêng của khoá) được bơm vào MỌI prompt sinh, bản đầu sinh xong metadata rồi không máy sinh nào đọc; và đề bài đổi thì rubric cũ chết theo: sinh lại unit-spec là xoá task content + rubric cũ để lượt sau dựng lại cho đề mới, không còn cảnh người học đọc đề mới, làm theo bước cũ, bị chấm bằng thước của một đề khác.
Điều bộ kiểm hiện CHƯA làm được
Nó bắt được lỗi hình thức, lỗi đáp án (bằng kiểm chéo và bằng dữ liệu người học), và trôi thuật ngữ. Nó chưa bắt được nội dung sai chuyên môn tinh vi, một đoạn giảng nghe hợp lý nhưng dạy lệch khái niệm. Lớp thẩm định chuyên môn bằng model mạnh đọc TRỌN bài như một người học là bước tiếp theo; và versioning nội dung vẫn là món nợ kiến trúc lớn nhất còn lại: từ 29.08.2026 mọi bản sinh được giữ lại trong course_content_candidates để so (xem Sới so bản), nhưng máy sinh vẫn ghi đè thẳng vào bảng đang phục vụ người học, bản thắng phiếu bầu chưa được đưa trở lại thành bản chính thức.
Và một giới hạn ở tầng trên nữa: bộ luật chấm nội dung là do người viết, nên nó chỉ đúng tới mức người viết nó đúng. Không có gì tự động đảm bảo thước đo khớp với yêu cầu ban đầu, xem Điều gì đảm bảo thước đo đúng? để biết chỗ lệch được bày ra như thế nào.
Chạy audit hàng loạt cho nội dung viết tay: workflow audit-courses
Nội dung viết tay được phép từ 03.09.2026, nhưng nó đi vòng qua bốn lớp lọc câu hỏi, không có lượt kiểm chéo đáp án, và không để lại dòng nào ở course_content_runs. auditCourse là lớp kiểm duy nhất còn với tới được, và nó chỉ chạy khi có người gọi. Cron 41 */3 chỉ chạy điều phối sinh nội dung; nó không audit hộ. Không gọi thì không bao giờ có.
Trang admin có nút audit từng khoá (ContentOpsCoursePage), nhưng một đợt soạn 50–90 khoá thì không bấm tay được. .github/workflows/audit-courses.yml (workflow_dispatch) gọi cho cả loạt.
Vì sao chạy ở CI chứ không ở phiên agent
Endpoint POST /api/course-intelligence/ops/audit/:slug nằm sau authMiddleware, chỉ nhận cookie phiên thành viên hoặc JWT Cloudflare Access. Cả hai đều là chứng thư. Chạy ở CI thì service token sống trong runner, Actions tự che nó trong log, và nó không bao giờ vào ngữ cảnh của một model, đúng ranh giới Tool Plane đặt ra: agent nhận năng lực, không nhận chứng thư. Cùng lý do đã ghi ở đầu smoke-test-tool-plane.yml.
Secret cần có
| Secret | Dùng để |
|---|---|
CF_ACCESS_CLIENT_ID | service token Access, dạng <id>.access |
CF_ACCESS_CLIENT_SECRET | phần bí mật của token đó |
CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID | đã có sẵn; dùng cho bước đọc finding |
Service token phải được một Access policy trên api.conan.school cho qua, và aud của ứng dụng Access đó phải nằm trong ACCESS_AUD (api.conan.school/wrangler.toml). Thiếu một trong hai thì mọi lượt gọi trả 401, job cố ý đỏ và in thẳng hai nguyên nhân này ra log.
Bốn chỗ đã tính trước
- Danh sách slug lấy từ file pack trong repo, rồi lọc lại theo D1. Repo là nguồn sự thật của nội dung viết tay, nhưng một pack có thể chưa có migration,
food-supplier-buyingđúng là trường hợp đó ngày 04.09.2026. Không lọc thì một lượt 404 làm đỏ cả job vì chuyện bình thường. - Không dùng
curl -f. Mã lỗi được ghi lại từng slug rồi tổng kết một lần, thay vì chết ở slug thứ ba và để phần còn lại không ai biết trạng thái. - Nghỉ giữa các lượt gọi (mặc định 5 giây): mỗi lượt tạo một Workflow instance thật và tiêu lượt gọi model.
- Chờ rồi đọc finding. Endpoint chỉ tạo Workflow rồi trả về ngay. Không chờ thì job xanh trong khi chưa có finding nào được ghi, đúng loại "xanh mà chưa kiểm gì" repo này đã dính nhiều lần.
settle_seconds: 0để tắt bước này.
Bảng finding trong job summary chỉ là đầu ra đếm được. Nội dung có đúng hay không thì vẫn phải người soạn đọc.