Repository navigation
[QTI] Inline choice interaction: descriptor, parsing, XML assembly and validation #6180
Description
❌ 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:
- Every dropdown is one
<qti-inline-choice-interaction>with its ownresponse-identifier, bound to its own<qti-response-declaration>withcardinality="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>. - 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). - 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 singleidthat errors already carry. <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).shuffleis one setting for the whole question. Every dropdown in an item is written with the sameshufflevalue, from the question's Shuffle setting. When parsing, the setting is on if at least one dropdown hasshuffle="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.- Response declarations appear in the same order as their dropdowns in the passage.
- Question marker. Every top-level element of the question (a direct child of
<qti-item-body>) carriesdata-studio-prompt.parsemoves the marked elements into the question state and strips the attribute, so neither rich text editor ever sees it;buildXMLadds 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. - 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.
- Sentinel for a passage with no dropdowns. When the passage has no dropdowns,
buildXMLappends the sentinel shown above: a<p>holding a<qti-inline-choice-interaction data-studio-sentinel>withresponse-identifier="studio_sentinel"and one empty choice, and no response declaration.parsedrops 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, anddata-studio-correctnaming the correct choice (absent when none is marked).shuffleis not kept on the dropdowns, because it is the question-levelshufflesetting (rule 5);buildXMLwrites it onto every dropdown. Any other attribute a source item carried is not kept either. data-studio-correctmust name one of the dropdown's own choices. It is an editor-only attribute:parseadds it andbuildXMLremoves 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.XMLSerializerwrites 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), neverXMLSerializer. - Neither string contains the markers: no
data-studio-prompt(rule 7) and no sentinel (rule 9).
The Change
- New
QtiInteraction.INLINE_CHOICEandQuestionType.INLINE_CHOICE. - An inline choice descriptor under
interactions/inlineChoice/(Descriptor.js,parse.js,validation.js), withPlacement.INLINE, registered ininteractions/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.
correctIdisnullwhendata-studio-correctis absent, or names no choice of that dropdown.textis the option's text content as written; trimming is left to validation.
parse(bodyXml, responseDeclarations) builds the state:
- Direct children of
<qti-item-body>that carrydata-studio-promptbecomeprompt, with the attribute removed. The remaining children becomepassage. - The sentinel's
<p>is dropped entirely (rule 9). - Each dropdown gets
data-studio-correctfrom the<qti-correct-response>of the declaration matching itsresponse-identifier, but only when that value is one of its own choices. Otherwise it gets no correct choice. shuffleis set totruewhen at least one dropdown hasshuffle="true", andfalseotherwise. Only then is every attribute other thanresponse-identifieranddata-studio-correctremoved from the dropdown elements,shuffleincluded.- 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'sdata-studio-correctfollows the rename. - Both strings are serialized as HTML (see State).
buildXML(state, questionType) writes the item:
- The body is the question's top-level elements, each with
data-studio-prompt, followed by the passage.data-studio-correctis removed, andshuffleis written on every dropdown as the question's setting ("true"or"false"). - When
getDropdownsreturns none, the sentinel is appended (rule 9). - One
<qti-response-declaration>per dropdown, in the ordergetDropdownsreturns them. Each hasidentifier= itsresponseIdentifier,cardinality="single",base-type="identifier", and a<qti-correct-response>holdingcorrectIdonly when it is notnull. 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 noid.validateItemShapecannot 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 (
getTypeOptionscontributes nothing). [QTI] Inline choice interaction editor #6182 turns it on.
Out of Scope
- Any UI: the passage node ([QTI] Inline choice chip and Insert action in the passage editor #6181), the options list and the question type option ([QTI] Inline choice interaction editor #6182).
- Carrying multiple declarations through the core, and response processing ([QTI] Support multiple response declarations per item and generate their response processing #6179).
- Mixing inline choice with another interaction type in the same item.
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_ANSWERandTOO_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
-
getDropdownsreturns every dropdown in passage order, with its options and correct choice, andcorrectId: nullwhen none is marked ordata-studio-correctnames a choice the dropdown does not have - Parsed passage and question strings contain no
data-studio-prompt, no sentinel, noshuffle, and no attribute on a dropdown other thanresponse-identifieranddata-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'sshufflesetting on every dropdown - An item in which only one of several dropdowns has
shuffle="true"parses withshuffle: true, and is rebuilt withshuffle="true"on every dropdown - An item in which no dropdown has
shuffle="true"parses withshuffle: 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
idstated for it: the choice identifier for option errors, the response identifier for dropdown errors, none forNO_INTERACTION -
resolveDescriptorresolves an item containingqti-inline-choice-interactionto the inline choice descriptor andQuestionType.INLINE_CHOICE - The question type selector does not offer Inline choice yet
Testing
-
parse.spec.jscovers 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.jscovers each validation case -
Descriptor.spec.jscovers matching and question type resolution
References
- [QTI] Inline Choice interaction #6178: Inline Choice interaction (tracking issue)
- [QTI] Support multiple response declarations per item and generate their response processing #6179: multiple response declarations and response processing
- QTI 3.0 Inline Choice Interaction: https://www.imsglobal.org/spec/qti/v3p0/impl
- Figma design spec (WIP): https://www.figma.com/design/B2IVU3zYih4iqhzDaWei73/QTI-work-2023?node-id=2028-12294
- Nearest references:
shared/views/QTIEditor/interactions/textEntry/(inline placement),shared/views/QTIEditor/interactions/choice/(choices with a correct answer)
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.

Activity