feat: expand builder docs, add practice LMS guide, remove CHART stimulus kinds

Document schema slot label assignment and usage in the admin integration guide. Add a step-by-step dynamic practice creation guide for course/module/lesson scopes. Remove CHART and FLOW_CHART from the stimulus component catalog.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Yared Yemane 2026-06-05 02:54:44 -07:00
parent 33355a4b23
commit ab986a08f0
3 changed files with 1177 additions and 23 deletions

View File

@ -0,0 +1,995 @@
# Dynamic Practice Creation — LMS Guide (Course / Module / Lesson)
This guide explains **step by step** how to create **practices** in the Learn English LMS hierarchy using **dynamic question types** (`DYNAMIC` questions with `question_type_definition_id` + `dynamic_payload`).
It is the companion to:
- **Type builder (definitions + components):** `docs/DYNAMIC_QUESTION_TYPE_BUILDER_ADMIN_INTEGRATION.md`
- **Lesson-only quick path (legacy + dynamic):** `docs/PRACTICE_CREATION_API_GUIDE.md`
**Base URL:** `{API_HOST}/api/v1`
**Auth:** `Authorization: Bearer <access_token>`
**Content-Type:** `application/json` (except file upload)
---
## Table of contents
1. [Architecture](#1-architecture)
2. [Prerequisites and permissions](#2-prerequisites-and-permissions)
3. [Standard response envelopes](#3-standard-response-envelopes)
4. [ID map (what to store after each step)](#4-id-map-what-to-store-after-each-step)
5. [Publishing model](#5-publishing-model)
6. [End-to-end flow overview](#6-end-to-end-flow-overview)
7. [Step 0 — Resolve LMS parent IDs](#7-step-0--resolve-lms-parent-ids)
8. [Step 1 — (Optional) Upload media](#8-step-1--optional-upload-media)
9. [Step 2 — Create or select a question type definition](#9-step-2--create-or-select-a-question-type-definition)
10. [Step 3 — Create dynamic question(s)](#10-step-3--create-dynamic-questions)
11. [Step 4 — Create PRACTICE question set](#11-step-4--create-practice-question-set)
12. [Step 5 — Add questions to the set](#12-step-5--add-questions-to-the-set)
13. [Step 6 — Create practice shell (course / module / lesson)](#13-step-6--create-practice-shell-course--module--lesson)
14. [Step 7 — Verify and inspect](#14-step-7--verify-and-inspect)
15. [Optional — Reorder, update, publish](#15-optional--reorder-update-publish)
16. [Worked example — Lesson practice with TABLE + OPTION](#16-worked-example--lesson-practice-with-table--option)
17. [Scope-specific quick reference](#17-scope-specific-quick-reference)
18. [API index](#18-api-index)
19. [QA checklist](#19-qa-checklist)
---
## 1. Architecture
### LMS hierarchy
```
Program
└── Course
└── Module
└── Lesson
```
A **practice** is a learner-facing activity (story, persona, tips) backed by a **question set** containing one or more **questions**.
### Database rule (one parent only)
Each row in `lms_practices` attaches to **exactly one** of:
| Scope | `parent_kind` | `parent_id` refers to |
|-------|---------------|------------------------|
| Course-level practice | `COURSE` | `courses.id` |
| Module-level practice | `MODULE` | `modules.id` |
| Lesson-level practice | `LESSON` | `lessons.id` |
You cannot attach one practice to multiple parents. Choose the scope that matches your curriculum design.
### How dynamic questions fit in
```mermaid
flowchart LR
subgraph definitions
DEF[Question type definition]
end
subgraph content
Q1[Dynamic question 1]
Q2[Dynamic question 2]
end
subgraph packaging
QS[Question set set_type=PRACTICE]
P[Practice shell]
end
DEF --> Q1
DEF --> Q2
Q1 --> QS
Q2 --> QS
QS --> P
P --> L[Lesson / Module / Course]
```
1. **Definition** — template (which stimulus/response slots exist).
2. **Questions** — instances with `dynamic_payload` (real TABLE rows, OPTION choices, PDF URLs, etc.).
3. **Question set** — ordered list of question IDs (`set_type: "PRACTICE"`).
4. **Practice** — links `question_set_id` to a course, module, or lesson.
---
## 2. Prerequisites and permissions
### Minimum permissions (admin authoring)
| Permission | Used for |
|------------|----------|
| `questions.list` | Component catalog, list definitions |
| `questions.create` | Definitions, dynamic questions |
| `questions.get` | Load question / definition details |
| `questions.update` | Update questions, definitions, publish practice |
| `question_sets.create` | Create PRACTICE set |
| `question_set_items.add` | Link questions to set |
| `question_set_items.list` | List questions in set, type summary |
| `question_set_items.update_order` | Reorder questions |
| `practices.create` | Create practice shell |
| `practices.list` | List practices under course/module/lesson |
| `practices.get` | Get practice by id |
| `practices.update` | Publish practice (`publish_status`) |
| `lessons.get` / `modules.get` / `courses.get` | Resolve parent IDs (as needed) |
File upload: authenticated user only (`POST /files/upload`).
### Related docs
- Full definition API reference: `DYNAMIC_QUESTION_TYPE_BUILDER_ADMIN_INTEGRATION.md`
- Postman collection: `postman/Dynamic-Question-Type-Builder.postman_collection.json`
---
## 3. Standard response envelopes
### Success — `domain.Response`
```json
{
"message": "Human-readable summary",
"data": {},
"success": true,
"status_code": 200,
"metadata": null
}
```
`status_code` in the body may be `200` or `201` depending on the endpoint.
### Error — `domain.ErrorResponse`
```json
{
"message": "Short error title",
"error": "Detailed validation or system message"
}
```
---
## 4. ID map (what to store after each step)
| Step | Capture | Used in |
|------|---------|---------|
| Upload media | `url`, `object_key` | `dynamic_payload` stimulus `value` |
| Create definition | `question_type_definition_id` | Create each dynamic question |
| Create question | `question_id` | Add to set (repeat per question) |
| Create question set | `question_set_id` (`set_id`) | Create practice |
| Create practice | `practice_id` | Admin UI, learner routes |
| Parent resolution | `course_id` / `module_id` / `lesson_id` | `parent_id` + `owner_id` |
---
## 5. Publishing model
Three layers can affect learner visibility:
| Layer | Field | Values | Notes |
|-------|--------|--------|-------|
| Question | `status` | `DRAFT`, `PUBLISHED`, `INACTIVE`, `ARCHIVED` | Use `PUBLISHED` for live content |
| Question set | `status` | `DRAFT`, `PUBLISHED`, … | Use `PUBLISHED` for live sets |
| Practice shell | `publish_status` | `DRAFT`, `PUBLISHED` | Omit or `PUBLISHED` on create; use `DRAFT` to hide until ready |
**Recommendation for go-live:** set question `status`, question set `status`, and practice `publish_status` to published when learners should see the practice immediately.
**Draft practice:** create with `"publish_status": "DRAFT"`, then `PUT /practices/:id` with `"publish_status": "PUBLISHED"` when ready.
---
## 6. End-to-end flow overview
| Step | Action | APIs (typical) |
|------|--------|----------------|
| 0 | Resolve `parent_id` (course / module / lesson) | `GET /courses/:id`, `GET /modules/:id`, `GET /lessons/:id` |
| 1 | Upload images / PDF / audio (if needed) | `POST /files/upload` |
| 2 | Create or pick question type definition | `GET /questions/type-definitions`, `POST /questions/type-definitions` |
| 3 | Create one or more dynamic questions | `POST /questions` (repeat) |
| 4 | Create PRACTICE question set | `POST /question-sets` |
| 5 | Add each question to set (ordered) | `POST /question-sets/:setId/questions` (repeat) |
| 6 | Create practice at chosen scope | `POST /practices` |
| 7 | Verify | `GET /lessons/:id/practices` (or course/module), `GET /question-sets/:setId/questions` |
---
## 7. Step 0 — Resolve LMS parent IDs
Before creating a practice, know the target **`parent_id`** and matching **`owner_type`** for the question set.
### List lessons in a module (example)
| | |
|--|--|
| **GET** | `/modules/:moduleId/lessons` |
| **Permission** | `lessons.list_by_module` |
**Query (optional):** `limit`, `offset` (see lesson list handler defaults).
**Success `200``data`:** array of lessons; capture `id` for `parent_id` when scope is `LESSON`.
### Get lesson / module / course
| Entity | Method / path | Permission |
|--------|---------------|------------|
| Lesson | `GET /lessons/:id` | `lessons.get` |
| Module | `GET /modules/:id` | `modules.get` |
| Course | `GET /courses/:id` | `courses.get` |
Use these to confirm the parent exists and to display titles in the admin UI.
---
## 8. Step 1 — (Optional) Upload media
Required when the definition uses `IMAGE`, `AUDIO_CLIP`, `AUDIO_PROMPT`, or `PDF_ATTACHMENT` stimulus slots.
### POST `/files/upload`
**Content-Type:** `multipart/form-data`
| Field | Value |
|-------|--------|
| `file` | Binary |
| `media_type` | `image`, `audio`, `video`, or `pdf` |
**Success `200``data`:**
```json
{
"object_key": "pdf/a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf",
"url": "https://minio.example.com/bucket/pdf/a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf?X-Amz-Algorithm=...",
"content_type": "application/pdf",
"media_type": "pdf",
"provider": "MINIO"
}
```
**Use in `dynamic_payload`:** set stimulus `value` to `data.url` (or store `minio://{object_key}` and resolve with `GET /files/url?key=...`).
**Errors `400`:**
```json
{
"message": "Invalid media_type",
"error": "media_type must be one of: image, audio, video, pdf"
}
```
---
## 9. Step 2 — Create or select a question type definition
Skip creation if reusing an existing ACTIVE definition.
### 9.1 List existing definitions
| | |
|--|--|
| **GET** | `/questions/type-definitions?include_system=true&status=ACTIVE` |
| **Permission** | `questions.list` |
**Success `200``data`:** array of definitions (see shape in type builder doc).
### 9.2 (Optional) Validate kinds
| | |
|--|--|
| **POST** | `/questions/validate-question-type-definition` |
| **Permission** | `questions.create` |
**Request:**
```json
{
"stimulus_component_kinds": ["QUESTION_TEXT", "TABLE", "IMAGE"],
"response_component_kinds": ["OPTION", "ANSWER_TIMER"]
}
```
**Success `200``data`:**
```json
{
"valid": true
}
```
### 9.3 Create definition (example with TABLE)
| | |
|--|--|
| **POST** | `/questions/type-definitions` |
| **Permission** | `questions.create` |
**Request:**
```json
{
"key": "lesson_table_mcq_v1",
"display_name": "Lesson Table MCQ",
"description": "Prompt + optional table + image; MCQ response",
"stimulus_component_kinds": ["QUESTION_TEXT", "TABLE", "IMAGE"],
"response_component_kinds": ["OPTION"],
"stimulus_schema": [
{
"id": "prompt",
"kind": "QUESTION_TEXT",
"label": "Question prompt",
"required": true,
"config": { "max_length": 2000 }
},
{
"id": "data_table",
"kind": "TABLE",
"label": "Reference table",
"required": true,
"config": { "max_rows": 30, "max_columns": 10 }
},
{
"id": "illustration",
"kind": "IMAGE",
"label": "Supporting image",
"required": false,
"config": {}
}
],
"response_schema": [
{
"id": "choices",
"kind": "OPTION",
"label": "Answer choices",
"required": true,
"config": { "min_options": 2, "max_options": 6 }
}
],
"status": "ACTIVE"
}
```
**Success `201``data` (full `QuestionTypeDefinition`):**
```json
{
"id": 42,
"key": "lesson_table_mcq_v1",
"display_name": "Lesson Table MCQ",
"description": "Prompt + optional table + image; MCQ response",
"stimulus_component_kinds": ["QUESTION_TEXT", "TABLE", "IMAGE"],
"response_component_kinds": ["OPTION"],
"stimulus_schema": [ ],
"response_schema": [ ],
"is_system": false,
"status": "ACTIVE",
"created_at": "2026-06-04T10:00:00Z",
"updated_at": null
}
```
**Capture:** `data.id``question_type_definition_id` (e.g. `42`).
---
## 10. Step 3 — Create dynamic question(s)
Repeat this step for each question in the practice.
### POST `/questions`
| | |
|--|--|
| **Permission** | `questions.create` |
**Rules:**
- `question_type` must be `"DYNAMIC"`.
- `question_type_definition_id` is **required**.
- `dynamic_payload` is **required**.
- Do **not** send top-level `question_text` (prompt lives in stimulus).
- Do **not** send legacy `options` / `short_answers` for pure dynamic MCQ (use `OPTION` in payload).
**Request (TABLE + OPTION example):**
```json
{
"question_type": "DYNAMIC",
"question_type_definition_id": 42,
"difficulty_level": "MEDIUM",
"points": 2,
"status": "PUBLISHED",
"dynamic_payload": {
"stimulus": [
{
"id": "prompt",
"kind": "QUESTION_TEXT",
"value": "Using the table, choose the correct past tense."
},
{
"id": "data_table",
"kind": "TABLE",
"value": {
"columns": ["Verb", "Past Form"],
"rows": [
["go", "went"],
["write", "wrote"],
["see", "saw"]
]
}
},
{
"id": "illustration",
"kind": "IMAGE",
"value": "https://minio.example.com/bucket/image/uuid.jpg"
}
],
"response": [
{
"id": "choices",
"kind": "OPTION",
"value": {
"options": [
{ "id": "a", "text": "He goed home.", "is_correct": false },
{ "id": "b", "text": "He went home.", "is_correct": true },
{ "id": "c", "text": "He go home.", "is_correct": false }
]
}
}
]
}
}
```
**TABLE `value` contract:**
| Field | Type | Description |
|-------|------|-------------|
| `columns` | `string[]` | Header labels |
| `rows` | `string[][]` | Each row length should match `columns.length` |
**Success `201``data`:**
```json
{
"id": 1001,
"question_type": "DYNAMIC",
"question_type_definition_id": 42,
"dynamic_payload": {
"stimulus": [ ],
"response": [ ]
},
"difficulty_level": "MEDIUM",
"points": 2,
"status": "PUBLISHED",
"created_at": "2026-06-04T11:00:00Z"
}
```
Note: `question_text` is **omitted** from the JSON response for `DYNAMIC` questions.
**Error `400` examples:**
```json
{
"message": "Invalid dynamic_payload",
"error": "dynamic_payload.stimulus: required element id \"data_table\" is missing"
}
```
```json
{
"message": "Invalid question_text",
"error": "question_text is not used for DYNAMIC questions; set prompt text in dynamic_payload stimulus (QUESTION_TEXT, INSTRUCTION, or TEXT_PASSAGE)"
}
```
**Capture:** `data.id``question_id` (repeat list: `[1001, 1002, ...]`).
---
## 11. Step 4 — Create PRACTICE question set
The question set groups questions. Its `owner_type` / `owner_id` should match the practice scope (recommended for reporting and sequence gating).
### POST `/question-sets`
| | |
|--|--|
| **Permission** | `question_sets.create` |
**Request — lesson scope:**
```json
{
"title": "Lesson 12 — Dynamic drill",
"description": "Practice question set for lesson 12",
"set_type": "PRACTICE",
"owner_type": "LESSON",
"owner_id": 12,
"status": "PUBLISHED",
"shuffle_questions": false
}
```
**Request — module scope:**
```json
{
"title": "Module 3 — Review set",
"set_type": "PRACTICE",
"owner_type": "MODULE",
"owner_id": 3,
"status": "PUBLISHED",
"shuffle_questions": false
}
```
**Request — course scope:**
```json
{
"title": "Course 1 — Capstone practice",
"set_type": "PRACTICE",
"owner_type": "COURSE",
"owner_id": 1,
"status": "PUBLISHED",
"shuffle_questions": false
}
```
| Field | Required | Notes |
|-------|----------|-------|
| `title` | Yes | Admin display |
| `set_type` | Yes | Must be `"PRACTICE"` for LMS practices |
| `owner_type` | Recommended | `LESSON`, `MODULE`, or `COURSE` (match practice parent) |
| `owner_id` | Recommended | ID of that entity |
| `description` | No | |
| `status` | No | Default `DRAFT`; use `PUBLISHED` for learners |
| `shuffle_questions` | No | Default `false` |
| `time_limit_minutes` | No | Optional |
| `passing_score` | No | Optional |
| `intro_video_url` | No | Optional |
**Success `201``data`:**
```json
{
"id": 55,
"title": "Lesson 12 — Dynamic drill",
"description": "Practice question set for lesson 12",
"set_type": "PRACTICE",
"owner_type": "LESSON",
"owner_id": 12,
"shuffle_questions": false,
"status": "PUBLISHED",
"created_at": "2026-06-04T11:30:00Z"
}
```
**Capture:** `data.id``question_set_id` / `set_id` (e.g. `55`).
---
## 12. Step 5 — Add questions to the set
Run once per `question_id`. `display_order` controls sequence (important for `STUDENT` practice gating).
### POST `/question-sets/:setId/questions`
| | |
|--|--|
| **Permission** | `question_set_items.add` |
**Path:** `setId` = question set id from Step 4.
**Request (first question):**
```json
{
"question_id": 1001,
"display_order": 1
}
```
**Request (second question):**
```json
{
"question_id": 1002,
"display_order": 2
}
```
| Field | Type | Required |
|-------|------|----------|
| `question_id` | `int64` | Yes |
| `display_order` | `int32` | No |
**Success `201``data`:**
```json
{
"id": 901,
"set_id": 55,
"question_id": 1001,
"display_order": 1
}
```
**Errors:** `400` invalid ids; `500` link failure.
### (Optional) Question type summary for set
| | |
|--|--|
| **GET** | `/question-sets/:setId/question-types` |
| **Permission** | `question_set_items.list` |
**Success `200``data`:**
```json
{
"question_set_id": 55,
"total_questions": 2,
"question_types": [
{
"question_type_definition_id": 42,
"key": "lesson_table_mcq_v1",
"display_name": "Lesson Table MCQ",
"count": 2
}
]
}
```
---
## 13. Step 6 — Create practice shell (course / module / lesson)
Links the question set to exactly one LMS parent.
### POST `/practices`
| | |
|--|--|
| **Permission** | `practices.create` |
**Request — lesson:**
```json
{
"parent_kind": "LESSON",
"parent_id": 12,
"title": "Lesson 12 — Table MCQ practice",
"story_description": "Read the table and choose the best answer.",
"story_image": "https://minio.example.com/bucket/image/story.webp",
"question_set_id": 55,
"quick_tips": "Check every row in the table before selecting.",
"publish_status": "DRAFT"
}
```
**Request — module:**
```json
{
"parent_kind": "MODULE",
"parent_id": 3,
"title": "Module 3 review",
"question_set_id": 55,
"publish_status": "PUBLISHED"
}
```
**Request — course:**
```json
{
"parent_kind": "COURSE",
"parent_id": 1,
"title": "Course-wide practice",
"question_set_id": 55,
"publish_status": "PUBLISHED"
}
```
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `parent_kind` | string | Yes | `COURSE`, `MODULE`, or `LESSON` |
| `parent_id` | int64 | Yes | Target entity id |
| `question_set_id` | int64 | Yes | From Step 4 |
| `title` | string | No | Empty string allowed |
| `story_description` | string | No | |
| `story_image` | string | No | URL |
| `persona_id` | int64 | No | `lms_personas` catalog id |
| `quick_tips` | string | No | |
| `publish_status` | string | No | `DRAFT` or `PUBLISHED`; default `PUBLISHED` if omitted |
**Success `201``data` (`domain.Practice`):**
```json
{
"id": 37,
"parent_kind": "LESSON",
"parent_id": 12,
"title": "Lesson 12 — Table MCQ practice",
"story_description": "Read the table and choose the best answer.",
"story_image": "https://minio.example.com/bucket/image/story.webp",
"persona_id": null,
"question_set_id": 55,
"publish_status": "DRAFT",
"quick_tips": "Check every row in the table before selecting.",
"created_at": "2026-06-04T12:00:00Z",
"updated_at": null
}
```
**Errors:**
| Status | `message` | Typical `error` |
|--------|-----------|-----------------|
| `404` | Lesson not found | Parent id invalid |
| `404` | Question set not found | Bad `question_set_id` |
| `404` | Persona not found | Bad `persona_id` |
| `400` | Invalid parent | Bad `parent_kind` |
**Capture:** `data.id``practice_id` (e.g. `37`).
---
## 14. Step 7 — Verify and inspect
### 14.1 List practices under parent
| Scope | GET |
|-------|-----|
| Lesson | `/lessons/:id/practices?limit=20&offset=0` |
| Module | `/modules/:id/practices?limit=20&offset=0` |
| Course | `/courses/:id/practices?limit=20&offset=0` |
**Permission:** `practices.list`
**Success `200``data`:**
```json
{
"practices": [
{
"id": 37,
"parent_kind": "LESSON",
"parent_id": 12,
"title": "Lesson 12 — Table MCQ practice",
"question_set_id": 55,
"publish_status": "DRAFT",
"created_at": "2026-06-04T12:00:00Z"
}
],
"total_count": 1,
"limit": 20,
"offset": 0
}
```
### 14.2 Get practice by id
| | |
|--|--|
| **GET** | `/practices/:id` |
| **Permission** | `practices.get` |
**Success `200``data`:** full `Practice` object (includes `question_set_id`).
### 14.3 List questions in set (admin — full dynamic payload)
Use **`question_set_id`** from the practice record (not `practice_id`).
| | |
|--|--|
| **GET** | `/question-sets/:setId/questions` |
| **Permission** | `question_set_items.list` |
**Success `200``data`:** array of full questions including `dynamic_payload` and `question_type_definition_id`.
For paginated learner-style listing with filters:
| | |
|--|--|
| **GET** | `/practices/:practiceId/questions?limit=10&offset=0&question_type=DYNAMIC` |
**Note:** This routes path parameter is named `practiceId` in OpenAPI but is implemented against **`question_sets.id`**. For admin, prefer **`GET /question-sets/:setId/questions`** using `practice.question_set_id` from Step 14.2.
**Paginated response shape (`GET /practices/.../questions`):**
```json
{
"questions": [
{
"id": 901,
"set_id": 55,
"question_id": 1001,
"display_order": 1,
"question_type": "DYNAMIC",
"dynamic_payload": { "stimulus": [ ], "response": [ ] },
"points": 2,
"question_status": "PUBLISHED"
}
],
"total_count": 1,
"limit": 10,
"offset": 0
}
```
---
## 15. Optional — Reorder, update, publish
### Reorder question in set
| | |
|--|--|
| **PUT** | `/question-sets/:setId/questions/:questionId/order` |
| **Permission** | `question_set_items.update_order` |
**Request:**
```json
{
"display_order": 2
}
```
**Success `200`:**
```json
{
"message": "Question order updated successfully",
"success": true,
"status_code": 200
}
```
### Publish practice shell
| | |
|--|--|
| **PUT** | `/practices/:id` |
| **Permission** | `practices.update` |
**Request:**
```json
{
"publish_status": "PUBLISHED"
}
```
**Success `200``data`:** updated `Practice` with `publish_status: "PUBLISHED"`.
### Update dynamic question content
| | |
|--|--|
| **PUT** | `/questions/:id` |
| **Permission** | `questions.update` |
Send updated `dynamic_payload` (and optional metadata). Do not send `question_text` for `DYNAMIC`.
### Remove question from set
| | |
|--|--|
| **DELETE** | `/question-sets/:setId/questions/:questionId` |
| **Permission** | `question_set_items.remove` |
---
## 16. Worked example — Lesson practice with TABLE + OPTION
**Goal:** Lesson `12` gets one practice with one dynamic TABLE+MCQ question.
| Step | API | Key ids |
|------|-----|---------|
| 1 | `POST /questions/type-definitions` | `definition_id = 42` |
| 2 | `POST /questions` | `question_id = 1001` |
| 3 | `POST /question-sets` (`owner_type: LESSON`, `owner_id: 12`) | `set_id = 55` |
| 4 | `POST /question-sets/55/questions` | links `1001` order `1` |
| 5 | `POST /practices` (`parent_kind: LESSON`, `parent_id: 12`, `question_set_id: 55`) | `practice_id = 37` |
| 6 | `GET /lessons/12/practices` | confirms practice listed |
| 7 | `GET /question-sets/55/questions` | confirms TABLE payload |
| 8 | `PUT /practices/37` `{ "publish_status": "PUBLISHED" }` | go live |
**Admin UI table editor → API:** bind columns/rows UI to stimulus slot `data_table` / kind `TABLE` before Step 2 (`POST /questions`).
---
## 17. Scope-specific quick reference
### Lesson practice
```json
// Question set
{ "owner_type": "LESSON", "owner_id": <lesson_id>, "set_type": "PRACTICE" }
// Practice
{ "parent_kind": "LESSON", "parent_id": <lesson_id>, "question_set_id": <set_id> }
```
**Verify:** `GET /lessons/<lesson_id>/practices`
### Module practice
```json
{ "owner_type": "MODULE", "owner_id": <module_id> }
{ "parent_kind": "MODULE", "parent_id": <module_id> }
```
**Verify:** `GET /modules/<module_id>/practices`
### Course practice
```json
{ "owner_type": "COURSE", "owner_id": <course_id> }
{ "parent_kind": "COURSE", "parent_id": <course_id> }
```
**Verify:** `GET /courses/<course_id>/practices`
---
## 18. API index
| # | Method | Path | Permission |
|---|--------|------|------------|
| 1 | GET | `/questions/component-catalog` | `questions.list` |
| 2 | GET | `/questions/type-definitions` | `questions.list` |
| 3 | POST | `/questions/type-definitions` | `questions.create` |
| 4 | POST | `/questions/validate-question-type-definition` | `questions.create` |
| 5 | POST | `/files/upload` | auth |
| 6 | GET | `/files/url` | auth |
| 7 | POST | `/questions` | `questions.create` |
| 8 | PUT | `/questions/:id` | `questions.update` |
| 9 | POST | `/question-sets` | `question_sets.create` |
| 10 | POST | `/question-sets/:setId/questions` | `question_set_items.add` |
| 11 | GET | `/question-sets/:setId/questions` | `question_set_items.list` |
| 12 | GET | `/question-sets/:setId/question-types` | `question_set_items.list` |
| 13 | PUT | `/question-sets/:setId/questions/:questionId/order` | `question_set_items.update_order` |
| 14 | DELETE | `/question-sets/:setId/questions/:questionId` | `question_set_items.remove` |
| 15 | POST | `/practices` | `practices.create` |
| 16 | GET | `/practices/:id` | `practices.get` |
| 17 | PUT | `/practices/:id` | `practices.update` |
| 18 | DELETE | `/practices/:id` | `practices.delete` |
| 19 | GET | `/lessons/:id/practices` | `practices.list` |
| 20 | GET | `/modules/:id/practices` | `practices.list` |
| 21 | GET | `/courses/:id/practices` | `practices.list` |
| 22 | GET | `/practices/:practiceId/questions` | `question_set_items.list` (see §14.3 note) |
---
## 19. QA checklist
- [ ] Parent course/module/lesson exists (`GET` returns 200)
- [ ] Definition includes `TABLE` (or other) slots used in payload
- [ ] Dynamic question created without `question_text` in request
- [ ] TABLE `value` has `columns` + `rows` aligned
- [ ] Question set `set_type` is `PRACTICE` and `owner_type` matches practice scope
- [ ] All questions added to set with correct `display_order`
- [ ] Practice `question_set_id` matches set id
- [ ] `parent_kind` / `parent_id` match intended scope
- [ ] `GET` list practices under parent shows new practice
- [ ] `GET /question-sets/:id/questions` shows `dynamic_payload`
- [ ] Publish: question `PUBLISHED`, set `PUBLISHED`, practice `publish_status: PUBLISHED` when going live
- [ ] `OPEN_LEARNER` sees unlocked content; `STUDENT` respects practice sequence on same owner scope
---
## Pitfalls
1. **Do not send `question_text`** on dynamic question create/update — use `QUESTION_TEXT` (or `INSTRUCTION`) in `dynamic_payload.stimulus`.
2. **`owner_type` on question set** should match **`parent_kind` on practice** for consistent gating and admin filters.
3. **One practice → one `question_set_id`** in normal authoring; add multiple questions to the **same set**, not multiple sets per practice.
4. **TABLE content is per question** — the definition only declares the slot; each `POST /questions` supplies its own `columns` / `rows`.
5. **`GET /practices/:practiceId/questions`** — use `question_set_id` from practice when calling set-based list endpoints (see §14.3).
6. **Dynamic scoring runtime** — verify learner app supports your definitions response shapes before release.
---
*Last aligned with backend: LMS practices (`COURSE`/`MODULE`/`LESSON`), dynamic questions, `PDF_ATTACHMENT`, `TABLE` stimulus, practice `publish_status`, DYNAMIC `question_text` API omission.*

View File

@ -15,6 +15,7 @@ This document is the **canonical integration reference** for wiring the Yimaru a
## Table of contents
1. [Concepts and architecture](#1-concepts-and-architecture)
- [Schema slot labels (`label`)](#schema-slot-labels-label)
2. [Authentication and RBAC](#2-authentication-and-rbac)
3. [Standard response envelopes](#3-standard-response-envelopes)
4. [Component catalog (stimulus and response kinds)](#4-component-catalog-stimulus-and-response-kinds)
@ -46,6 +47,83 @@ The feature has **two layers**. Do not conflate them in the UI.
- **`stimulus_component_kinds` / `response_component_kinds`:** Allowed component type codes (from catalog).
- **`stimulus_schema` / `response_schema`:** Form blueprint — each item has `id`, `kind`, `label`, `required`, optional `config` (UI hints; server stores but does not enforce `config` limits on save today).
### Schema slot labels (`label`)
Each stimulus and response slot in a question type definition can carry a **per-field display name** via the `label` property on `DynamicElementDefinition`. This is separate from the definitions top-level **`display_name`** (the name of the whole question type in admin pickers).
| Property | Scope | Purpose | Set when | Used when |
|----------|-------|---------|----------|-----------|
| `display_name` | Whole definition | “Lesson Table MCQ” in type lists | `POST` / `PUT /questions/type-definitions` | Picking a question type |
| `label` | Each schema slot | “Reference table”, “Question prompt” on form fields | Same APIs, inside `stimulus_schema[]` / `response_schema[]` | Authoring a question from that type |
**Assigning `label` at definition create/update**
Set `label` on every entry you add to `stimulus_schema` and `response_schema` when calling:
- `POST /questions/type-definitions` (create)
- `PUT /questions/type-definitions/:id` (update — send the full schema when replacing slots)
Example — two stimulus slots with labels:
```json
"stimulus_schema": [
{
"id": "prompt",
"kind": "QUESTION_TEXT",
"label": "Question prompt",
"required": true,
"config": { "max_length": 2000 }
},
{
"id": "data_table",
"kind": "TABLE",
"label": "Reference table",
"required": true,
"config": { "max_rows": 30, "max_columns": 10 }
}
]
```
| `label` rule | Detail |
|--------------|--------|
| **Optional in API** | Server validates `id` and `kind` only; `label` may be omitted or `null`. |
| **Recommended in UI** | Always collect a label in the type builder so question authoring screens have human-readable field titles. |
| **Not in component catalog** | `GET /questions/component-catalog` returns kind codes only (`QUESTION_TEXT`, `TABLE`, …). There are no server-default labels per kind — your admin app supplies them. |
| **Persisted and returned** | Labels are stored in `question_type_definitions` JSONB and echoed on `GET` / `POST` / `PUT` responses inside `stimulus_schema` / `response_schema`. |
| **Not on question instances** | `dynamic_payload` slots use `id`, `kind`, `value` only. Labels are **never** sent on `POST /questions`; always read them from the linked definition. |
**Using `label` when authoring questions (Workflow B)**
1. User selects a definition (`question_type_definition_id`).
2. Load full schema: `GET /questions/type-definitions/:id`.
3. For each `stimulus_schema` entry, render a form section:
- **Field title:** `schema.label` if present, else fallback (see below).
- **Field key / payload `id`:** `schema.id` (e.g. `"data_table"`).
- **Widget:** driven by `schema.kind` (table editor, image upload, rich text, etc.).
- **Required indicator:** `schema.required`.
- **Hints / limits:** `schema.config` (client-enforced today).
4. Repeat for `response_schema` (e.g. “Answer choices” for `OPTION`).
5. On submit, map UI state keyed by `schema.id``dynamic_payload` instances **without** `label`:
```json
{ "id": "data_table", "kind": "TABLE", "value": { "columns": ["A", "B"], "rows": [["1", "2"]] } }
```
**Recommended UI fallback when `label` is missing**
```typescript
function slotLabel(schema: DynamicElementDefinition): string {
if (schema.label?.trim()) return schema.label.trim();
return humanizeKind(schema.kind); // e.g. "QUESTION_TEXT" → "Question text"
}
```
Optionally append `schema.id` in debug/preview mode only (e.g. `Reference table (data_table)`).
**Updating labels later**
Changing a slots `label` on `PUT /questions/type-definitions/:id` updates what authors see on **new edits**; existing `dynamic_payload` content is unchanged. Do not rename `schema.id` on live definitions unless you migrate existing questions — `id` is the stable join key between schema and payload.
### Instance fields (question)
- **`question_type`:** Must be `"DYNAMIC"` when using the builder.
@ -145,9 +223,7 @@ Load dynamically via **`GET /questions/component-catalog`**. Do not hardcode kin
| `AUDIO_CLIP` | Embedded audio clip | `string` URL |
| `TEXT_PASSAGE` | Reading passage | `string` |
| `IMAGE` | Image URL | `string` URL |
| `CHART` | Chart data | `object` (app-defined) |
| `TABLE` | Reference table | `{ "columns": string[], "rows": string[][] }` |
| `FLOW_CHART` | Flow chart | `object` (app-defined) |
| `MATCHING_INPUTS` | Matching exercise inputs | `object` (app-defined) |
| `SELECT_MISSING_WORDS` | Passage with blanks | `object` (app-defined) |
| `PDF_ATTACHMENT` | **Question-side PDF** (read-only for learner) | `string` URL (from `POST /files/upload` with `media_type=pdf`) |
@ -195,11 +271,9 @@ Load dynamically via **`GET /questions/component-catalog`**. Do not hardcode kin
"AUDIO_CLIP",
"TEXT_PASSAGE",
"IMAGE",
"CHART",
"MATCHING_INPUTS",
"SELECT_MISSING_WORDS",
"TABLE",
"FLOW_CHART",
"PDF_ATTACHMENT"
],
"response_component_kinds": [
@ -352,19 +426,27 @@ Persists a custom (non-system) definition. Enforces: valid kinds, schema IDs, an
| `response_schema` | `DynamicElementDefinition[]` | Recommended | |
| `status` | `string` | No | `ACTIVE` (default) or `INACTIVE` |
**`DynamicElementDefinition`:**
**`DynamicElementDefinition` (each `stimulus_schema` / `response_schema` item):**
```json
{
"id": "unique_slot_id",
"kind": "QUESTION_TEXT",
"label": "Optional label",
"label": "Question prompt",
"required": true,
"config": {}
}
```
**Success `201``data`:** full `QuestionTypeDefinition` object:
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `id` | `string` | Yes | Stable slot key; must match `dynamic_payload[].id` when authoring questions. Use lowercase snake_case (e.g. `data_table`, `prompt`). |
| `kind` | `string` | Yes | Component kind from catalog (e.g. `QUESTION_TEXT`, `TABLE`, `OPTION`). |
| `label` | `string` | No | **Human-readable field title** for the question authoring UI. Assign when creating the type (see [Schema slot labels](#schema-slot-labels-label)). Not sent on `POST /questions`. |
| `required` | `boolean` | Yes | If `true`, authors must provide a `value` for this slot in `dynamic_payload`. |
| `config` | `object` | No | UI hints (max length, max rows/columns, etc.); stored server-side; not enforced on save today. |
**Success `201``data`:** full `QuestionTypeDefinition` object (includes `stimulus_schema` / `response_schema` with `label` values echoed back):
```json
{
@ -466,7 +548,7 @@ Persists a custom (non-system) definition. Enforces: valid kinds, schema IDs, an
}
```
Note: `key` is **not** updatable via this handler.
Note: `key` is **not** updatable via this handler. When updating schema slots, you may change **`label`** on any `stimulus_schema` / `response_schema` entry without changing `id` (see [Schema slot labels](#schema-slot-labels-label)).
**Success `200``data`:**
@ -687,6 +769,7 @@ Use when the UI stored `minio://` references instead of long presigned URLs.
| `question_type_definition_id` | **Required** |
| `dynamic_payload` | **Required**; must satisfy definition schema |
| `options` / `short_answers` | Not used for pure dynamic MCQ (use `OPTION` in payload) |
| Slot `label` | **Not sent** on create/update — field titles come from `GET /questions/type-definitions/:id``stimulus_schema[].label` / `response_schema[].label` |
**Success `201``data`:** `questionRes` (no `question_text` when dynamic):
@ -970,8 +1053,8 @@ Server does not yet enforce `config.max_rows` / `max_columns`; mirror in UI.
| A2 | Load kind pickers | `GET /questions/component-catalog` |
| A3 | User selects stimulus/response kinds | — |
| A4 | (Optional) Pre-validate kinds | `POST /questions/validate-question-type-definition` |
| A5 | User builds `stimulus_schema` + `response_schema` | — |
| A6 | Submit definition | `POST /questions/type-definitions` |
| A5 | User builds `stimulus_schema` + `response_schema`; for each slot set `id`, `kind`, **`label`**, `required`, `config` | — |
| A6 | Submit definition (labels stored per slot) | `POST /questions/type-definitions` |
| A7 | Show `id`, `key`, `display_name`; navigate to list | — |
### Workflow B — Author a dynamic question
@ -980,7 +1063,7 @@ Server does not yet enforce `config.max_rows` / `max_columns`; mirror in UI.
|------|--------|-----|
| B1 | Choose mode **DYNAMIC** | — |
| B2 | Pick definition | `GET /questions/type-definitions?status=ACTIVE` |
| B3 | Render form from `stimulus_schema` + `response_schema` | `GET /questions/type-definitions/:id` (if needed) |
| B3 | Load schema; render form using each slots **`label`** as field title and **`id`** as state key | `GET /questions/type-definitions/:id` |
| B4 | Upload assets (image/pdf/audio) | `POST /files/upload` |
| B5 | Build table/options in UI → `dynamic_payload` | — |
| B6 | Submit question | `POST /questions` |
@ -1017,10 +1100,10 @@ See `docs/PRACTICE_CREATION_API_GUIDE.md` for question set + lesson practice lin
|-----------|----------------|
| `ComponentCatalogLoader` | Fetches catalog once per session |
| `DefinitionKindPicker` | Multi-select from catalog |
| `SchemaSlotEditor` | Repeater for schema entries (`id`, `kind`, `label`, `required`, `config`) |
| `DefinitionForm` | Create/update definition |
| `DefinitionPicker` | Searchable list of ACTIVE definitions |
| `DynamicQuestionComposer` | Renders inputs per schema slot |
| `SchemaSlotEditor` | Repeater for schema entries; includes **Label** text input per row (`id`, `kind`, `label`, `required`, `config`) |
| `DefinitionForm` | Create/update definition; maps slot editor rows → `stimulus_schema` / `response_schema` with `label` |
| `DefinitionPicker` | Searchable list of ACTIVE definitions (uses top-level `display_name`, not slot labels) |
| `DynamicQuestionComposer` | Renders inputs per schema slot; field captions from `schema.label` (fallback: humanized `kind`) |
| `TableEditor` | Emits TABLE `value` `{ columns, rows }` |
| `OptionListEditor` | Emits OPTION `value` |
| `MediaUploadField` | Wraps `POST /files/upload`, inserts URL into stimulus |
@ -1065,16 +1148,92 @@ type QuestionTypeDefinition = {
};
```
### Assigning `label` in the type builder UI
When the admin adds a stimulus or response component to a new definition:
1. **Kind** comes from `ComponentCatalogLoader` / `DefinitionKindPicker` (e.g. `TABLE`).
2. **Id** — auto-generate from kind (`table` → `data_table`) or let admin edit; must stay unique per side.
3. **Label** — show a required (client-side) text input; pre-fill with a humanized default:
```typescript
const KIND_DEFAULT_LABELS: Record<string, string> = {
QUESTION_TEXT: "Question prompt",
INSTRUCTION: "Instructions",
TEXT_PASSAGE: "Reading passage",
IMAGE: "Image",
TABLE: "Reference table",
PDF_ATTACHMENT: "PDF document",
AUDIO_CLIP: "Audio clip",
AUDIO_PROMPT: "Audio prompt",
OPTION: "Answer choices",
SHORT_ANSWER: "Short answer",
TEXT_INPUT: "Text input",
ANSWER_TIMER: "Time limit (seconds)",
// …extend for other kinds used in your product
};
function defaultLabelForKind(kind: string): string {
return KIND_DEFAULT_LABELS[kind] ?? kind.replace(/_/g, " ").toLowerCase();
}
```
4. On **Create definition** submit, include each slots admin-edited label in the API body:
```typescript
stimulus_schema: stimulusSlots.map((slot) => ({
id: slot.id,
kind: slot.kind,
label: slot.label.trim() || defaultLabelForKind(slot.kind),
required: slot.required,
config: slot.config ?? {},
})),
```
5. On **Update definition**, send the same shape; changing `label` does not require re-authoring existing questions.
### Using `label` in the question composer UI
```typescript
function renderSlotField(
schema: DynamicElementDefinition,
value: unknown,
onChange: (v: unknown) => void,
) {
const title = schema.label?.trim() || defaultLabelForKind(schema.kind);
return (
<FormField
label={title}
required={schema.required}
hint={configHint(schema.config)}
>
<SlotEditor kind={schema.kind} value={value} onChange={onChange} />
</FormField>
);
}
```
- **Stimulus slots** — render in `stimulus_schema` order (or a stable sort); use `label` as the visible section heading.
- **Response slots** — same pattern for `response_schema` (e.g. label “Answer choices” above the option list editor).
- **Payload build**`label` is display-only; never include it in `dynamic_payload`.
### Building `dynamic_payload` from schema
For each `stimulus_schema` / `response_schema` entry:
1. Find UI state keyed by `schema.id`.
2. Emit `{ id: schema.id, kind: schema.kind, value: <editor value> }`.
1. Find UI state keyed by `schema.id` (not by `label` — labels can change on the definition).
2. Emit `{ id: schema.id, kind: schema.kind, value: <editor value> }`**no `label` property**.
3. Include every `required: true` slot.
4. Do not add extra ids not in schema (allowed by server if kind matches, but confuses UI).
5. Never send top-level `question_text` when `question_type === "DYNAMIC"`.
**Stimulus `id``label` ↔ payload mapping example:**
| `stimulus_schema` slot | `label` (UI title) | `dynamic_payload` instance |
|------------------------|-------------------|----------------------------|
| `{ "id": "prompt", "kind": "QUESTION_TEXT", "label": "Question prompt" }` | “Question prompt” | `{ "id": "prompt", "kind": "QUESTION_TEXT", "value": "Choose the correct answer." }` |
| `{ "id": "data_table", "kind": "TABLE", "label": "Reference table" }` | “Reference table” | `{ "id": "data_table", "kind": "TABLE", "value": { "columns": [...], "rows": [...] } }` |
---
## 12. Validation and error handling
@ -1148,8 +1307,12 @@ Custom admin-created definitions have `is_system: false`.
- [ ] Catalog loads; kinds match §4
- [ ] Validate endpoint catches timer-only response
- [ ] Create definition returns `id`; list/get round-trip
- [ ] System definitions cannot be deleted
- [ ] Create definition returns `id`; list/get round-trip
- [ ] `stimulus_schema[].label` / `response_schema[].label` persist on create and echo on GET
- [ ] Question composer shows slot `label` as field title (fallback when label omitted)
- [ ] `dynamic_payload` submit does not include `label` on instances
- [ ] Updating definition slot `label` via PUT updates composer captions without breaking existing questions
- [ ] System definitions cannot be deleted
- [ ] PDF upload (`media_type=pdf`) returns `url` + `object_key`
- [ ] Dynamic question create without `question_text` succeeds
- [ ] Sending `question_text` on dynamic create returns 400

View File

@ -17,11 +17,9 @@ const (
StimulusAudioClip StimulusComponentKind = "AUDIO_CLIP"
StimulusTextPassage StimulusComponentKind = "TEXT_PASSAGE"
StimulusImage StimulusComponentKind = "IMAGE"
StimulusChart StimulusComponentKind = "CHART"
StimulusMatchingInputs StimulusComponentKind = "MATCHING_INPUTS"
StimulusSelectMissingWords StimulusComponentKind = "SELECT_MISSING_WORDS"
StimulusTable StimulusComponentKind = "TABLE"
StimulusFlowChart StimulusComponentKind = "FLOW_CHART"
// StimulusPDFAttachment is question-side PDF content (URL from MinIO upload or HTTPS).
StimulusPDFAttachment StimulusComponentKind = "PDF_ATTACHMENT"
)
@ -72,11 +70,9 @@ var (
StimulusAudioClip,
StimulusTextPassage,
StimulusImage,
StimulusChart,
StimulusMatchingInputs,
StimulusSelectMissingWords,
StimulusTable,
StimulusFlowChart,
StimulusPDFAttachment,
}
stimulusSet map[string]struct{}