GPT-4o-miniを利用して、クイズ形式応答を行うLINE Bot (複数ジャンル対応型・GPT-4omniへ切替も可)

前回ご紹介したGPT-4o-miniとDALL-E3とDeepLを同時に使用できるLINE Botを更に応用し、
https://qiita.com/ussoewwin/items/6e0a9c6de7c806852107
クイズ形式の応答を行うことが出来るLINE BotをGASで作成しました。

前バージョンに搭載している各種機能もそのまま使用できます。それらの機能については、上のリンクから以前の記事をご参照ください。

尚、今回のバージョンでは前回バージョンに実装していた、トリガーに一致しないあらゆる入力に対して、仮に「A」と返すコードは削除しています。

今バージョンにおいては、その際には何も反応しない形になっています。

初期設定としては算数と漢字に関するクイズを行うプロンプトを組んでいますが、プロンプトのカスタマイズ次第では、別なジャンルのクイズ形式に変更することも出来ます。

実際の使用例は下図のようになります。
![タイトルなし.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3661150/467115d4-36c3-6cc1-c81c-e9e117f10352.png)

![タイトルなし2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3661150/d9ff9ec3-d813-2837-5af8-0e38bffdcaec.png)


上図のように、LINE Bot上で「算数クイズ」または「漢字クイズ」と入力すると、GPT-4o-miniがランダムに算数と漢字、日本史、地理に関するクイズを作成し、送信してきます。

ユーザーがそれに対して返答を行うと、次にGPT-4o-miniはあらかじめ用意しておいた正解を返信します。

まず始めに、下のリンクからGSSファイルを開き、メニューファイルから「Make a copy」を選択して、ご自身のGoogle Driveにコピーしてください。
https://docs.google.com/spreadsheets/d/1WYzwJOORUn39uVG4wsatZ97KoIS4NgfRRDKYCopR818/edit?usp=sharing

GSSファイルをコピーしたら、下図のようにスクリプトを開いてください。
![タイトルなし.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3661150/8e37df4b-52ae-a46f-7fbe-ac9191e22629.png)


スクリプトエディタを開いたら、所定の位置にLINEのアクセストークンとOpenAIのAPIキー、DeepLのAPIキーを入力してください。
![タイトルなし2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3661150/5af4bd29-0d70-8dac-7964-5317230db694.png)


それだけで、後はデプロイと初回認証を行い、LINE developersのWebhookにリンクさせるだけで使用できます。

DeepLのキーを入力しなくても、英訳機能以外のLINE Bot自体は動作します。
(GPT-3.5Turboとの会話機能の中でも、英訳自体は可能です。トークンは消費しますが)

尚、参考までにmain.gsファイルのコードは下のように作成しております。
```main.gs
// LINE Bot 設定
const CHANNEL_ACCESS_TOKEN = 'ここに入力する'; 

// AI設定
const openAIApiKey = "ここに入力する";

//英語翻訳機能を使う為のDEEL API Key
const DEEPL_API_KEY = 'ここに入力する';

const logSheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('log');
const DALLE_API = "https://api.openai.com/v1/images/generations";
var replyToken, json;

function doPost(e) {
  json = JSON.parse(e.postData.contents);
  replyToken = json.events[0].replyToken;
  if (typeof replyToken === 'undefined') {
    return;
  }

  // アニメーション効果関数のロード
  const userId = json.events[0].source.userId;
  loadinganimation(userId);

  var userMessage = json.events[0].message.text;
  var messages;

  if (userMessage.includes("こんにちはGPT")) {
    var replyText; // AIからの応答を保存する変数

    if (/こんにちはGPT、.*駅発、.*駅着/.test(userMessage)) {
    // 交通手段と費用に関するプロンプトを作成
    var prompt = userMessage + "前述の合理的な交通手段と費用を算出して1000字以内で出力してください。読みやすいように箇条書きにして、改行してください";
    replyText = getAIChatAnswer(prompt); // 関数を呼び出し、promptを渡す

    }else if (/.*、レシピ/.test(userMessage)) {
    // 交通手段と費用に関するプロンプトを作成
    var prompt = userMessage + "前述の合理的なレシピと予想される費用を概算で良いので算出し、1000字以内で出力してください。読みやすいように箇条書きにして、改行してください";
    replyText = getAIChatAnswer(prompt); // 関数を呼び出し、promptを渡す

    } else {
      // 通常の応答
      replyText = getAIChatAnswer(userMessage); // 関数を呼び出し
    }

    messages = [{'type': 'text', 'text': replyText}]; // LINEメッセージオブジェクトを作成
    
  } else if (userMessage.startsWith("絵を描いて")) {
    // 絵を描くリクエストに対して画像を生成
    messages = getAIImageAnswer(userMessage);
    
  } else if (userMessage.includes("英訳して")) {
    var textToTranslate = userMessage.replace("英訳して", "");
    var translatedText = translateTextWithDeepL(textToTranslate, "EN");
    messages = [{'type': 'text', 'text': translatedText}];
    
  // クイズ機能の追加
  } else if (userMessage.includes("算数クイズ")) {
    const quizPrompt = "ランダムに簡単な算数の問題を1つ生成し、その問題と正確な回答を出して、「問題」　〇〇「正解」〇〇と表示してください。必ず問題と正解部分は改行して、分けてください。また、表題の「問題」と問題本文は改行しないでください";
    const quizResponse = getAIChatAnswer(quizPrompt);
    const splitResponse = quizResponse.split("\n"); // 仮定: 問題と回答が改行で区切られている
    const question = splitResponse[0];
    const answer = splitResponse.slice(1).join("\n"); // 回答部分が複数行にわたる可能性があるため

    // スクリプトプロパティに回答を保存
    const properties = PropertiesService.getScriptProperties();
    properties.setProperty('quizAnswer', answer);

    messages = [{'type': 'text', 'text': question}];

    // LINEに返信する処理を呼び出す
    const linebotClient = new LineBotSDK.Client({ channelAccessToken: CHANNEL_ACCESS_TOKEN });
    try {
      linebotClient.replyMessage(replyToken, messages);
    } catch (e) {
      log_to_sheet("A", e);
    }
    return; // この処理で返信するため、以降の処理は実行しない
  
  } else if (userMessage.includes("漢字クイズ")) {
    const quizPrompt = "ランダムに漢字の読み書きの問題を1つ生成し、その問題と正確な回答を出して、「問題」　〇〇「正解」〇〇と表示してください。必ず問題と正解部分は改行して、分けてください。また、表題の「問題」と問題本文は改行しないでください";
    const quizResponse = getAIChatAnswer(quizPrompt);
    const splitResponse = quizResponse.split("\n"); // 仮定: 問題と回答が改行で区切られている
    const question = splitResponse[0];
    const answer = splitResponse.slice(1).join("\n"); // 回答部分が複数行にわたる可能性があるため

    // スクリプトプロパティに回答を保存
    const properties = PropertiesService.getScriptProperties();
    properties.setProperty('quizAnswer', answer);

    messages = [{'type': 'text', 'text': question}];

    // LINEに返信する処理を呼び出す
    const linebotClient = new LineBotSDK.Client({ channelAccessToken: CHANNEL_ACCESS_TOKEN });
    try {
      linebotClient.replyMessage(replyToken, messages);
    } catch (e) {
      log_to_sheet("A", e);
    }
    return; // この処理で返信するため、以降の処理は実行しない
  
  } else if (userMessage.includes("日本史クイズ")) {
    const quizPrompt = "ランダムに簡単な日本史の問題を1つ生成し、その問題と正確な回答を出して、「問題」　〇〇「正解」〇〇と表示してください。必ず問題と正解部分は改行して、分けてください。また、表題の「問題」と問題本文は改行しないでください";
    const quizResponse = getAIChatAnswer(quizPrompt);
    const splitResponse = quizResponse.split("\n"); // 仮定: 問題と回答が改行で区切られている
    const question = splitResponse[0];
    const answer = splitResponse.slice(1).join("\n"); // 回答部分が複数行にわたる可能性があるため

    // スクリプトプロパティに回答を保存
    const properties = PropertiesService.getScriptProperties();
    properties.setProperty('quizAnswer', answer);

    messages = [{'type': 'text', 'text': question}];

    // LINEに返信する処理を呼び出す
    const linebotClient = new LineBotSDK.Client({ channelAccessToken: CHANNEL_ACCESS_TOKEN });
    try {
      linebotClient.replyMessage(replyToken, messages);
    } catch (e) {
      log_to_sheet("A", e);
    }
    return; // この処理で返信するため、以降の処理は実行しない
  
  } else if (userMessage.includes("地理クイズ")) {
    const quizPrompt = "ランダムに簡単な地理の問題を1つ生成し、その問題と正確な回答を出して、「問題」　〇〇「正解」〇〇と表示してください。必ず問題と正解部分は改行して、分けてください。また、表題の「問題」と問題本文は改行しないでください";
    const quizResponse = getAIChatAnswer(quizPrompt);
    const splitResponse = quizResponse.split("\n"); // 仮定: 問題と回答が改行で区切られている
    const question = splitResponse[0];
    const answer = splitResponse.slice(1).join("\n"); // 回答部分が複数行にわたる可能性があるため

    // スクリプトプロパティに回答を保存
    const properties = PropertiesService.getScriptProperties();
    properties.setProperty('quizAnswer', answer);

    messages = [{'type': 'text', 'text': question}];

    // LINEに返信する処理を呼び出す
    const linebotClient = new LineBotSDK.Client({ channelAccessToken: CHANNEL_ACCESS_TOKEN });
    try {
      linebotClient.replyMessage(replyToken, messages);
    } catch (e) {
      log_to_sheet("A", e);
    }
    return; // この処理で返信するため、以降の処理は実行しない
  }

  // ユーザーが何か回答した場合
  else {
    const properties = PropertiesService.getScriptProperties();
    const savedAnswer = properties.getProperty('quizAnswer');
    if (savedAnswer) {
      messages = [{'type': 'text', 'text': `正解は: ${savedAnswer}`}];
      properties.deleteProperty('quizAnswer'); // 回答を表示した後はプロパティを削除

      // LINEに返信する処理を呼び出す
      const linebotClient = new LineBotSDK.Client({ channelAccessToken: CHANNEL_ACCESS_TOKEN });
      try {
        linebotClient.replyMessage(replyToken, messages);
      } catch (e) {
        log_to_sheet("A", e);
      }
      return; // この処理で返信するため、以降の処理は実行しない
    }
  
  }

  const linebotClient = new LineBotSDK.Client({ channelAccessToken: CHANNEL_ACCESS_TOKEN });
  try {
    linebotClient.replyMessage(replyToken, messages);
  } catch (e) {
    log_to_sheet("A", e);
  }
  return ContentService.createTextOutput(JSON.stringify({'content': 'post ok'})).setMimeType(ContentService.MimeType.JSON);
}

//DALL-E3に関する関数
function getAIImageAnswer(text) {
  var imageURL = generateImageURL(text);
  var messages = [
    {'type':'text', 'text': '画像ができました！'},
    {'type':'image', 'originalContentUrl': imageURL, 'previewImageUrl': imageURL}
  ];
  return messages;
}

function generateImageURL(text) {
  var options = {
    "method": "post",
    "headers": {
      "Content-Type": "application/json",
      "Authorization": "Bearer " + openAIApiKey
    },
    "payload": JSON.stringify({
      "prompt": text,
      "model": "dall-e-3",
      "response_format": "url"
    })
  };
  var response = UrlFetchApp.fetch(DALLE_API, options);
  var data = JSON.parse(response.getContentText());
  return data.data[0].url;
}

//GPT-4o-miniに関する関数
function getAIChatAnswer(prompt, userId) {
  var requestOptions = {
    "method": "post",
    "headers": {
      "Content-Type": "application/json",
      "Authorization": "Bearer " + openAIApiKey
    },
    "payload": JSON.stringify({
      "model": "gpt-4o-mini",
      "messages": [
         {"role": "user", "content": prompt}
      ]
    })
  };
  var response = UrlFetchApp.fetch("https://api.openai.com/v1/chat/completions", requestOptions);
  var responseText = response.getContentText();
  var json = JSON.parse(responseText);
  return json.choices[0].message.content.trim();
}

function log_to_sheet(column, text) {
  var lastRow;
  if(logSheet.getRange(column + "1").getValue() == ""){
    lastRow = 0;
  } else if(logSheet.getRange(column + "2").getValue() == ""){
    lastRow = 1;
  } else {
    lastRow = logSheet.getRange(column + "1").getNextDataCell(SpreadsheetApp.Direction.DOWN).getRow();
    if(lastRow >= 1000){
      logSheet.getRange(column + "1:" + column + "1000").clearContent();
      lastRow = 0;
    }
  }
  var putRange = column + String(lastRow + 1);
  logSheet.getRange(putRange).setValue(text);
}




```

既存の機能に関する解説は割愛しますが、上のコードの中でクイズ応答機能に関する設定は、それぞれ下図の部分で行います。
![タイトルなし2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3661150/cf41f534-91ee-7549-6b7a-b9b912168ba4.png)

![タイトルなし.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3661150/37c493ef-0e8b-004f-8de1-51877849bd95.png)


ここでGPT-4o-miniに対するプロンプトを指定していますが、技術的な要点としては、GASのScriptProperties機能を使用していることです。

ScriptPropertiesにGPT-4o-miniが生成した正解の部分だけをバッファとして一時保存し、ユーザーが回答を入力すると、保存された正解が表示される理屈になります。

当初の構想としては、AIに採点してもらうつもりでコードを組んだのですが、実行してみた処、精度が壮絶に悪く、とても実用に耐えないことがわかりました。

その為、次善の策として何とか回答の精度を担保できるアイデアを考えたのですが、最初のプロンプトの時点でGPT-4o-miniに問題だけでなく正解も生成させ、
(このやり方だと、ほぼ確実な正解が表示される)

それをGAS側で問題の部分と回答の部分を分離し、最初は問題だけを表示させる形にしました。

ここを任意に変更することで、理論上は初期設定である算数と漢字のテスト以外のクイズ形式に変更することも出来るのですが、

GPT-3.5Turboが生成できるクイズは、かなり得意な分野と不得意な分野に分かれるようです。

テストコードとして日本史の問題も実装していますが、精度においては算数クイズなどよりは劣る場合、ハルシネーションが起きる可能性が高いです。

大雑把に、文章系は苦手、数字系は得意...という感じがします。

また、このコードでは「算数クイズ」「漢字クイズ」という文字をトリガーにしていますが、当然この部分もご自身の環境に合わせて、変更することが出来ます。

...

main.gs下部に記述しているgetAIChatAnswer関数を変更する事で、初期設定ではGPT-4o-mimiを使用している設定を、より精度の高い最新のGPT-4omniに切り替えることも可能です。
![タイトルなし.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3661150/b03d3d91-82ac-c454-ea78-54dee353d8fd.png)


main.gs下部に記述しているgetAIChatAnswer関数を変更する事で、初期設定ではGPT-4o-miniを使用している設定を、最新のGPT-4omniに切り替えることも可能です。
![タイトルなし.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3661150/df6f66c2-1f92-4d9e-a1c0-a193bf0bc67f.png)

モデル名は、下図のようにgpt-4o-2024-05-13(※最新モデルは随時変わります)と入力してください。
![タイトルなし.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3661150/ba1bb865-fad9-3825-c903-4a5b3ddbe930.png)


モデルをGPT-4omniに変更すると、下図のようにGPT-4o-miniでは精度が低い日本史のクイズなども、かなり高い精度で実行できるようになります。
![タイトルなし.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3661150/157fc9f2-b2e1-7de9-db22-800b4ac2af30.png)

但し一つ問題があって、GPT-4o-miniからGPT-4omniに切り替えた時に、GPT-4o-mini用に最適化したプロンプトでは、クイズ機能の応答が正しく動作しない場合があります。

以下に、なるべくGPT-4omniに最適化したバージョンをアップいたしましたので、GPT-4omniへモデルを変更した際に、クイズ機能に支障が出る場合にはこちらを使用してみてください。
https://docs.google.com/spreadsheets/d/1VZb5JU6R8VvShslw0V_I3jeJ8uKJRE6J6wHrh68X3Y4/edit?usp=sharing

但し、同様にGPT-4omni用に最適化したプロンプトは、GPT-4o-miniに戻した時に、クイズ機能の応答が正確に動作しない傾向があるようです。