[Markdown AI] 複数AIモデルのワークフローで作るクイズ生成サイト

## はじめに

この記事は [Markdown AI Advent Calendar 2024](https://qiita.com/advent-calendar/2024/markdownai)の15日目の記事です。

https://qiita.com/advent-calendar/2024/markdownai

Markdown AIを使って英単語のクイズを生成するwebサイトを作ってみました。

https://mdown.ai/content/c1814f86-4306-482c-83d5-8cbba6382377

## Markdown AIとは

https://mdown.ai/

Markdownベースで作成したドキュメントをwebページとして簡単に公開できるサービスです。プログラミングやインフラの専門知識が無くても、簡単にwebサイトを作れるところが売りのようです。

さらに最近のアップデートで、生成AIを使った機能を組み込むこともできるようになりました。

## なぜクイズ生成サイトを作ったか

生成AIについては話題にのぼらない日がないぐらいですので、エンジニアとして生成AIを使ったサービスを作ってみたいとは常日頃から考えていました。

しかし実際にやってみるとこれが難しい。基本的に生成AIでできることってチャットのUI上でできてしまうんですよね。

クイズをテーマにしたのは応用が効くためです。今回は英単語のクイズにしましたが、同じ要領でいろいろなカテゴリーのクイズサイトを作ることができます。

そういうわけで、自分の企画力の無さ故もあり、ベタですがクイズの生成サイトを作ってみました。

## 実際に作成してみる

まずはMarkdonw AIのページにログインします。googleアカウント連携でログインできます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/173520/429c63b8-ebb8-081e-5504-a3fb3bd305b2.png)

次にプロジェクトの作成です。左上のプラスボタンをクリックすると、webページのテンプレートが作成されます。ここにMarkdownで記述していけば、その内容がHTMLとして反映されます。

HTMLとしての表示の確認はViewボタンから確認できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/173520/17365c13-5ec8-e539-6635-7b650b6b8c7b.png)

と、ここでMarkdown AIでのwebサイトの作り方は既にたくさんの方が書いているのでそこは省略します。実際それほど難しくないので、画面をポチポチするだけでできます。

この記事では、Markdown AIが実装したサーバーAI機能にフォーカスして説明していきます。

### サーバーAI機能とは

簡単に言えば、ChatGPT, Claude, Google geminiといった生成AIのAPIを利用できる機能です。選択できるAIは、gpt-4o-mini, gpt-3.5-turbo, gpt-4, cluade, geminiと代表的なものはほぼカバーされています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/173520/07175317-3e0f-d508-36f5-921a17288f6e.png)

### AIモデルの作成

サーバーAI機能をページに追加するには、まずはAIモデルを作成しなくてはなりません。AIモデルは、既に示したプロジェクト作成ページのロボットアイコンのボタンから作成できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/173520/86142e68-d5ed-65c4-df5a-d4aff80e419c.png)

基本的な入力内容は、以下の通りです。

- Select Model

ベースモデルを選択します。現在、選択できるモデルは既に挙げた5モデルです。

- Model Name:

モデルの名前をつけます。なんでもOKです。

- Prompt

これが重要です！ベースモデルに何をさせるかの指示を書きます。
ここに何も書かなくても利用はできますが、それでは普通にChatGPTやClaudeを使うのと何も変わりません。Promptとして与える指示内容で、これから作るwebサイトの機能のオリジナリティが決まります。

Promptの記述については、7つのR (Request, Role, Rule, Reguration, Review&Refine, Reference, Run Scenario)をベースにすべしと言われています。ただし、必ずしもこれら全てを指定しなくてもよいです。実際に、Markdown AIの[公式のサンプル](https://mdown.com/ja/forest-mental-clinic/)では、このうち4つとRecommendが使われています。

今回、作成したクイズ生成モデルのPromptは以下の通りです。

```.md
## Request
英単語の問題を作ってください

## Role
あなたは英語試験の問題作成者です

## Rule
・問題を1問作ってください
・問題には回答の選択肢を用意してください
・毎回、違う問題を作ってください
```

- Knowledge

おそらくRAG(Retrieval-Augmented Generation)の機能が利用できるものと思われますが、現在はまだ利用できないようです。PDFファイルやwebページのURLを渡してみましたが、実行するとエラーになってしまいました。これが利用可能になれば、かなりカスタマイズ性が向上するので、個人的にはとても期待しています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/173520/420529e9-d37a-d321-1862-42f62b2a65df.png)

あとはCommentという項目もありますが、これは機能には影響しません。自由につけられるメタ情報のようなものです。

以上の入力が完了したら、画面下部のCreateボタンでモデルを作成できます。作ったモデルの動作確認は、PlaygroundボタンをクリックしてチャットUI上で試すことができます。

### サーバーAI機能の追加

AIモデルの作成ができれば、いよいよこれをMarkdownページに追加できます。

プロジェクトページに戻って、画面上部のInsertボタンをクリックしてみましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/173520/21262141-ec40-a853-e52d-c02244cc5cd3.png)

表示されるメニューの"Insert a sample script that uses Server AI."を選択すると、以下のようなコードがMarkdown上に反映されます。

```.js
<div style="display: inline-block;">
  <input type="text" id="text-1734246584" style="width: 200px;" value="Teach me about Markdown.">
  <button type="button" id="button-1734246584">Run AI</button>
</div>
<div id="answer-1734246584"></div>
  
<script>
(() => {
  const button = document.getElementById('button-1734246584');
  button.addEventListener('click', async event => {
    button.disabled = true;
  
    const serverAi = new ServerAI();
    const message = document.getElementById('text-1734246584').value;
    const answer = await serverAi.getAnswerText('<Your ServerAI ID>', '', message);
  
    document.getElementById('answer-1734246584').innerText = answer;
    button.disabled = false;
  });
})();
</script>
```

Viewボタンから確認すると分かりますが、これ生成AIへの入力のためのテキストフォルダーとそのレスポンスを表示するためのHTML/Javascriptコードです。(Markdownだけでサイトが作れるんじゃなかったのかよ!😡)

ここで注目するのは、以下の2点です。
一つが、上記のコードで、**Your ServerAI ID**としているところ。これがServerAI機能を呼び出すAPI Keyになります。
もう一つが、ServerAI機能を呼び出す関数。

```.js
const answer = await serverAi.getAnswerText('<Your ServerAI ID>', '', message);
```

これさえ分かれば、あとは通常のwebサイトの作成と同じです。JavascriptとHTMLでゴリゴリ実装していくだけです。上手くカスタマイズしてオリジナルのwebサイトを作っていきましょう。

### 複数AIモデルの利用

今回やってみてはまった点が、生成AIが特定のパターンの応答しか返してこなくなったことです。
最初は一つのモデルで「クイズの作成」と「ユーザーの回答の答え合わせ」を行わせようとしたのですが、なぜか「答え合わせ」の方をやってくれないことが多々ありました。

そこで考えたのが、「クイズの作成」と「答え合わせ」のそれぞれを行うモデルを別々に用意することです。
「答え合わせ」用のモデルに関しては以下のようなPromptを指定しています。

```.md
## Request
・与えられた英語の問題と回答に対して、正解か、不正解かを判定してください

## Role
・あなたは英語の問題の採点者です

## Rule
・ユーザーの回答に対して正解か不正解か明示してください
・問題の解説を提供してください

## Recommend
・回答の際は、以下のように「正解です」または「不正解です」と言ってください。
「正解です！ 解説： 文の内容から、取締役会は再生可能エネルギーへの投資に対して「決意を持っている」ことが示されています。「resolute（決意のある）」は、明確で強い意志を持っていることを意味し、文の意図に合致しています。 他の選択肢について： A) hesitant（ためらっている）は、決定に対して不安や迷いがあることを示し、文の内容とは逆です。 C) indifferent（無関心）は、投資に対して興味や関心がないことを意味し、文の意図には合いません。 D) ambiguous（あいまいな）は、決定が不明確であることを示し、こちらも文の内容とは異なります。 このように、文脈に合った単語を選ぶことが重要です。」
```

AIモデルの使い分けは、ServerAI IDを使い分ければ簡単に実装できます。HTMLで実装したUIと組み合わせれば、以下のような簡易的なAIモデルによるワークフローを構築できます。

1. AIモデル1: 設定された難易度に従ってクイズを作成
1. ユーザー : 回答を入力
1. AIモデル2: 設問と回答を受け取って、正解or不正解を判定

あとは、HTMLとJavascript、CSSでゴリゴリ実装して以下のようになりました。(Markdownはどこへやら...😅)
コードは長くなるので、最後に載せておきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/173520/51f39fce-1da9-eab5-2977-a14b5580b487.png)

## おわりに

確かにMarkdown AIを使えば簡単にwebサイトが作れます。さらにサーバーAIの機能によってインタラクティブな機能を追加することまでできるようになっているのは魅力的です。
しかし、そこにオリジナリティのある何かを作るにはもうひと工夫が必要と痛感しました。

### プロンプトエンジニアリングが重要

私自身、プロンプトエンジニアリングについてそこまで詳しくなかったのですが、生成AIに思い通りの答えを出させるためには、**入力が面倒なプロンプト**を書かなくてはならないことがあり、逆にそれが生成AIアプリケーションのアイディアになるということを学びました。
Markdown AIのサーバーAI機能のように、予めその入力が長くて面倒なプロンプトを指定して特定の仕事をするようにカスタマイズするだけで、面白い機能が作れます。

### 複数AIモデルを組み合わせてワークフローを実現

AIエージェントとまではいかないですが、役割の異なる、複数のAIモデルとwebのUIを組み合わせて、ワークフローを実装できることが分かりました。これまでのように機能をプログラミングで実装するのではなく、プロンプトで特定の役割を与えたAIモデルを作り、それらを組み合わせることで提供するサービスを実現するというのが、AIネイティブなサービス開発になっていくのかもしれません。

### 既存技術の知見も用いないとオリジナリティは出せない

ここは難しいところで、生成AIを使って自由度の高いサイトを作ろうとすると、やはりチャットUIかClaudeが提供するようなArtifactsが現状ベストなのでしょう。
自由度を捨てて、生成AIに固定の仕事をさせて、既存のサイトでサービスを提供しようとすると、やはり既存のwebアプリケーション技術はどうしても必要になってきます。
まあ、それこそ生成AIを使って作ればいいということになるのかもしれませんが。

### サーバーAI機能は制限されている？

β版ということもあるせいか、何度もテストしているとサーバーAI機能が上手く動作していないように感じました。クイズを生成しなくなったり(実行回数制限がある？)、毎回同じクイズを出題するようになったり(キャッシュを返しているだけ？)といった動作が見て取れました。
Knowledge機能もそうですが、Insertボタンから画像生成AIが利用できるようになっていたりと、着実に機能アップはされているようなので、今後に期待したいです。

## 参考

https://qiita.com/phibi-soon/items/61c7654f5ea7a7bd0318

https://mdown.com/ja/forest-mental-clinic/

## コード

```.js
## AI English Quiz Creator

### 難易度を設定

<div style="display: inline-block;">
  TOEICスコア:
  <select id="level">
    <option>400</option>
    <option>500</option>
    <option>600</option>
    <option>700</option>
    <option>800</option>
    <option>900</option>
  </select>
点レベルの問題を
<input type="number" id="iteration" value="5" style="width: 50px;" />問
出題します。
</div>
<br/>
<br/>

### Quiz

<div id="quizarea">
  <div id="start-wrapper" style="display: inline-block;">
      <button type="button" id="start">START</button>
  </div>

  <div id="index"></div>
  <br/>
  <div id="question-wrapper">
    <div id="question"></div>
  </div>
  <br/>
  <div id="result-wrapper">
    <div id="result"></div>
  </div>
  <br/>

  <div id="answer-wrapper">
    <input type="text" id="text-1734048469" style="width: 200px;" placeholder="Input your answer">
    <button type="button" id="button-1734048469">Answer</button>
  </div>
  <br/>

  <div id="action-wrapper">
      <button type="button" id="reset">RESET</button>
      <button type="button" id="next">NEXT</button>
  </div>
</div>

<script>
(() => {
  const serverAi = new ServerAI();
  const makerId = '<My Quiz Maker ID>';
  const answerId = '<My Quiz Answerer ID>';
  const level = document.getElementById('level');
  const iterationNum = document.getElementById('iteration');

  var iter = 0;
  var numOfSuccess = 0;
  const prompt = `TOEICで${level.value}点レベルの問題を作ってください。`;

  const startButton = document.getElementById('start');
  const answerButton = document.getElementById('button-1734048469');
  const nextButton = document.getElementById('next');
  const resetButton = document.getElementById('reset');

  const initState = () => {
    iter = 0;
    numOfSuccess = 0;
    startButton.style.display = 'block';
    answerButton.style.display = 'none';
    nextButton.style.display = 'none';
    resetButton.style.display = 'none';
    document.getElementById('index').style.display = 'none';
    document.getElementById('question').style.display = 'none';
    document.getElementById('question').innerText = '';
    document.getElementById('text-1734048469').style.display = 'none';
    document.getElementById('result').style.display = 'none';
  };

  const createQuiz = async () => {
      const question = await serverAi.getAnswerText(makerId, '', prompt);
      if (!question) {
        alert('問題の作成に失敗しました');
      }
      document.getElementById('index').innerText = `第${iter}問`;
      document.getElementById('question').innerText = question;
      document.getElementById('text-1734048469').style.display = 'inline-block';
      answerButton.style.display = 'inline-block';
  };

  startButton.addEventListener('click', async event => {
    startButton.disabled = true;
    iter++;
    await createQuiz();
    startButton.style.display = 'none';
    startButton.disabled = false;
    nextButton.style.display = 'block';
    resetButton.style.display = 'block';
  });

  answerButton.addEventListener('click', async event => {
    answerButton.disabled = true;
    const question = document.getElementById('question').innerText;
    const answer = document.getElementById('text-1734048469').value;
    const result = await serverAi.getAnswerText(answerId, '', `${question}、回答: ${answer}`);
    if (!result) {
      alert('回答の取得に失敗しました')
    }
    if (result.indexOf('不正解') < 0) {
      numOfSuccess++;
    }
    document.getElementById('question').innerText = result;
    document.getElementById('text-1734048469').style.display = 'none';
    answerButton.style.display = 'none';
    answerButton.disabled = false;
  });

  nextButton.addEventListener('click', async event => {
    nextButton.disabled = true;
    iter++;
    if (iter <= iterationNum.value) {
      await createQuiz();
    } else {
      document.getElementById('index').style.display = 'none';
      document.getElementById('question').style.display = 'none';
      document.getElementById('text-1734048469').style.display = 'none';
      nextButton.style.display = 'none';
      const result = `Your Achievement: ${numOfSuccess}/${iterationNum.value}`;
      document.getElementById('result').innerText = result;
      document.getElementById('result').style.display = 'block';
      document.getElementById('text-1734048469').style.display = 'none';
      answerButton.style.display = 'none';
    }
    nextButton.disabled = false;
  });

  resetButton.addEventListener('click', async event => {
    initState();
  });
})();
</script>

<style>
  body {
    font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
    margin: 0 auto;
    padding: 30px;
    background-color: #f0f2f5;
    color: #333;
  }
  h2 {
    color: #1a73e8;
    border-bottom: 2px solid #1a73e8;
    padding-bottom: 10px;
    margin-bottom: 20px;
  }
  select, input[type="number"], input[type="text"] {
    padding: 8px;
    border: 1px solid #d1d1d1;
    border-radius: 3px;
    font-size: 14px;
  }
  button {
    background-color: #1a73e8;
    color: white;
    border: none;
    padding: 10px 15px;
    border-radius: 4px;
    cursor: pointer;
    transition: background-color 0.2s;
    font-size: 14px;
  }
  button:hover {
    background-color: #1557b0;
  }
  button:disabled {
    background-color: #a0a0a0;
    cursor: not-allowed;
  }
  #quizarea {
    width: 100%;
  }
  #index {
    font-size: 16px;
    font-weight: bold;
    color: #1a73e8;
    margin-bottom: 15px;
  }
  #start-wrapper {
    width: 100%;
  }
  #start {
    display: block;
    margin: 20px auto;
    text-align: center;
  }
  #question-wrapper {
    display: flex;
    justify-content: center;
  }
  #result-wrapper {
    display: flex;
    justify-content: center;
  }
  #answer-wrapper {
    display: flex;
    justify-content: center;
  }
  #action-wrapper {
    display: flex;
    justify-content: space-between;
    width: 100
  }
  #next {
    display: none;
  }
  #reset {
    display: none;
  }
  #question {
    width: 600px;
  }
  #text-1734048469 {
    display: none;
    margin: 02px;
  }
  #button-1734048469 {
    display: none;
  }
  #result {
    display: none;
    width: 400px;
    font-size: 1.5rem;
  }
</style>
```