---
url: https://docs.nemo12.com/architecture/sdd-056-ai-teen-content-editor.md
description: >-
  Bản nháp trình duyệt và sửa nội dung AI Teen trong Dolphin (SDD-056): vì sao
  nội dung không nên là code, ba phương án và mô hình dữ liệu.
---

# SDD-056 · Trình duyệt và sửa nội dung AI Teen trong Dolphin

Chỉ đạo chủ dự án 07.10.2026 (SRC-1284): `dolphin.nemo12.com` có một khu liệt kê mọi course AI Teen,
xuống unit, lesson, Activity, Bước và cổng đọc. Mọi trang chi tiết **chỉ đọc mặc định**; nút "Sửa"
vào chế độ sửa, "Lưu" và "Huỷ" hiện đúng ô ấy (bảng chế độ của
[Guided Journey §2c](../design-system/guided-journey/anatomy.md)). Mentor đủ quyền sửa được tiêu đề, câu
hỏi, outcomes, concept, phần đọc, câu cổng, câu kiểm và chỉ báo.

Các PR dựng phải theo skill `content-editor` (`.claude/skills/content-editor/SKILL.md`), skill ấy trỏ
về đây. **Trạng thái 08.10.2026: M1-M4 xong, M5 dựng phần lõi** (§11b, §11c): lịch sử version và khôi
phục về nháp, phát hành theo course (staff/admin), rollback (admin), learn đọc bản phát hành lúc chạy với
bản chụp trong bundle làm dự phòng. Bản phát hành đầu tiên là chính nội dung TS hôm nay (seed), nên
learner chưa thấy gì đổi cho tới lần phát hành đầu từ Dolphin.

## 1. Vấn đề: nội dung đang là code

Nội dung AI Teen sống trong TypeScript ở `apps/learn/src/aiTeen/` (SDD-043 §2):

| File | Chứa | Cỡ (07.10.2026) |
| --- | --- | --- |
| `program.ts` | 3 course, `SKILL_LEVELS`, `focusSkills` | 131 dòng |
| `courses/aiTeen{1,2,3}.ts` | unit, lesson (Guiding Question, `slot`, `cycle`, outcomes, concepts, output), Performance Task | 633-744 dòng mỗi file |
| `lessons/<course>-u<n>.ts` | `BODIES`: khối đọc + bài tập (choice, practice), xếp theo VỊ TRÍ lesson | 18 file |
| `gates/<course>-u<n>.ts` | `GATES`: phần đọc, mỗi phần 3 câu × 3 phương án | 18 file, 864 câu |
| `skillGoals.ts` | `LEVEL_INDICATORS` (chỉ báo của thang level) | 131 dòng |

Nhiều thứ ngoài trang học đọc nội dung ấy LÚC BUILD: các test giữ luật soạn (`program`, `gate`,
`cycle`, `slots`, `skillGoals`), `scripts/aiteen/gen-api-catalog.mjs` sinh `taskSkills.ts` cho API,
`apps/web/src/site/aiTeenProgram.ts` đối chiếu bản công khai. Sửa nội dung từ Dolphin vì thế cần
một nguồn sự thật khác file TS, mà không làm mất những cổng ấy.

## 2. Ba phương án

| | (a) D1 là nguồn, learn đọc qua API | (b) Dolphin mở GitHub PR sửa file TS | (c) Lai: D1 để sửa và phát hành, repo giữ bản chụp sinh ra |
| --- | --- | --- | --- |
| Kiểm hợp lệ | phải chuyển luật từ test sang server | CI chạy đúng test hiện có (mạnh nhất) | MỘT module luật dùng chung, chạy ở API (lưu, phát hành) và ở CI (bản chụp) |
| Slot ổn định (§12.4) | server giữ sổ slot | `slots.test.ts` như nay | server giữ sổ slot, CI kiểm lại trên bản chụp |
| Độ trễ sửa → learner thấy | ngay khi phát hành | PR + CI + gộp + deploy, 15-30 phút | ngay khi phát hành |
| Rollback | trỏ về bản phát hành cũ | `git revert` + deploy | trỏ về bản phát hành cũ (tức thì); bản chụp theo sau |
| CI và test | CI không đọc được D1 production: mất cổng nội dung | giữ nguyên | giữ nguyên trên bản chụp |
| Bản dự phòng khi API hỏng | không có (như IELTS, SDD-038) | không cần | bản chụp trong bundle |
| Xung đột với phiên khác | không | cao: các phiên sửa đúng những file này mỗi ngày | thấp: chỉ bot ghi bản chụp |
| Bí mật | không thêm | token GitHub có quyền ghi nằm trong worker | token chỉ ở GitHub Actions |
| Chi phí | D1 + edge cache, gần 0 | mỗi lần Lưu một lượt CI (ngân sách Actions đã từng cạn, skill `cost-audit`) | D1 + một workflow mỗi lần phát hành |
| Người sửa thấy gì | bản nháp, xem trước, lịch sử | một PR chờ người khác gộp | bản nháp, xem trước, lịch sử |

✍️ **Chọn (c), nghiêng về (a): D1 là nguồn sự thật của nội dung AI Teen; learn đọc bản PHÁT HÀNH qua
API lúc chạy; repo giữ một bản chụp TS SINH RA từ bản phát hành, dùng làm bản dự phòng trong bundle
và làm đầu vào cho mọi test và cổng CI hiện có.**

* (b) bị loại vì nó biến mentor thành người gửi PR: mỗi lần Lưu là một lượt CI, một PR chờ gộp, và va
  chạm với các phiên đang sửa đúng `courses/*.ts`; muốn xem trước phải chờ deploy; worker phải giữ
  token ghi vào repo.
* (a) thuần bị loại vì CI không đọc được D1 production: mọi luật soạn đang giữ bằng test sẽ thành luật
  chỉ server biết, và learn mất bản dự phòng (IELTS đã chấp nhận điều đó ở SDD-038, nhưng IELTS không
  có hàng chục thứ build-time dựa vào nội dung).
* (c) giữ được cả hai: sửa và phát hành tức thì như (a), mọi cổng và test như hôm nay chạy trên bản chụp.
  Tiền lệ trong repo: khoá bố mẹ Marlins (Coral ghi D1, script kéo về TS lúc build, SDD-013), chỉ
  khác là ở đây learn còn đọc thẳng bản phát hành để không phải chờ deploy.

## 3. Mô hình dữ liệu

✍️ **Một tài liệu JSON mỗi thực thể, kiểm bằng zod, không chuẩn hoá thành bảng cột.** Khuôn nội dung
còn đổi hằng tuần (SRC-1281 thêm Activity và gắn phần đọc vào Activity); chuẩn hoá thì mỗi lần đổi
khuôn là một migration. Truy vấn chỉ cần theo khoá, nên JSON không mất gì.

| Bảng | Khoá | Cột chính | Luật |
| --- | --- | --- | --- |
| `ai_teen_content_docs` | `doc_key` | `kind` (`program` · `course` · `unit` · `lesson`), `course_id`, `schema_version`, `draft_json`, `draft_rev`, `draft_updated_by`, `draft_updated_at`, `live_version` | bản nháp hiện tại; `draft_rev` tăng mỗi lần Lưu |
| `ai_teen_content_versions` | (`doc_key`, `version`) | `json`, `fingerprint`, `created_by`, `created_at`, `note` | chỉ CHÈN, không sửa, không xoá |
| `ai_teen_content_releases` | `release_id` | `course_id`, `manifest_json` (`doc_key` -> `version`), `created_by`, `created_at`, `note`, `validator_version` | một bản phát hành = cả course |
| `ai_teen_content_live` | `course_id` | `release_id`, `updated_by`, `updated_at` | con trỏ bản đang chạy; rollback = đổi con trỏ |
| `ai_teen_slot_registry` | (`course_id`, `unit_no`, `slot`) | `fingerprint`, `first_released_at`, `retired_at` | sổ slot của SDD-043 §12.4, nạp từ `data/legacy-slots.json` + slot hiện có |

* `doc_key`: `program:ai-teen`, `course:ai-teen-1`, `unit:ai-teen-1:u1`, `lesson:ai-teen-1:u1:s7`. Lesson
  gắn **slot**, không gắn vị trí; vị trí của lesson là thứ tự trong `unit.lessons` (danh sách slot).
* Tài liệu lesson gói chung khung lesson + thân bài (khối, bài tập) + cổng đọc + Activity và Bước: đó
  là MỘT đơn vị soạn, và dấu vân tay slot cần cả thân bài lẫn cổng.
* Bước tick được (SDD-043 §15) mang id khai trong tài liệu; editor sinh id từ tên lúc tạo, và khoá id
  sau lần phát hành đầu.
* Chỉ báo: tài liệu `program:ai-teen` giữ `SKILL_LEVELS` và `LEVEL_INDICATORS`; id chỉ báo
  `<skill>:<level>:<n>` không đổi khi sửa chữ hay đổi thứ tự (SDD-043 §13.2).
* ✍️ **Phát hành theo course, không theo từng tài liệu**: learner không bao giờ thấy một unit đã đổi
  danh sách lesson trong khi lesson mới chưa phát hành. Luật chéo (bốn chặng mỗi unit, concept được
  dạy, slot) chỉ kiểm được trên cả course.

## 4. Vai và quyền

Vai lấy từ `role_assignments` (`workers/api/src/shared/authz.ts`); người qua Cloudflare Access của
Dolphin nhận vai `mentor` (SDD-023 §27). Không thêm vai, không thêm phạm vi: **vai mentor là đủ để
sửa bản nháp** (chủ dự án 07.10.2026, SRC-1291, xem §12).

| Việc | mentor | staff | admin |
| --- | --- | --- | --- |
| Duyệt mọi trang (chỉ đọc), xem lịch sử | có | có | có |
| Sửa bản nháp, khôi phục một bản cũ về nháp | có | có | có |
| Phát hành một course | không | có | có |
| Rollback (đổi con trỏ live về bản cũ) | không | không | có |

* Bản 0.2 có thêm phạm vi editor (bảng `ai_teen_content_editors`, admin cấp/thu). SRC-1291 bỏ nó:
  route cấp/thu đã gỡ, API không còn đọc hay ghi bảng ấy; bảng để nguyên (migration 0338 đã chạy,
  xoá bảng là một migration không đem lại gì).
* ✍️ Phát hành tách khỏi sửa: nội dung đi thẳng tới learner, nên người sửa và người bấm phát hành là
  hai vai (staff trở lên), như Coral tách soạn khỏi duyệt (SDD-013).
* Server kiểm quyền ở MỌI route (QG-008); nút bị ẩn trên giao diện chỉ là tiện, không phải cổng.

## 5. API

| Route | Quyền | Việc |
| --- | --- | --- |
| `GET /v1/mentor/ai-teen/content` | mentor+ | cây course -> unit -> lesson kèm trạng thái (nháp khác bản live? có lỗi chặn phát hành?) |
| `GET /v1/mentor/ai-teen/content/docs/{docKey}` | mentor+ | bản nháp, bản live, `draft_rev`, danh sách lỗi và cảnh báo hiện tại |
| `PUT /v1/mentor/ai-teen/content/docs/{docKey}` | editor+ | `{draft, rev}`; sai `rev` thì 409 `STALE_DRAFT` ("Có người vừa sửa, tải lại"); lỗi hình dạng thì 400 và không ghi |
| `GET .../docs/{docKey}/versions`, `GET .../versions/{v}`, `POST .../versions/{v}/restore` | mentor+ | lịch sử kèm trường đã đổi; nội dung một version; khôi phục một bản cũ thành bản NHÁP (không phát hành), `{rev}` như PUT |
| `GET /v1/mentor/ai-teen/content/courses/{id}/releases` | mentor+ | bản đang chạy, các bản phát hành, lỗi chặn và số cảnh báo của bản nháp hiện tại |
| `POST /v1/mentor/ai-teen/content/courses/{id}/releases` | staff+ | `{note?}`; dựng ứng viên từ mọi bản nháp của course, chạy đủ luật §6; có lỗi chặn thì 409 `RELEASE_BLOCKED` kèm danh sách (cảnh báo không chặn); giống hệt bản đang chạy thì 409 `NO_CHANGES`; qua thì tạo version cho tài liệu đã đổi, tạo release, đổi con trỏ, ghi sổ slot, trong MỘT batch D1 |
| `POST .../courses/{id}/live` | admin | `{release_id}`: rollback |
| `GET /v1/public/ai-teen/content/{course}/live` | công khai | `{release_id}`, cache ngắn (60 giây) |
| `GET /v1/public/ai-teen/content/{course}/releases/{id}` | công khai | nội dung đã phát hành, bất biến, cache dài |
| `GET /v1/mentor/ai-teen/content/courses/{id}/preview` | editor+ | bản ứng viên từ bản nháp, cho learn mở ở chế độ xem trước (chưa dựng) |

* ✍️ Route đọc nội dung đã phát hành là **công khai**: nội dung không bí mật (www đã có bản giới thiệu),
  và route công khai cache được ở edge cho mọi người. Tiến độ của learner vẫn sau đăng nhập.
* Rate limit `enforceRateLimit` cho PUT và release (luật `aiTeenContentWrite`, 60 / 60 giây / tài
  khoản); thân PUT tối đa 256 KB (một lesson lớn nhất hiện nay dưới 40 KB).
* Mọi route ghi để lại `audit_log` (§8). Test hành vi theo skill `api-test`.

## 6. Luật kiểm hợp lệ: một module, ba chỗ chạy

Luật rút từ các test hiện có vào một module thuần dùng chung (dự kiến `packages/ai-teen-content/`:
kiểu, zod schema, `validateDoc(doc)`, `validateCourse(course)` trả danh sách `{code, severity, path,
message}`). Test của learn gọi module ấy thay cho luật viết lại; API gọi khi Lưu và khi phát hành; CI
gọi trên bản chụp. Một luật, ba chỗ, không ai viết lại luật lần hai.

| Luật | Nguồn hôm nay | Mức |
| --- | --- | --- |
| Compelling, Essential, Guiding Question kết thúc bằng "?"; CQ khác EQ | `program.test.ts`, CD-4 | lỗi |
| 3-5 key concept mỗi unit; mỗi concept đúng 3 ví dụ + 3 hiểu lầm và được ít nhất một lesson dạy; concept của lesson thuộc unit | `program.test.ts`, `cycle.test.ts` | lỗi |
| Thân bài ≥4 khối, ≥1 `example`; 3-5 bài tập có ≥1 `choice` và ≥1 `practice`; ≥2 `choice` mỗi lesson | `program.test.ts`, `cycle.test.ts` | lỗi |
| `choice` đúng một phương án đúng, mọi phương án có phản hồi; checklist thực hành ≥3 mục | `program.test.ts` | lỗi |
| Cổng: ≥2 phần, các phần phủ đủ mọi khối đúng một lần; đúng 3 câu mỗi phần, 3 phương án khác nhau, mỗi nhiễu khai hiểu lầm, có lời giải | `gate.test.ts`, CD-8c | lỗi |
| Ký tự cấm trong chữ learner đọc: `/[–—“”‘’…]\|\.\.\./` (em dash, en dash, ngoặc cong, dấu ba chấm) | `gate.test.ts`, `cycle.test.ts`, `check-no-emdash` | lỗi |
| Đáp án không là phương án dài nhất duy nhất (gợi ý độ dài); câu hỏi cổng tối đa ~14 chữ, phương án ~8 chữ | CD-8c.2, `check-option-length-cue` (bản IELTS) | cảnh báo mỗi câu; lỗi khi quá 40% câu của một lesson |
| Bốn lesson mỗi unit theo đúng thứ tự chặng (AI Teen 2-3); khuôn của course khi course khai `template` (AI Teen 1, SRC-1281); chặng không lùi | `cycle.test.ts`, CD-8d | lỗi |
| Outcomes: course và unit 2-4 câu "Tôi có thể ..."; lesson ≥1 câu `^Tôi (hiểu\|nhận ra\|gọi tên được) ` nhắc đủ concept | `cycle.test.ts`, CD-6 | lỗi |
| Slot 1-20, không trùng trong unit; slot đã phát hành giữ dấu vân tay (số phương án, vị trí đáp án, số mục checklist, số khối mỗi phần) | `slots.test.ts`, SDD-043 §12.4 | lỗi |
| Id Bước tick được: khai, không trùng trong Activity, không dạng `part-\d+` / `q\d+`; đã phát hành thì không đổi | SDD-043 §15.2 | lỗi |
| Chỉ báo là một mảnh của câu gợi ý level; 1-3 chỉ báo mỗi level | `skillGoals.test.tsx`, SDD-043 §13.2 | lỗi |
| Skill của task có trong CCS; skill bị bỏ (`genai-3.3-2`) không quay lại | `program.test.ts` | lỗi |
| Phần nghĩa của CD-8 (mỗi hiểu lầm có nhiễu bắt được, phép thử ngược §5e của `course-design`, không nhiễu nào cũng đúng) | người soát | danh sách tự soát hiện ở màn phát hành, không máy chấm |

* **Lỗi hình dạng** (sai kiểu, thiếu trường bắt buộc để vẽ được trang) chặn Lưu. **Lỗi nội dung** không
  chặn Lưu (bản nháp được phép dở dang) nhưng chặn phát hành và hiện thành danh sách "Chặn phát hành"
  cạnh nút, mỗi mục bấm được để tới đúng ô.
* `validator_version` ghi vào release: đổi luật thì biết bản phát hành nào được kiểm bằng luật cũ.

## 7. Giao diện trong Dolphin

* Một mục mới trong `GROUPS` của `apps/mentors/src/sections.ts`: **AI Teen content** (`/ai-teen-content`).
  Chữ giao diện tiếng Anh gốc, bản tiếng Việt trong `apps/mentors/src/lang.tsx` (như mọi màn Dolphin);
  nội dung course giữ nguyên tiếng Việt.
* Trang: danh sách course -> course (unit) -> unit (trường unit + danh sách lesson theo thứ tự) -> lesson
  (khung, Activity và Bước, phần đọc kèm cổng, câu kiểm, thực hành) -> chỉ báo (`program`).
* **Mỗi card là một đơn vị sửa** (Guided Journey §2c): chỉ đọc mặc định; "Sửa" ở ô cố định
  `w-[7.75rem]` đổi thành "Lưu" + "Huỷ" không xê dịch; một card sửa mỗi lúc; mở card khác là Huỷ.
  "Lưu" gửi cả tài liệu kèm `rev`. Cùng khuôn với màn Albums của Dolphin (SDD-019 §12-§14).
* Huy hiệu trạng thái ở đầu trang: "Đang chạy" / "Bản nháp chưa phát hành" / "Có lỗi chặn phát hành".
  Lịch sử: danh sách version kèm người, ngày DD.MM.YYYY, diff theo trường, nút khôi phục về nháp.
* Xem trước: nút "Xem trước trên learn" mở trang learn của chính lesson ở chế độ xem trước (chỉ vai
  editor+, nhãn "Bản xem trước" dính trên cùng, không ghi tiến độ).
* Phát hành ở trang course: tóm tắt tài liệu đã đổi, danh sách chặn, danh sách tự soát §6, ô ghi chú.

## 8. Dấu vết

* `audit_log` cho mọi lượt Lưu, khôi phục, phát hành, rollback, cấp phạm vi:
  `ai_teen_content.save`, `.restore`, `.release`, `.rollback`, `.grant`, `detail_json` chỉ chứa khoá, `rev`, `version`,
  `release_id` (`shared/audit.ts` từ chối chữ nội dung trong chi tiết).
* Nội dung từng bản nằm ở `ai_teen_content_versions` (chỉ chèn), nên diff giữa hai bản luôn dựng lại được.
* Bản chụp trong git là dấu vết thứ hai: mỗi release thành một commit có mã release trong message.

## 9. Learn đọc nội dung thế nào

1. Lúc build: bundle có bản chụp, trang vẽ được ngay kể cả khi API hỏng. Bản chụp của một course là file
   TS (`courses/`, `lessons/`, `gates/`), hoặc `released/<course>.json` khi bản live đã được kéo về
   (§10). `snapshot.generated.ts` ghi mã release mà bundle của mỗi course bằng.
2. Lúc chạy (`apps/learn/src/aiTeen/liveContent.ts`, gọi một lần mỗi lượt tải trang ở gốc AI Teen): hỏi
   `.../live` (cache 60 giây). Cùng mã với bản chụp, `null`, hay lỗi bất kỳ: giữ bản chụp, không tải gì
   thêm. Khác mã: tải `.../releases/{id}` (bất biến), kiểm bằng CHÍNH module luật dùng chung (hình dạng,
   mọi luật chặn, sổ slot learn mang theo) và so **khung** (unit, danh sách slot, chặng, dấu vân tay slot)
   với bản chụp; qua cả hai mới áp, chữ đổi tại chỗ trên chính các object, không remount. Module luật và
   zod chỉ tải khi cần (`applyRelease.ts` nạp động), nên bundle chính không nặng thêm.
3. ✍️ **Mã release là băm nội dung** (`releaseIdOf`: băm của \[khoá, băm JSON] xếp theo khoá). Seed, API và
   learn đặt tên cùng một nội dung giống hệt nhau, nên bản phát hành đầu (seed từ TS) có đúng mã bản chụp
   ghi: learn không tải gì và không đổi gì cho tới lần phát hành đầu từ Dolphin.
4. API (tiến độ, cổng, tick bước, evidence) đọc bản live để biết bước nào có thật và bước nào tick được;
   khi đó server tự chấm trắc nghiệm được (SDD-043 §8 ✍️ "khi nội dung vào D1 thì server tự chấm"). Chưa
   dựng (M6).

## 10. Bản chụp và CI

* `npm run aiteen:pull` (`scripts/aiteen/pull-content.mjs`) đọc release live của ba course từ API công
  khai; course nào live bằng chính nội dung TS thì không có file; course nào khác thì ghi
  `apps/learn/src/aiTeen/released/<course>.json` rồi gọi `npm run aiteen:export` sinh lại
  `snapshot.generated.ts`. Một file kéo về mà không qua luật thì được trả lại như cũ và script dừng.
* `npm run aiteen:check` (trong `npm run check:code`, CI chạy cho mọi commit có code): seed và
  `snapshot.generated.ts` khớp nội dung; mỗi file `released/*.json` qua luật dùng chung (hình dạng, luật
  chặn, sổ slot), khung bằng khung TS, và mã release đúng là băm nội dung (sửa tay là đỏ).
* ✍️ Khác bản 0.3: bản chụp của một release là JSON, KHÔNG sinh lại file TS. Sinh lại `courses/*.ts` sẽ xoá
  mọi chú thích soạn bài trong đó và va với các phiên đang sửa đúng những file ấy trong lúc chuyển; JSON
  qua cùng luật và cùng kiểm khung, learn áp nó lúc tải module. Workflow tự mở PR bản chụp sau mỗi
  release (§10 bản 0.3) chưa dựng: hôm nay staff chạy `npm run aiteen:pull` và commit
  `content(ai-teen): release <id>`; thiếu bước ấy thì learner vẫn thấy bản live qua API, chỉ bản dự phòng
  là cũ.

## 11. Kế hoạch chuyển

| Bước | Việc | Xong khi |
| --- | --- | --- |
| M0 | Tài liệu này, SDD-043 §15, skill `content-editor`, nâng `course-design` (PR này) | `check:docs` xanh |
| M1 | Rút luật từ test vào `packages/ai-teen-content` (đã dựng, §11b); test learn gọi module | mọi test learn xanh, không đổi hành vi |
| M2 | Migration năm bảng (§3); `scripts/aiteen/export-content.mjs` (đã dựng, §11b) sinh seed (bản nháp + version 1 + release 1 + sổ slot) từ TS; nạp qua skill `seed-data` | đếm 3 course, 18 unit, 84 lesson, 1059 câu cổng (sau SRC-1281); export lại từ D1 ra TS không diff |
| M3 | Dolphin chỉ đọc: cây, mọi trang chi tiết, lịch sử | e2e mentors xem đủ bốn cấp |
| M4 | Sửa bản nháp + kiểm hợp lệ + audit; phát hành còn tắt; workflow bản chụp chạy so sánh | sửa một chữ trong nháp, bản live và learn không đổi |
| M5 | Chuyển: bật phát hành và rollback; learn đọc bản live lúc chạy; file TS thành bản chụp sinh ra; skill `course-design` và `make-meaning-lesson` đổi sang soạn bằng bản nháp | phát hành một sửa chữ, learner thấy trong 60 giây, PR bản chụp tự gộp |
| M6 | Server tự chấm trắc nghiệm, bản kê bước tick đọc từ bản live | test API chấm sai khi client nói đúng |

* **Đóng băng ngắn giữa M2 và M5**: trong khoảng ấy mọi sửa TS phải chạy lại export và seed, hoặc dừng
  sửa nội dung. Giữ khoảng này ngắn; đừng để PR tái cấu trúc AI Teen 1 (SRC-1281) chạy song song với M2.
* Phiên Claude soạn nội dung sau M5 không dùng giao diện: script dự định `scripts/aiteen/push-drafts.mjs` kiểm luật ở
  máy rồi sinh SQL ghi BẢN NHÁP, nạp qua `seed-data`; staff phát hành trong Dolphin.
* Chi phí: năm bảng nhỏ (dưới vài MB kể cả lịch sử một năm), đọc lúc chạy đi qua edge cache, một lượt
  Actions vài phút mỗi release. Không thêm dịch vụ, không thêm thư viện UI.

## 11b. Lát đầu đã dựng (07.10.2026, SRC-1284)

| Bước | Đã có | Chưa |
| --- | --- | --- |
| M1 | `packages/ai-teen-content`: zod schema bốn loại tài liệu (`.strict()`), khoá tài liệu, `fingerprint` / `holdsSlot` (chuyển từ `slots.ts`, learn re-export), `validateProgram` / `validateCourseDoc` / `validateUnit` / `validateLesson` / `validateCourse` / `issuesOfDoc`, `docsFromLearn` (TS -> tài liệu). `apps/learn/src/aiTeen/contentRules.test.ts` chạy module trên nội dung thật; các ca luật trùng trong `program`, `cycle`, `gate`, `slots.test.ts` đã gỡ | luật của rubric product |
| M2 | Migration `0338_ai_teen_content_editor.sql`: năm bảng §3 + `ai_teen_content_editors` (phạm vi editor, xem mục khác thiết kế ngay dưới bảng) + `ai_teen_content_import` (bảng trung chuyển của seed). `scripts/aiteen/export-content.mjs` sinh `scripts/aiteen/seed-content-{program,ai-teen-1,ai-teen-2,ai-teen-3}.sql`: 3 course, 18 unit, 84 lesson, 1059 câu cổng, 99 slot; `--check` báo seed cũ | nạp lên production (lệnh ở skill `content-editor` §0); export ngược D1 -> TS (M5) |
| M3 | `GET /v1/mentor/ai-teen/content` (cây), `GET .../docs/{docKey}` (nháp, live, rev, lỗi, ngữ cảnh unit). Dolphin `/ai-teen-content`: course -> unit -> lesson, trang unit và lesson chỉ đọc | lịch sử version, xem trước trên learn, trang `program` (chỉ báo) |
| M4 | `PUT .../docs/{docKey}` cho UNIT và LESSON: card Sửa -> Lưu/Huỷ cùng ô `w-[7.75rem]`, luật chạy ngay khi gõ (cùng module), lỗi hiện dưới ô, huy hiệu "Bản nháp chưa phát hành", 409 `STALE_DRAFT`, 400 sai hình dạng không ghi, `audit_log` `ai_teen_content.save`; mọi mentor sửa được (SRC-1291) | sửa course và program; khôi phục version |
| M5 | Xem §11c (08.10.2026) | workflow tự mở PR bản chụp; xem trước trên learn; mở khoá khung (slot mới) |

Khác thiết kế, có lý do:

* **Không có phạm vi editor** (SRC-1291): lát đầu dựng bảng `ai_teen_content_editors` thay cho dòng
  `role_assignments` có `scope_type` (cột ấy có CHECK từ migration 0001); quyết định 07.10.2026 bỏ
  luôn phạm vi, nên bảng còn đó nhưng không ai dùng.
* **Mã lỗi**: không phải mentor nhận 401 (luật "401 chứ không 403"); mentor xin phát hành nhận 403
  `SCOPE_REQUIRED`; `rev` cũ nhận 409 `STALE_DRAFT`.
* **Khung bị khoá ở lát này**: Lưu chỉ đổi CHỮ. Đổi danh sách lesson của unit, số key concept, slot,
  chặng, hay dấu vân tay slot của lesson (số câu, số phương án, vị trí đáp án, số mục checklist, số khối
  mỗi phần) trả 400 `STRUCTURE_LOCKED`; đó là việc của bước có phát hành.
* **Seed không đè bản nháp**: chạy lại seed sau khi file TS đổi thì tài liệu có bản nháp bằng bản live
  nhận chữ mới, tài liệu đang có bản nháp sửa trong Dolphin giữ nguyên bản nháp ấy; version mới chỉ chèn
  khi chữ khác bản gần nhất; release đặt tên theo băm nội dung nên chạy lại là không đổi gì.
* **Hai luật hạ thành cảnh báo** (đo trên nội dung 07.10.2026): gợi ý độ dài quá 40% câu của lesson
  (đa số lesson vượt, luật chưa từng được kiểm cho AI Teen), và ký tự cấm ở thân bài đọc, thực hành,
  câu hỏi unit (trước nay test của learn không kiểm những trường ấy). Ký tự cấm ở khung lesson,
  outcomes, câu cổng và câu kiểm AI Teen 1 vẫn là lỗi như test cũ.
* Chữ giao diện Dolphin: gốc tiếng Anh + bản tiếng Việt ở `lang.tsx` như mọi màn Dolphin; thông điệp
  của luật viết tiếng Việt vì chúng nói về chữ tiếng Việt của bài.

## 11c. Lát M5 đã dựng (08.10.2026, SRC-1299)

Chủ dự án giao toàn quyền 08.10.2026 ("Bạn quyết toàn bộ và làm hết đi").

| Phần | Đã có |
| --- | --- |
| Lịch sử, khôi phục | `GET .../versions` (mới nhất trước, trường đã đổi so với version trước, cờ live / giống nháp), `GET .../versions/{v}`, `POST .../versions/{v}/restore` đi đúng đường Lưu (hình dạng, khoá khung, `rev`), `audit_log` `ai_teen_content.restore`. Dolphin: card "Lịch sử version" ở mọi trang unit và lesson, chỉ đọc mặc định; "Sửa" hiện nút "Khôi phục về nháp" trên từng version |
| Phát hành | `POST .../courses/{id}/releases` (staff, admin): lỗi chặn thì 409 `RELEASE_BLOCKED` kèm danh sách, cảnh báo cho qua; version mới chỉ cho tài liệu đã đổi; release đặt tên bằng băm nội dung; con trỏ live; sổ slot nhận slot mới (`INSERT OR IGNORE`, dấu vân tay đầu là lịch sử); một batch D1; `audit_log` `ai_teen_content.release` (mã release, bản trước, số trang đổi, số cảnh báo) |
| Rollback | `POST .../courses/{id}/live` chỉ admin, đổi con trỏ và `live_version` theo manifest, không xoá gì, bản nháp giữ nguyên (nên hiện lại là "Bản nháp chưa phát hành"); `audit_log` `ai_teen_content.rollback` |
| Dolphin | card "Phát hành" ở trang course: huy hiệu "Sẵn sàng phát hành" / "n lỗi chặn phát hành" + "n lưu ý (không chặn)", danh sách lỗi bấm tới trang có lỗi, danh sách release; "Sửa" mở ô ghi chú và nút "Phát hành course này" (staff+), và "Quay về bản này" trên từng release cũ (admin) |
| Đọc công khai | `GET /v1/public/ai-teen/content/{course}/live` (cache 60 giây) và `.../releases/{id}` (bất biến, cache một năm); có tên trong bảng PUBLIC của `authCoverage.test.ts` |
| learn | §9: `liveContent.ts`, `applyRelease.ts`, `releaseApply.ts`, `snapshot.generated.ts`, `released/`; test `liveContent.test.ts` và hai ca e2e (bản mới hơn thì hiện chữ mới; API hỏng thì giữ bản chụp) |
| Seed | `scripts/aiteen/seed-content-*.sql` sinh lại: nội dung không đổi, chỉ mã release đổi sang `releaseIdOf`; nạp lại là bản phát hành đầu bằng đúng nội dung hôm nay |

Quyết định ở lát này:

* ✍️ **Cảnh báo không chặn phát hành** (chủ dự án 08.10.2026: chưa dọn nội dung). Chỉ `error` và `shape` chặn.
* ✍️ **Khung vẫn khoá** (`STRUCTURE_LOCKED` như §11b): một bản phát hành chỉ đổi CHỮ. learn từ chối áp bản
  phát hành nào có khung khác bản chụp, nên câu trả lời đã lưu của learner không bao giờ trỏ sang câu
  khác. Mở khoá khung (thêm lesson, slot mới) là bước sau, cùng lúc với việc learn hết so khung.
* ✍️ **Bản nháp giống bản live thì giữ nguyên văn bản live**: lưu một bản nháp có nội dung bằng bản live
  (khôi phục version live, lưu không đổi gì) ghi lại đúng chuỗi JSON của bản live, nên "Bản nháp chưa phát
  hành" và băm release không báo đổi chỉ vì thứ tự khoá JSON khác (seed ghi theo thứ tự của learn, Lưu ghi
  theo thứ tự của schema).
* Không có migration mới: năm bảng của 0338 đủ cho mọi việc trên.
* Thứ tự deploy: api, rồi nạp lại seed ba course (để bản live đổi sang mã băm mới bằng bản chụp), rồi
  learn và mentors. learn deploy trước seed vẫn an toàn về khung (kiểm luật + khung), nhưng sẽ áp bản live
  cũ nếu nội dung TS đã đổi từ lần seed trước.

## 12. Còn mở

* ✅ Đã chốt 08.10.2026 (SRC-1299): cảnh báo (gợi ý độ dài 40%, ký tự cấm ở thân bài đọc / thực hành) KHÔNG
  chặn phát hành; chủ dự án chưa muốn dọn nội dung.
* ✅ Đã chốt 07.10.2026 (SRC-1291): MỌI mentor sửa được bản nháp nội dung AI Teen, không cần cấp quyền
  riêng. Staff và admin vẫn sửa; phát hành chỉ staff/admin (bật từ 08.10.2026, §11c); mỗi lượt Lưu
  vẫn để một dòng `audit_log` `ai_teen_content.save`. Lý do chấp nhận rủi ro: bản nháp không tới
  learner, cổng thật là phát hành, và audit cho biết ai đổi gì.
* Ai được phát hành ngoài staff/admin.
* Mentor có được tạo lesson mới (cấp slot mới) hay chỉ sửa lesson có sẵn; thiết kế cho phép, quyền mặc
  định chỉ cho staff.
* Rubric product (`data/products.json`) và danh mục CCS chép nguyên có vào editor không; đợt đầu: không.

## Trace

| REQ | Mục |
| --- | --- |
| REQ-MEN-22 | §2 (quyết định), §3 (dữ liệu), §4 (quyền), §5 (API), §6 (kiểm hợp lệ), §7 (giao diện), §8 (dấu vết), §9-§11 (đọc, bản chụp, chuyển), §11b (lát đầu đã dựng), §11c (lát M5) |
