Skip to content

feat: derive Perseus archives from Perseus-expressible QTI items#6035

Merged
rtibbles merged 4 commits into
learningequality:unstablefrom
rtibblesbot:issue-6001-e8b010
Jul 11, 2026
Merged

feat: derive Perseus archives from Perseus-expressible QTI items#6035
rtibbles merged 4 commits into
learningequality:unstablefrom
rtibblesbot:issue-6001-e8b010

Conversation

@rtibblesbot

@rtibblesbot rtibblesbot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Older Kolibri renders Perseus while the authored source of truth is now native QTI XML, so a node authored entirely as native QTI would fail to render on those clients. When every assessment item on a node is a native QTI interaction Perseus can express (single/multiple choice, text/math input), this derives a Perseus archive from the QTI XML and publishes it alongside the QTI package; a node containing any non-expressible interaction publishes QTI only, never a partial or invalid Perseus.

References

Closes #6001. Builds on the hint-catalog contract from #6011.

Reviewer guidance

  • utils/publish.py:261_node_is_perseus_derivable gates Perseus emission on every item being expressible; confirm a mixed node (one derivable + one non-expressible native QTI item) degrades to QTI-only rather than emitting a partial archive.
  • utils/publish.py:403 — the generalized stale-preset cleanup now diffs against the full generator list; verify it never deletes a preset a node still needs when both archives are produced.
  • utils/assessment/qti/perseus_derive.py — all parse/derive failures log + return None/False; confirm a single malformed raw_data cannot abort the channel publish.

AI usage

Used Claude Code to implement the reverse QTI→Perseus derivation pipeline. Verified with the touched pytest suites (tests/utils/qti/, test_exercise_creation.py, test_exportchannel.py) and flake8/black.


@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

🟢 Updating PR

Last updated: 2026-07-11 02:47 UTC

@rtibblesbot rtibblesbot marked this pull request as ready for review July 10, 2026 00:04

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

Code changes make sense - I don't think we've got sufficient coverage (either practically or in the tests) for the HTML to markdown conversion though to ensure losslessness.

def _render_element(el):
localname = etree.QName(el).localname

if localname == "math":

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.

This seems very sparse and does not cover the full range of markdown expressible formatting that could exist? We explicitly specifiy gfm-like markdown with linkify disabled in our markdown to HTML transformation, so I would expect this to cover the inverse here.

We should test that we can losslessly convert from markdown to HTML and back again using the combination of this utility and the utility in markdown.py - when the source markdown is valid for this flavour of markdown - a single canonical "cover all the bases" markdown chunk will work for this.

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.

Good call — I've extended the converter to cover the full gfm-like flavour (linkify disabled) that render_markdown emits, rather than the sparse subset it had. New coverage: headings, strikethrough, inline code, links, hard breaks, horizontal rules, fenced code blocks (with language), blockquotes, ordered/nested lists, and tables — the inverse of every construct CommonMark + tables + strikethrough + the $$…$$ math plugin produce.

To pin down losslessness as you suggested, I added test_round_trips_losslessly_with_render_markdown: a single canonical "cover all the bases" markdown chunk goes markdown → HTML via render_markdown (markdown.py) → markdown via html_to_markdown, and asserts the result equals the original. Per-element unit tests back each new rule.

One deliberate exception to the identity round-trip: images. A QTI raw_data <img> carries a bare <checksum>.<ext> src, and the img rule re-adds the ${☣ CONTENTSTORAGE}/ placeholder so Perseus's base.process_image_strings matches — an asymmetric media transform, not a formatting one. That's covered separately by test_image_gets_content_storage_prefix, and I noted the exclusion in a comment on the canonical chunk and the module docstring.

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

Reverse QTI→Perseus derivation, cleanly structured. The log+skip resilience (one bad item can't abort a publish) is a good call. CI green; no UI files (Phase 3 N/A).

  • blocking (perseus.py) — derived Perseus ids won't match Kolibri's DB metadata; breaks AC #5. See inline.
  • suggestion (test_html_to_markdown.py) — round-trip test skips the one transform with real asymmetry (images). See inline.
  • suggestion (perseus_derive.py) — response-declaration lookup hardcodes RESPONSE. See inline.
  • praise (publish.py) — see inline.

ACs from #6001 — choice/text/math, hints, QTI-only fallback: covered by tests. AC #5 (older Kolibri renders): unverified, blocked by the inline finding.

Follow-up (separate issue, not this PR): exercise.json is now consumed only by restore_channel via import_tools.py; Kolibri renders from DB metadata. Decide whether to drop it or fix the recorded type.

Comments on lines not in diff

  • publish.py:773suggestion: assessment_mapping records the item type as "qti" for derived items. Harmless for Perseus rendering, but wrong input for import_tools.generate_assessment_item. Fold into the exercise.json follow-up.


def process_assessment_item(self, assessment_item):
if assessment_item.type == exercises.QTI:
derived = derive_perseus_item(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: create_kolibri_assessment_metadata (publish.py:790-794) sets assessment_item_ids from the QTI manifest (hex_to_qti_id() K-ids) whenever a QTI zip exists — always, for these dual-published nodes. The derived Perseus items are written as {assessment_id}.json, raw hex (perseus.py:125). Kolibri resolves Perseus items via the DB metadata, so the K-ids never match the hex filenames and older Kolibri can't render the node. This is the first time both zips coexist for one node, so the mismatch is new; test_native_qti_choice_item_publishes_both_archives only asserts the files exist.

Fix direction:

  • Name derived items by hex_to_qti_id(assessment_id) so filenames match the metadata.
  • Consider deriving the node's items in a single pass, proxies carrying the QTI id.
  • Gate dual-publish on that one result; skip the whole Perseus archive if any item fails, so a partial archive is impossible.
  • Add a test asserting AssessmentMetaData.assessment_item_ids equals the Perseus zip's item-JSON filename stems.

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 13ad1e3. One correction to the direction: for these dual-published nodes the items are native QTI, and QTIExerciseGenerator._create_native_qti_item records the manifest resource identifier as the item XML's root identifier attribute (archive.py:86), not hex_to_qti_id(assessment_id) — that hex_to_qti_id path only applies to legacy→QTI conversion. get_assessment_ids_from_manifest reads back those resource identifiers, so AssessmentMetaData.assessment_item_ids holds the root identifiers.

So derive_perseus_item now names the derived proxy by root.get("identifier") (the item's own QTI id), and the Perseus item JSON is written under that stem — matching the metadata. Missing-identifier items log + skip (degrade to QTI-only). Added test_native_qti_perseus_ids_match_assessment_metadata in test_exportchannel.py asserting the Perseus zip's item-JSON stems equal the published node's assessment_item_ids.

return _SUPPORTED_INTERACTIONS.get(_local(interactions[0]))


def _response_declaration(root):

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.

suggestion: _response_declaration hardcodes identifier == "RESPONSE", matching the forward converter. An item authored with a different response-identifier would silently derive zero correct answers rather than being rejected. Key the lookup off the interaction's own response-identifier so a mismatch degrades to QTI-only via the gate.

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 13ad1e3. _response_declaration now takes the identifier from the interaction's own response-identifier attribute. A new _derivable_interaction helper resolves the declaration and returns None when it does not exist, so both derive_perseus_item and is_perseus_derivable reject a mismatch — the node degrades to QTI-only rather than deriving zero correct answers. Added test_custom_response_identifier_derives_correct_answers (non-RESPONSE identifier derives correctly) and test_response_identifier_mismatch_degrades_to_qti_only.

Comment thread contentcuration/contentcuration/utils/publish.py

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

Re-review of 13ad1e3 — the three prior findings are resolved and correct. One new blocking issue the id fix introduces: exercise.json now disagrees with the item files it ships alongside. See inline.

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

All comments addressed. Let's make no more changes to the code, but clean up the commit history and PR body to reflect the final state, not the way we got here.

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

Please re-redo the commit history as you added two more commits after you redid it.

rtibblesbot and others added 4 commits July 10, 2026 19:45
Reverse of render_markdown for the full gfm-like flavour (linkify
disabled) it emits: headings, emphasis, strikethrough, inline and fenced
code, links, images, hard breaks, horizontal rules, blockquotes,
ordered/nested lists, tables, and $$…$$ math. Flow-content lxml elements
become Perseus markdown, dropping interaction placeholders and re-adding
the CONTENTSTORAGE image prefix that native QTI raw_data omits.

A canonical round-trip test drives the real forward path
(strip_content_storage_placeholder + render_markdown) and asserts
markdown -> HTML -> markdown is lossless for valid gfm-like markdown.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Parse a type==QTI item's raw_data and, when its single interaction is a
choice or text-entry interaction Perseus can express, produce a
DerivedAssessmentItem proxy carrying the legacy type/question/answers/
hints; return None (log + skip) for any non-expressible or unparseable
item so the node degrades to QTI-only rather than emitting a partial
archive. Interactions dispatch through a localname -> deriver table.

The response declaration is resolved from the interaction's own
response-identifier, so an item authored with a non-RESPONSE identifier
derives correctly and a mismatch degrades to QTI-only rather than
silently deriving zero correct answers. Hints are read back from
kolibri-hint catalog cards (learningequality#6011 contract).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
process_assessment_item swaps in the derived proxy for type==QTI items
and skips non-expressible ones, so create_exercise_archive emits Perseus
item JSON for choice/text-entry QTI. Each item's raw_data is derived once
and reused, and the derived item JSON is named by the QTI item's root
identifier — the id the QTI manifest records in
AssessmentMetaData.assessment_item_ids for these dual-published nodes —
so older Kolibri resolves the derived item by that same id. exercise.json's
all_assessment_items and assessment_mapping are rewritten from the derived
proxies so their ids and types agree with the item files that ship, keeping
restore_channel able to open and map every item.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
recurse_nodes emits both the QTI package and a Perseus archive when a
node has native QTI items and every item is Perseus-expressible; QTI only
otherwise, so a node containing any non-expressible interaction never
ships a partial or invalid Perseus. The expressible-types gate is sourced
from PerseusExerciseGenerator.TEMPLATE_MAP so it can't drift from what the
generator can render, and the stale-preset cleanup is generalized to diff
against the full generator list rather than a single generator.

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

Copy link
Copy Markdown
Contributor Author

Re-squashed. The two follow-up commits (fix: preserve block separator after display math and refactor: inline single-use interaction deriver lookup) are folded into the html_to_markdown and perseus_derive feat commits respectively, so the history is back to the four logical commits reflecting final state. Tree is byte-identical to the prior tip — force-pushed 6eb2139e1.

@rtibbles rtibbles merged commit e9066e4 into learningequality:unstable Jul 11, 2026
13 checks passed
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.

[QTI] Dual-publish Perseus zips for choice/text-entry (math input only) QTI items

2 participants