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 ## Table of contents
1. [Concepts and architecture](#1-concepts-and-architecture) 1. [Concepts and architecture](#1-concepts-and-architecture)
- [Schema slot labels (`label`)](#schema-slot-labels-label)
2. [Authentication and RBAC](#2-authentication-and-rbac) 2. [Authentication and RBAC](#2-authentication-and-rbac)
3. [Standard response envelopes](#3-standard-response-envelopes) 3. [Standard response envelopes](#3-standard-response-envelopes)
4. [Component catalog (stimulus and response kinds)](#4-component-catalog-stimulus-and-response-kinds) 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_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). - **`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) ### Instance fields (question)
- **`question_type`:** Must be `"DYNAMIC"` when using the builder. - **`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 | | `AUDIO_CLIP` | Embedded audio clip | `string` URL |
| `TEXT_PASSAGE` | Reading passage | `string` | | `TEXT_PASSAGE` | Reading passage | `string` |
| `IMAGE` | Image URL | `string` URL | | `IMAGE` | Image URL | `string` URL |
| `CHART` | Chart data | `object` (app-defined) |
| `TABLE` | Reference table | `{ "columns": string[], "rows": string[][] }` | | `TABLE` | Reference table | `{ "columns": string[], "rows": string[][] }` |
| `FLOW_CHART` | Flow chart | `object` (app-defined) |
| `MATCHING_INPUTS` | Matching exercise inputs | `object` (app-defined) | | `MATCHING_INPUTS` | Matching exercise inputs | `object` (app-defined) |
| `SELECT_MISSING_WORDS` | Passage with blanks | `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`) | | `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", "AUDIO_CLIP",
"TEXT_PASSAGE", "TEXT_PASSAGE",
"IMAGE", "IMAGE",
"CHART",
"MATCHING_INPUTS", "MATCHING_INPUTS",
"SELECT_MISSING_WORDS", "SELECT_MISSING_WORDS",
"TABLE", "TABLE",
"FLOW_CHART",
"PDF_ATTACHMENT" "PDF_ATTACHMENT"
], ],
"response_component_kinds": [ "response_component_kinds": [
@ -352,19 +426,27 @@ Persists a custom (non-system) definition. Enforces: valid kinds, schema IDs, an
| `response_schema` | `DynamicElementDefinition[]` | Recommended | | | `response_schema` | `DynamicElementDefinition[]` | Recommended | |
| `status` | `string` | No | `ACTIVE` (default) or `INACTIVE` | | `status` | `string` | No | `ACTIVE` (default) or `INACTIVE` |
**`DynamicElementDefinition`:** **`DynamicElementDefinition` (each `stimulus_schema` / `response_schema` item):**
```json ```json
{ {
"id": "unique_slot_id", "id": "unique_slot_id",
"kind": "QUESTION_TEXT", "kind": "QUESTION_TEXT",
"label": "Optional label", "label": "Question prompt",
"required": true, "required": true,
"config": {} "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 ```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`:** **Success `200``data`:**
@ -687,6 +769,7 @@ Use when the UI stored `minio://` references instead of long presigned URLs.
| `question_type_definition_id` | **Required** | | `question_type_definition_id` | **Required** |
| `dynamic_payload` | **Required**; must satisfy definition schema | | `dynamic_payload` | **Required**; must satisfy definition schema |
| `options` / `short_answers` | Not used for pure dynamic MCQ (use `OPTION` in payload) | | `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): **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` | | A2 | Load kind pickers | `GET /questions/component-catalog` |
| A3 | User selects stimulus/response kinds | — | | A3 | User selects stimulus/response kinds | — |
| A4 | (Optional) Pre-validate kinds | `POST /questions/validate-question-type-definition` | | A4 | (Optional) Pre-validate kinds | `POST /questions/validate-question-type-definition` |
| A5 | User builds `stimulus_schema` + `response_schema` | — | | A5 | User builds `stimulus_schema` + `response_schema`; for each slot set `id`, `kind`, **`label`**, `required`, `config` | — |
| A6 | Submit definition | `POST /questions/type-definitions` | | A6 | Submit definition (labels stored per slot) | `POST /questions/type-definitions` |
| A7 | Show `id`, `key`, `display_name`; navigate to list | — | | A7 | Show `id`, `key`, `display_name`; navigate to list | — |
### Workflow B — Author a dynamic question ### 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** | — | | B1 | Choose mode **DYNAMIC** | — |
| B2 | Pick definition | `GET /questions/type-definitions?status=ACTIVE` | | 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` | | B4 | Upload assets (image/pdf/audio) | `POST /files/upload` |
| B5 | Build table/options in UI → `dynamic_payload` | — | | B5 | Build table/options in UI → `dynamic_payload` | — |
| B6 | Submit question | `POST /questions` | | 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 | | `ComponentCatalogLoader` | Fetches catalog once per session |
| `DefinitionKindPicker` | Multi-select from catalog | | `DefinitionKindPicker` | Multi-select from catalog |
| `SchemaSlotEditor` | Repeater for schema entries (`id`, `kind`, `label`, `required`, `config`) | | `SchemaSlotEditor` | Repeater for schema entries; includes **Label** text input per row (`id`, `kind`, `label`, `required`, `config`) |
| `DefinitionForm` | Create/update definition | | `DefinitionForm` | Create/update definition; maps slot editor rows → `stimulus_schema` / `response_schema` with `label` |
| `DefinitionPicker` | Searchable list of ACTIVE definitions | | `DefinitionPicker` | Searchable list of ACTIVE definitions (uses top-level `display_name`, not slot labels) |
| `DynamicQuestionComposer` | Renders inputs per schema slot | | `DynamicQuestionComposer` | Renders inputs per schema slot; field captions from `schema.label` (fallback: humanized `kind`) |
| `TableEditor` | Emits TABLE `value` `{ columns, rows }` | | `TableEditor` | Emits TABLE `value` `{ columns, rows }` |
| `OptionListEditor` | Emits OPTION `value` | | `OptionListEditor` | Emits OPTION `value` |
| `MediaUploadField` | Wraps `POST /files/upload`, inserts URL into stimulus | | `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 ### Building `dynamic_payload` from schema
For each `stimulus_schema` / `response_schema` entry: For each `stimulus_schema` / `response_schema` entry:
1. Find UI state keyed by `schema.id`. 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> }`. 2. Emit `{ id: schema.id, kind: schema.kind, value: <editor value> }`**no `label` property**.
3. Include every `required: true` slot. 3. Include every `required: true` slot.
4. Do not add extra ids not in schema (allowed by server if kind matches, but confuses UI). 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"`. 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 ## 12. Validation and error handling
@ -1149,6 +1308,10 @@ Custom admin-created definitions have `is_system: false`.
- [ ] Catalog loads; kinds match §4 - [ ] Catalog loads; kinds match §4
- [ ] Validate endpoint catches timer-only response - [ ] Validate endpoint catches timer-only response
- [ ] Create definition returns `id`; list/get round-trip - [ ] 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 - [ ] System definitions cannot be deleted
- [ ] PDF upload (`media_type=pdf`) returns `url` + `object_key` - [ ] PDF upload (`media_type=pdf`) returns `url` + `object_key`
- [ ] Dynamic question create without `question_text` succeeds - [ ] Dynamic question create without `question_text` succeeds

View File

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