Conversation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…egistration task Removed out-of-scope file and require that Task 6 accidentally added: - Deleted lib/atomic_assessments_import/exam_soft/oc_tech.rb (will be created by Task 9) - Reverted lib/atomic_assessments_import/exam_soft.rb to original state - Added direct require to spec to load document_parser independently Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds QuestionParser.parse(nodes, number), which classifies each DocumentParser block's nodes into stem/metadata/answer/option/group lines and determines question type (multiple_choice, multiple_response, fitb, bowtie, drag_and_drop, unknown). Produces the ParsedQuestion struct consumed by the upcoming ItemBuilder.
Wires DocumentParser, QuestionParser, and ItemBuilder together via pandoc's --extract-media, collecting embedded images into memory-backed assets (rewriting <img src> to the ___EXPORT_ROOT___/assets/... convention) and building one Activity per exam file. Extracted build_items/declared_count_errors helpers out of convert_single to satisfy Rubocop's PerceivedComplexity check.
Registers ExamSoft::OcTech::Converter for rtf/docx/html/xhtml/zip under the examsoft_octech source, documents the new source in the README, and bumps VERSION to 0.5.0. Verified against all 8 real OC Tech RTFs: HW 1-5 convert cleanly (HW 2/4/5 with image assets), Test 2/3/4 correctly raise the missing-answer-key error. No parser bugs found in the sweep.
An in-stem instruction line beginning with "Answer:" (e.g. rounding instructions) that appears before the "Question ID:" metadata line was being misclassified as a real answer key by ANSWER_RE, producing a bogus multi-part FITB question whose first part's "correct answer" was instruction prose instead of a real value (seen in NUR_216_DCT_HW_2.rtf Q5). Gate ANSWER_RE classification on seen_metadata so it only fires after the metadata line has been parsed; genuine Answer/Answer Part N lines always follow metadata in OC Tech exports. The indexed-answer branch is untouched since it legitimately fires before metadata.
OC Tech errors all shipped with qti_item_id/index nil, so the Rails app's find_or_create_by(qti_item_id:, question_index:) dedup collapsed every error after the first into a single row. finalize_errors now runs once inside #convert on the outgoing result (after convert_zip merges per-entry results), assigning each error a distinct 0-based index.
Pandoc (and other libs in the conversion path) can raise bare StandardErrors (e.g. RuntimeError "couldn't unpack docx container" for a corrupt .docx) that AtomicAssessmentsImport::Error alone wouldn't catch, aborting the whole zip. convert_zip_entry now rescues StandardError as a defense-in-depth net, and convert_single wraps any unexpected StandardError (excluding the already-named AtomicAssessmentsImport::Error/MissingAnswerError cases) with the filename prefix so single-file failures name the file too.
…iles A document with no numbered questions (e.g. a non-exam RTF, or every block failing to classify) previously produced a published activity with zero items. convert_single now raises AtomicAssessmentsImport::Error naming the file when item-building yields no items; inside a zip this counts as a failed entry rather than setting converted_any.
…docs - convert_zip: when nothing converts, include the collected per-entry error messages in the raised text instead of a bare generic message. - question_parser: fold a lone "(choose N):" group's options back into plain options (dropping the group) so a single group heading doesn't silently reclassify an MC/MR question as fitb. - converter: asset containment check now requires the resolved path to start with the media dir plus File::SEPARATOR, not just the prefix. - README: note that bowtie questions are exempt from the answer-key requirement (they import unscored with a teacher-facing warning). - converter_spec: add a two-good-entries zip case (2 activities, 6 items, assets present). - classification.rb: apply the one rubocop-autocorrectable indent fix.
Learnosity requires scoring_type to score a shorttext response; without it, imported FITB questions rendered with max score 0/0 and everything was marked wrong.
Mirrors ShortAnswer's alternate-answers contract: each string in "alternate answers" is a full ;-joined answer set across all blanks, split into a value array parallel to the primary answer. Omitted entirely when absent/empty.
Rewrite build_fitb to emit one clozetext FillInTheBlank question per
FITB question instead of one shorttext per answer part. Blanks are
detected from numbered markers (__N__) and underscore runs (3+, one
run = one blank) in document order; markers are pre-replaced with
{{response}} so FillInTheBlank passes the text through unchanged. When
markers are absent, one {{response}} paragraph is appended per answer;
when the marker count doesn't match the answer count, the stem is left
alone and a warning is raised. Comma-alternates only apply to
single-blank questions; multi-blank questions with comma-alternates
fall back to primaries with a warning, and any answer containing a
semicolon (which would corrupt the ;-join) also gets a warning.
FITB_MARKER_RE was scanning/substituting over the raw stem HTML string. Image srcs rewritten to ___EXPORT_ROOT___/assets/... contain two runs of 3+ underscores, so every image-bearing FITB counted 2 phantom markers — producing misleading mismatch warnings and, when phantom+real markers happened to equal the answer count, corrupting the img src via sub. Parse the stem with Nokogiri::HTML.fragment, scan and replace markers only within TEXT nodes (in document order), and serialize back with to_html. Element attributes are never touched.
Implement FormatDetector module with ordered FORMATS array and detect method to identify OC Tech format vs classic fallback. Detects OC Tech via stats header line or Question ID metadata. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…r file (#2237)
Rewrites ExamSoft::Converter to detect format (classic vs OC Tech) per
document via FormatDetector and route to the matching pipeline
(ClassicPipeline/OcTech.convert_document), wrapping every converted
file's items in one activity, prefixing conversion warnings with the
filename, and raising when a file yields zero items. finalize_errors,
source_format, collect_assets!, and build_activity move verbatim from
the now-deleted ExamSoft::OcTech::Converter.
Path-based only: the old file-content Tempfile branch is dropped since
the registry and Que job always invoke converters with a path/File
whose #path pandoc can read directly.
lib/atomic_assessments_import.rb: "examsoft_octech" registrations now
point at the same ExamSoft::Converter as "examsoft" for single files,
since detection is automatic; the application/zip registration is
parked until Task 5 wires zip support into the unified converter.
Test changes:
- oc_tech/converter_spec.rb re-pointed at ExamSoft::Converter (proves
OC Tech detection through the unified entry point); its zip examples
relocated to the new converter_zip_spec.rb, skipped until Task 5.
- rtf_converter_spec.rb gains activity/prefix/index/zero-item examples.
- classic specs: `data[:activities]` is no longer `[]` (one activity is
always produced now); several Tempfile.new("name.ext") calls switched
to the two-arg form so the suffix pandoc/Extname sees isn't mangled.
- "no correct answer" specs (rtf/html/docx) now strip only the first
answer marker instead of all of them, since stripping every marker
left zero convertible items and tripped the new empty-items guard
instead of exercising the warning; spec/fixtures/no_correct.docx
regenerated to match (Q1 unmarked, Q2/Q3 intact).
- spec/fixtures/mixed_types.html: dropped a "Total Questions: 4" header
line that coincidentally matched FormatDetector's OC Tech stats-line
probe, misrouting this classic fixture into the OcTech pipeline.
- two "no convertible questions" examples changed from prose-only
bodies to empty ones: ClassicPipeline's single-chunk fallback treats
any non-empty stem as a minimal short_answer question, so prose text
alone no longer guarantees zero items.
266 examples, 0 failures, 6 pending (parked zip specs).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…cuous warning tests
Moves the zip plumbing (SUPPORTED_EXTENSIONS, convert_zip, skip_entry?, convert_zip_entry, merge_result!, build_error) verbatim from the deleted OC Tech-only converter into ExamSoft::Converter, and restores the zip branch in #convert so both classic and OC Tech documents can be mixed in the same zip, each routed through its own pipeline per entry. Unskips the OC Tech zip examples parked in converter_zip_spec.rb by Task 4 and adds the classic-zip and mixed-zip examples. The prose-only "no convertible questions" example now uses an empty document body instead of prose text, since ClassicPipeline's single-chunk fallback (added in Task 4) treats any non-empty stem as a minimal question. Also raises Metrics/PerceivedComplexity's Max to 20 in .rubocop.yml to match the existing CyclomaticComplexity override, since convert_zip's branching (skip/unsupported/failure-isolation checks) was already over the default threshold before this move.
Deletes the four remaining examsoft_octech registrations and registers application/zip under the unified examsoft source, so classic and OC Tech documents (including mixed zips) all route through ExamSoft::Converter via a single "examsoft" source. Merges the ExamSoft and ExamSoft OC Tech README sections into one ExamSoft section describing auto-detection, per-format question types, the OC Tech answer-key requirement (with bowtie exemption), and per-entry zip failure isolation. Real-data sweep against the unified source confirms no regressions: 5 OC Tech homework RTFs convert identically to the pre-unification baseline (item/question counts, clozetext blanks, embedded images), the 3 answer-key-less test RTFs are rejected with the expected error, and the classic fixture still produces a single-activity archive.
…requires Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Single-response MCs now omit the template key (defaults to horizontal, multiple_responses: false); multiple-response MCs use "multiple response" (horizontal layout, multiple_responses: true), instead of the previous block/upper-alpha layout.
There was a problem hiding this comment.
Pull request overview
This PR unifies ExamSoft importing behind a single examsoft converter that auto-detects classic vs. OC Tech “exam printout” formats per document, adds ZIP batch support with per-entry isolation, and consistently emits one Learnosity Activity per input file/entry (including assets/features in the export archive).
Changes:
- Introduces
ExamSoft::FormatDetectorplus extracted pipelines (ClassicPipeline,OcTech) and HTML normalization to route documents to the correct parser. - Adds per-file Activity creation, ZIP conversion with entry-level failure isolation, sequential error indexing, and asset extraction/rewriting into
assets/. - Extends question support (bowtie/classification + alternate answers for short answer/FITB), expands specs/fixtures, updates docs, and bumps gem version to 0.5.0.
Reviewed changes
Copilot reviewed 41 out of 43 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| spec/fixtures/oc_tech/practice_exam.rtf | Adds OC Tech sample with image + FITB/MC/multipart FITB. |
| spec/fixtures/oc_tech/no_answers.rtf | Adds OC Tech sample missing answer key for error coverage. |
| spec/fixtures/oc_tech/generate_fixtures.rb | Script to regenerate OC Tech RTF fixtures with embedded PNG. |
| spec/atomic_assessments_import/questions/short_answer_spec.rb | Adds specs for alt_responses behavior in short answer. |
| spec/atomic_assessments_import/questions/fill_in_the_blank_spec.rb | Adds specs for alt_responses behavior in FITB/cloze. |
| spec/atomic_assessments_import/questions/classification_spec.rb | New specs for Learnosity classification question output. |
| spec/atomic_assessments_import/questions/bowtie_spec.rb | New specs for Learnosity bowtie question output. |
| spec/atomic_assessments_import/export_spec.rb | Ensures export ZIP includes assets/features and tolerates missing keys. |
| spec/atomic_assessments_import/examsoft/rtf_converter_spec.rb | Updates expectations for activities + layout changes; adds error-index/prefix tests. |
| spec/atomic_assessments_import/examsoft/oc_tech/question_parser_spec.rb | Unit tests for OC Tech question parsing rules. |
| spec/atomic_assessments_import/examsoft/oc_tech/pipeline_spec.rb | Tests OC Tech pipeline contract and missing-answer behavior. |
| spec/atomic_assessments_import/examsoft/oc_tech/item_builder_spec.rb | Tests OC Tech item/question building (MC/FITB/bowtie/classification). |
| spec/atomic_assessments_import/examsoft/oc_tech/document_parser_spec.rb | Tests OC Tech document block splitting/title/count parsing. |
| spec/atomic_assessments_import/examsoft/oc_tech/converter_spec.rb | Integration coverage for unified converter routing OC Tech inputs. |
| spec/atomic_assessments_import/examsoft/integration_spec.rb | Updates integration expectations around header warnings and activities. |
| spec/atomic_assessments_import/examsoft/html_normalizer_spec.rb | New tests for <br> paragraph splitting normalization. |
| spec/atomic_assessments_import/examsoft/html_converter_spec.rb | Updates HTML converter expectations for activities + tempfile extensions. |
| spec/atomic_assessments_import/examsoft/format_detector_spec.rb | New tests verifying classic vs. OC Tech format detection. |
| spec/atomic_assessments_import/examsoft/docx_converter_spec.rb | Updates DOCX converter expectations for activities + tempfile extensions. |
| spec/atomic_assessments_import/examsoft/converter_zip_spec.rb | New ZIP conversion tests: isolation, indexing, mixed-format support. |
| spec/atomic_assessments_import/examsoft/classic_pipeline_spec.rb | New tests for extracted classic pipeline behavior. |
| spec/atomic_assessments_import_spec.rb | Verifies unified examsoft registration and removal of examsoft_octech source. |
| README.md | Documents unified converter behavior, ZIP support, formats, and OC Tech constraints. |
| lib/atomic_assessments_import/version.rb | Bumps gem version to 0.5.0. |
| lib/atomic_assessments_import/questions/short_answer.rb | Adds exactMatch + optional alt_responses emission. |
| lib/atomic_assessments_import/questions/question.rb | Adds bowtie and classification to Question.load and requires. |
| lib/atomic_assessments_import/questions/fill_in_the_blank.rb | Adds optional alt_responses emission for clozetext blanks. |
| lib/atomic_assessments_import/questions/classification.rb | Implements Learnosity classification question builder. |
| lib/atomic_assessments_import/questions/bowtie.rb | Implements Learnosity bowtie question builder (unscored import). |
| lib/atomic_assessments_import/export.rb | Writes features/ JSON and assets/ files into export ZIP. |
| lib/atomic_assessments_import/exam_soft/oc_tech/question_parser.rb | New OC Tech parser for stems/options/metadata/answers/groups. |
| lib/atomic_assessments_import/exam_soft/oc_tech/item_builder.rb | New OC Tech item/question builder incl. FITB templating and warnings. |
| lib/atomic_assessments_import/exam_soft/oc_tech/document_parser.rb | New OC Tech doc splitter + header/title/count extraction. |
| lib/atomic_assessments_import/exam_soft/oc_tech.rb | New OC Tech pipeline entrypoint returning pipeline contract. |
| lib/atomic_assessments_import/exam_soft/html_normalizer.rb | Extracted HTML normalization (<br> splitting) used by unified converter. |
| lib/atomic_assessments_import/exam_soft/format_detector.rb | Adds probe table to select OC Tech vs. classic pipeline per document. |
| lib/atomic_assessments_import/exam_soft/extractor.rb | Removes default “block layout” template to adopt horizontal MC layout. |
| lib/atomic_assessments_import/exam_soft/converter.rb | Refactors into unified orchestrator w/ activities, ZIP support, assets, error indexing. |
| lib/atomic_assessments_import/exam_soft/classic_pipeline.rb | Extracts classic chunk/extract pipeline from prior converter. |
| lib/atomic_assessments_import.rb | Registers ZIP MIME type and minor formatting cleanup. |
| Gemfile.lock | Reflects gem version bump to 0.5.0. |
| .rubocop.yml | Adds Metrics/PerceivedComplexity threshold. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| module AtomicAssessmentsImport | ||
| module ExamSoft | ||
| class Converter | ||
| SUPPORTED_EXTENSIONS = %w[.rtf .docx .html .htm].freeze |
| zip_path = "assets/#{File.basename(local)}" | ||
| assets[zip_path] = File.binread(local) | ||
| img["src"] = "___EXPORT_ROOT___/#{zip_path}" |
|
|
||
| ## CSV input format | ||
|
|
||
| All columns are optional execpt "Option A", "Option B", and "Correct Answer". |
mpetrowi
left a comment
There was a problem hiding this comment.
I think this is looking good. I want to understand more why there are two formats though. Could you look into that?
Also, please remove the name oc_tech from everywhere and name it something generic. This is a public github repo that we share with institutions for internal conversion purposes so we want to keep it generic.
|
Converting to draft per team decision: we're shipping the minimal #2237 fix first — #19 adds activity-per-file grouping to the classic importer with a ~45-line diff, and takes the 0.5.0 version number. This branch stays as the preserved home of the broader ExamSoft work (per-file format auto-detection, OC Tech exam-printout support incl. clozetext FITB/bowtie/classification/image assets, zip imports with per-entry failure isolation) needed for the OC Tech migration. When it revives it will renumber to 0.6.0 and rebase over whatever has merged. 🤖 Generated with Claude Code |
Summary
The ExamSoft importer is now one unified converter: the single
examsoftsource auto-detects each file's format and routes it to the right parser. This supersedes the earlier separateexamsoft_octechsource from this PR's first iteration (removed before ever shipping).ExamSoft::FormatDetector): an ordered probe table checked per file. OC Tech "exam printout" files are recognized by their pipe-delimited stats header (… | Total Questions: N | …before the first question) orQuestion ID: … | Point Value: …metadata lines; anything unmatched falls back to the classic pipeline — zero behavior change for unrecognized/messy files. Adding a future format = one pipeline module + one probe + one table row..zipof.rtf/.docx/.htmlfiles (formats can be mixed) converts one activity per entry with per-entry failure isolation — one bad file no longer aborts the batch, and the failure names the file.clozetext(inline{{response}}at underscore/__N__markers, multi-part answers as multi-blank), nativebowtie(imported unscored + warning),classificationfor NCLEX drag-and-drop, embedded images extracted toassets/and uploaded via the existing___EXPORT_ROOT___flow, per-question metadata (ID → title/source, points, rationale). Files without answer keys are rejected with a "request an ExamSoft export that includes answers" error (bowtie exempt).Behavior changes for existing
examsoftimports (0.4.0 → 0.5.0).zipuploads convert (previously failed as unsupported).insert_conversion_errors)..htmand uppercase extensions convert.metadata.import_type: "examsoft_octech"as a format label (the string survives only there — it is no longer a source; Rails never branches on it)..pathwith a real extension (the Rails job always passes paths — unaffected).ui_style: {type: "horizontal"}) instead of the block/upper-alpha style — a deliberate appearance change requested during manual verification.The atomic-assessments app needs no code changes — its existing ExamSoft option and job path work as-is. The Rails-side branch now carries only docs + (post-release) the Gemfile bump.
Verification
Release ask
@mpetrowi — same ask as before: publish 0.5.0 after merge. The Rails Gemfile bump is staged to follow the release.
Design spec + plan:
docs/superpowers/specs/2026-07-27-examsoft-unified-converter-design.mdand sibling plan in the atomic-assessments repo (branchfeature/examsoft-octech-import).🤖 Generated with Claude Code