---
url: >-
  https://docs.nemo12.com/architecture/sdd-027-content-foundry/itemforge-and-costs.md
description: >-
  ItemForge sinh kho câu hỏi thế nào, hai mắt xích cuối của xưởng là gì và chi
  phí từng lượt gọi model được ghi sổ ra sao.
---

# SDD-027 · ItemForge, hai mắt xích cuối và sổ chi tiêu model

Một phần của [SDD-027](./index.md).

## 17. ItemForge — sinh kho câu hỏi (WF-20, SRC-638)

Chỉ đạo chủ dự án 2026-08-28: dựng workflow sinh 16 câu mỗi bài, kèm nhận định **"luôn có rất
nhiều ràng buộc để sinh ra các câu đó; càng nhiều ràng buộc thì có thể càng khó tạo ra."**

Nhận định ấy có số liệu của chính dự án đứng sau: SRC-398 ghi llama-3.3 sinh MCQ tiếng Việt **sai
khoảng 50%** (1.317 câu phải rà, 542 câu viết lại) khi được hỏi cả chùm câu một lượt. Và ngay
trong ngày dựng hệ này, prompt v2 của LessonForge đã hỏng vì **cách diễn đạt ràng buộc**: chú thích
số lượng đặt trong giá trị trường (`action: "string (mảng ít nhất 3 mục)"`) làm model hiểu chính
trường ấy là mảng, ba trường cùng sai một kiểu. Càng dồn ràng buộc vào một chỗ, càng dễ hiểu nhầm
ràng buộc đang nói về cái gì.

### 17.1 Bốn cách hạ áp lực ràng buộc

| Cách | Làm gì | Bớt được ràng buộc nào cho model |
| --- | --- | --- |
| **Kế hoạch khe** (`itemSlots.ts`) | Chốt trước 16 khe, mỗi khe một vai + một hiểu lầm + một mức khó | Phủ hết hiểu lầm · đủ bốn vai · rải độ khó — **ba ràng buộc khó nhất, do code bảo đảm** |
| **Một khe một lượt** | Mỗi lượt gọi model sinh đúng MỘT câu | Từ ~11 ràng buộc cùng lúc xuống ~4 |
| **Code gánh phần code gánh được** | `rotateAnswer` xoay vị trí đáp án, độ khó gán theo khe | Luật "đáp án không dồn quá 40% một ô" không bao giờ trượt, và không nhắc trong prompt |
| **Hình dạng tách khỏi số lượng** | Khuôn JSON chỉ nói kiểu; ràng buộc liệt kê riêng bên dưới | Đúng lỗi prompt v2 đã trả giá |

### 17.2 Sổ đăng ký ràng buộc (`itemRules.ts`)

Mười một ràng buộc, mỗi cái là **mã chạy được** kèm lời sửa gửi thẳng cho model — không phải lời
dặn trong prompt, vì lời dặn thì model bỏ qua được còn hàm kiểm thì không.

**Cứng (trượt là trả lại):** R1 đủ 4 phương án · R2 không phương án rỗng · R3 không trùng nhau ·
R4 đáp án hợp lệ · R5 đề đủ dài · R6 không markdown thô · **R7 nhiễu phải gắn một hiểu lầm ĐÃ KHAI
của bài** · R8 không trùng đề đã có · R9 không gạch dài.
**Mềm (ghi lại, vẫn nhận):** R10 các phương án dài xấp xỉ nhau (chặn mẹo đoán theo độ dài) ·
R11 cấm "tất cả đều đúng".

Bộ này cố ý **trùng khớp với `screening.ts` bên api** ở phần cứng: lệch nhau thì xưởng cho qua thứ
mà cổng nạp chặn, tức sinh ra để vứt đi.

### 17.3 Đo áp lực ràng buộc — câu trả lời cho chỉ đạo

`measurePressure()` đếm mỗi mã ràng buộc trượt bao nhiêu lượt, ghi vào cột
`content_runs.constraint_pressure` (migration 0171). Nhờ vậy một bài tắc không còn là *"sinh mãi
không ra"* mà thành *"R7 chặn 7/10 lượt"* — một con số để **người** quyết định nới ràng buộc nào,
hay sửa prompt ở chỗ nào. Không đo được thì "càng nhiều ràng buộc càng khó tạo" mãi là một cảm
giác, không thành một quyết định.

### 17.4 Ranh giới

Câu sinh ra nằm ở `content_item_drafts` (0171), **không vào thẳng `items`** — cùng luật §1 và
xưởng. `items` còn có trigger chặn publish khi thiếu `quality_json` (0067), nên một bảng riêng giữ
ranh giới rõ: đây là **bản thảo**, chưa phải câu hỏi.

Cầu nối để câu hỏi bám vào bài: **migration 0170** cho mỗi `curriculum_lesson` một `skill_node`
(id = lesson id, `objective_vi` = Guiding Question). Đây không phải thiết kế mới mà là thi hành
chuẩn có sẵn — CD-8 thành phần 2 đã liệt kê "Knowledge node" là phần bắt buộc của Lesson, CD-4.3
đã chỉ đúng cột `objective_vi`; chỉ là chưa ai dựng cầu, nên trước đó **điều kiện `live` của CD-5
không đo được**. Mọi giá trị đều suy ra từ dữ liệu đã có, không phải người gõ vào.

### 17.5 Vòng chấm câu hỏi — chỗ ràng buộc máy hết tác dụng (SRC-640)

**Bài học đắt nhất của cả đợt.** Mười sáu câu ItemForge sinh ra ngày 2026-08-28 **thoả gần hết
ràng buộc máy** (chỉ một ràng buộc MỀM trượt 2 lượt), mà đọc kỹ vẫn thấy:

* **đáp án SAI về mặt toán học**: *"đếm từ 1 đến 20, sau đó đếm tiếp từ 20 đến tổng số"* — đếm
  trùng số 20;
* **đề ra NGOÀI phạm vi bài**: bài *"Đếm đến 100"* bậc 1 mà hỏi *"hơn 100 học sinh"*.

Không một ràng buộc đếm-được nào bắt được hai lỗi ấy. Nghĩa là nhận định *"càng nhiều ràng buộc
càng khó tạo"* đúng, nhưng chưa đủ: **cái khó thật nằm ở chỗ những ràng buộc quyết định nhất không
đo được bằng hàm thuần**. Chúng cần một người đọc — hoặc một model đóng vai người đọc.

| Thành phần | Cách làm |
| --- | --- |
| Rubric | **8 chiều CC-QAF-1.0, chép đúng trọng số** của `quality.ts` bên api. Hai thước khác nhau cho cùng một loại nội dung thì sớm muộn cho hai kết luận khác nhau |
| Evidence-gated | Chấm mà không trích được bằng chứng **từ chính câu hỏi** thì điểm chiều ấy về 0. Không có luật này, model chấm mọi thứ 0.9 kèm lời khen chung chung |
| Hai góc nhìn | `teacher` (đáp án đúng chưa, có trong phạm vi bài không) và `struggling_learner` (**có mẹo nào đoán trúng mà không cần hiểu bài không**) — góc thứ hai bắt đúng loại lỗi mà người soi chuẩn xác hay bỏ qua |
| Bắt TỰ GIẢI | Prompt chấm buộc model giải câu hỏi trước khi cho điểm `accuracy`; đáp án đánh dấu đúng mà thật ra sai thì accuracy = 0 |
| Chiều thiếu | Tính là **0**, không bỏ qua — bỏ qua thì né chiều khó lại được điểm cao hơn |
| Ngưỡng | ≥0,72 tổng · ≥0,5 từng chiều · ≥2 evaluator — **trùng đúng cổng nạp bên api**, lệch là sinh ra để vứt đi |

Kết quả ghi vào `content_item_drafts.quality_json` / `gate_ok` / `gate_reasons` (migration 0172),
nên người rà đọc được **bằng chứng** chứ không chỉ con số.

## 18. Hai mắt xích cuối: câu hỏi vào `items`, và điều kiện `live` (SRC-641)

Trước mục này chuỗi đứt ở hai chỗ, và vì thế **không khoá nào có thể `live` được dù sinh bao nhiêu
nội dung**:

| Chỗ đứt | Hệ quả |
| --- | --- |
| Câu hỏi ở `content_item_drafts`, learner làm bài trên `items` | Kho câu sinh ra không ai làm được |
| Điều kiện CD-5 ("mọi bài ≥16 câu published") không mã nào kiểm, không mã nào đổi `status` | Mọi khoá đứng mãi ở `draft` — đúng chữ *"Đang soạn"* learner thấy |

`POST /items/promote` nạp câu từ bản thảo vào `items` ở status **`candidate`**, không phải
`published`. Hai lý do: luật §1 (người duyệt mới đưa nội dung tới learner), và `items` có trigger
chặn publish khi thiếu `quality_json` (0067) — nạp thẳng `published` sẽ bị DB chặn, và **DB đang
làm đúng**. Từ `candidate`, đường publish sẵn có bên api lo phần còn lại với đúng bộ ngưỡng của nó.
Câu chưa qua cổng chất lượng (§17.5) thì **không được nạp**, và hàm nói ra lý do từng câu.

`GET|POST /courses/{id}/live-check` kiểm CD-5: GET chỉ xem còn thiếu gì (bài nào thiếu bao nhiêu
câu), POST nâng `draft → live` **nếu đủ**. Không hạ ngưỡng bao giờ — thiếu thì giữ nguyên status và
trả về khoảng cách, vì một khoá `live` mà bài chưa đủ câu thì learner mở ra gặp bài trống.

## 19. Sổ chi tiêu từng lượt gọi model (SRC-1225, 04.10.2026)

Trần chi phí ngày (`FOUNDRY_DAILY_USD_CAP`) cộng `content_run_trials` và `content_item_drafts`,
nhưng hai bảng ấy chỉ giữ chi phí của LẦN THỬ CUỐI mỗi step: Workflows retry một step hỏng tới hai
lần và chi phí các lần trước mất theo exception (audit 03.10.2026). Một model trượt liên tục bị
tính tiền ba lần mà sổ ghi một.

* Bảng `foundry_spend_ledger` (migration 0329): mỗi lượt gọi tốn tiền một dòng `(run_id,
  model_key, step, usd, ok, at)`, ghi ngay trong `callModel` (`workers/foundry/src/models.ts`),
  tức bên trong callback của step: mỗi lần thử một dòng, step phát lại từ cache thì không ghi trùng.
* Trần ở cả ba chỗ (chốt của `startRun`, câu INSERT có điều kiện, `/items/runs`) lấy
  `MAX(cách cộng cũ, tổng sổ theo giờ gọi thật trong 24 giờ)`. Cách cũ giữ cho giai đoạn chuyển
  tiếp, khi sổ chưa có dữ liệu của hôm trước.
* Ghi sổ hỏng (bảng chưa có vì code lên trước migration) thì bỏ qua: sổ để đếm tiền, không được
  làm đổ lượt sinh. Test: `models.test.ts` (ba lượt gọi hỏng là ba dòng, ok=0).
