Skip to content

Unify ExamSoft converter: format auto-detection, activities per file, OC Tech support - #18

Draft
Bamboo72 wants to merge 31 commits into
mainfrom
feature/oc-tech-converter
Draft

Bamboo72 wants to merge 31 commits into
mainfrom
feature/oc-tech-converter

Conversation

@Bamboo72

@Bamboo72 Bamboo72 commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Summary

The ExamSoft importer is now one unified converter: the single examsoft source auto-detects each file's format and routes it to the right parser. This supersedes the earlier separate examsoft_octech source from this PR's first iteration (removed before ever shipping).

  • Format detection (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) or Question 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.
  • Activities (fixes the grouping half of atomicjolt/atomic-assessments#2237): every file — both formats — now produces one Learnosity Activity containing its items in order, titled from the exam header when present, else the filename. Splitting a single file containing multiple exams is deferred (commented at the build site) until a real sample defines an exam boundary.
  • Zip support for everything: a .zip of .rtf/.docx/.html files (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.
  • OC Tech format capabilities (unchanged from this PR's earlier iteration): MC/multi-response, fill-in-the-blank as clozetext (inline {{response}} at underscore/__N__ markers, multi-part answers as multi-blank), native bowtie (imported unscored + warning), classification for NCLEX drag-and-drop, embedded images extracted to assets/ 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 examsoft imports (0.4.0 → 0.5.0)

  1. One Activity per file now appears alongside the items (#2237).
  2. .zip uploads convert (previously failed as unsupported).
  3. Conversion warnings are prefixed with the source filename.
  4. The informational "Exam header detected: …" warning is gone — the header is the activity title now.
  5. Truly-empty files fail with a clear "no questions could be converted" error instead of silently importing nothing (prose-only files still import their minimal item, as before).
  6. Embedded images in classic files now upload as Learnosity assets (previously dangling refs).
  7. Duplicate-key warning drops are fixed: every warning persists (sequential unique indexes for insert_conversion_errors).
  8. .htm and uppercase extensions convert.
  9. Items from OC Tech-formatted files carry 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).
  10. Direct gem API: file-like arguments must expose .path with a real extension (the Rails job always passes paths — unaffected).
  11. Multiple choice questions (both formats) now import with Learnosity's standard horizontal layout (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

  • Suite: 274 examples, 0 failures (~90 new since 0.4.0)
  • Real-data sweep (all OC Tech sample RTFs through the unified source): identical results to the pre-unification baseline — 5 HW files convert incl. images and multi-blank clozetext, answer-less Test files correctly rejected
  • Mixed-format zip test: classic + OC Tech entries → two activities, each parsed by its own pipeline
  • Classic fixtures produce identical items/questions to 0.4.0 (regression-guarded), plus the new activity

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.md and sibling plan in the atomic-assessments repo (branch feature/examsoft-octech-import).

🤖 Generated with Claude Code

Bamboo72 and others added 29 commits July 20, 2026 17:49
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>
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>
@Bamboo72 Bamboo72 changed the title Add ExamSoft (OC Tech) converter with image assets and activities Unify ExamSoft converter: format auto-detection, activities per file, OC Tech support Jul 27, 2026
Bamboo72 added 2 commits July 27, 2026 11:18
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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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::FormatDetector plus 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
Comment on lines +172 to +174
zip_path = "assets/#{File.basename(local)}"
assets[zip_path] = File.binread(local)
img["src"] = "___EXPORT_ROOT___/#{zip_path}"
Comment thread README.md

## CSV input format

All columns are optional execpt "Option A", "Option B", and "Correct Answer".

@mpetrowi mpetrowi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

@Bamboo72
Bamboo72 marked this pull request as draft July 27, 2026 19:00
@Bamboo72

Copy link
Copy Markdown
Contributor Author

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

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.

3 participants