---
url: https://docs.nemo12.com/architecture/sdd-051-essay-program.md
description: >-
  Bản nháp NEMO ESSAY (SDD-051) ở learn.nemo12.com/essay: nguyên tắc thiết kế,
  địa chỉ, nội dung trong repo và migration.
---

# SDD-051 - NEMO ESSAY

> Nguồn: SRC-1153 (chủ dự án 30.09.2026). Yêu cầu và danh sách tính năng: [PRD-009](../product/prd-009-essay.md).
> Migration: `0317_essay_program.sql`. Mã: `workers/api/src/modules/essay/`, `apps/learn/src/essay/`,
> `apps/mentors/src/EssayReview.tsx`, `apps/admin/src/pages/SpeakOrders.tsx`.

## 1. Nguyên tắc thiết kế

1. **Chữ của learner chỉ learner ghi được.** Mọi route ghi chữ đòi CHÍNH CHỦ (`learners.user_id` = người
   đăng nhập), không dùng `canAccessLearner` vì hàm ấy mở cho người nhà và mọi mentor/staff (SRC-037).
2. **AI chỉ hỏi, và điều đó được máy chặn**, không chỉ được dặn trong prompt.
3. **Quá trình được ghi từ snapshot, không từ phím gõ.**
4. **Nội dung thư viện sống trong repo** (JSON), có cổng audit; D1 chỉ giữ thứ learner tạo ra.

## 2. Địa chỉ

| Địa chỉ | Trang | Ai mở được |
| --- | --- | --- |
| `/essay` | Hub: giới thiệu, 6 bước, lối vào, nút ghi danh | mọi người đã đăng nhập |
| `/essay/library`, `/essay/library/{id}` | Thư viện, trang đọc | mọi người đã đăng nhập |
| `/essay/notebook` | My notebook | mọi người đã đăng nhập (dữ liệu của chính mình) |
| `/essay/register` | Đăng ký, chuyển khoản, mã kích hoạt | mọi người đã đăng nhập |
| `/essay/stories`, `/essay/stories/{id}` | Story Bank, Reflection Ladder | đã ghi danh |
| `/essay/drafts`, `/essay/drafts/{prompt}` | Bản nháp, lịch sử, so sánh, nhận xét, coach, nhật ký | đã ghi danh |
| `/essay/drills` | Show, don't tell | đã ghi danh |

Route `essay` là chương trình cấp cao nhất trong `apps/learn/src/App.tsx` (khuôn `/ai-teen`), nằm sau cổng đăng
nhập. Hub chính của learn (`Root`) có mục "Programs" với thẻ NEMO ESSAY. Chưa ghi danh mà mở trang công cụ
thì thấy lời mời đăng ký (API trả 403 `NOT_ENROLLED`).

## 3. Nội dung trong repo

* `apps/learn/src/essay/content/essays/*.json`: 12 bài mẫu. Mỗi bài có `paragraphs` là mảng các đoạn, mỗi
  đoạn là mảng cặp câu `{en, vi}` (căn một-một); `highlights` tham chiếu chỉ số câu phẳng toàn bài
  `{from, to, skill, label, note_vi}`; metadata `type`, `themes`, `steps`, `word_count`, `word_limit`, `summary_vi`.
* `drills.json`: 18 câu "kể" kèm gợi ý tiếng Việt và 2 bản viết lại mẫu.
* `story-prompts.json`: 14 câu hỏi Story Bank kèm tag gợi ý.
* `content.ts` giữ bảng chín kỹ năng (màu, biểu tượng, giải thích), sáu bước, sáu bậc thang.
* Cổng: `scripts/audit-content.mjs` khu `essay`: đúng 12 bài, 2 bài mỗi loại, `word_count` khớp đếm thật,
  độ dài (Personal Statement 550-650, bài phụ 150-350), mọi câu có đủ EN/VI, 8-15 highlight không chồng nhau
  và nằm trong bài, ít nhất 5 kỹ năng, không em dash / en dash; 15+ bài luyện đủ 2 bản mẫu.

## 4. Đơn hàng và ghi danh

Khuôn NEMO SPEAK (SRC-1130): `POST /v1/learners/{id}/essay-orders` tạo đơn 8.000.000đ, nội dung chuyển
khoản `ESY-XXXXXX` (bảng chữ bỏ 0/O/1/I/L), gửi thư `essay-order`; bấm lại trả đúng đơn cũ. Admin
`GET /v1/admin/essay-orders`, `POST /v1/admin/essay-orders/{id}/paid` sinh `ESSAY-XXXXXX` và gửi thư
`essay-activation`. `POST /v1/learners/{id}/essay-enroll` nhập mã. Đơn và nhập mã dùng `canAccessLearner`
để bố mẹ mua hộ; mã thuộc về đơn, không thuộc người đặt.

**Vì sao bảng song song thay vì nới bảng SPEAK.** `speak_course_orders.course_id` có CHECK
`('speak-2','speak-3')`; D1/SQLite không sửa CHECK nếu không dựng lại bảng, tức là chép lại một bảng đang giữ
tiền thật. `speak_course_enrollments` còn mang `first_session_index` của lịch cuốn chiếu mà essay không có. Hai
bảng cùng hình dạng rẻ và an toàn hơn; tab admin "Đơn SPEAK" (`apps/admin/src/pages/SpeakOrders.tsx`) gọi cả
hai endpoint, gộp theo thời gian, và gửi "Đã nhận tiền" tới đúng endpoint theo `course_id` ('essay').

## 5. Bản nháp, phiên bản, so sánh, nhật ký

* `essay_drafts`: một bản nháp mỗi (learner, đề); sáu đề ở `modules/essay/catalog.ts` (`common-app` 650 chữ,
  `why-major` 250, `why-college` 250, `activities` 150, `community` 300, `challenge` 350).
* `PUT .../essay/drafts/{prompt}` kèm `session_key` (sinh mỗi lần mở trang). **Snapshot theo phiên lưu**: cùng
  `session_key` và phiên bản cuối còn trong 30 phút thì ghi đè phiên bản ấy; khác phiên hoặc quá 30 phút thì mở
  phiên bản mới; chữ không đổi thì không có phiên bản mới.
* `GET .../versions/{no}`, `GET .../diff?from&to`: so sánh theo từ bằng LCS (`modules/essay/diff.ts`).
* `essay_process_log`: `draft_snapshot`, `ladder_answer`, `comment`, `ai_turn`, `drill`, `story`. Chỉ ghi ở các
  lượt lưu; không có bảng phím gõ nào.
* Giao diện: đếm chữ trực tiếp, đỏ khi vượt trần; lưu khi bấm Save hoặc rời ô chữ (chặn lượt lưu kép).

## 6. Mentor

* Cổng mentor (`apps/mentors`) có tab **Essay** trong trang một learner (`EssayReview.tsx`).
* `GET /v1/mentor/essay/learners`: learner đã ghi danh VÀ được giao cho mentor này (`mentor_assignments`
  active); admin thấy tất cả.
* `GET /v1/mentor/essay/learners/{id}/drafts`: chỉ khi được giao.
* `POST /v1/mentor/essay/drafts/{draftId}/comments`: `kind` = `question` | `observation` | `rubric`
  (rubric bắt buộc một trong chín kỹ năng), neo bằng `anchor_start/anchor_end`; máy chủ cắt `anchor_text` từ
  bản nháp hiện tại. Nhánh mentor chỉ INSERT vào `essay_comments`; không route nào cho mentor ghi
  `essay_drafts`, và route ghi của learner đòi chính chủ nên mentor, admin, bố mẹ gọi vào đều 401.
* Learner thấy nhận xét ngay trong bản nháp: đoạn được neo tô vàng, bấm để xem.

## 7. AI coach chỉ hỏi

`POST .../essay/coach {kind: draft|story, ref_id}` (`modules/essay/coach.ts`):

1. Prompt `essay.coach-questions@1` (registry `shared/prompts.ts`, model `llama70bFast` qua AI Gateway) cấm
   viết văn, trả JSON 3-5 câu hỏi gắn chín kỹ năng.
2. **Lọc sau**: loại câu không kết thúc bằng `?`, dài quá 240 ký tự, có xuống dòng, có dấu hiệu viết lại
   ("here is", "you could write", "viết lại", "câu mẫu"...), có câu tiếng Anh ngôi thứ nhất kiểu văn bài luận,
   có trích dẫn từ 7 từ trở lên, hoặc có từ hai câu khẳng định đứng trước câu hỏi. Còn dưới 3 câu thì lượt ấy hỏng.
3. Hỏng thì **thử lại đúng một lần**, rồi trả câu hỏi mẫu soạn sẵn (`source: "template"`).
4. **Trần tiền**: đọc sổ ngày `ai:usage:<ngày>` dùng chung với cron (`shared/aiBudget.ts`); chạm
   `AI_DAILY_USD_CAP` thì không gọi model, trả câu hỏi mẫu. Mỗi lượt gọi được cộng vào sổ.
5. **Trần lượt**: `RATE_LIMITS.essayAiCoach` 8 lượt / learner / ngày (429).
6. Mỗi lượt ghi `ai_turn` vào nhật ký, kèm nguồn và các câu hỏi.

## 8. Story Bank, Reflection Ladder, bài luyện, ghi chú

* `essay_stories`: thẻ câu chuyện `{prompt_id, title, body, themes, skills, essay_types}`.
* `essay_ladder_rungs`: sáu bậc (What happened? → ... → What does it reveal about me?). `PUT .../ladder/{n}`
  trả 409 `RUNG_LOCKED` nếu bậc n-1 chưa có hoặc dưới 80 ký tự; giao diện khoá bậc tương ứng.
* `essay_drill_attempts`: mỗi lần lưu một lượt viết lại; giao diện hiện 2 bản mẫu sau khi lưu.
* `essay_marks`: highlight + ghi chú riêng theo chỉ số câu; chỉ chính chủ đọc/sửa/xoá, không cần ghi danh.

## 9. Kiểm chứng

* `workers/api/src/modules/essay/routes.test.ts` (D1 thật): đơn/mã cho khoá 'essay'; cổng ghi danh; ghi chú
  riêng tư (bố mẹ, người lạ, mentor, admin đều 401); mentor chỉ nhận xét và chỉ learner được giao, không sửa
  được chữ; coach (câu hỏi sạch, văn hộ bị lọc + thử lại + câu mẫu, trần tiền, trần lượt); snapshot + lịch
  sử + diff; bậc thang khoá; bài luyện.
* `apps/learn/e2e/essay.spec.ts` và 11 địa chỉ `/essay/**` trong `mobileAudit.spec.ts`.
* `scripts/audit-content.mjs` khu `essay`.

## Trace

| Nguồn | Mục |
| --- | --- |
| SRC-1153 thư viện, highlight, song ngữ, sổ tay | §3, §8 |
| SRC-1153 đăng ký như SPEAK | §4 |
| SRC-1153 bản nháp, phiên bản, nhật ký | §5 |
| SRC-1153 mentor chỉ nhận xét | §6 |
| SRC-1153 AI chỉ hỏi | §7 |
| SRC-1153 Story Bank, Ladder, Show don't tell | §8 |
