---
url: https://docs.nemo12.com/reference/student-portrait.md
description: >-
  Student Portrait: bức tranh tương lai gia đình vẽ về con và chỗ để con vẽ lại
  bằng lời mình, thiết kế SDD-015.
---

# Student Portrait

> Bức tranh tương lai gia đình vẽ về đứa trẻ — và chỗ để đứa trẻ vẽ lại bằng lời của mình.

Thiết kế: [SDD-015](../architecture/sdd-015-student-portrait.md) · Code: `workers/api/src/modules/portraits/routes.ts` · UI: `apps/marlins/src/Portrait.tsx`, `apps/learn/src/PortraitView.tsx` · Migrations: 0011, 0017, 0029

***

## 1. Hai luật nền

### Luật 1 — Portrait là **aspiration input**, không phải lệnh cho hệ thống

Portrait **không override Recommendation Engine** (REQ-POR-07). Bố mẹ ghi "con sẽ vào Harvard" thì hệ thống **không** vì thế mà đổi bài học ngày mai của đứa trẻ.

Kiểm chứng được bằng code: không có đường nào từ bảng `portraits` vào [Learner Model](models.md), [Goal Engine](engines.md#2-goal-engine) hay [Need Signal](engines.md#7-need-signal-engine-chọn-bài-trong-phòng-lab). Goal Model chỉ đọc `learner_exam_targets`, `semester_exam_schedule`, `school_enrollments` — **không đọc `portraits`**.

Vì sao cứng rắn vậy: ước mơ của bố mẹ là dữ liệu quý về **bối cảnh gia đình**, nhưng biến nó thành tín hiệu điều khiển sẽ khiến hệ thống dạy theo kỳ vọng người lớn thay vì theo năng lực đứa trẻ. Đó chính xác là cái mà một nền tảng học tập không được phép làm.

### Luật 2 — **Nemo luôn xem được** portrait về mình

REQ-POR-08. `GET /v1/learners/{id}/portraits` dùng `requireLearnerAccess` — chính learner luôn nằm trong `via: "self"`, không có cờ nào ẩn được portrait khỏi đứa trẻ.

**Không có bức tranh tương lai nào được vẽ sau lưng đứa trẻ.** Nếu bố mẹ không muốn con đọc, thì đó là thứ không nên viết vào đây.

***

## 2. Cấu trúc — 6 bảng

```
portraits ────┬── portrait_versions          (lịch sử mỗi lần sửa)
              ├── portrait_learner_sections  (CỘT CỦA CON — 0029)
              ├── portrait_reactions         (agree / unsure / disagree)
              └── portrait_tracks ── milestones
                                        ▲
external_activities (độc lập, gắn learner) ┘ (milestone cũng gắn trực tiếp portrait được)
interactions (target_type='portrait')        (comment)
```

### 2.1 `portraits` — bức tranh

| Cột | Ý nghĩa |
| --- | --- |
| `created_by_user_id` | Ai vẽ. **Cap 3 active tính theo từng phụ huynh**, không phải theo learner — bố và mẹ mỗi người 3 bức |
| `name`, `description`, `cover` | Tên, mô tả, emoji bìa (ảnh chờ media pipeline — SDD-015 §7) |
| `target_age`, `target_date` | Mốc tương lai: "con lúc 18 tuổi" |
| `sections_json` | **10 mục lớn**, mỗi mục là mảng câu (≤30 câu, mỗi câu ≤300 ký tự) |
| `status` | `active` ⇄ `archived` — **không hard-delete** |
| `version` | Tăng mỗi lần PATCH |

10 `SECTION_KEYS`: `academic_goals` · `capabilities` · `interests` · `languages` · `sports` · `creative` · `personal_development` · `lifestyle` · `experiences` · `career`.

**Cap 3 active có lý do sư phạm** (REQ-POR-01): ba kịch bản để **so sánh – bàn bạc – tinh chỉnh**. Một bức duy nhất dễ thành mệnh lệnh; mười bức thì không ai bàn nổi. Ba là con số vừa đủ để gia đình đặt cạnh nhau và chọn.

### 2.2 `portrait_tracks` — lộ trình dài (vai trò như EPIC)

Track là thứ chạy **xuyên nhiều năm**: IELTS, nền tảng Toán, portfolio. Milestone là các **mốc nằm trên** track.

| Cột | Ý nghĩa |
| --- | --- |
| `portrait_id` | **NULL = track xuyên suốt mọi hướng đi**, không thuộc riêng bức nào |
| `kind` | `exam` · `academic` · `capability` · `language` · `habit` · `portfolio` · `experience` · `personal` |
| `cadence` | Nhịp lặp — "thi thử mỗi 6 tháng" |
| `baseline_value` → `target_value` | Xuất phát → đích của cả track |
| `status` | `planned` · `active` · `paused` · `done` · `dropped` |

`portrait_id` nullable là điểm thiết kế đáng chú ý: "học tiếng Anh cho tốt" đúng với **mọi** tương lai có thể của đứa trẻ, nên nó không nên bị nhốt vào một kịch bản nào.

### 2.3 `milestones` — mốc

Gắn `track_id` (đường mới) **hoặc** `portrait_id` (giữ tương thích ngược từ 0011). Có `target_value` / `expected_value` / `actual_result` và `confidence` (0–1).

Status: `planned` → `in_progress` → `achieved` / `missed` / `rescheduled`.

`missed` **vẫn được giữ lại**, không xoá. Một mốc lỡ hẹn là thông tin thật về nhịp học của đứa trẻ; xoá nó đi là làm đẹp hồ sơ và mất dữ liệu.

### 2.4 `external_activities` — hoạt động ngoài

Guitar, tiếng Trung, cờ vua… Hai cột đánh giá **tách theo người khai** (REQ-POR-03):

| Cột | Ai được sửa | Ý nghĩa |
| --- | --- | --- |
| `learner_interest` 1–5 | **CHỈ learner** | Con thích tới mức nào |
| `parent_support` 1–5 | **CHỈ parent** | Bố mẹ ủng hộ tới mức nào |
| `hours_per_week`, `frequency`, `importance` | CHỈ parent | Dữ liệu tổ chức |

Thi hành ở API, không phải chỉ ẩn nút:

```ts
if (b.learner_interest != null && role !== "learner")
  return errorResponse(c, "AUTHORIZATION_ERROR", "Mức thích là của Nemo — chỉ Nemo tự khai");
if (role !== "parent" && (b.parent_support != null || b.hours_per_week != null || …))
  return errorResponse(c, "AUTHORIZATION_ERROR", "Field này chỉ phụ huynh sửa");
```

**Không gộp hai con số thành một điểm "phù hợp".** Chênh lệch giữa chúng chính là thông tin: con thích 2 mà bố mẹ ủng hộ 5 là một cuộc trò chuyện cần có, không phải một con số trung bình 3.5 vô nghĩa.

### 2.5 `portrait_reactions`

`(portrait_id, section_key, user_id)` → `agree` · `unsure` · `disagree`. `section_key` mặc định `_portrait` = phản ứng với cả bức. UPSERT — đổi ý thì ghi đè, không cộng dồn.

***

## 3. Cơ chế CẬP NHẬT

### 3.1 Quyền — chặt hơn `learnerAccess()` có chủ đích

Module này **không** dùng `learnerAccess()` chuẩn cho đường ghi. Nó có hàm riêng `familyRole()` chỉ trả **ba giá trị**:

```
learner.user_id == session.user_id                       → "learner"
session ∈ family_members(owner|guardian)                 → "parent"
còn lại                                                  → null
```

Khác biệt so với [quyền chuẩn](permissions.md#3-thuật-toán-learneraccess--nguồn-của-mọi-quyết-định): **mentor, staff, admin đều ra `null`**. Họ **không viết được** vào portrait.

Vì sao: portrait là bức tranh **gia đình** vẽ về con. Một mentor — dù thiện chí — không có tư cách viết vào đó. Ranh giới này quan trọng hơn sự tiện lợi.

Nhưng route **chỉ đọc** (`bundle`, `comments`) vẫn dùng `requireLearnerAccess` để Dolphin hỗ trợ được — và lượt đọc đó **có ghi audit** ([permissions §3](permissions.md#ghi-nhật-ký-theo-via)).

| Hành động | learner | parent | mentor/staff/admin |
| --- | --- | --- | --- |
| Đọc bundle, comment | ✅ | ✅ | 📝 đọc, có audit |
| Tạo/sửa portrait, tracks | ❌ | ✅ | ❌ |
| Viết **cột của con** | ✅ **chỉ mình em** | ❌ | ❌ |
| React, comment | ✅ | ✅ | ❌ |
| Milestone, activity | ✅ tạo được | ✅ | ❌ |
| `learner_interest` | ✅ **chỉ em** | ❌ | ❌ |
| `parent_support` | ❌ | ✅ **chỉ bố mẹ** | ❌ |

### 3.2 Sửa portrait = snapshot rồi mới ghi đè

`PATCH /v1/portraits/{id}` chạy một `DB.batch` hai bước:

```ts
1. INSERT INTO portrait_versions (portrait_id, version, snapshot_json, changed_by_user_id)
     ← chụp TOÀN BỘ hàng hiện tại trước khi đụng vào
2. UPDATE portraits SET … version = version + 1
```

Mọi field dùng `COALESCE(?, cũ)` nên PATCH một field không xoá các field khác.

Giá trị của `portrait_versions`: kỳ vọng của bố mẹ **thay đổi theo thời gian**, và chính sự thay đổi đó là thứ đáng nhìn lại. "Năm ngoái bố mẹ viết gì về con?" trả lời được, và thường là một cuộc trò chuyện xúc động hơn nhiều so với nội dung hiện tại.

### 3.3 Không có engine, không có version model

Giống [Parent Model](parent-model.md#1-parent-model-là-declared-model-không-phải-computed-model): Portrait là **declared model**. Không có `portrait` trong `MODEL_KINDS`, không đi qua `saveModelVersion()`, không kích hoạt [WF-04](workflows.md#2-wf-04--evidence--model-update).

`portrait_versions` là lịch sử **do người sửa**, khác hẳn `learner_model_versions` là lịch sử **do engine tính**.

***

## 4. Cách DÙNG — bundle một lần gọi

`GET /v1/learners/{id}/portraits` trả **toàn bộ** trong một request (5 query song song + 1 query cột của con):

```jsonc
{
  "role": "parent",              // vai của người đang xem, để UI biết hiện nút gì
  "portraits":  [ { …, "sections": {…} } ],
  "tracks":     [ … ],
  "milestones": [ … ],
  "activities": [ … ],
  "reactions":  [ … ],
  "learner_sections": [ { "portrait_id": "…", "section_key": "career", "items": ["…"] } ]
}
```

Trang portrait cần tất cả để vẽ, và chia nhỏ thành 6 request sẽ làm trang nhấp nháy từng mảng khi tải.

::: tip Bọc try/catch quanh `portrait_learner_sections`
Migration 0029 chạy tay nên bảng có thể chưa tồn tại. Query cột của con được bọc `try/catch` → trả mảng rỗng thay vì làm hỏng cả trang. Đây là nguyên tắc "code deploy trước migration" của QG-004: tính năng mới thiếu thì chấp nhận được; trang trắng thì không.
:::

Tiêu thụ ở cả hai app: `apps/marlins` (bố mẹ vẽ) và `apps/learn` (con xem + viết cột của mình).

***

## 5. Hai cột — tiếng nói của con

Bổ sung mới nhất (SRC-106, migration 0029), và là phần quan trọng nhất của cả tính năng.

Mỗi mục lớn có **hai cột**:

| Cột | Bảng | Ai viết |
| --- | --- | --- |
| Bố mẹ viết | `portraits.sections_json` | Chỉ phụ huynh |
| **Con tự viết** | `portrait_learner_sections` | **Chỉ chính Nemo** |

Tên mục dùng chung để hai bên nói **cùng một chuyện**; nội dung thì mỗi bên một cột.

```ts
// PUT /v1/portraits/{id}/learner-sections
if (role !== "learner")
  return errorResponse(c, "AUTHORIZATION_ERROR", "Chỉ chính Nemo được viết phần của mình");
```

Bố mẹ **đọc được** cột của con (nó nằm trong bundle) nhưng **không viết hộ được**. Ngay cả với thiện chí tốt nhất.

Vì sao cần đến mức có hẳn một bảng riêng: trước 0029, đứa trẻ chỉ có thể bấm 👍/🤔/👎 với bức tranh người lớn vẽ. Đó là cho phép **phản ứng**, không phải cho phép **nói**. SDD-015 §1 đòi Nemo có tiếng nói bằng **chính lời của mình** — và một mảng chuỗi do em ấy tự viết là cách rẻ nhất, thật nhất để làm được điều đó.

***

## 6. Alignment — tín hiệu trao đổi, không phải điểm số

REQ-POR-05. Tính ở **frontend** (`apps/marlins/src/Portrait.tsx`), từ khoảng cách giữa `learner_interest` và `parent_support`:

| Chênh lệch | Nhãn |
| --- | --- |
| ≤ 1 | Đồng thuận cao |
| = 2 | Nên trao đổi |
| ≥ 3 | Cần nói chuyện |

Ba điều cố ý:

1. **Không dùng màu đỏ** (REQ-UX-03) — lệch quan điểm trong gia đình không phải lỗi.
2. **Không có "ai đúng"** — nhãn mô tả *khoảng cách*, không phán xét bên nào.
3. **Không lưu, không vào model** — chỉ là gợi ý hiển thị. Không có bảng `alignment_scores` và không nên có: chấm điểm mức đồng thuận của một gia đình là việc không thuộc về phần mềm.

***

## 7. Bất biến — vi phạm là bug

1. Portrait **không bao giờ** ảnh hưởng Goal Model / Recommendation / bài học kế tiếp.
2. Learner **luôn** đọc được portrait về mình.
3. Mentor/staff/admin **không viết được** bất cứ thứ gì trong portrait.
4. Chỉ chính learner viết được `portrait_learner_sections` và `learner_interest`.
5. Chỉ phụ huynh viết được `parent_support` và các field tổ chức.
6. Mỗi PATCH portrait **phải** snapshot vào `portrait_versions` trước.
7. Cap **3 active / phụ huynh / learner** — vượt thì trả `CONFLICT` (409), gợi ý archive.
8. Không hard-delete: portrait `archived`, milestone `missed` đều giữ nguyên.

## 8. Khoảng trống đã biết

| Việc | Trạng thái |
| --- | --- |
| REQ-POR-06 — ảnh (avatar/cover/timeline) qua Media System + moderation | ⏳ hiện chỉ emoji `cover` |
| **Cấm suy luận đặc điểm từ ảnh** (trí tuệ, tính cách) | 🔒 luật đã ghi (REQ-POR-06), sẽ có hiệu lực khi ảnh chạy |
| REQ-POR-07 — Planning Engine **đề xuất** milestone từ portrait | ⏳ hôm nay milestone hoàn toàn do người tạo |
| Alignment tính ở backend, có lịch sử theo thời gian | ⏳ hiện tính ở frontend, không lưu |
| Migration 0029 vào chuỗi migration chuẩn | ⚠️ đang chạy tay — vì thế mới cần try/catch ở §4 |

## Trace

* REQ-POR-01 (portrait, cap 3) · 02 (track + milestone) · 03 (activity, hai cột đánh giá) · 04 (collaboration + version) · 05 (alignment) · 07 (aspiration input, không override) · 08 (Nemo luôn xem được) · 09 (cột của con).
* US-55, US-56, US-57.
* Nguồn: SRC-040 (Student Portrait & Family Future Planning), SRC-106 (hai cột), SRC-079 (danh sách chỉ hiện tên), SRC-105.
* Thiết kế: [SDD-015](../architecture/sdd-015-student-portrait.md) §1/§7; interaction: [SDD-005](../architecture/sdd-005-interaction.md).
* Kiểm chứng: QG-005, QG-008.
* Liên quan: [Parent Model](parent-model.md) · [Models](models.md#8-student-portrait-declared) · [Permissions](permissions.md) · [State Machines](state-machines.md#12-portrait-reaction).
