🔀

4択クイズの選択肢シャッフルで解説が壊れたので、{{A}}マーカー方式を作った話

に公開

はじめに

学習アプリ「Q-Master 」を個人開発しています。

学習アプリ Q-Master

このアプリは4択クイズアプリですが、「選択肢シャッフル」機能を入れたときの話です。

シャッフルを入れた理由はシンプルで、「Aの位置にあるから正解」と位置で覚えてしまう問題を防ぐためでした。学習アプリとしては当然ほしい機能です。

ところが、シャッフルを有効にした瞬間、解説が全部ウソになりました。


起きた問題:解説文がすべて誤解説になる

たとえば、こんな問題があるとします。

問題: エネルギーを生成する細胞小器官はどれか?
A: 葉緑体
B: リボソーム
C: ミトコンドリア(正解)
D: ゴルジ体

解説にはこう書いてあります。

正解はCです。 ミトコンドリアはATPを生成する器官です。
Aの葉緑体は光合成を行う器官で、Bのリボソームはタンパク質合成を行います。

ここでシャッフルが走り、選択肢の中身がこう並び替わったとします。

A: ミトコンドリア(元C → 正解)
B: ゴルジ体(元D)
C: 葉緑体(元A)
D: リボソーム(元B)

正解はAの位置に移動しました。しかし解説文は「正解はCです」のまま。Cの位置には葉緑体がいます。

解説が嘘をついている。

しかもこれ、全問題で起きます。シャッフルするたびに解説の参照先がずれるので、解説がある問題すべてが壊れます。


失敗した解決策

文字列の単純置換

最初に思いついたのは、「シャッフル結果に合わせて解説中のA, B, C, Dを置換すればいいのでは」でした。

たとえば、元のCが表示位置Aに移動したなら、解説中の「C」を「A」に置換する。

ところが、連鎖置換が起きます。

C → A に置換したい
A → C に置換したい

この2つを順番に実行すると:

  1. まず C → A に置換 → 「正解はAです。Aの葉緑体は…」
  2. 次に A → C に置換 → 「正解はCです。Cの葉緑体は…」

元に戻ってしまいました。 さらにステップ1で変換した「A」まで巻き込まれて、もう何が何やらわかりません。

正規表現で「選択肢っぽいA」だけを置換

では正規表現で「選択肢として使われているA」だけを狙えばいいのでは?

しかし、日本語の解説文には様々なパターンがあります。

  • 選択肢A
  • (A)(A)
  • A:A:
  • A.(ただし A.5% のような小数は除外しなければならない)
  • Aが正解答えはA
  • **A:**(Markdownの太字内)
  • AはAを(日本語の助詞パターン)

パターンを列挙し始めるとキリがなく、しかも連鎖置換問題は解決していません


要件の整理

ここで一度立ち止まって、必要な条件を整理しました。

  1. シャッフル後の表示位置に合わせて、解説中の選択肢参照を正しく変換する
  2. 連鎖置換(A→B→C→...)が起きない
  3. 選択肢ではない「A」(英単語、略語、小数など)を壊さない
  4. 既存の問題データ(マーカーなし)も壊れない後方互換性

{{A}} マーカー方式の設計

解決策は、選択肢の参照を専用のマーカーで明示的に書くことでした。

解説文中で選択肢を参照するとき、生のA, B, C, Dではなく {{A}}, {{B}}, {{C}}, {{D}} と書きます。

**正解は{{C}}です。** ミトコンドリアはATPを生成する器官です。

**誤答の理由:**
- {{A}}: 葉緑体は光合成を行う器官で、植物細胞にのみ存在します
- {{B}}: リボソームはタンパク質を合成する器官です
- {{D}}: ゴルジ体はタンパク質の修飾・輸送を行います

マーカー方式のポイントは2つです。

  1. 問題を作る人は「元の選択肢キー」で書く。シャッフル後にどうなるかは気にしない。
  2. アプリ側がシャッフル結果に応じてマーカーを自動置換する。表示時に正しいキーに差し替わる。

つまり、データ層で「何を参照しているか」を明示し、表示層で「今の位置」に変換するという分離です。


実装の仕組み

双方向マッピングの生成

シャッフル時、まず2つのマッピングを作ります。

function createChoiceShuffleMapping() {
    const originalKeys = ['A', 'B', 'C', 'D'];
    const shuffledKeys = shuffleArray([...originalKeys]);

    const displayMapping = {};  // 表示位置 → 元のキー
    const reverseMapping = {}; // 元のキー → 表示位置

    shuffledKeys.forEach((originalKey, index) => {
        const displayKey = originalKeys[index];
        displayMapping[displayKey] = originalKey;
        reverseMapping[originalKey] = displayKey;
    });

    return { displayMapping, reverseMapping };
}

たとえばシャッフル結果が [C, A, D, B] なら:

表示位置 元のキー 選択肢の中身
A C ミトコンドリア
B A 葉緑体
C D ゴルジ体
D B リボソーム
  • displayMapping: {A:'C', B:'A', C:'D', D:'B'} — 「表示Aには元Cの内容がある」
  • reverseMapping: {A:'B', B:'D', C:'A', D:'C'} — 「元Aは今、表示Bにいる」

マーカーの置換ロジック

解説文中の {{A}} を「元Aの現在の表示位置」に置換します。ここで使うのが 一時プレースホルダーです。

function replaceChoiceKeysInExplanation(explanation, reverseMapping) {
    if (!explanation || !reverseMapping) return explanation;

    // マーカー方式のチェック
    const hasMarkers = /\{\{[A-D]\}\}/.test(explanation);

    if (hasMarkers) {
        let result = explanation;

        // Step 1: 一時プレースホルダーに置換(衝突回避)
        for (const [originalKey, newKey] of Object.entries(reverseMapping)) {
            result = result.replace(
                new RegExp(`\\{\\{${originalKey}\\}\\}`, 'g'),
                `__MARKER_${newKey}__`
            );
        }
        // Step 2: プレースホルダーを実際のキーに置換
        result = result.replace(/__MARKER_([A-D])__/g, '$1');

        return result;
    }

    // ... フォールバック処理(後述)
}

なぜ一時プレースホルダーが必要かというと、直接置換すると連鎖問題が再発するからです。

// ダメな例(直接置換)
{{C}} → A, {{A}} → B とすると
Step1: {{C}} → A → Step2で {{A}} → B に巻き込まれる!

// 一時プレースホルダー方式
Step1: {{C}} → __MARKER_A__, {{A}} → __MARKER_B__  (衝突しない)
Step2: __MARKER_A__ → A, __MARKER_B__ → B           (一括変換)

これで連鎖置換は完全に回避できます。


後方互換:マーカーがない既存問題への対応

{{A}} マーカーを導入する前に作られた問題には、生のA, B, C, Dで書かれた解説があります。これらも可能な限り正しく変換するために、フォールバックのパターン検出を入れています。

// マーカーがない場合のフォールバック(抜粋)
for (const [originalKey, newKey] of Object.entries(reverseMapping)) {
    // 「選択肢A」→「選択肢X」
    result = result.replace(new RegExp(`選択肢${originalKey}`, 'g'), ...);
    // (A) → (X)
    result = result.replace(new RegExp(`\\(${originalKey}\\)`, 'g'), ...);
    // A: → X:(ただし英単語の一部は除外)
    result = result.replace(new RegExp(`([^a-zA-Z])${originalKey}:`, 'g'), ...);
    // A. → X.(ただし小数点は除外)
    result = result.replace(new RegExp(`([^a-zA-Z0-9])${originalKey}\\.([^0-9])`, 'g'), ...);
    // 「Aが正解」「Aは誤り」などの助詞パターン
    result = result.replace(new RegExp(`([^a-zA-Z])${originalKey}(が|は|を|の|も)`, 'g'), ...);
}

10種類以上のパターンに対応していますが、正直これは 完璧ではありません。英文の解説で "A is correct" のようなケースは対応しきれない場合があります。

だからこそ、新規問題では {{A}} マーカーを使うべきなのです。フォールバックはあくまで 移行期間の保険 です。


Markdownと共存させるときの注意点

解説文はMarkdownで書かれているため、{{A}} がMarkdownの構文と干渉しないか確認が必要でした。

結論として、{{A}} はMarkdownのどの構文とも衝突しません

  • {{ }} はMarkdownの標準構文ではない
  • 太字 **{{A}}** やリスト - {{A}}: 説明 の中でも問題なく動作する
  • コードブロック `{{A}}` の中では置換されますが、コードブロック内で選択肢を参照するケースはほぼない

これは設計時に検討した結果、マーカーのフォーマットとして {{ }} を選んだ理由のひとつです。[A]<A> ではMarkdownやHTMLと衝突する可能性がありました。


AIプロンプト設計との連携

Q-MasterにはAI(Claude API)で問題を自動生成する機能があります。この機能では、プロンプトの中でマーカー方式を必須ルールとして指定しています。

## 【選択肢シャッフル対応 - 最重要】

解説で選択肢を参照する際は、必ずマーカー方式を使用してください。

### 必須ルール
1. すべての問題で shuffleReady: true を設定
2. 解説では必ず {{A}}, {{B}}, {{C}}, {{D}} を使用
3. 生のA, B, C, Dは絶対に使わない

つまり、データの設計がAIプロンプトの設計にそのまま反映されているということです。マーカー方式を導入したことで、AI生成された問題も最初からシャッフル対応になります。

問題ごとに shuffleReady: true フラグを持たせていて、このフラグが true の問題だけがシャッフル対象になります。AI生成の問題は自動的に true になるので、ユーザーが意識する必要はありません。


この方式のメリット・デメリット

メリット

  • 連鎖置換が起きない: 一時プレースホルダー方式で確実に回避
  • データ層と表示層の分離: 問題作成者はシャッフルを意識しなくていい
  • AI生成との相性がいい: プロンプトで指定するだけでシャッフル対応問題が生成される
  • 後方互換がある: マーカーなしの古い問題もフォールバックで対応

デメリット

  • 既存問題の移行コスト: 手動で {{A}} に書き換える必要がある(自動変換は不完全)
  • フォールバックの限界: パターン検出は100%ではない
  • 問題データへの制約: 解説に {{A}} という文字列を「そのまま表示したい」ケースがあると困る(実用上はほぼない)

まとめ

選択肢シャッフルは学習アプリにおいて「位置暗記」を防ぐほぼ必須の機能です。しかし、シャッフルすると解説が壊れるという地味だけど確実に起きる問題があります。

解決の鍵は、解説中の選択肢参照を明示的なマーカーにすることでした。

  1. {{A}} マーカーで「何を参照しているか」をデータ層で明示する
  2. 表示時にシャッフル結果に応じて自動置換する
  3. 一時プレースホルダーで連鎖置換を回避する
  4. マーカーなしの古い問題にはフォールバックパターンで対応する

クイズアプリや学習アプリを作っている方が同じ問題にぶつかったとき、この記事が参考になれば幸いです。


Q-Master: https://hiroe28.github.io/Q-Master/
GitHub: https://github.com/Hiroe28/Q-Master

Discussion