diff --git a/docs/DYNAMIC_PRACTICE_CREATION_LMS_GUIDE.md b/docs/DYNAMIC_PRACTICE_CREATION_LMS_GUIDE.md new file mode 100644 index 0000000..df0b82f --- /dev/null +++ b/docs/DYNAMIC_PRACTICE_CREATION_LMS_GUIDE.md @@ -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 ` +**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": , "set_type": "PRACTICE" } + +// Practice +{ "parent_kind": "LESSON", "parent_id": , "question_set_id": } +``` + +**Verify:** `GET /lessons//practices` + +### Module practice + +```json +{ "owner_type": "MODULE", "owner_id": } +{ "parent_kind": "MODULE", "parent_id": } +``` + +**Verify:** `GET /modules//practices` + +### Course practice + +```json +{ "owner_type": "COURSE", "owner_id": } +{ "parent_kind": "COURSE", "parent_id": } +``` + +**Verify:** `GET /courses//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.* diff --git a/docs/DYNAMIC_QUESTION_TYPE_BUILDER_ADMIN_INTEGRATION.md b/docs/DYNAMIC_QUESTION_TYPE_BUILDER_ADMIN_INTEGRATION.md index 8a51748..a2c6a06 100644 --- a/docs/DYNAMIC_QUESTION_TYPE_BUILDER_ADMIN_INTEGRATION.md +++ b/docs/DYNAMIC_QUESTION_TYPE_BUILDER_ADMIN_INTEGRATION.md @@ -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 = { + 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 ( + + + + ); +} +``` + +- **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: }`. +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: }` — **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 diff --git a/internal/domain/question_type_builder.go b/internal/domain/question_type_builder.go index 7b2b071..635ea80 100644 --- a/internal/domain/question_type_builder.go +++ b/internal/domain/question_type_builder.go @@ -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{}