Skip to content

[QTI] Gap match interaction: descriptor, parsing, XML assembly and validation #6203

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 gap match 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 gap node and the editor UI (#6204) build on, and it is fully testable without them.

Complexity: Medium
Target branch: unstable

Context

The interaction maps to <qti-gap-match-interaction>. In the design, a gap match question has three authored regions:

  • an optional Question (a rich-text prompt, like the other interactions' prompts)
  • a Passage of rich text holding one or more gaps, each showing its correct answer
  • optional Distractors: extra answers in the learner's pool that fill no gap

Unlike inline choice (#6180), the whole item is one interaction with one response declaration. The passage sits inside the interaction, so the question is its <qti-prompt> and the two can't be confused when the item is parsed back.

Associate is the nearest reference for distractors and pair-based correct responses (interactions/associate/). Match is the nearest for setting max-associations and match-max (interactions/match/).

The server's QTI 3.0 XSD validation accepts the interaction. Kolibri's QTI viewer doesn't render it yet (kolibri#15213).

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" cardinality="multiple" base-type="directedPair">
    <qti-correct-response>
      <qti-value>choice_w1n7erxx gap_a1b2c3d4</qti-value>
      <qti-value>choice_s9r1ngxx gap_e5f6g7h8</qti-value>
    </qti-correct-response>
  </qti-response-declaration>

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

  <qti-item-body>
    <qti-gap-match-interaction response-identifier="RESPONSE" shuffle="true" max-associations="2">
      <!-- Question (optional) -->
      <qti-prompt>
        <p>Identify the missing words in this famous quotation from Shakespeare's Richard III:</p>
      </qti-prompt>

      <!-- Answers, in gap order -->
      <qti-gap-text identifier="choice_w1n7erxx" match-max="1">winter</qti-gap-text>
      <qti-gap-text identifier="choice_s9r1ngxx" match-max="1">spring</qti-gap-text>
      <!-- Distractors -->
      <qti-gap-text identifier="choice_s0mm3rxx" match-max="1">summer</qti-gap-text>
      <qti-gap-text identifier="choice_a0t0mnxx" match-max="1">autumn</qti-gap-text>

      <!-- Passage -->
      <p>Now is the <qti-gap identifier="gap_a1b2c3d4"/> of our discontent</p>
      <p>Made glorious <qti-gap identifier="gap_e5f6g7h8"/> by this sun of York;</p>
    </qti-gap-match-interaction>
  </qti-item-body>
</qti-assessment-item>

Rules:

  1. The item has one <qti-gap-match-interaction>, bound to one <qti-response-declaration> with cardinality="multiple" and base-type="directedPair". Each gap with an answer contributes one <qti-value> pairing its answer's <qti-gap-text> identifier with the gap's identifier, in that order.
  2. Every gap's answer is written as a <qti-gap-text>. Gaps whose answers have the same content share one <qti-gap-text>, whose match-max is the number of gaps it fills; every other <qti-gap-text> has match-max="1".
  3. Distractors are <qti-gap-text> elements that appear in no pair.
  4. max-associations is the number of gaps. shuffle is always "true": the pool is shuffled for the learner, the passage never is.
  5. Children appear in schema order: <qti-prompt> (omitted when the question is empty), the answers in gap order, the distractors, then the passage.
  6. Gap and gap text identifiers are unique within the item and stable across edits: adding, removing or reordering a gap must not rename the others. Elements without one get generateRandomSlug('gap') / generateRandomSlug('choice').
  7. <qti-gap-text> content is inline: text, inline formatting and math (XSD GapTextDType, and the inline-only mode in [QTI] Add an inline-only mode to TipTapEditor for gap match choices #6193). The passage is rich text, and a gap can sit inside formatted content (e.g. within bold text or a list item).
  8. A gap with no answer yet is written as a <qti-gap> with no <qti-gap-text> and no pair.

The Change

  • New QtiInteraction.GAP_MATCH and QuestionType.GAP_MATCH.
  • A gap match descriptor under interactions/gapMatch/ (Descriptor.js, parse.js, validation.js), with Placement.BLOCK, registered in interactions/descriptors.js.
  • parse and buildXML round-trip the question, the passage, every gap's answer and the distractors, following the rules above. The state keeps gaps in passage order, addressable by gap identifier, each holding its answer's content, so the gap node ([QTI] Gap match interaction editor, with the gap node and Insert action #6204) can refer to the same gap. Distractors are a separate list.
  • Scoring is all-or-nothing: the declaration's correct response is scored by match_correct, which the core already writes. No mapping is written.
  • Validation reuses the existing ValidationError codes:
    • a passage with no gaps → NO_CORRECT_ANSWER
    • a gap with no answer → EMPTY_ANSWER_CONTENT, attributed to its gap identifier
    • an empty distractor → EMPTY_CHOICE_CONTENT, attributed to its identifier
    • a distractor with the same content as an answer or another distractor → DUPLICATE_DISTRACTOR_CONTENT, with the repeated text, as in associate
  • The question is optional, so an empty question is not an error. Two gaps with the same answer are valid (rule 2).
  • The question type is not offered in the selector yet (getTypeOptions contributes nothing). [QTI] Gap match interaction editor, with the gap node and Insert action #6204 turns it on.

Out of Scope

Acceptance Criteria

General

  • Parsing and rebuilding an item with a question, several gaps and distractors returns the same question, passage, gap order, answers, distractors and identifiers
  • An item with an empty question is written without <qti-prompt> and reopens with an empty question
  • Rebuilt XML follows every rule above and passes server-side QTI validation
  • Two gaps with the same answer are written as one <qti-gap-text> with match-max="2" paired with both gaps, and parse back as two gaps with that answer
  • A gap with no answer is written with no <qti-gap-text> or pair, and parses back with no answer
  • Removing or reordering a gap in the state leaves the other gaps' and answers' identifiers unchanged in the rebuilt XML
  • Each validation case listed above is reported with the identifier or text it belongs to
  • resolveDescriptor resolves an item containing qti-gap-match-interaction to the gap match descriptor and QuestionType.GAP_MATCH
  • The question type selector does not offer Gap match yet

Testing

  • parse.spec.js covers the round trips above, including missing identifiers, shared answers and a gap with no answer
  • validation.spec.js covers each validation case
  • Descriptor.spec.js covers matching and question type resolution

Testing

pnpm jest --config jest_config/jest.conf.js contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions

References

AI usage

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

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions