Skip to content

Ingest QTI 3.0 packages as per-item QTI exercises - #753

Merged
rtibbles merged 7 commits into
learningequality:mainfrom
rtibblesbot:issue-337-b8c822
Sep 25, 2026
Merged

rtibbles merged 7 commits into
learningequality:mainfrom
rtibblesbot:issue-337-b8c822

Conversation

@rtibblesbot

@rtibblesbot rtibblesbot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • A QTI 3.0 package passed as ContentNode(uri=...) becomes one practice quiz per test, or one quiz of all loose items.
  • Each question is a type="QTI" item with its <qti-assessment-item> XML in raw_data.
  • Image refs are rewritten to <checksum>.<ext> and uploaded as exercise_image.
  • Items that are not QTI 3.0, fail Studio's QTI 3.0 item XSD, or reference non-image media are skipped with a warning.
  • The XSD is vendored from Studio under ricecooker/utils/qti_xsd/; validation adds an lxml dependency.
  • <qti-stylesheet> elements, and elements and attributes outside the XSD's namespaces, are stripped from items.
  • Chef extra_fields override the quiz defaults.

References

Fixes #337. Studio contract: learningequality/studio#6032. Follow-up: #752.

Reviewer guidance

  • To check the payload: pass each node.to_dict()["questions"][i]["raw_data"] from test_single_test_package_becomes_a_practice_quiz to Studio's contentcuration.utils.assessment.qti.validation.validate_qti_item; both return valid.
  • ExerciseNode's m/n defaults and mastery validation moved up to ContentNode so a one-test package can become the exercise itself; does anything outside exercises rely on ContentNode skipping them?
  • Items outside every test are dropped when the package has at least one test.

Deviations from the issue spec

Criterion Why not
Studio accepts the upload via the internal API (learningequality/studio#6002) Not uploaded to a live Studio; payload checked only against Studio's XSD validator and media-reference contract.
Referenced media is uploaded and linked to its item Images only; an item referencing other media is skipped, per the approved plan and Studio's image-only contract.

AI usage

Used Claude Code to plan and implement the ingestion from the maintainer-approved design. Verified with the full test suite, prek, and Studio's QTI XSD validator run on the produced payload.

🤖 Generated with Claude Code


@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly

How was this generated?
  • Ran pre-flight CI checks (lint, format, tests) and verified all pass
  • Rebased onto the target branch and resolved any conflicts
  • Reorganized commit history into clean, logical commits
  • Audited the diff to ensure only issue-relevant files are changed
  • Built PR body from the repository's PR template with evidence blocks
@rtibblesbot

🟡 Waiting for feedback

Last updated: 2026-09-25 18:24 UTC

@rtibblesbot rtibblesbot reopened this Sep 24, 2026
@rtibblesbot
rtibblesbot force-pushed the issue-337-b8c822 branch 5 times, most recently from d7bd100 to a50caed Compare September 24, 2026 17:31
@rtibblesbot
rtibblesbot marked this pull request as ready for review September 24, 2026 17:32

@rtibbles rtibbles left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Uploaded to unstable Studio: every QTI 3.0 package in 1EdTech's qti-examples, plus Citolab's biologie and examples. 24 exercises, 322 QTI items and 112 images committed, all complete, with practice-quiz defaults and chef overrides stored as intended. Two changes before merge.

Comment thread ricecooker/utils/pipeline/convert.py Outdated

def _build_qti_question(self, member, package, images):
"""``images`` maps media package paths to file dicts, shared across items."""
text, root = read_qti3(package.directory, member, "qti-assessment-item")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocking: One XSD-invalid item fails the whole channel upload. 1EdTech's data-attributes.xml has a qti-rubric-block without the required use attribute. It passes here; Studio's validate_qti_item rejects it, Studio fails the node and its 10-node add_nodes batch, and ricecooker refuses to commit.

Validate each item against the XSD Studio uses (schema/xsd, root imsqti_itemv3p0p1_v1p0.xsd) and reject failures like other unusable items, naming the first XSD error in the warning. Test: an XSD-invalid item is rejected and the package's valid items still ingest.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5c4dcc8. Studio's XSD tree (studio@3bdac307) is vendored in ricecooker/utils/qti_xsd/. _build_qti_question validates each item against it and rejects failures, naming the first error (for data-attributes.xml: "fails the QTI 3.0 schema at line 15: … The attribute 'use' is required but missing."). New test: test_schema_invalid_items_are_rejected. I searched for other paths that send QTI to Studio. QTIQuestion is built only from this pipeline output (nodes.py:1170), so this is the one place. lxml is now a direct dependency (it was already installed via ebooklib/pycaption).

Comment thread ricecooker/utils/pipeline/convert.py Outdated
if path is None or not os.path.isfile(path):
raise ValueError(f"references missing media {ref}")
if extract_path_ext(path) not in self.QTI_IMAGE_EXTENSIONS:
raise ValueError(f"references non-image media {ref}")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Strip <qti-stylesheet> elements from the item instead of rejecting it. Every item in Citolab's biologie package links ../css/cito_itemstyle.css, so 32 of its 40 items are rejected for the stylesheet alone. The qti-stylesheet case in test_items_with_unusable_media_are_rejected and the media line in docs/exercises.md change with it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 71e675a. <qti-stylesheet> elements (both self-closing and paired) are now stripped before validation and media handling. 32llxx.xml now passes validation with its stylesheet removed. The stylesheet case is gone from test_items_with_unusable_media_are_rejected. New test: test_item_stylesheets_are_stripped. docs/exercises.md is updated. The class search found no other non-rendering QTI elements that cause rejection.

@rtibbles rtibbles left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reran on unstable Studio at f3918b3 with the unfiltered items package: 24 exercises, 327 QTI items and 112 images committed, all complete; data-attributes.xml is rejected with its XSD error. One change before merge.

Comment thread ricecooker/utils/pipeline/convert.py Outdated
def _build_qti_question(self, member, package, images):
"""``images`` maps media package paths to file dicts, shared across items."""
text, root = read_qti3(package.directory, member, "qti-assessment-item")
text = strip_stylesheets(text)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Strip every element and attribute outside the namespaces Studio's XSDs define, not just <qti-stylesheet>. 29 of biologie's 40 items are still rejected: 32cpek.xml puts a dep:dep-calculator in qti-companion-materials-info, and grpStrict.any needs a schema we don't have. Each of these items also has a standard <qti-calculator>, so nothing is lost.

  • Parse with lxml, not regex. Which elements are foreign depends on each document's xmlns bindings.
  • Keep the QTI, MathML, SSML, XInclude and XML namespaces (the targetNamespaces in qti_xsd/).
  • Do the <qti-stylesheet> removal in the same pass and delete _STYLESHEET_RE.
  • Validate the stripped tree instead of re-parsing the text.
  • Test: an item with a foreign-namespace element in qti-companion-materials-info ingests without it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 8c636d1 (parse_qti_item in ricecooker/utils/qti.py):

  • One lxml pass drops <qti-stylesheet> plus any element or attribute whose namespace isn't one of the 5 XSD targetNamespaces; cleanup_namespaces then drops the unused xmlns: bindings
  • _STYLESHEET_RE is gone; validate_qti_item validates the stripped tree, and raw_data is that tree serialized
  • Test: test_foreign_namespace_markup_is_stripped puts a dep: element in qti-companion-materials-info and a foreign attribute on qti-item-body; both fail the XSD unstripped
  • Searched qti.py and convert.py for other regex or text handling of item XML: _STYLESHEET_RE was the only one
  • History reshaped: stylesheet stripping now lives in the media commit, namespace stripping and the dropped identifier check in the schema commit

Carries a QTI 3.0 item as raw_data with its exercise images (studio#6032).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@rtibbles rtibbles left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reran on unstable Studio at d97e727: 24 exercises, 353 QTI items and 139 images committed, all complete; biologie now ingests 36 of 40 items. One change before merge.

Comment thread ricecooker/utils/pipeline/convert.py Outdated
leaf["extra_fields"] = extra_fields
return leaf

def _build_qti_exercises(self, manifest, package):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocking: Move QTI exercise building out of IMSCPConversionHandler into a per-package builder class in ricecooker/utils/qti.py, rather than growing convert.py further.

  • Hold the package, the pipeline, and the question and image caches on the instance
  • Drop the build_question and store_image closures, and the build_question parameter threaded through _build_qti_test_exercise and _build_qti_exercise
  • The handler keeps one call, e.g. QTIExerciseBuilder(package, self.get_pipeline()).exercises(manifest)
  • No behaviour change: the existing QTI tests in tests/pipeline/test_convert.py should pass unchanged

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in e14364a: QTIExerciseBuilder in ricecooker/utils/qti.py holds the package, pipeline, and question/image caches; the handler makes the one call.

  • Both closures and the threaded build_question parameter are gone; image refs map through a bound method.
  • convert.py imports qti inside handle_file because qti imports its ImageConversionHandler/SVGValidationHandler, which would otherwise be a cycle.
  • Searched convert.py for other QTI code: only the is_qti_resource guard in _build_leaf stays.
  • tests/pipeline/test_convert.py passes unchanged.

parse_imscp_manifest returns every QTI resource as qti_resources, since
QTI packages have no organization.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
rtibblesbot and others added 4 commits September 25, 2026 11:16
Each imsqti_test_* resource becomes a practice-quiz exercise of its items in
test order; loose items with no test become one. Non-3.0 items are rejected.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- ContentNode builds QTIQuestions from decomposed exercise leaves
- Chef's mastery_model/m/n/randomize/options.modality override the practice-quiz defaults
- Exercise validation and mastery defaults lifted from ExerciseNode to ContentNode
- Document QTI packages in docs/exercises.md

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Studio fails the whole channel commit on one schema-invalid item, so
validate each item against Studio's vendored QTI 3.0 item XSD.

- Strip elements and attributes outside the XSD's namespaces first; its strict wildcards reject them
- Drop the missing-identifier check; the XSD requires identifier

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@rtibbles rtibbles left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes make sense, manual testing checks out, and the code organization helps to keep it more readable.

@rtibbles
rtibbles merged commit 2fabb44 into learningequality:main Sep 25, 2026
57 checks passed
@rtibblesbot
rtibblesbot deleted the issue-337-b8c822 branch September 25, 2026 20:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Handle QTI exercises

2 participants