Render and grade Perseus questions inside mixed QTI packages#15055
Render and grade Perseus questions inside mixed QTI packages#15055rtibblesbot wants to merge 10 commits into
Conversation
Parse each manifest resource's declared <file> dependencies into resourcesMap[id].files, and add qtiPackage.getFile(path) to read arbitrary package files (for asset-URL resolution). QTIViewer now provides qtiResource and falls back to an ancestor-injected package or resource when it loaded none of its own, enabling sandbox/test injection in itemData mode. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PerseusRendererIndex can now render from an embellished itemData (raw Perseus JSON string + a package image-URL map + a source id) instead of opening its own zip, so a Perseus question carried inside a QTI package resolves its images without setUpPerseusFile. resolveItemData branches on the embellished shape: it retains an injected registry entry (no zipFile — the blob/data URLs are owned by the QTI zip / sandbox), lets replaceImageUrls merge the map in and substitute URLs, then renders through the existing pipeline. getImageUrl gains an exact-match-first, basename-fallback lookup so package paths match Perseus's lookup names forgivingly, while restoreImageUrls's invert still round-trips the full LOCALPATH ref. cleanUpPerseusFile null-guards the missing zipFile. Plain object/string itemData is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Register a PerseusCustomInteraction for qti-custom-interaction elements
marked data-type="perseus". It builds an image-url map from the resource's
declared manifest dependencies (without parsing the item JSON) and drives the
existing Perseus renderer through a nested ContentViewer fed embellished
itemData.
On check, a response-committer registry on AssessmentItem lets the interaction
pull the nested renderer's checkAnswer() result and write {correct, simpleAnswer,
answerState} into the schemaless RESPONSE record before response processing runs,
so the wrapper's inline processing derives SCORE.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Build Artifacts
Smoke test screenshot |
rtibbles
left a comment
There was a problem hiding this comment.
Some very poor decisions about architecture, levels of abstraction and delegation of responsibilities.
| } | ||
|
|
||
| describe('QTISandboxPage', () => { | ||
| describe('perseusPackageFor', () => { |
There was a problem hiding this comment.
This is unnecessary - our sandbox tests are purely to assert behaviour of the sandbox page, not to assert things about the nature of fixtures. This is over testing.
There was a problem hiding this comment.
Removed the fixture-shape assertions. The sandbox spec now only asserts sandbox-page behaviour (parseAnswerStateInput) — and the perseusPackageFor export it was testing is gone entirely, since the sandbox no longer builds a package (see the QTIViewer thread).
| [OrderInteraction.tag]: OrderInteraction, | ||
| [InlineChoiceInteraction.tag]: InlineChoiceInteraction, | ||
| [InlineChoice.tag]: InlineChoice, | ||
| [PerseusCustomInteraction.tag]: PerseusCustomInteraction, |
There was a problem hiding this comment.
This now requires that the PerseusCustomInteraction component is responsible for rendering any custom interactions that appear in the QTI XML. I think it would be better to make a small, general purpose custom interaction component, that then delegates to the perseus component iff the custom interaction has the appropriate attributes.
If not, it should just raise an error.
There was a problem hiding this comment.
Done. Added a small general-purpose CustomInteraction (registered for qti-custom-interaction) that delegates to PerseusCustomInteraction when data-type="perseus" and raises an error for any other delivery engine. PerseusCustomInteraction is now only responsible for rendering a Perseus item.
|
|
||
| const { responses, processResponses } = qtiContext; | ||
|
|
||
| // Interactions whose answer is only known at check time (e.g. the Perseus |
There was a problem hiding this comment.
This feels leaky - pretty sure we should be able to handle this internally to the perseus custom interaction component without leaking these details out.
There was a problem hiding this comment.
Agreed — removed the registerResponseCommitter registry from AssessmentItem entirely. The Perseus interaction now writes its own RESPONSE record as the learner answers (it owns its grading), guarded against the graphie re-render feedback loop. AssessmentItem just runs response processing, which reads the already-current record.
| * @param {string} itemId - The selected sandbox item's identifier. | ||
| * @returns {{ qtiPackage: object|null, qtiResource: object|null }} | ||
| */ | ||
| export function perseusPackageFor(itemId) { |
There was a problem hiding this comment.
Why does this need to happen in the sandbox? Wouldn't this be required for the itemData path in any QTIViewer interaction? This seems to be at the wrong level of abstraction.
There was a problem hiding this comment.
You are right — it belongs on the itemData path in QTIViewer, not the sandbox. QTIViewer now synthesizes the package/resource from files carried on itemData ({ xml, files }), so any interaction on the itemData path resolves assets the same way. The sandbox just embellishes the itemData it feeds the viewer; perseusPackageFor/the mock-package provide() are gone.
| // loaded none of its own (e.g. the sandbox or tests inject a mock | ||
| // package in itemData mode). Descendant interactions resolve assets | ||
| // through these. | ||
| const injectedPackage = inject('qtiPackage', null); |
There was a problem hiding this comment.
God no. What are you doing? The QTIViewer is the source of truth here.
There was a problem hiding this comment.
Fixed. Dropped the ancestor inject('qtiPackage'/'qtiResource') fallback. QTIViewer is now the sole source of truth: it provides either the package it loaded from the zip, or one it synthesizes from the files carried on itemData — never one injected from above.
…verage Add a perseus fixture module holding Perseus items sampled from the Kolibri QA Channel (a plain-image item and a graphie item), each with its wrapper QTI XML, verbatim Perseus JSON, and assets inlined as data: URLs. The QTI sandbox builds a mock qtiPackage/qtiResource from the selected fixture so a Perseus item renders through QTIViewer's injected-fallback path, listed under a new "Perseus (mixed QTI)" category. Extend the tests to cover the real image-url mapping end to end: graphie .svg/-data.json deps resolve from the injected map with web+graphie: refs preserved and no LOCALPATH token leaking into answer state, and the rendered Perseus subtree exposes accessible, focusable controls matching native QTI interactions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Perseus-in-QTI sandbox mock served each inlined asset as its data: URL. Perseus's markdown image rule runs URLs through simple-markdown's sanitizeUrl, which rejects the data: protocol as an XSS defence, so a plain `` image rendered with src=null and never loaded. A real package serves assets as blob: URLs (kolibri-zip's ExtractedFile.toUrl), which pass the sanitizer, so mirror that: the mock now converts the inlined data: URL into a blob: URL, cached per asset. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Scoring a Perseus item can itself emit an interaction — a graphie image re-rendering as it resolves — and a host that re-checks on every interaction then re-enters the committer synchronously: interaction → re-check → commit → checkAnswer → interaction → …, overflowing the stack and dropping the item into the ContentViewer error boundary. Guard the committer against re-entry and stop forwarding interactions emitted while it is scoring, so a genuine user interaction still checks exactly once. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A rejected buildEmbellished() left the QTI custom interaction spinning KCircularLoader indefinitely, since embellished stayed null forever. Track the failure and fall through to an empty container so the item settles. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PerseusCustomInteraction captured data-perseus-path once in setup() and ran the asset build a single time, so when the component instance is reused for a different item (e.g. sandbox navigation between the two Perseus fixtures) the previous item's content stayed rendered. Drive the build from a watcher on the reactive path prop instead, with a token guard so a slower earlier build cannot resolve after a newer one and clobber it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rework the Perseus-in-QTI wiring per review feedback on delegation of
responsibilities and levels of abstraction:
- QTIViewer owns the package. Drop the ancestor-injected qtiPackage/
qtiResource fallback; instead QTIViewer synthesizes them from the files
carried on itemData, so it is the single source of truth whether the item
came from a zip or was injected directly. The sandbox now embellishes the
itemData it feeds the viewer ({ xml, files }) rather than provide()-ing a
mock package — asset resolution for the itemData path is a viewer concern,
not a sandbox one.
- Delegate custom interactions. AssessmentItem registers a small,
general-purpose CustomInteraction for qti-custom-interaction that delegates
to PerseusCustomInteraction when data-type="perseus" and raises an error
otherwise, instead of PerseusCustomInteraction owning every custom
interaction tag.
- Grade in-place. The Perseus interaction writes its RESPONSE record itself as
the learner answers (guarded against the graphie re-render feedback loop),
so AssessmentItem no longer exposes a response-committer registry.
- Drop the over-testing of fixture shape from the sandbox spec.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cover the reviewer-requested source-of-truth behaviour directly: given object itemData, QTIViewer synthesizes a package whose getFile serves the item JSON and each declared asset to the embedded Perseus interaction — with no injected package in the tree. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4ebb10d to
b5da871
Compare
Summary
web+graphie:images from the package.Acceptance criteria
All of #14892's acceptance criteria are met (mirrored here — the issue checkboxes can't be edited from this account):
CustomInteractionis registered forqti-custom-interactioninAssessmentItem's SafeHTML map alongside the native interactions and delegates toPerseusCustomInteraction.qtiResource.files(the parsed<file>deps) filtered to the item's siblingimages/directory.web+graphie:images resolve fromperseus/images/— via the reusedreplaceImageUrls.getImageUrlbasename fallback, exact-match first.${☣ LOCALPATH}/…refs — via the reusedrestoreImageUrls.RESPONSErecord stores the restored answer state, asserted free ofblob:/data:URLs.correctto theRESPONSErecord — on each interaction the wrapper grades the Perseus item and writes{correct, simpleAnswer, answerState}into theRESPONSErecord, so it is current when response processing reads it at check time.SCOREmatches the standalone Perseus renderer's correct/incorrect result — inlineqti-field-valueresponse processing, assertedSCORE1/0 inperseusInQti.spec.js.tabindex="-1"/aria-hidden).__fixtures__/perseus/adds a plain-image and a graphie item.injectedItemData.spec.jsandPerseusCustomInteraction.spec.js.References
Closes #14892. Publish counterpart: learningequality/studio#6047. Builds on #11428.
Reviewer guidance
kolibri/plugins/qti_viewer/frontend/components/interactions/PerseusCustomInteraction.vue— the reentrancy guard drops interactions emitted while scoring; check it cannot drop a genuine user interaction that arrives mid-check.kolibri/plugins/perseus_viewer/frontend/views/PerseusRendererIndex.vuegetImageUrl— check the exact-key match still precedes the basename fallback.kolibri/plugins/qti_viewer/frontend/components/__fixtures__/perseus/index.js— the sandbox mock'sblob:object URLs are never revoked; confirm that is acceptable for the dev-only sandbox.Screenshots
QTI sandbox (
/qti_sandbox/<itemId>), Perseus questions rendering inside the QTI viewer:SCORE1 (record showscorrect:true)Accessibility (axe) of the rendered Perseus item: 0 violations in the Perseus subtree. The one page-level violation is the sandbox's pre-existing answer-state debug input (
.value-input), dev-only and outside this change.AI usage
Used Claude Code to implement the feature against the pre-approved plan and to diagnose two rendering bugs that browser QA surfaced but the stubbed unit tests missed. Verified with the full
qti_viewerandperseus_viewerJest suites,prek, and manual browser QA of rendering, grading, and keyboard operation.@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly
How was this generated?
🟡 Waiting for feedback
Last updated: 2026-07-23 23:23 UTC