feat: derive Perseus archives from Perseus-expressible QTI items#6035
Conversation
rtibbles
left a comment
There was a problem hiding this comment.
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": |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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:773 — suggestion:
assessment_mappingrecords the item type as"qti"for derived items. Harmless for Perseus rendering, but wrong input forimport_tools.generate_assessment_item. Fold into theexercise.jsonfollow-up.
|
|
||
| def process_assessment_item(self, assessment_item): | ||
| if assessment_item.type == exercises.QTI: | ||
| derived = derive_perseus_item(assessment_item) |
There was a problem hiding this comment.
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_idsequals the Perseus zip's item-JSON filename stems.
There was a problem hiding this comment.
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): |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
rtibbles
left a comment
There was a problem hiding this comment.
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.
670940c to
df5c78a
Compare
rtibbles
left a comment
There was a problem hiding this comment.
Please re-redo the commit history as you added two more commits after you redid it.
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>
00bd57c to
6eb2139
Compare
|
Re-squashed. The two follow-up commits ( |
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_derivablegates 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 + returnNone/False; confirm a single malformedraw_datacannot 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?
🟢 Updating PR
Last updated: 2026-07-11 02:47 UTC