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:
parent
33355a4b23
commit
ab986a08f0
995
docs/DYNAMIC_PRACTICE_CREATION_LMS_GUIDE.md
Normal file
995
docs/DYNAMIC_PRACTICE_CREATION_LMS_GUIDE.md
Normal 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 route’s 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 definition’s 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.*
|
||||
|
|
@ -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 definition’s 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 slot’s `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 slot’s **`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 slot’s 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
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user