---
url: https://docs.nemo12.com/architecture/sdd-044-sat-practice.md
description: >-
  Bản nháp NEMO SAT (SDD-044): sân luyện Digital SAT trong learn, mô hình nội
  dung, cổng vào và mục tiêu điểm theo PRD-004.
---

# SDD-044 — NEMO SAT

Yêu cầu: [PRD-004](../product/prd-004-sat.md). Trạng thái **draft**, quyết định của chủ dự án
ngày 24.09.2026 ở PRD-004 §10 đã áp vào thiết kế này.

## 1. Hình dạng tổng thể

```
learn.nemo12.com/sat/**           apps/learn/src/sat/        (sub-app, cùng khuôn apps/learn/src/ielts/)
        │  fetch, cookie phiên
api.nemo12.com/v1/sat/**          workers/api/src/modules/sat/        learner: goal, tests, practice, report
api.nemo12.com/v1/admin/sat/**    workers/api/src/modules/satContent/ staff: soạn, duyệt, ghép đề
        │
D1 nemo12-platform                sat_items · sat_passages · sat_forms · sat_form_modules
                                  sat_goals · sat_sessions · sat_answers · sat_mistakes
                                  → learner_evidence (có sẵn) · learner_goals (có sẵn, kind 'sat')
```

Nguyên tắc chọn:

* **Tái dùng khuôn IELTS, không tái dùng mã IELTS.** Khung (`Shell`, `Chrome`, cổng đăng nhập,
  router con) chép theo `apps/learn/src/ielts/`; logic đề và điểm viết riêng vì luật SAT khác hẳn
  (module theo độ khó, thang 200-800, đáp án tự điền).
* **Một bảng phiên, một bảng câu trả lời** cho mọi ngữ cảnh (bài đo, luyện miền, module có giờ, ôn
  sổ lỗi). `ielts_practice_attempts` chỉ lưu tổng đúng/sai nên không dựng lại được báo cáo theo
  miền; SAT cần từng câu.
* **Đánh số migration bằng `npm run migration:new`**, không tự +1.

## 2. Mô hình nội dung (REQ-SAT-23..26)

```sql
sat_passages (id, section CHECK IN ('rw','math'), body_md, source, license, created_by, ...)
sat_items (
  id, section 'rw'|'math', domain TEXT,          -- 8 miền, xem bảng dưới
  skill TEXT, node_id TEXT,                      -- node SA1-SA5 (knowledge/sat.md)
  difficulty INTEGER CHECK (1..3),
  passage_id NULL REFERENCES sat_passages,
  stem_md, format CHECK IN ('mcq','spr'),
  choices_json NULL,                             -- 4 lựa chọn khi mcq
  answer_json,                                   -- mcq: "B"; spr: ["3/4","0.75",".75"]
  rationale_md, source, license,
  status CHECK IN ('draft','review','approved','retired'),
  author_id, reviewer_id,                        -- reviewer <> author (REQ-SAT-24, CHECK)
  calibrated_p REAL NULL, attempts_n INTEGER DEFAULT 0
)
sat_forms (id, title, status, routing_json)      -- ngưỡng module 1 → M2 dễ/khó theo phần
sat_form_modules (form_id, section, stage CHECK IN ('m1','m2_easy','m2_hard'), position, item_id)
```

| section | domain (khoá) | Tỉ lệ câu trong một đề |
| --- | --- | --- |
| rw | `craft_structure` · `information_ideas` · `conventions` · `expression_ideas` | 28 · 26 · 26 · 20 % |
| math | `algebra` · `advanced_math` · `psda` · `geometry_trig` | 35 · 35 · 15 · 15 % |

* `source` cấm chứa `collegeboard` / `bluebook` (CHECK + test), đúng REQ-SAT-24.
* Kiểm ghép đề (`satContent/validateForm.ts`): đủ 27/22 câu mỗi module, phân bố miền lệch ≤ 1
  câu so với bảng, không câu nào lặp trong một đề, mọi câu `approved`.
* Kho câu **soạn mới** (PRD-004 §10, quyết định 5: không dùng lại `scripts/items-sat-pearl.json`).
  AI soạn nháp vào `draft`, người duyệt đẩy lên `approved`; nạp production qua `seed-data`.

## 3. Cổng và mục tiêu (REQ-SAT-01..03)

* Router: `apps/learn/src/App.tsx` chuyển mọi `/sat` và `/sat/*` sang `apps/learn/src/sat/Sat.tsx`
  (cùng khuôn `aiTeen/AiTeen.tsx`): `/sat`, `/sat/goal`, `/sat/test/{id}`, `/sat/report/{id}`.
  `SatHub` cũ đã bỏ; bốn lối vào nội dung Squid nay là khối Courses trên trang chủ `/sat`.
* API `/v1/sat/**` đi qua `requireLearner` như `/v1/ielts/**` (SDD-038 §24); `authCoverage.test.ts`
  thêm tiền tố.
* `sat_goals (learner_id PK, target_total, target_rw, target_math, test_date, deadline, school_group, updated_at)`
  và đồng bộ một dòng `learner_goals` kind `sat` để Goal engine có sẵn dùng được.
* Đề xuất mục tiêu: bảng `SCHOOL_GROUPS` dùng chung với `apps/web/src/site/SatGraphics.tsx`
  (chuyển vào `packages/` để một nguồn); mục tiêu = cận trên của dải nhóm, chia hai phần theo tỉ lệ
  hiện tại của learner (mặc định 50/50).

## 4. Bài đo và điểm (REQ-SAT-04..09)

**Phiên:** `sat_sessions (id, learner_id, kind CHECK IN ('diagnostic','retest','domain','module','review'), form_id NULL, section NULL, stage NULL, started_at, deadline_at, submitted_at, result_json)`.
Mỗi module của bài đo là một phiên con (`parent_id`) để hết giờ một module không mất cả bài.

**Định tuyến:** nộp M1 → server đếm câu đúng → so `routing_json` (mặc định: R\&W ≥ 15/27, Math
≥ 13/22 → M2 khó) → trả về M2. Server quyết, không để client quyết, vì client nói dối được.

**Đồng hồ:** `deadline_at` do server đặt; client đếm ngược từ đó; câu trả lời nộp sau `deadline_at + 5s`
bị bỏ. Mất mạng giữa chừng: câu trả lời lưu cục bộ và gửi lại, server nhận theo `answered_at`.

**Điểm ước lượng:** không có bảng quy đổi chính thức (PRD-004 §3.3). v1 dùng bảng nội bộ theo
`(section, stage m2, số câu đúng tổng)` → điểm 200-800, dựng từ phân bố độ khó của đề và hiệu
chỉnh khi có điểm thi thật learner tự khai. Kết quả luôn mang `estimate: true, band: ±40`.

**Mức 7 bậc mỗi miền:** tỉ lệ đúng có trọng số độ khó (1/1.5/2) của các câu thuộc miền trong phiên,
cắt thành 7 bậc theo ngưỡng cố định trong `modules/sat/scoring.ts`.

**Nguyên nhân sai (REQ-SAT-08):** `sat_answers.cause CHECK IN ('knowledge','misread','pacing','careless') NULL`;
gợi ý mặc định: `pacing` nếu bỏ trống hoặc trả lời trong 10 giây cuối module; `careless` nếu
cùng `skill` đã đúng ≥ 2 lần trước đó; còn lại `knowledge`. Learner đổi được.

## 5. Luyện (REQ-SAT-10..14)

* `domain`: chọn 10 câu `approved` của miền/node, ưu tiên câu chưa làm, độ khó quanh mức hiện tại.
* `module`: lấy một module từ đề chưa dùng, đúng giờ thi.
* `review`: câu trong `sat_mistakes (learner_id, item_id, cause, due_at, streak, cleared_at)`;
  lịch 1-3-7-14 ngày lấy từ Retention (SDD-017); `streak = 2` cách ≥ 3 ngày thì `cleared_at`.
* Lời giải chỉ trả về sau khi câu đã khoá đáp án (REQ-SAT-14); `GET` câu hỏi không bao giờ mang
  `answer_json` hay `rationale_md`.

## 6. Lộ trình, đo lại, sẵn sàng (REQ-SAT-15..18)

* `modules/sat/plan.ts`: đầu vào (ngày thi, giờ/tuần, 3 miền hổng, điểm hiện tại, mục tiêu) → danh
  sách tuần; mỗi tuần: số phiên `domain` theo miền, 1 phiên `module`, và một `retest` mỗi 3-4 tuần.
  Điểm mong đợi mỗi mốc: nội suy tuyến tính hiện tại → mục tiêu, trừ 2 tuần đệm cuối.
* Lưu `sat_plans (learner_id, plan_json, version, created_at)`; kéo dời mốc tạo version mới.
* Readiness: truy vấn 2 phiên `diagnostic|retest` gần nhất đã nộp đủ; cả hai ≥ `target_total`.
* Dashboard `/sat`: một lời gọi `GET /v1/sat/home` trả đủ (điểm gần nhất, mục tiêu, ngày còn lại,
  ≤ 3 việc hôm nay, chuỗi ngày) để màn đầu không chờ nhiều lượt gọi (bài học SRC-992).

## 7. Bằng chứng, phụ huynh, mentor (REQ-SAT-19..22)

* Mỗi `sat_answers` ghi một dòng `learner_evidence` (`subject_id='sat'`, `node_id` = node của câu,
  `source='sat'`, `type` = kind của phiên, `observed_performance` 0/1, `reliability` 0.9 cho
  diagnostic/module, 0.6 cho domain/review, `difficulty`). Ghi trong cùng batch D1.
* `GET /v1/sat/effort` (REQ-SAT-20), cùng hình dạng `ielts-effort`; dashboard `sat-learners`
  (SDD-037 §7.8) đọc từ đây, và bàn SAT thêm cột "21 ngày không có attempt".
* Marlins: `GET /v1/parent/learners/:id/sat` trả mục tiêu, mốc, điểm các lần đo, readiness;
  kiểm quan hệ guardian như các route parent hiện có.

## 8. API

Mọi đường learner nằm dưới `/v1/learners/{learnerId}/sat` (đổi từ `/v1/sat` của bản nháp 0.1 khi
code đợt 1, 24.09.2026): một phụ huynh có nhiều con, và quyền kiểm theo từng learner bằng
`canAccessLearner` như sân IELTS. Code: `workers/api/src/modules/sat/routes.ts`, `admin.ts`, luật
chấm thuần trong `scoring.ts`.

| Method | Path (sau `/v1/learners/{learnerId}/sat`) | Việc | Đợt |
| --- | --- | --- | --- |
| GET/PUT | `/goal` | đọc / lưu mục tiêu | 1 |
| GET | `/home` | dashboard: mục tiêu, bài đang dở, 5 lần đo gần nhất, đã có đề chưa | 1 |
| POST | `/sessions` | mở bài đo `{kind: diagnostic|retest}`; bài đang dở thì trả lại chính nó | 1 |
| GET | `/sessions/{id}` | module đang mở (không đáp án, không độ khó); tự nộp module quá hạn | 1 |
| PUT | `/sessions/{id}/answers/{itemId}` | lưu câu trả lời / đánh dấu; 409 sau hạn + 5 giây | 1 |
| POST | `/sessions/{id}/submit` | nộp module; M1 thì server chọn M2 | 1 |
| GET | `/sessions/{id}/report` | báo cáo + lời giải, chỉ sau khi nộp cả bài | 1 |
| PUT | `/answers/{sessionId}/{itemId}/cause` | gắn nguyên nhân sai (phiên chứa câu, kiểm thuộc learner) | 2 |
| POST | `/practice` | mở phiên luyện `{kind: domain|module|review}` | 2 |
| GET | `/practice/{id}` | câu của phiên; đáp án chỉ kèm câu đã khoá hoặc khi phiên đã nộp | 2 |
| PUT | `/practice/{id}/answers/{itemId}` | domain/review: chốt đáp án, trả lời giải ngay, khoá câu (409 nếu gửi lại); module: lưu như phòng thi | 2 |
| POST | `/practice/{id}/submit` | nộp; module thì chấm, ghi bằng chứng `sat_test`, câu sai vào sổ lỗi | 2 |
| GET | `/mistakes` | sổ lỗi: câu đang mở, số tới hạn, số đã ra khỏi sổ | 2 |
| GET/PUT | `/plan` | lộ trình hiện hành kèm `slip` và `readiness` / dựng lại theo `hours_per_week` (tạo phiên bản mới trong `sat_plans`) | 3 |
| PUT | `/plan/weeks/{week}` | learner dời mốc: bật/tắt bài đo lại của một tuần | 3 |
| GET | `/effort` | phút (phiên đã nộp, mỗi phiên tối đa 75), số câu, độ chính xác 7/30 ngày, số câu theo ngày 14 ngày | 3 |
| GET | `/summary` | bản cho phụ huynh (Marlins): mục tiêu, điểm các lần đo, mốc, sẵn sàng, mốc trượt; không có từng câu | 3 |

Staff (`/v1/admin/sat`, vai `admin`): `GET/POST /items`, `GET /items/{id}`,
`POST /items/{id}/review` (approve/reject/retire, người duyệt khác người soạn), `GET/POST /forms`,
`PUT /forms/{id}/modules/{section}/{stage}`, `GET /forms/{id}/validate`, `POST /forms/{id}/publish`.

**Đợt 2 (code 25.09.2026, migration 0291).** Phiên luyện lấy câu từ `sat_session_items` (bài đo lấy
từ đề). `sat_answers.revealed_at` là khoá lời giải (REQ-SAT-14). Sổ lỗi thêm `last_correct_at` để
thi hành "đúng hai lần cách nhau ≥ 3 ngày thì ra khỏi sổ". Lịch ôn 1-3-7-14 ngày tính trong
`scoring.ts` (`nextDue`, `afterRetry`), CHƯA đi qua engine Retention (SDD-017): REQ-SAT-13 vì vậy
còn ⏳. Báo cáo bài đo trả thêm `causes` (đã gắn hoặc gợi ý) và `priorities` (ba miền mất nhiều
điểm nhất, nhân tỉ trọng miền); `/home` trả `tasks` (≤ 3), `streak` (ngày giờ Việt Nam) và
`mistakes_due`. Giao diện: `apps/learn/src/sat/Practice.tsx` (`/sat/practice`, `/sat/practice/{id}`,
`/sat/mistakes`).

**Đợt 3 (code 25.09.2026).** Luật lộ trình, mốc trượt và sẵn sàng là hàm thuần trong
`workers/api/src/modules/sat/plan.ts`; đường đi trong `progress.ts`. Hạn ôn sổ lỗi nay tính bằng engine
Retention (`reviewWindowDates` + `initialStability`, cùng cách `ieltsSkills/next.ts` dùng), thay cho
bảng 1-3-7-14 viết tay. Phụ huynh: thẻ "SAT của con" trong Portrait của Marlins
(`apps/marlins/src/Portrait.tsx`), gọi `/summary`. Bàn SAT: dashboard `sat-learners` thêm hai cột
`sat_score` (điểm ước lượng lần đo gần nhất) và `sat_weakest` (miền mất nhiều điểm nhất); tín hiệu
"21 ngày không có attempt" đã có sẵn qua `stalledAfterDays: 21`, vì mọi câu trả lời SAT đều ghi
`learner_evidence`. Giao diện learner: `apps/learn/src/sat/PlanPage.tsx` (`/sat/plan`).

**Giờ nghỉ (thêm khi code):** module đầu của phần Math mở với `deadline_at = NULL`; lượt `GET` đầu
tiên sau giờ nghỉ mới đặt hạn 35 phút. Nhờ vậy 10 phút nghỉ không ăn vào giờ làm bài, và learner
bỏ đi giữa giờ nghỉ thì bài vẫn chờ nguyên ở đó.

## 9. Kiểm chứng

* Behavior test (`/api-test`) trên D1 thật: định tuyến M2 theo ngưỡng, hết giờ bỏ câu muộn, không
  lộ đáp án trước khi khoá, reviewer ≠ author, nguồn College Board bị chặn, học sinh A không đọc
  được phiên của B, phụ huynh chỉ đọc con mình.
* E2E (`/e2e-check`): làm hết một bài đo rút gọn (form test 4 × 3 câu) tới báo cáo.
* Cổng nội dung: `validateForm` chạy trong CI cho mọi form `approved`.

## 10. Chia đợt

| Đợt | Gồm | REQ |
| --- | --- | --- |
| 1 | Khung `/sat/**`, mục tiêu, bảng nội dung, người chơi đề, bài đo + báo cáo, admin nạp câu | 01, 02, 04, 05, 07, 19, 23, 24, 25 |
| 2 | Nguyên nhân sai, luyện miền, module có giờ, sổ lỗi, dashboard | 08, 09, 10, 11, 12, 14, 18 |
| 3 | Lộ trình, đo lại, readiness, ôn giãn cách, effort, phụ huynh, bàn SAT | 03, 06, 13, 15, 16, 17, 20, 21, 22, 26 |

## 11. Cố ý không làm

* Không dùng IRT ở v1: cần hàng nghìn attempt mỗi câu mới hiệu chỉnh được; bảng quy đổi nội bộ +
  nhãn "ước lượng" trung thực hơn một mô hình trông khoa học mà thiếu dữ liệu.
* Không cho AI tự đẩy câu vào `approved`: AI được soạn nháp, người duyệt quyết.
* Không dựng ứng dụng thi mô phỏng Bluebook toàn màn hình khoá trình duyệt.

## 12. Trace

| REQ | Mục |
| --- | --- |
| REQ-SAT-01..03 | §3 |
| REQ-SAT-04..09 | §4 |
| REQ-SAT-10..14 | §5 |
| REQ-SAT-15..18 | §6 |
| REQ-SAT-19..22 | §7 |
| REQ-SAT-23..26 | §2 |
