Skip to content

[QTI] Inline choice interaction: descriptor, parsing, XML assembly and validation #6180

Closed

Description

@AlexVelezLl

❌ This issue is not open for contribution. Visit Contributing guidelines to learn about the contributing process and how to find suitable issues.

Overview

Add the inline choice interaction's headless layer: its descriptor, parsing, XML assembly and validation. This task has no UI. It fixes the XML shape and the in-memory state that the passage node (#6181) and the editor UI (#6182) build on, and it is fully testable without them.

Complexity: Medium
Target branch: unstable

Context

The interaction maps to <qti-inline-choice-interaction>. In the design, an inline choice question has two authored regions:

  • an optional Question (a rich-text prompt, like the other interactions' prompts)
  • a Passage of rich text holding one or more dropdowns ("chips"), each with its own plain-text options and one correct answer

Each dropdown is its own interaction with its own response declaration. Carrying several declarations through the editor core, and scoring across them, is #6179.

Text entry is the only other Placement.INLINE interaction and the nearest reference for recovering authored content from the item body (interactions/textEntry/parse.js). Choice is the nearest reference for choices with a correct answer (interactions/choice/).

Kolibri's QTI viewer already renders the interaction (kolibri/plugins/qti_viewer/frontend/components/interactions/InlineChoiceInteraction.vue), and the server's QTI 3.0 XSD validation accepts it.

Why the sentinel and the question marker

Two things the rest of the editor cannot express for this interaction are handled inside the plugin, rather than by adding rules to the editor core that only this interaction needs. If another passage-based interaction (e.g. Gap Match) turns out to need the same, that is the time to lift them into the core.

The sentinel (rule 9) keeps an unfinished question recognisable. The editor core only knows an item's question type from an interaction element in its body (parseItem, resolveDescriptor). An author who picks Inline choice and writes a passage before inserting any dropdown has saved an item with no interaction in it. On reload, that card has no interaction to edit, so it shows the empty-question placeholder instead of the editor, with no question type selector either, and the author cannot get back to their question. We also cannot save an empty dropdown to hold the place: the sync endpoint validates raw_data against the QTI 3.0 XSD on every save, not only at publish (viewsets/assessmentitem.py), and the XSD requires at least one <qti-inline-choice> in a <qti-inline-choice-interaction>, inside a block such as <p>. The sentinel is the smallest schema-valid dropdown there is. It exists only in saved XML: parse drops it, it has no response declaration so nothing scores it, and the NO_INTERACTION error it leaves behind keeps the question incomplete, so publishing (which only includes complete nodes) never ships it.

The question marker (rule 7) keeps the question apart from the passage. Block interactions hold their question in a <qti-prompt> child; inline interactions have no such element, so here both the question and the passage are plain block elements side by side in <qti-item-body>. Without a marker, telling them apart on reload would be a guess such as "the first paragraph is the question", which is wrong as soon as the question has two paragraphs or is empty. data-* attributes are QTI 3.0's extension mechanism: they are schema-valid, and Kolibri's viewer ignores them.

QTI XML reference

<qti-assessment-item xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0"
  identifier="item_k2lm9qaz" title="Question" adaptive="false" time-dependent="false">

  <qti-response-declaration identifier="response_xq7tbn2c" cardinality="single" base-type="identifier">
    <qti-correct-response>
      <qti-value>choice_a1b2c3d4</qti-value>
    </qti-correct-response>
  </qti-response-declaration>
  <qti-response-declaration identifier="response_pw4rzk8d" cardinality="single" base-type="identifier">
    <qti-correct-response>
      <qti-value>choice_m0o0n0aa</qti-value>
    </qti-correct-response>
  </qti-response-declaration>

  <!-- Outcome declarations and response processing are generated by the core (#6179) -->

  <qti-item-body>
    <!-- Question (optional): every top-level element is marked -->
    <p data-studio-prompt="">Select the correct words to complete the sentence:</p>

    <!-- Passage -->
    <p>
      The Earth
      <qti-inline-choice-interaction response-identifier="response_xq7tbn2c" shuffle="true">
        <qti-inline-choice identifier="choice_a1b2c3d4">revolves</qti-inline-choice>
        <qti-inline-choice identifier="choice_e5f6g7h8">stays</qti-inline-choice>
      </qti-inline-choice-interaction>
      around the Sun, and the
      <qti-inline-choice-interaction response-identifier="response_pw4rzk8d" shuffle="true">
        <qti-inline-choice identifier="choice_s0u0n0aa">Sun</qti-inline-choice>
        <qti-inline-choice identifier="choice_s0t0a0rs">Stars</qti-inline-choice>
        <qti-inline-choice identifier="choice_m0o0n0aa">Moon</qti-inline-choice>
      </qti-inline-choice-interaction>
      orbits the Earth.
    </p>
  </qti-item-body>
</qti-assessment-item>

The same question before any dropdown has been inserted. The sentinel keeps the item recognisable as inline choice, and never reaches the editor:

<qti-item-body>
  <p data-studio-prompt="">Select the correct words to complete the sentence:</p>
  <p>The Earth revolves around the Sun, and the Moon orbits the Earth.</p>
  <p>
    <qti-inline-choice-interaction response-identifier="studio_sentinel" data-studio-sentinel="">
      <qti-inline-choice identifier="studio_sentinel"/>
    </qti-inline-choice-interaction>
  </p>
</qti-item-body>

Rules:

  1. Every dropdown is one <qti-inline-choice-interaction> with its own response-identifier, bound to its own <qti-response-declaration> with cardinality="single", base-type="identifier" and exactly one <qti-value> in <qti-correct-response>. A dropdown with no correct option yet has no <qti-correct-response>.
  2. Response identifiers are unique within the item and stable across edits: adding, removing or reordering a dropdown must not rename the others. Kolibri stores the learner's answer state keyed by response identifier (AssessmentItem.vue).
  3. Choice identifiers are unique within the whole item, not just their dropdown, and stable across edits; choices without one get generateRandomSlug('choice'). QTI only requires uniqueness within an interaction; the item-wide rule lets a validation error point at an option with the single id that errors already carry.
  4. <qti-inline-choice> content is plain text. The passage around the dropdowns is rich text, and a dropdown can sit inside formatted content (e.g. within bold text or a list item).
  5. shuffle is one setting for the whole question. Every dropdown in an item is written with the same shuffle value, from the question's Shuffle setting. When parsing, the setting is on if at least one dropdown has shuffle="true", so an item imported from another tool that mixes values opens shuffled and is saved with every dropdown shuffled. A new question starts with it off, as in the choice interaction.
  6. Response declarations appear in the same order as their dropdowns in the passage.
  7. Question marker. Every top-level element of the question (a direct child of <qti-item-body>) carries data-studio-prompt. parse moves the marked elements into the question state and strips the attribute, so neither rich text editor ever sees it; buildXML adds it to every top-level element of the question. Unmarked children are the passage, so an item authored elsewhere opens with an empty question and everything in the passage.
  8. Every dropdown has at least one option. By convention a new dropdown starts with one empty option rather than none, so a dropdown is always schema-valid without a special encoding. A dropdown that is still unfinished (empty option, no correct option, fewer than two options) is caught by validation, not by the XML.
  9. Sentinel for a passage with no dropdowns. When the passage has no dropdowns, buildXML appends the sentinel shown above: a <p> holding a <qti-inline-choice-interaction data-studio-sentinel> with response-identifier="studio_sentinel" and one empty choice, and no response declaration. parse drops the sentinel together with its <p>, so it never reaches the editor state. It is written only when there are no real dropdowns.

State

The state holds the question and the passage as two HTML strings, and the question's shuffle setting, and nothing else:

/**
 * @typedef {object} InlineChoiceState
 * @property {string} prompt  - HTML of the question; '' when there is none.
 * @property {string} passage - HTML of the passage, with every dropdown in place.
 * @property {boolean} shuffle - The question's Shuffle setting, applied to every dropdown (rule 5).
 */

A dropdown in passage is written like this:

<p>The Earth
  <qti-inline-choice-interaction response-identifier="response_xq7tbn2c" data-studio-correct="choice_a1b2c3d4">
    <qti-inline-choice identifier="choice_a1b2c3d4">revolves</qti-inline-choice>
    <qti-inline-choice identifier="choice_e5f6g7h8">stays</qti-inline-choice>
  </qti-inline-choice-interaction>
  around the Sun.</p>

Restrictions on the state:

  • The passage is the only copy of the dropdowns. There is no separate list of dropdowns in the state. [QTI] Inline choice chip and Insert action in the passage editor #6181 keeps each dropdown's data on its TipTap node so undo, redo and copy / paste carry it; a second copy in the state would drift from the node on the first undo. Anything that needs the dropdowns as data reads them with getDropdowns (below).
  • A dropdown element carries exactly two attributes: response-identifier, and data-studio-correct naming the correct choice (absent when none is marked). shuffle is not kept on the dropdowns, because it is the question-level shuffle setting (rule 5); buildXML writes it onto every dropdown. Any other attribute a source item carried is not kept either.
  • data-studio-correct must name one of the dropdown's own choices. It is an editor-only attribute: parse adds it and buildXML removes it, so it never appears in saved XML.
  • Option content is plain text (rule 4): a <qti-inline-choice> holds only a text node.
  • Both strings are HTML serializations, with an explicit end tag on every element. The passage is handed to TipTapEditor, which parses it as HTML. XMLSerializer writes an empty option as <qti-inline-choice identifier="…"/>, and the HTML parser does not treat /> as self-closing on unknown elements, so the next option would end up nested inside the empty one. Every new dropdown starts with an empty option (rule 8), so this breaks on the first insert. Serialize through an HTML document (e.g. innerHTML), never XMLSerializer.
  • Neither string contains the markers: no data-studio-prompt (rule 7) and no sentinel (rule 9).

The Change

  • New QtiInteraction.INLINE_CHOICE and QuestionType.INLINE_CHOICE.
  • An inline choice descriptor under interactions/inlineChoice/ (Descriptor.js, parse.js, validation.js), with Placement.INLINE, registered in interactions/descriptors.js.

getDropdowns(passage), exported from parse.js, is the one reader of dropdowns out of the state. buildXML and validation use it here, and #6182 uses it for the selected chip's options.

/**
 * @param {string} passage - InlineChoiceState.passage
 * @returns {Array<{
 *   responseIdentifier: string,
 *   options: Array<{ id: string, text: string }>,
 *   correctId: string|null,
 * }>} Every dropdown, in passage order
 */
  • It is pure: it reads the string and does not change it.
  • correctId is null when data-studio-correct is absent, or names no choice of that dropdown.
  • text is the option's text content as written; trimming is left to validation.

parse(bodyXml, responseDeclarations) builds the state:

  1. Direct children of <qti-item-body> that carry data-studio-prompt become prompt, with the attribute removed. The remaining children become passage.
  2. The sentinel's <p> is dropped entirely (rule 9).
  3. Each dropdown gets data-studio-correct from the <qti-correct-response> of the declaration matching its response-identifier, but only when that value is one of its own choices. Otherwise it gets no correct choice.
  4. shuffle is set to true when at least one dropdown has shuffle="true", and false otherwise. Only then is every attribute other than response-identifier and data-studio-correct removed from the dropdown elements, shuffle included.
  5. Missing identifiers are generated: generateRandomSlug('response') for a dropdown, generateRandomSlug('choice') for an option. A choice identifier already used elsewhere in the item is replaced, and the dropdown's data-studio-correct follows the rename.
  6. Both strings are serialized as HTML (see State).

buildXML(state, questionType) writes the item:

  1. The body is the question's top-level elements, each with data-studio-prompt, followed by the passage. data-studio-correct is removed, and shuffle is written on every dropdown as the question's setting ("true" or "false").
  2. When getDropdowns returns none, the sentinel is appended (rule 9).
  3. One <qti-response-declaration> per dropdown, in the order getDropdowns returns them. Each has identifier = its responseIdentifier, cardinality="single", base-type="identifier", and a <qti-correct-response> holding correctId only when it is not null. No mapping is written; scoring comes from [QTI] Support multiple response declarations per item and generate their response processing #6179.

Validation reuses the existing ValidationError codes. As in the choice interaction, an error about an option carries that option's choice identifier as its id, which is unambiguous because choice identifiers are item-wide (rule 3). An error about a dropdown carries its response identifier.

  • an option whose trimmed text is empty → EMPTY_CHOICE_CONTENT, on that option

  • options with the same text in one dropdown → DUPLICATE_CHOICE_CONTENT, on every option in the duplicate group

  • a dropdown with no correct option → NO_CORRECT_ANSWER, on the dropdown

  • a dropdown with fewer than two options → TOO_FEW_CHOICES, on the dropdown. One option leaves the learner nothing to choose between.

  • a passage with no dropdowns → NO_INTERACTION, with no id. validateItemShape cannot report this one, because the sentinel counts as an interaction, so the descriptor's validation reports it. It also keeps an unfinished question incomplete, so publish (which only includes complete nodes) never ships the sentinel.

  • The question is optional, so an empty question is not an error.

  • The question type is not offered in the selector yet (getTypeOptions contributes nothing). [QTI] Inline choice interaction editor #6182 turns it on.

Out of Scope

Acceptance Criteria

General

  • Parsing and rebuilding an item with a question and several dropdowns returns the same question, passage text, dropdown order, options, correct answers and identifiers
  • An item with an empty question, and an item whose question has several paragraphs, both reopen with the question and passage they were saved with
  • Rebuilt XML marks every top-level question element with data-studio-prompt, and neither the question nor the passage state contains the attribute after parsing
  • An item whose passage has no dropdowns saves exactly one sentinel, passes server-side QTI validation, and reopens as Inline choice with its question and passage and no dropdowns
  • Adding a dropdown to such a passage removes the sentinel from the rebuilt XML
  • A dropdown with a single empty option round-trips, and reports EMPTY_CHOICE_CONTENT, NO_CORRECT_ANSWER and TOO_FEW_CHOICES
  • Rebuilt XML follows every rule above and passes server-side QTI validation
  • A dropdown with no correct option is written without <qti-correct-response>, and parses back with no correct option
  • Removing or reordering a dropdown in the state leaves the other dropdowns' response identifiers unchanged in the rebuilt XML
  • getDropdowns returns every dropdown in passage order, with its options and correct choice, and correctId: null when none is marked or data-studio-correct names a choice the dropdown does not have
  • Parsed passage and question strings contain no data-studio-prompt, no sentinel, no shuffle, and no attribute on a dropdown other than response-identifier and data-studio-correct
  • A parsed passage holding a dropdown with an empty option, parsed again as HTML, gives the same dropdowns (the empty option does not swallow the next one)
  • Saved XML contains no data-studio-correct, and writes the question's shuffle setting on every dropdown
  • An item in which only one of several dropdowns has shuffle="true" parses with shuffle: true, and is rebuilt with shuffle="true" on every dropdown
  • An item in which no dropdown has shuffle="true" parses with shuffle: false, and so does a new question
  • Parsing an item where two dropdowns share a choice identifier gives the second one a new identifier, and keeps its correct choice pointing at the renamed option
  • Each validation case listed above is reported with the id stated for it: the choice identifier for option errors, the response identifier for dropdown errors, none for NO_INTERACTION
  • resolveDescriptor resolves an item containing qti-inline-choice-interaction to the inline choice descriptor and QuestionType.INLINE_CHOICE
  • The question type selector does not offer Inline choice yet

Testing

  • parse.spec.js covers the round trips above, including missing identifiers, a dropdown with no correct response, the question marker, the sentinel, getDropdowns, and the HTML serialization of an empty option
  • validation.spec.js covers each validation case
  • Descriptor.spec.js covers matching and question type resolution

References

AI usage

I used Claude Code to draft this issue from the Figma design and the existing interaction plugins, which it read to ground the Context and rules. I decided how to split the work and reviewed and edited the text.

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions