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 · 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, Goal Engine hay Need Signal. 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:
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 → nullKhác biệt so với quyền chuẩn: 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).
| 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:
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 + 1Mọ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: Portrait là declared model. Không có portrait trong MODEL_KINDS, không đi qua saveModelVersion(), không kích hoạt WF-04.
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):
{
"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.
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.
// 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ố ý:
- Không dùng màu đỏ (REQ-UX-03) — lệch quan điểm trong gia đình không phải lỗi.
- Không có "ai đúng" — nhãn mô tả khoảng cách, không phán xét bên nào.
- Không lưu, không vào model — chỉ là gợi ý hiển thị. Không có bảng
alignment_scoresvà 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
- Portrait không bao giờ ảnh hưởng Goal Model / Recommendation / bài học kế tiếp.
- Learner luôn đọc được portrait về mình.
- Mentor/staff/admin không viết được bất cứ thứ gì trong portrait.
- Chỉ chính learner viết được
portrait_learner_sectionsvàlearner_interest. - Chỉ phụ huynh viết được
parent_supportvà các field tổ chức. - Mỗi PATCH portrait phải snapshot vào
portrait_versionstrước. - Cap 3 active / phụ huynh / learner — vượt thì trả
CONFLICT(409), gợi ý archive. - Không hard-delete: portrait
archived, milestonemissedđề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 §1/§7; interaction: SDD-005.
- Kiểm chứng: QG-005, QG-008.
- Liên quan: Parent Model · Models · Permissions · State Machines.