---
url: https://docs.nemo12.com/reference/data-dictionary/other-5.md
description: >-
  Từ điển dữ liệu D1, domain Khác (phần 5/11): 24 bảng, grammar_live_signups tới
  ielts_micro_progress; cột, kiểu, khoá ngoại, index.
---

# Data Dictionary · Khác (phần 5/11)

> ⚙️ **Trang này sinh tự động** từ source code bằng `scripts/gen-reference.mjs` — đừng sửa tay, sửa code rồi chạy `npm run gen:reference`.

Thuộc [Data Dictionary](../data-dictionary.md). 24 bảng, `grammar_live_signups` tới `ielts_micro_progress`.

## `grammar_live_signups`

Migration 0340 - Đăng ký buổi Live của NEMO GRAMMAR và bản ghi từng buổi. SRC-1307.  ## Vì sao migration này tồn tại  Chủ dự án 09.10.2026: NEMO GRAMMAR có buổi Live qua Zoom mỗi tối thứ Hai 20:00-21:30 (giờ VN), từ 12.10.2026. Learner bấm tham gia thì mới thấy link Zoom, và mỗi buổi có bản ghi để xem lại. Buổi là lịch cố định nên máy chủ tính ra từ một mốc (`grammarLive/schedule.ts`), giống Speak together (migration 0303): không có bảng buổi, không có việc định kỳ phải sinh lịch trước. Chỉ hai thứ phải nhớ: ai đã tham gia buổi nào, và link bản ghi (YouTube không công khai) của buổi đã xong. Không có bảng đăng ký thì nút tham gia không nhớ được gì và link Zoom lộ cho mọi người.  ## Chạy lại không đổi gì  CREATE TABLE / INDEX IF NOT EXISTS.

migration: `0340_grammar_live_signups_and_recordings.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `session_id` | TEXT | NOT NULL |
| `learner_id` | TEXT | NOT NULL REFERENCES learners(id) ON DELETE CASCADE |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| — | *table constraint* | `PRIMARY KEY (session_id, learner_id)` |

**Khóa ngoại:** `learner_id` → `learners`

**Index:** `idx_grammar_live_signups_learner`(learner_id, session_id)

## `gull_entities`

Migration 0306 - Gull, customer understanding. SRC-1094, SDD-047.  ## Vì sao migration này tồn tại  Gull (gull.nemo12.com) là System of Record for Customer Understanding (PRD-007). Mười hai loại thực thể (Program, Stakeholder, Persona, JTBD, Need, Pain, Journey, Stage, Step, Touchpoint, Evidence, Opportunity) có chung phần lớn trường quản trị, nên chúng nằm trong MỘT bảng `gull_entities` với cột `kind`; trường riêng của từng loại nằm trong `fields` (JSON). Giá trị của Gull nằm ở đường nối, nên quan hệ là bảng riêng `gull_links`, có thứ tự (`position`, cho Stage trong Journey và Step trong Stage) và có thể gắn một chương trình (`program_id`: Program Context).  Không có giao diện sửa (Q-187): chủ dự án sửa bằng script (`scripts/gull-build-seed.mjs` rồi workflow seed-data). Lịch sử phiên bản vẫn phải có (PRD-007 §4.4), nên trigger ghi bản cũ vào `gull_history` trước mỗi lần sửa hoặc xoá: người sửa không phải nhớ.  ## Chạy lại không đổi gì  Mọi câu IF NOT EXISTS.

migration: `0306_gull_customer_understanding.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY,               -- slug tiếng Anh ổn định, vd `persona-g11-hanoi-ielts` |
| `kind` | TEXT | NOT NULL CHECK (kind IN ('program','stakeholder','persona','jtbd','need','pain' |
| `name` | TEXT | NOT NULL |
| `summary` | TEXT | NOT NULL DEFAULT '' |
| `fields` | TEXT | NOT NULL DEFAULT '{}',     -- JSON, trường riêng của từng loại (SDD-047 §3) |
| `status` | TEXT | NOT NULL DEFAULT 'draft' CHECK (status IN ('hypothesis','draft','evidence_backed','validated','deprecated')) |
| `important` | INTEGER | NOT NULL DEFAULT 0,     -- 1 = nhận định quan trọng, phải có Evidence trừ khi là hypothesis |
| `tags` | TEXT | NOT NULL DEFAULT '\[]',     -- JSON mảng chuỗi |
| `owner` | TEXT | — |
| `reviewer` | TEXT | — |
| `source` | TEXT | ,                           -- vd `docs/product/parent-jtbd.md` |
| `generated_by` | TEXT | NOT NULL DEFAULT 'human',  -- 'human' hoặc 'ai:\<model>' |
| `confidence` | REAL | CHECK (confidence IS NULL OR (confidence >= 0 AND confidence <= 1)) |
| `human_verified_at` | TEXT | — |
| `version` | INTEGER | NOT NULL DEFAULT 1 |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| `updated_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |

**Index:** `idx_gull_entities_kind`(kind, status)

## `gull_history`

migration: `0306_gull_customer_understanding.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT |
| `entity_id` | TEXT | NOT NULL |
| `version` | INTEGER | NOT NULL |
| `snapshot` | TEXT | NOT NULL,                       -- JSON của dòng TRƯỚC khi đổi |
| `change` | TEXT | NOT NULL CHECK (change IN ('update','delete')) |
| `changed_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |

**Index:** `idx_gull_history_entity`(entity_id, version)

## `gull_links`

migration: `0306_gull_customer_understanding.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `from_id` | TEXT | NOT NULL REFERENCES gull_entities(id) |
| `to_id` | TEXT | NOT NULL REFERENCES gull_entities(id) |
| `rel` | TEXT | NOT NULL,                       -- 'contains' (journey→stage→step), 'has_pain', 'supports' (evidence→x)… SDD-047 §3.2 |
| `program_id` | TEXT | NOT NULL DEFAULT '',            -- '' = quan hệ toàn cục; khác '' = chỉ đúng trong chương trình ấy |
| `position` | INTEGER | — |
| `note` | TEXT | — |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| — | *table constraint* | `PRIMARY KEY (from_id, to_id, rel, program_id)` |

**Khóa ngoại:** `from_id` → `gull_entities` · `to_id` → `gull_entities`

**Index:** `idx_gull_links_to`(to_id)

## `gull_program_context`

Program Context: phần khác biệt của một thực thể toàn cục trong một chương trình.

migration: `0306_gull_customer_understanding.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `entity_id` | TEXT | NOT NULL REFERENCES gull_entities(id) |
| `program_id` | TEXT | NOT NULL REFERENCES gull_entities(id) |
| `summary` | TEXT | NOT NULL DEFAULT '' |
| `fields` | TEXT | NOT NULL DEFAULT '{}' |
| — | *table constraint* | `PRIMARY KEY (entity_id, program_id)` |

**Khóa ngoại:** `entity_id` → `gull_entities` · `program_id` → `gull_entities`

## `ielts_content_clusters`

Cụm nội dung: 8 cụm đọc + 8 cụm nghe. Bài khởi động không thuộc cụm nào.

migration: `0255_ielts_content.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `skill` | TEXT | NOT NULL CHECK (skill IN ('reading','listening')) |
| `slug` | TEXT | NOT NULL |
| `unit` | INTEGER | NOT NULL DEFAULT 1 |
| `domain` | TEXT | NOT NULL |
| `name_en` | TEXT | NOT NULL |
| `name_vi` | TEXT | NOT NULL |
| `field_en` | TEXT | NOT NULL DEFAULT '' |
| `field_vi` | TEXT | NOT NULL DEFAULT '' |
| `note_vi` | TEXT | NOT NULL DEFAULT '' |
| `vocabulary_json` | TEXT | NOT NULL DEFAULT '\[]' |
| `status` | TEXT | NOT NULL DEFAULT 'published' CHECK (status IN ('draft','published')) |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| `updated_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |

**Index:** `idx_content_clusters_slug`(skill, slug) UNIQUE

## `ielts_content_items`

Một bài: passage đọc, clip nghe, hoặc bài khởi động.

migration: `0255_ielts_content.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `skill` | TEXT | NOT NULL CHECK (skill IN ('reading','listening','warmup')) |
| `cluster_id` | TEXT | REFERENCES ielts_content_clusters(id) ON DELETE CASCADE |
| `seq` | INTEGER | NOT NULL |
| `slug` | TEXT | NOT NULL DEFAULT '' |
| `title` | TEXT | NOT NULL |
| `kind` | TEXT | NOT NULL DEFAULT '' |
| `domain` | TEXT | NOT NULL DEFAULT '' |
| `body_json` | TEXT | NOT NULL |
| `extra_json` | TEXT | NOT NULL DEFAULT '{}' |
| `status` | TEXT | NOT NULL DEFAULT 'published' CHECK (status IN ('draft','published')) |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| `updated_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |

**Khóa ngoại:** `cluster_id` → `ielts_content_clusters`

**Index:** `idx_content_items_cluster`(cluster_id, seq) · `idx_content_items_skill`(skill, status, seq)

## `ielts_content_keys_seen`

Migration 0331 - ielts content keys seen. SRC-1229.  ## Vì sao migration này tồn tại  Đề đầy đủ ghép từ CHÍNH những câu của kho luyện, và đường chấm luyện (`POST .../ielts-content/check`) trả đáp án của mọi câu learner gửi lên. Không có sổ này thì server không biết learner đã cầm đáp án của câu nào, nên lúc ghép đề nó có thể đưa đúng những câu learner vừa xem đáp án hôm qua vào bài đo hôm nay, và band trả về đo trí nhớ chứ không đo sức. Mỗi dòng là "learner này đã nhận đáp án của câu này, lần đầu lúc nào"; lúc ghép đề server chọn bài có ít câu đã lộ nhất.  ## Chạy lại không đổi gì  `CREATE TABLE IF NOT EXISTS` + index `IF NOT EXISTS`. Không nạp dữ liệu cũ: những đáp án đã trả trước ngày có sổ không còn dấu vết nào để dựng lại.

migration: `0331_ielts_content_keys_seen.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `learner_id` | TEXT | NOT NULL |
| `question_id` | TEXT | NOT NULL |
| `seen_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| — | *table constraint* | `PRIMARY KEY (learner_id, question_id)` |

**Index:** `idx_ielts_content_keys_seen_question`(question_id)

## `ielts_content_questions`

Câu hỏi của một bài. Bảng riêng: xem đoạn đầu file.

migration: `0255_ielts_content.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `item_id` | TEXT | NOT NULL REFERENCES ielts_content_items(id) ON DELETE CASCADE |
| `seq` | INTEGER | NOT NULL |
| `sub_skill` | TEXT | NOT NULL |
| `kind` | TEXT | NOT NULL DEFAULT 'choice' CHECK (kind IN ('choice','gap')) |
| `prompt` | TEXT | NOT NULL |
| `quote` | TEXT | NOT NULL DEFAULT '' |
| `options_json` | TEXT | NOT NULL DEFAULT '\[]' |
| `answer_json` | TEXT | NOT NULL DEFAULT '\[]' |
| `accept_json` | TEXT | NOT NULL DEFAULT '\[]' |
| `why` | TEXT | NOT NULL DEFAULT '' |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |

**Khóa ngoại:** `item_id` → `ielts_content_items`

**Index:** `idx_content_questions_item`(item_id, seq)

## `ielts_diag4_attempts`

Migration 0307 - Chẩn đoán 4 kỹ năng IELTS, mỗi kỹ năng một bài (SRC-1099).  Chủ dự án 28.09.2026: mỗi kỹ năng lớn một bài test riêng; mỗi sub-skill 3 câu; sau mỗi câu learner trả lời thêm "chắc chắn đến đâu" để hệ thống phân biệt hiểu thật với đoán; làm xong cả 4 bài thì hệ thống tạo báo cáo và learner xem lại được. Thay bài đo 6 câu mỗi kỹ năng cũ (các phiên cũ giữ nguyên).  ## Hai bảng  `ielts_diag4_attempts`: một lượt làm MỘT kỹ năng. `items_json` chụp lại đề đã phát (mã câu, sub-skill, đáp án) lúc bắt đầu, để kho câu có đổi sau đó thì lượt đã làm vẫn chấm đúng đề learner đã thấy. `answers_json` lưu dần (thoát ra làm tiếp được), `result_json` là kết quả chấm lúc nộp.  `ielts_diag4_reports`: một báo cáo mỗi khi đủ bốn kỹ năng đã nộp tính từ báo cáo trước. Lưu thành ảnh chụp để xem lại lần chẩn đoán cũ đúng như lúc ấy, và so sánh các lần với nhau.  ## Chạy lại không đổi gì  CREATE TABLE / INDEX IF NOT EXISTS.

migration: `0307_ielts_diag4.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `learner_id` | TEXT | NOT NULL REFERENCES learners(id) ON DELETE CASCADE |
| `skill` | TEXT | NOT NULL CHECK (skill IN ('listening','reading','speaking','writing')) |
| `items_json` | TEXT | NOT NULL |
| `answers_json` | TEXT | NOT NULL DEFAULT '{}' |
| `result_json` | TEXT | — |
| `report_id` | TEXT | — |
| `started_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| `submitted_at` | TEXT | — |

**Khóa ngoại:** `learner_id` → `learners`

**Index:** `idx_ielts_diag4_attempts_learner`(learner_id, skill, started_at DESC)

## `ielts_diag4_reports`

migration: `0307_ielts_diag4.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `learner_id` | TEXT | NOT NULL REFERENCES learners(id) ON DELETE CASCADE |
| `report_json` | TEXT | NOT NULL |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |

**Khóa ngoại:** `learner_id` → `learners`

**Index:** `idx_ielts_diag4_reports_learner`(learner_id, created_at DESC)

## `ielts_diagnostic_items`

Bài đo ngắn IELTS dùng chung bảng `assessment_sessions` (kind='diagnostic', subject_id='ielts'), không dựng bảng phiên thứ hai. Nhưng một phiên đo IELTS cần biết nó đã hỏi những STRAND nào để tầng suy luận quy kết quả về đúng kỹ năng; `item_ids_json` chỉ có id câu.

migration: `0218_ielts_declarations.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `session_id` | TEXT | NOT NULL |
| `item_id` | TEXT | NOT NULL |
| `strand` | TEXT | NOT NULL,              -- listening · reading · writing · speaking · language |
| `node_id` | TEXT | NOT NULL |
| `position` | INTEGER | NOT NULL |
| — | *table constraint* | `PRIMARY KEY (session_id, item_id)` |

**Index:** `idx_ielts_diag_session`(session_id, position)

## `ielts_exam_papers`

Migration 0257 - Đề thi Writing và Speaking vào D1. SRC-888.  Chỉ đạo chủ dự án 2026-09-20: soạn Reading, Writing, Speaking cho đủ 20 đề mỗi kỹ năng.  ## Vì sao hai kỹ năng này cần bảng riêng, còn Đọc và Nghe thì không  Đề Đọc và đề Nghe được GHÉP từ bài có sẵn: ba passage cùng cụm, bốn clip khác lĩnh vực. Chúng không cần chỗ chứa nào vì chúng không phải nội dung, chúng là một cách xếp nội dung (SRC-862).  Đề Viết và Nói thì ngược lại: không có "bài" nào để ghép. Một đề Writing LÀ hai đề bài, một đề Speaking LÀ ba chùm câu hỏi. Nội dung ấy hiện nằm trong mã nguồn worker (`PRODUCTION_LADDER`), đúng chỗ mà SRC-875 đã chốt là không được để nội dung nữa: sửa một đề bài phải sửa code, deploy lại worker, và màn admin không với tới.  ## Hai bảng, không phải một  Một đề có nhiều phần, và phần mới là thứ learner mở ra làm. Nhét các phần vào một cột JSON của bảng đề thì mọi chỗ đọc một phần đều phải tải cả đề rồi tự tách - và màn admin sẽ phải ghi lại cả đề khi sửa một dòng, đúng kiểu ghi đè làm mất việc của người khác.  ## `body_md` giữ bảng số liệu của Task 1  Task 1 Academic vốn mô tả BIỂU ĐỒ. Hệ chưa có chỗ chứa hình, nên đề ở đây dùng dạng TABLE - một dạng Task 1 có thật trong kỳ thi, không phải đồ thay thế tạm. Số liệu viết bằng chữ trong `body_md`, và learner mô tả đúng như mô tả một biểu đồ.

migration: `0257_ielts_exam_papers.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `skill` | TEXT | NOT NULL CHECK (skill IN ('writing','speaking')) |
| `slug` | TEXT | NOT NULL |
| `seq` | INTEGER | NOT NULL |
| `name` | TEXT | NOT NULL |
| `note` | TEXT | NOT NULL DEFAULT '' |
| `minutes` | INTEGER | NOT NULL |
| `status` | TEXT | NOT NULL DEFAULT 'published' CHECK (status IN ('draft','published')) |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| `updated_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |

**Index:** `idx_exam_papers_slug`(skill, slug) UNIQUE

## `ielts_exam_parts`

migration: `0257_ielts_exam_papers.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `paper_id` | TEXT | NOT NULL REFERENCES ielts_exam_papers(id) ON DELETE CASCADE |
| `seq` | INTEGER | NOT NULL |
| `label` | TEXT | NOT NULL |
| `title` | TEXT | NOT NULL |
| `minutes` | INTEGER | NOT NULL |
| `prompt_en` | TEXT | NOT NULL |
| `body_md` | TEXT | NOT NULL DEFAULT '' |
| `questions_json` | TEXT | NOT NULL DEFAULT '\[]' |
| `min_words` | INTEGER | — |
| `min_seconds` | INTEGER | — |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |

**Khóa ngoại:** `paper_id` → `ielts_exam_papers`

**Index:** `idx_exam_parts_paper`(paper_id, seq)

## `ielts_exam_quiz_attempts`

Migration 0224 — Quiz "con hiểu gì về kỳ thi IELTS". SRC-738 (chỉ đạo chủ dự án 2026-09-15).  ── Vì sao KHÔNG dùng lại `assessment_sessions` như bài đo bốn kỹ năng ──────────────────────  Hai thứ trông giống nhau (đều là câu hỏi trắc nghiệm) nhưng là hai loại việc khác hẳn:    · Bài đo bốn kỹ năng ĐO NĂNG LỰC. Câu nằm trong `items`, gắn `skill_nodes`, kết quả thành     `learner_evidence` và đi vào Learner Model. Chấm sau khi nộp cả bài, không hiện đáp án     giữa chừng, vì hiện đáp án làm hỏng chính phép đo.   · Quiz này DẠY. Nó hỏi về LUẬT CHƠI của kỳ thi (mấy kỹ năng, band tính thế nào, Task 2 nặng     hơn Task 1 bao nhiêu) — những thứ learner hiểu sai thì luyện sai suốt nhiều tháng. Nó chấm     NGAY và hiện giải thích, vì mục đích là sửa chỗ hiểu sai chứ không phải cho điểm.  Nhét quiz này vào `assessment_sessions` sẽ bơm bằng chứng "làm sai 3 câu" vào Learner Model về một thứ KHÔNG PHẢI năng lực tiếng Anh: hiểu nhầm cách tính band không làm Reading của một đứa trẻ yếu đi. Đó là lý do có bảng riêng, và là lý do bảng này không nối vào `skill_nodes`.  Câu hỏi nằm trong MÃ NGUỒN (`modules/ielts/examQuiz.ts`), không nằm trong CSDL: chúng là kiến thức về một kỳ thi ổn định nhiều năm, sửa chúng là một commit có review, không phải một lượt INSERT. Bảng này chỉ lưu LƯỢT LÀM.

migration: `0224_ielts_exam_quiz.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `learner_id` | TEXT | NOT NULL REFERENCES learners(id) ON DELETE CASCADE |
| `quiz_version` | INTEGER | NOT NULL DEFAULT 1 |
| `answered` | INTEGER | NOT NULL |
| `correct` | INTEGER | NOT NULL |
| `answers_json` | TEXT | NOT NULL DEFAULT '\[]' |
| `misconceptions_json` | TEXT | NOT NULL DEFAULT '\[]' |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |

**Khóa ngoại:** `learner_id` → `learners`

**Index:** `idx_ielts_quiz_learner`(learner_id, created_at DESC)

## `ielts_full_test_attempts`

Migration 0316 - ielts full test ladder. SRC-1146, SDD-038 §149.  ## Vì sao migration này tồn tại  Chủ dự án 30.09.2026: learn.nemo12.com/ielts có một khu làm ĐỀ ĐẦY ĐỦ (Listening, Reading, Writing, Speaking), 50 đề, mở khoá tuần tự. Đề k+1 chỉ mở khi đề k có một lượt làm XONG và NGHIÊM TÚC. "Nghiêm túc" phải do server quyết từ dữ liệu đã lưu, không phải do app tự khai, nên server cần giữ chính những thứ nó dùng để quyết: mốc giờ bắt đầu và nộp của từng phần (đóng dấu bằng đồng hồ SERVER), câu trả lời, số lần rời tab, và kết luận hợp lệ kèm lý do.  Bốn bảng:   · ielts_full_test_attempts  - một lượt làm một đề. `valid` NULL khi chưa nộp hết.   · ielts_full_test_sections  - một phần của một lượt. PK (attempt_id, skill): nộp lại một phần                                 là ghi đè đúng dòng ấy, không đẻ thêm dòng.   · ielts_full_test_comments  - nhận xét chung do AI viết, MỘT dòng mỗi lượt (PK attempt_id),                                 sinh một lần rồi giữ nguyên. `INSERT OR IGNORE` trên PK là thứ                                 bảo đảm hai request chạy chồng không sinh hai bản.   · ielts_full_test_unlocks   - admin mở tay một đề cho một learner. Dấu vết đầy đủ nằm ở                                 `audit_log`; bảng này chỉ giữ trạng thái đang có hiệu lực.  ## Lượt cũ  Trước migration này KHÔNG có lượt làm đề đầy đủ nào được lưu: các đề từng kỹ năng (SRC-930) chỉ ghi điểm thô vào sổ công sức, không giữ mốc giờ hay câu trả lời. Không có dữ liệu để chứng minh một lượt cũ là nghiêm túc, nên không có gì để "cho qua" (grandfather); mọi learner bắt đầu từ đề 1. Xem SDD-038 §149.  ## Chạy lại không đổi gì  Toàn bộ là `CREATE TABLE IF NOT EXISTS` / `CREATE INDEX IF NOT EXISTS`.

migration: `0316_ielts_full_test_ladder.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `learner_id` | TEXT | NOT NULL REFERENCES learners(id) ON DELETE CASCADE |
| `test_no` | INTEGER | NOT NULL CHECK (test_no BETWEEN 1 AND 50) |
| `papers_json` | TEXT | NOT NULL DEFAULT '{}' |
| `status` | TEXT | NOT NULL DEFAULT 'in_progress' CHECK (status IN ('in_progress','finished')) |
| `started_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| `finished_at` | TEXT | — |
| `valid` | INTEGER | — |
| `reasons_json` | TEXT | NOT NULL DEFAULT '\[]' |
| `warnings_json` | TEXT | NOT NULL DEFAULT '\[]' |
| `report_json` | TEXT | — |

**Khóa ngoại:** `learner_id` → `learners`

**Index:** `idx_full_test_attempts_learner`(learner_id, test_no)

## `ielts_full_test_comments`

migration: `0316_ielts_full_test_ladder.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `attempt_id` | TEXT | PRIMARY KEY REFERENCES ielts_full_test_attempts(id) ON DELETE CASCADE |
| `text_vi` | TEXT | NOT NULL |
| `source` | TEXT | NOT NULL CHECK (source IN ('ai','template')) |
| `model` | TEXT | — |
| `prompt_ref` | TEXT | — |
| `usd` | REAL | NOT NULL DEFAULT 0 |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |

**Khóa ngoại:** `attempt_id` → `ielts_full_test_attempts`

## `ielts_full_test_sections`

migration: `0316_ielts_full_test_ladder.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `attempt_id` | TEXT | NOT NULL REFERENCES ielts_full_test_attempts(id) ON DELETE CASCADE |
| `skill` | TEXT | NOT NULL CHECK (skill IN ('listening','reading','writing','speaking')) |
| `started_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| `submitted_at` | TEXT | — |
| `answers_json` | TEXT | NOT NULL DEFAULT '\[]' |
| `result_json` | TEXT | — |
| `blur_count` | INTEGER | NOT NULL DEFAULT 0 |
| — | *table constraint* | `PRIMARY KEY (attempt_id, skill)` |

**Khóa ngoại:** `attempt_id` → `ielts_full_test_attempts`

## `ielts_full_test_unlocks`

migration: `0316_ielts_full_test_ladder.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `learner_id` | TEXT | NOT NULL REFERENCES learners(id) ON DELETE CASCADE |
| `test_no` | INTEGER | NOT NULL CHECK (test_no BETWEEN 1 AND 50) |
| `unlocked_by` | TEXT | NOT NULL |
| `reason` | TEXT | NOT NULL DEFAULT '' |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| — | *table constraint* | `PRIMARY KEY (learner_id, test_no)` |

**Khóa ngoại:** `learner_id` → `learners`

## `ielts_learner_models`

Migration 0229 — Nơi IELTS Learner Model được LƯU, không chỉ được tính. SRC-792 (nhịp 3/3).  ## Vì sao cần lưu, khi đã có hàm tính  `modules/ielts/layer.ts` dựng lát cắt IELTS từ lời khai cộng bằng chứng, và dựng lại từ đầu mỗi lần có ai mở trang. Cách ấy đúng ở một điểm quan trọng và sai ở ba điểm khác:    ĐÚNG: kết quả không bao giờ cũ hơn dữ liệu. Không có cache nào để lệch.   SAI 1: không trả lời được "tháng trước con ở đâu". Mỗi lượt tính xong là vứt đi, nên hệ không          bao giờ nói được "Reading của con đã đi từ 5.5 lên 6.0" — mà đó đúng là câu một đứa trẻ          luyện sáu tháng cần nghe nhất.   SAI 2: không ai kiểm được hệ đã từng nói gì. Mentor nhớ buổi trước hệ xếp Writing là chỗ yếu          nhất, giờ mở ra thấy Speaking, và không có cách nào biết vì dữ liệu đổi hay vì thuật          toán đổi.   SAI 3: mọi màn đọc đều trả giá tính lại, kể cả khi chẳng có bằng chứng mới nào.  Bảng này KHÔNG thay `layer.ts`. Nó lưu lại ẢNH CHỤP của từng lần tính, và `engine_version` nói ảnh ấy do bản thuật toán nào sinh ra. Đổi thuật toán là tăng số ấy, và những ảnh cũ vẫn đọc được đúng như lúc chúng ra đời.  ## Vì sao một bảng RIÊNG, không dùng `learner_skill_state`  `learner_skill_state` giữ mastery 0..1 cho mỗi node kỹ năng, chung cho mọi môn. IELTS cần một hình dạng khác hẳn: bốn kỹ năng cố định, thang band 0..9 nửa bậc, một mức tin cậy cho cả lát cắt, và quan trọng nhất là CHỖ LỆCH giữa điều learner tin về mình với điều bài làm nói ra. Không ô nào trong `learner_skill_state` chứa được chỗ lệch ấy, mà đó là thứ có giá trị nhất của lát cắt.  ## Mỗi lần tính một dòng, không ghi đè  `INSERT` mới chứ không `UPDATE` dòng cũ: lịch sử là lý do bảng này tồn tại. Dòng mới nhất của một learner là lát cắt đang dùng; các dòng trước là thứ trả lời được câu "đã đi được tới đâu". Số dòng không phình: engine chỉ ghi khi có bằng chứng mới hoặc lời khai mới (xem `model.ts`).

migration: `0229_ielts_learner_model.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `learner_id` | TEXT | NOT NULL REFERENCES learners(id) ON DELETE CASCADE |
| `engine_version` | INTEGER | NOT NULL |
| `evidence_count` | INTEGER | NOT NULL DEFAULT 0 |
| `evidence_through` | TEXT | — |
| `layer_json` | TEXT | NOT NULL |
| `listening` | REAL | , reading REAL, writing REAL, speaking REAL |
| `confidence` | REAL | NOT NULL DEFAULT 0 |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |

**Khóa ngoại:** `learner_id` → `learners`

**Index:** `idx_ielts_models_learner`(learner_id, created_at DESC)

## `ielts_lexicon_domains`

Một dòng dẫn cho kho từ của mỗi lĩnh vực.

migration: `0255_ielts_content.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `domain` | TEXT | PRIMARY KEY |
| `note_vi` | TEXT | NOT NULL DEFAULT '' |

## `ielts_lexicon_families`

Họ từ của một lĩnh vực (SRC-784, nay rời khỏi bundle theo cùng chỉ đạo).

migration: `0255_ielts_content.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `domain` | TEXT | NOT NULL |
| `head` | TEXT | NOT NULL |
| `tier` | INTEGER | NOT NULL CHECK (tier BETWEEN 1 AND 3) |
| `vi` | TEXT | NOT NULL |
| `members_json` | TEXT | NOT NULL DEFAULT '\[]' |
| `collocations_json` | TEXT | NOT NULL DEFAULT '\[]' |
| `example` | TEXT | NOT NULL DEFAULT '' |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |

**Index:** `idx_lexicon_families_domain`(domain, tier)

## `ielts_micro_exercises`

Migration 0250 — Tầng BÀI TẬP giữa năng lực nhỏ và câu hỏi. SRC-850.  Chỉ đạo chủ dự án 2026-09-19: "Click vào một trong các card (ví dụ như Main Idea hay Detail) thì cần vào một trang mới. Trong đó có nhiều bài tập, và chia theo 5 levels. Mỗi levels có khá nhiều bài tập. Learners có thể filter để chọn ra bài tập để làm bài. Mỗi bài tập đều có URL riêng biệt."  ## Vì sao cần một tầng nữa, chứ không chỉ thêm cột `level` vào câu hỏi  Trước bản này, một năng lực là MỘT thang dài: mở `/ielts/skills/reading/main-idea` ra là đi từ câu đầu tới câu cuối, không có chỗ dừng và không có gì để gửi cho nhau. Đặt `level` lên từng câu thì vẫn không giải được hai việc chủ dự án vừa nêu:    · "Mỗi bài tập đều có URL riêng" — URL phải trỏ vào một THỨ CÓ RANH GIỚI. Một câu hỏi thì quá     nhỏ (URL cho từng câu là 3.500 URL không ai gửi cho ai), còn cả một level thì quá to.   · "Learners có thể filter để chọn ra bài tập" — lọc chỉ có nghĩa khi có nhiều VẬT THỂ để lọc.     Lọc ra 350 câu rời thì learner vẫn đứng trước cùng một danh sách không biết bắt đầu từ đâu.  Một bài tập ở đây là một buổi làm việc trọn vẹn: 5–10 đoạn, làm hết trong chừng mười phút, có điểm kết và có câu nói con vừa làm được gì.  ## Năm bậc, và composition của từng bậc nằm trong dữ liệu chứ không trong đầu người soạn  Chỉ đạo, nguyên văn rút gọn:    1 — 5 đoạn, đều tiếng Việt.   2 — 5 đoạn tiếng Việt + 5 đoạn tiếng Anh, trong đó bản Việt DỊCH TỪ bản Anh.   3 — 10 đoạn ngắn, đều tiếng Anh.   4 — 5 Việt + 5 Anh, nhưng dài hơn bậc 2.   5 — gần như đề thi thật.  Bậc 2 và 4 là chỗ duy nhất trong cả hệ có QUAN HỆ giữa hai câu hỏi: đoạn Việt số 1 và đoạn Anh số 1 là cùng một đoạn văn qua hai thứ tiếng. Quan hệ ấy phải nằm trong cột (`pair`), không nằm trong quy ước "cứ seq n và seq n+5 thì là một cặp": quy ước ấy đúng cho tới bài đầu tiên có 6 đoạn Việt, và lúc sai thì nó sai im lặng — trang ghép nhầm hai đoạn không liên quan rồi nói với learner rằng đây là bản dịch của nhau.  Vì sao cặp ấy đáng có: learner làm đoạn tiếng Việt trước, hiểu thao tác, rồi gặp LẠI ĐÚNG đoạn ấy bằng tiếng Anh. Phần khó duy nhất còn lại là chữ — đúng tinh thần thang bốn chặng ở 0238, nay làm chặt hơn một bậc.

migration: `0250_micro_skill_exercises.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `micro_skill_id` | TEXT | NOT NULL REFERENCES ielts_micro_skills(id) ON DELETE CASCADE |
| `level` | INTEGER | NOT NULL CHECK (level BETWEEN 1 AND 5) |
| `seq` | INTEGER | NOT NULL |
| `title_en` | TEXT | NOT NULL |
| `title_vi` | TEXT | NOT NULL |
| `blurb_vi` | TEXT | NOT NULL |
| `status` | TEXT | NOT NULL DEFAULT 'draft' CHECK (status IN ('draft','published')) |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| `updated_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| `task_type_id` | TEXT | REFERENCES ielts_task_types(id) — thêm ở 0264_reading_task_types.sql |
| `show_count` | INTEGER |  — thêm ở 0290_micro_exercise_show_count.sql |
| `minutes` | INTEGER |  — thêm ở 0292_micro_exercise_minutes.sql |

**Khóa ngoại:** `micro_skill_id` → `ielts_micro_skills`

**Index:** `idx_micro_exercises_addr`(micro_skill_id, level, seq) UNIQUE · `idx_micro_exercises_list`(micro_skill_id, status, level, seq) · `idx_micro_exercises_task_type`(task_type_id, status, level, seq)

## `ielts_micro_progress`

Bài đang làm dở: giữ chỗ learner dừng lại — SRC-919.  Chỉ đạo chủ dự án 2026-09-20: "Hệ thống cần đánh dấu 'bài nào đang làm dở', 'bài nào đã làm xong rồi'."  "Đã xong" đã đọc được từ `learning_events` (SRC-917): mỗi bài làm hết ghi một dòng action='practice'. "Đang làm dở" thì KHÔNG đọc được từ đó, vì theo đúng thiết kế ấy một bài bỏ giữa chừng không để lại dòng nào — và đó là chủ ý, không phải thiếu sót: ghi lúc mở trang thì mở ra rồi đóng lại cũng thành "đã học" và chuỗi ngày (SRC-801) mất nghĩa.  Nên trạng thái dở nằm ở bảng riêng này, và nó giữ CẢ các lựa chọn đã chấm chứ không chỉ một con số. Lý do: một thẻ báo "đang dở 3/5" mà bấm vào lại phải làm lại từ câu 1 thì cái nhãn ấy chỉ tố cáo learner chứ không giúp gì. Có `picks` thì bấm vào là về đúng chỗ đang đứng.  Một dòng cho mỗi (learner, bài): đây là VỊ TRÍ HIỆN TẠI, không phải lịch sử — lịch sử là việc của `learning_events`. Làm xong bài thì dòng này bị xoá, nên bảng luôn nhỏ bằng số bài đang dở.

migration: `0267_ielts_micro_progress.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `learner_id` | TEXT | NOT NULL |
| `ref` | TEXT | NOT NULL,          -- 'exercise:\<skill>.\<code>.l\<level>.\<seq>', cùng quy ước learning_events |
| `answered` | INTEGER | NOT NULL DEFAULT 0 |
| `total` | INTEGER | NOT NULL DEFAULT 0 |
| `picks` | TEXT | NOT NULL DEFAULT '{}',  -- JSON {item_id: chỉ số đã chọn} |
| `updated_at` | TEXT | NOT NULL DEFAULT (datetime('now')) |
| — | *table constraint* | `PRIMARY KEY (learner_id, ref)` |

**Index:** `idx_micro_progress_learner_ref`(learner_id, ref)
