Yimaru-BackEnd/docs/DYNAMIC_PRACTICE_CREATION_LMS_GUIDE.md
Yared Yemane ab986a08f0 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>
2026-06-05 02:54:44 -07:00

27 KiB
Raw Blame History

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
  2. Prerequisites and permissions
  3. Standard response envelopes
  4. ID map (what to store after each step)
  5. Publishing model
  6. End-to-end flow overview
  7. Step 0 — Resolve LMS parent IDs
  8. Step 1 — (Optional) Upload media
  9. Step 2 — Create or select a question type definition
  10. Step 3 — Create dynamic question(s)
  11. Step 4 — Create PRACTICE question set
  12. Step 5 — Add questions to the set
  13. Step 6 — Create practice shell (course / module / lesson)
  14. Step 7 — Verify and inspect
  15. Optional — Reorder, update, publish
  16. Worked example — Lesson practice with TABLE + OPTION
  17. Scope-specific quick reference
  18. API index
  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

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).

  • 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

{
  "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

{
  "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 200data: 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 200data:

{
  "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:

{
  "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 200data: array of definitions (see shape in type builder doc).

9.2 (Optional) Validate kinds

POST /questions/validate-question-type-definition
Permission questions.create

Request:

{
  "stimulus_component_kinds": ["QUESTION_TEXT", "TABLE", "IMAGE"],
  "response_component_kinds": ["OPTION", "ANSWER_TIMER"]
}

Success 200data:

{
  "valid": true
}

9.3 Create definition (example with TABLE)

POST /questions/type-definitions
Permission questions.create

Request:

{
  "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 201data (full QuestionTypeDefinition):

{
  "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.idquestion_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):

{
  "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 201data:

{
  "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:

{
  "message": "Invalid dynamic_payload",
  "error": "dynamic_payload.stimulus: required element id \"data_table\" is missing"
}
{
  "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.idquestion_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:

{
  "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:

{
  "title": "Module 3 — Review set",
  "set_type": "PRACTICE",
  "owner_type": "MODULE",
  "owner_id": 3,
  "status": "PUBLISHED",
  "shuffle_questions": false
}

Request — course scope:

{
  "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 201data:

{
  "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.idquestion_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):

{
  "question_id": 1001,
  "display_order": 1
}

Request (second question):

{
  "question_id": 1002,
  "display_order": 2
}
Field Type Required
question_id int64 Yes
display_order int32 No

Success 201data:

{
  "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 200data:

{
  "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:

{
  "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:

{
  "parent_kind": "MODULE",
  "parent_id": 3,
  "title": "Module 3 review",
  "question_set_id": 55,
  "publish_status": "PUBLISHED"
}

Request — course:

{
  "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 201data (domain.Practice):

{
  "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.idpractice_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 200data:

{
  "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 200data: 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 200data: 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):

{
  "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:

{
  "display_order": 2
}

Success 200:

{
  "message": "Question order updated successfully",
  "success": true,
  "status_code": 200
}

Publish practice shell

PUT /practices/:id
Permission practices.update

Request:

{
  "publish_status": "PUBLISHED"
}

Success 200data: 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

// 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

{ "owner_type": "MODULE", "owner_id": <module_id> }
{ "parent_kind": "MODULE", "parent_id": <module_id> }

Verify: GET /modules/<module_id>/practices

Course practice

{ "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.