---
url: https://docs.nemo12.com/architecture/sdd-045-journey-engine.md
description: >-
  Bản nháp Journey Engine (SDD-045): quản lý hành trình và kích hoạt, bốn mối
  quan tâm tách nhau, trạng thái suy ra không ghi làm nguồn.
---

# SDD-045 - Journey Management & Activation Engine

Yêu cầu: [PRD-005](../product/prd-005-journey-engine.md). Nguồn: SRC-1077 (SDD mẫu của chủ dự án,
27.09.2026). Chỗ nào bản này khác SDD mẫu thì ghi rõ lý do và mã quyết định.

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

```text
                 registry.ts (Journey Matrix, policies)
                          |
facts.ts  ----->  engine.ts (thuần)  <-----  learner_journey_states (ảnh chụp lần trước)
(Learner State)        |
                       v
             portfolio + foreground + next action
                       |
          service.ts: ghi ảnh chụp + journey_events (idempotent)
                       |
         routes.ts: learner view  |  admin trace
                       |
   learn /ielts (JourneyNextUp)   admin #/journeys
```

Mã: `workers/api/src/modules/journeys/` (`rules.ts`, `registry.ts`, `facts.ts`, `engine.ts`,
`service.ts`, `routes.ts`). Giao diện: `apps/learn/src/JourneyNextUp.tsx`,
`apps/admin/src/pages/Journeys.tsx`. Dữ liệu: migration `0301_journey_engine_state_and_events.sql`.

## 2. Bốn mối quan tâm tách nhau

| Mối quan tâm | Câu hỏi | Chỗ |
| --- | --- | --- |
| Journey Definition | Hành trình này là gì? | `registry.ts` |
| Journey State | Learner có đang ở trong nó không? | `engine.ts#deriveState` |
| Priority | Nó quan trọng tới đâu lúc này? | `engine.ts#scoreJourney` + policy |
| Next Best Action | Learner nên làm gì? | `engine.ts#pickAction` + catalog |

Engine là hàm THUẦN: không chạm D1, không đọc đồng hồ (nhận `now`). Mọi kịch bản của §13 chạy
không cần cơ sở dữ liệu, và test không đỏ theo lịch.

## 3. Trạng thái suy ra, không ghi làm nguồn (✍️ Q-159)

SDD mẫu lưu `journey_instances` làm nguồn sự thật. Bản này **suy ra trạng thái từ dữ liệu học mỗi
lần đánh giá**, cùng lý do `ielts/journey.ts` cũ từ chối lưu "đang ở bước mấy": một cột trạng thái
sẽ lệch khỏi sự thật ngay lần đầu learner xoá mục tiêu. D1 chỉ giữ ba thứ không suy ra được:
ảnh chụp lần trước (để biết lần này có ĐỔI không, và hành trình nào đang đứng trước), mốc
`snoozed_until`, và sổ sự kiện.

Luật suy trạng thái (`deriveState`), theo thứ tự:

1. Luật xong đúng: `lifecycle` thì COMPLETED; `episodic` chỉ COMPLETED nếu lần trước đã ở trong
   đợt (ELIGIBLE/ACTIVE/PAUSED/COMPLETED/EXPIRED), nếu không thì NOT_ELIGIBLE. Learner chưa từng
   nghỉ thì Recovery là NOT_ELIGIBLE, không phải COMPLETED.
2. Luật hết hạn đúng: EXPIRED nếu đã ở trong đợt, nếu không thì NOT_ELIGIBLE.
3. Không đủ điều kiện: NOT_ELIGIBLE.
4. `snoozed_until` còn hạn: PAUSED.
5. Luật kích hoạt đúng: ACTIVE, không thì ELIGIBLE.

`recurring` (Daily Learning) không có luật xong (registry test chặn).

## 4. Journey Registry (✍️ Q-160)

Định nghĩa sống trong `registry.ts`, sửa bằng PR, không bằng màn admin: đổi một luật là đổi hành
vi của mọi learner, và thay đổi ấy cần review, test và một commit nói vì sao. Registry là DỮ LIỆU
(luật khai báo, không phải hàm), nên ngày chuyển vào D1 chỉ là đổi chỗ đọc.

```ts
JourneyDefinition {
  id, version, name, purpose, horizon, owner, status, kind,   // lifecycle | episodic | recurring
  eligibility?, activation?, completion?, expiration?,        // Rule
  factors: { urgency, goal_relevance, need, impact, readiness }, // Factor
  policy, actions: JourneyAction[], success_metrics
}
```

`validateRegistry()` chặn ở test: id trùng, policy không tồn tại, catalog rỗng, gợi ý cuối có
điều kiện (phải có gợi ý mặc định), hành trình `recurring` có luật xong, trọng số policy không cộng
bằng 1, và em dash trong chữ gợi ý. Đổi luật thì tăng `version`: sổ ghi phiên bản đã dùng.

## 5. Luật và dữ kiện

**Luật** (`rules.ts`): `{ all: [...] }` hoặc `{ any: [...] }`, lồng được; mỗi điều kiện
`{ fact, op, value }` với `op` trong `== != < <= > >=`. Kết quả luôn kèm vết từng điều kiện
(`fact`, `op`, `expected`, `actual`, `pass`) - chính thứ trang admin bày. Dữ kiện `null` (chưa
biết) chỉ khớp `== null`: "chưa có ngày thi" không được lọt qua `days_remaining <= 30`.

**Nhân tố:** hằng số, hoặc bậc thang trên một dữ kiện (`steps: [[op, ngưỡng, giá trị]]`, bậc đầu
khớp thắng, `else` nếu không bậc nào). Boolean đọc như 1/0.

**Learner State Provider** (`facts.ts`) - một chỗ đọc duy nhất; mỗi truy vấn bọc try/catch trả
"chưa có" (code có thể lên trước migration):

| Dữ kiện | Nguồn |
| --- | --- |
| `goal.exists`, `goal.target_date`, `goal.days_remaining`, `goal.overall` | `learner_ielts_goals` active. Ngày mục tiêu dùng làm ngày thi (✍️ Q-162) |
| `profile.answered` | `learner_ielts_profile_facts` + `learner_ielts_declarations` |
| `diagnosis.exam_quiz`, `diagnosis.measured` | `ielts_exam_quiz_attempts`; `assessment_sessions` diagnostic completed hoặc `learner_ielts_baselines` |
| `plan.milestones`, `progress.milestone_due_days_ago` | `learner_ielts_milestones` của mục tiêu đang dùng |
| `progress.reviewed`, `goal.reached_reviewed` | `journey_events` kind `action_accepted` sau ngày mốc / sau lần sửa mục tiêu |
| `activity.ever`, `activity.inactive_days`, `activity.today` | `learning_events`, chỉ các action `practice, review, submit, read` (✍️ Q-163) tính theo ngày học (04:00 VN) |
| `capability.measured_overall`, `capability.reached` | ảnh chụp Learner Model IELTS mới nhất (`readIeltsModel`), cần đủ bốn kỹ năng |
| `focus.skill`, `focus.gap`, `focus.why` | kỹ năng xa mục tiêu nhất; chưa đo thì kỹ năng ít luyện nhất 14 ngày |

## 6. Ưu tiên

Policy: trọng số năm nhân tố (cộng bằng 1), `min_score`, `eligible_multiplier` (0.6).

```text
score = Σ weight_f × factor_f   × (ACTIVE ? 1 : eligible_multiplier)
```

Hành trình không ACTIVE/ELIGIBLE có điểm 0. Năm policy MVP: `setup_v1`, `daily_v1`,
`recovery_v1`, `exam_urgency_v1`, `goal_progress_v1` (ngưỡng 0.75, để Goal Achievement chưa đạt
không bao giờ đứng trước). Trọng số là phỏng đoán có lý do ghi trong mã, đo lại sau bốn tuần (Q-172).

**Suppression** được diễn bằng nhân tố thay vì một luật riêng: `exam_preparation.readiness` rơi về
0.3 khi learner nghỉ >= 7 ngày, nên Recovery thắng (PRD-005 §8).

## 7. Dữ liệu (migration 0301)

| Bảng | Vai trò |
| --- | --- |
| `learner_journey_states` (PK learner_id, journey_id) | Ảnh chụp lần đánh giá gần nhất: `state`, `definition_version`, `priority_score`, `is_foreground`, `action_id`, `snoozed_until`, `evaluated_at` |
| `journey_events` | Sổ chỉ chèn: `transition` (from, to), `foreground`, `action_accepted`, `snoozed`; kèm `definition_version`, `reasons_json` |

SDD mẫu liệt kê mười một bảng (definitions, versions, stages, rules, policies...). Chín bảng
không có vì registry nằm trong mã (§4) và trạng thái suy ra (§3).

## 8. Hành trình đứng trước

1. Ứng viên = ACTIVE hoặc ELIGIBLE, không PAUSED, điểm >= `min_score` của policy.
2. Xếp: điểm, rồi `urgency`, `goal_relevance`, `impact`, rồi thứ tự trong registry.
3. **Giữ mạch:** hành trình đang đứng trước (ảnh chụp) ở lại nếu kém đứng đầu <= 0.05.
4. Không ứng viên nào: không có thẻ (learner vẫn còn bốn lối vào tự chọn trên trang chủ).

Mỗi hành trình không phải ứng viên mang `not_candidate_reason` (đang hoãn, trạng thái, điểm dưới
ngưỡng); hành trình thắng mang `foreground_reason`.

## 9. API

| Đường | Ai | Làm gì |
| --- | --- | --- |
| `GET /v1/learners/{id}/journeys` | `canAccessLearner` | Đánh giá, ghi sổ, trả bản learner: `foreground`, `next_action`, `journeys[] {id, name, horizon, state}`. Không có điểm, nhân tố |
| `POST /v1/learners/{id}/journeys/re-evaluate` | như trên | Đánh giá lại tường minh (FR8) |
| `POST /v1/learners/{id}/journeys/{journeyId}/snooze` | như trên | "Để sau" 24 giờ; 409 nếu hành trình không ACTIVE/ELIGIBLE |
| `POST /v1/learners/{id}/journeys/{journeyId}/actions/{actionId}/accept` | như trên | Ghi learner đã bấm; 404 nếu action không có trong catalog |
| `GET /v1/admin/journeys` | admin | Registry, policy, lỗi cấu hình, số learner theo (hành trình, trạng thái) |
| `GET /v1/admin/learners/{id}/journeys` | admin | Vết đầy đủ: facts, ảnh chụp trước, từng journey (luật, nhân tố, điểm, lý do), sổ sự kiện. **Không ghi gì** |

Endpoint cũ `GET /v1/learners/{id}/ielts-journey` (năm bước) đã bỏ cùng `ielts/journey.ts`.

## 10. Đánh giá đồng bộ, ghi idempotent (✍️ Q-161)

SDD mẫu vẽ Event Router + Cloudflare Workflow + cache. MVP không cần: trạng thái suy ra từ dữ liệu
học, nên mọi sự kiện domain (nộp bài, đổi mục tiêu, ngày tới gần) đã nằm trong dữ liệu `facts.ts`
đọc; đánh giá lúc learner mở trang luôn đúng với dữ liệu mới nhất, không có cảnh "sự kiện chưa tới".
Chín truy vấn nhỏ, không cần cache.

**Đồng thời:** mỗi dòng ảnh chụp đổi bằng `UPDATE ... WHERE state = <đã đọc> AND is_foreground =
<đã đọc>` (optimistic concurrency); dòng `transition`/`foreground` chỉ chèn khi chính câu ấy đổi
được dòng. Hai tab mở cùng lúc thì một lần chuyển, một dòng sổ.

**Hỏng:** ghi sổ lỗi thì nuốt, gợi ý vẫn trả về. Phía learn, lỗi đọc thì thẻ im lặng biến mất,
không hiện "Journey Engine failed" (SDD mẫu §23).

Khi nào cần Workflows: hành trình phải CHỦ ĐỘNG đi ra ngoài (thư nhắc khi Recovery bật) - việc
ấy cần một lịch chạy, không cần learner mở trang.

## 11. Giao diện

**Learn** - `JourneyNextUp` ở đầu cụm "What to do now" trên trang chủ `/ielts` (✍️ Q-164), và thay
`JourneyStrip` năm bước trên trang "Con và IELTS" (`/squid/ielts-profile/ielts`, đọc lại sau khi
nộp quiz). Thẻ: chấm cam, nhãn "Next up", tên hành trình (tiếng Anh), câu gợi ý và câu vì sao
(tiếng Việt, do server viết), nút "Làm ngay" (ghi `accept` với `keepalive` rồi chuyển trang) và
"Để sau" (hoãn rồi vẽ lại với hành trình kế tiếp). Chữ theo SRC-953 (✍️ Q-165).

**Admin** - tab `Journeys` (`#/journeys`): bảng registry kèm số learner mỗi trạng thái, bảng policy,
ô tra learner. `#/journeys/<learnerId>`: việc tiếp theo và vì sao hành trình ấy đứng trước; từng
hành trình theo thứ hạng với luật đủ điều kiện / kích hoạt / xong / hết hạn (mỗi điều kiện ✓ ✗ kèm
giá trị thật), nhân tố, điểm, lý do không phải ứng viên; bảng dữ kiện; sổ sự kiện. Nút
"Journeys →" trên mỗi dòng của tab Learners.

**Trang công khai** - năm bước thay bằng tám hành trình ở `nemo12.com/ielts/journey`,
`/{students|parents}/university/ielts/journey`, khối trên trang môn IELTS của học sinh, và mọi
nhãn liên kết (`apps/web`, dữ liệu chung ở `ieltsJourneySteps.ts`). URL giữ nguyên. SAT giữ năm
bước (`journeyLabel` mặc định).

**Bàn mentor** (SDD-037 `desk/steps.ts`) giữ năm bước theo lô cho tới Q-169.

## 12. Bảo mật và riêng tư

Đường learner qua `canAccessLearner` (401 cho nhà khác, không lộ learner có tồn tại). Đường vết
chỉ `admin`. Bản learner không có điểm, nhân tố hay dữ kiện thô. Registry chỉ đọc qua API; đổi luật
là commit có review. Phụ huynh chưa có đường (Q-168).

## 13. Kiểm chứng

| Tầng | File | Gác |
| --- | --- | --- |
| Engine thuần | `journeys/engine.test.ts` (27 ca) | Registry hợp lệ; luật và null; kịch bản learner mới, học đều, tới mốc, quay lại (3/7 ngày, xong khi học lại, chưa từng nghỉ), sắp thi (60/20 ngày, hết hạn), ba hành trình giành nhau, giữ mạch trong/ngoài biên, "Để sau" và hết hạn hoãn |
| API + D1 thật | `journeys/routes.test.ts` (19 ca) | Quyền (401, admin); bản learner không lộ điểm; dữ kiện thật đổi đúng hành trình; sổ không ghi hai lần; "Để sau"; 409; accept đóng Learning Progress; 404 action lạ; vết admin không ghi sổ |
| E2E learn | `e2e/smoke.spec.ts` | Thẻ một việc, "Để sau" nhường chỗ, thẻ đứng giữa "What to do now" và "Practise a skill" |

## 14. Cố ý không làm (MVP)

* Registry trong D1 và màn sửa luật (Q-160) · Event Router, Workflows, cache (Q-161) · bảng
  `journey_stages` (mỗi hành trình MVP có ít bước, catalog có điều kiện là đủ) · AI chọn hành
  trình · phễu phân tích theo thời gian (REQ-JRN-16).

## 15. Trace

| REQ | Mục |
| --- | --- |
| REQ-JRN-01 | §4 |
| REQ-JRN-02 | §4, §5 |
| REQ-JRN-03 | §5 |
| REQ-JRN-04 | §3, §7 |
| REQ-JRN-05 | §6 |
| REQ-JRN-06 | §8 |
| REQ-JRN-07 | §8, §11 |
| REQ-JRN-08 | §9, §10 |
| REQ-JRN-09 | §7, §9 |
| REQ-JRN-10 | §9, §11 |
| REQ-JRN-11 | §8, §9, §11 |
| REQ-JRN-12 | §4 (PRD-005 §6) |
| REQ-JRN-13 | §11 |
| REQ-JRN-14 | §9, §11 |
| REQ-JRN-15 | §12 |
| REQ-JRN-16 | §14 |
