2023年4月2日日曜日

GAS で ChatGPT とやりとりできる簡単な LINE bot の作り方

-----
・追記(2023-4-4)
system ロールへの指示を複数登録、任意に使用できるように修正しました。

・追記(2023-4-14)
文章のランダムさを制御する temperature パラメータを調整できるように修正しました。
-----

先日作った「GAS で ChatGPT の API を利用した簡単な LINE bot を作ってみる」に引き続き、複数回のやりとりができるバージョンを作ってみました。



文頭に「続き)」と付けると会話が続きます。


やりとりは Google スプレッドシートに保存されます。

「chat」シート

文頭に「続き)」がない場合、やりとりは削除されます。


また system ロールへの指示も可能です。system ロールへの指示はスプレッドシートに直接書き込んでおきます。

「system」シート

watchword は system ロールへの指示を使用する際の合言葉です。文頭に「'watchword'!」の形式で書くことで使用できます。


例えば、「要約!」を付けると「3行で要約してください」という指示がされます(実際には英訳して指示)。



「昔話!」なら「与えられた題材から3行で昔話を作ってください」という指示がされ、簡単な昔話を作ってくれます。



さらに、文章のランダムさを制御する temperature パラメータの値を保存しておく「temperature」シートを作成しておきます。中身は空白で大丈夫です。

質問を始める際に、文末に「(厳密」もしくは「(創造」を付けることで使用できます。「(厳密」であればランダムさは「0」、「(創造」であれば「1」になります。何も付けない場合は「0.5」です。




「続き)」を使ってやりとりを継続すると、ランダムさも引き継がれます。


それでは作り方です。

LINE Messaging API チャンネルの作成とアクセストークンの取得。及び、OpenAI の API キー取得は済んでる想定です。

それぞれ、GAS の「プロジェクトの設定」よりスクリプト プロパティとして追加・保存しておきます。プロパティ名は「Line_key」と「OpenAI_key」としています。

以下、コードになります。

const spreadsheet = SpreadsheetApp.openById('スプレッドシート ID');
const chatSheet = spreadsheet.getSheetByName('chat');
let chatLastRow = chatSheet.getLastRow();
let chatRange = chatSheet.getRange(2, 1, chatLastRow, 2);
const systemSheet = spreadsheet.getSheetByName('system');
const systemLastRow = systemSheet.getLastRow();
const systemWatchwordRange = systemSheet.getRange(2, 1, systemLastRow - 1, 1);
const systemContentRange = systemSheet.getRange(2, 2, systemLastRow - 1, 1);
const tempSheet = spreadsheet.getSheetByName('temperature');
const tempRange = tempSheet.getRange(1, 1);

function doPost(e) {
  try {
    const replyToken = getReplyToken(e);
    const requestMessage = getRequestMessage(e);
    const openAiParams = generateOpenAIParams(requestMessage);
    const response = UrlFetchApp.fetch('https://api.openai.com/v1/chat/completions', openAiParams);
    const resData = JSON.parse(response.getContentText());
    const replyMessage = resData.choices[0].message.content;
    setAssistantMessage(replyMessage); // スプレッドシートに'role': 'assistant'で格納
    const translatedMessage = LanguageApp.translate(replyMessage, 'en', 'ja');
    const replyContent = `${requestMessage[requestMessage.length - 1]['content']}\n-----\n${translatedMessage}\n\n${replyMessage}`;
    const linePayload = generateLinePayload(replyToken, replyContent);
    sendLineMessage(linePayload);
  } catch (error) {
    console.error(`An error occurred: ${error}`);
  }
}

// pushメッセージからreplyTokenを取得する関数
function getReplyToken(e) {
  const event = JSON.parse(e.postData.contents).events[0];
  return event.replyToken;
}

// リクエストメッセージを生成する関数
function getRequestMessage(e) {
  const event = JSON.parse(e.postData.contents).events[0];
  const message = event.message;
  let messageContent = message.type === 'text' ? message.text : '';
  // リクエストメッセージの配列を生成
  const systemWatchwordArry = systemWatchwordRange.getValues().flat();
  const systemContentArry = systemContentRange.getValues().flat();
  let systemWatchword = "";
  let systemContent = "";
  if (messageContent.startsWith('続き)')) {
    messageContent = LanguageApp.translate(messageContent.replace('続き)', ''), '', 'en');
    chatSheet.getRange(chatLastRow + 1, 1, 1, 2).setValues([['user', messageContent]]);
  } else {
    // temperatureを取得してスプレッドシートにセット
    if (messageContent.endsWith('(創造')) {
      messageContent = messageContent.replace('(創造', '');
      tempRange.setValue(1);
    } else if (messageContent.endsWith('(厳密')) {
      messageContent = messageContent.replace('(厳密', '');
      tempRange.setValue(0);
    } else {
      tempRange.setValue(0.5);
    }
    // systemロールの読み込み
    for (let i = 0; i < systemWatchwordArry.length; i++) {
      if (messageContent.startsWith(systemWatchwordArry[i] + '!')) {
        systemWatchword = systemWatchwordArry[i] + '!';
        systemContent = LanguageApp.translate(systemContentArry[i], '', 'en');
        break;
      }
    }
    messageContent = LanguageApp.translate(messageContent.replace(systemWatchword, ''), '', 'en');
    chatRange.clear();
    if (systemContent) {
      chatSheet.getRange(2, 1, 2, 2).setValues([['system', systemContent], ['user', messageContent]]);
      chatRange = chatSheet.getRange(2, 1, 2, 2);
    } else {
      chatSheet.getRange(2, 1, 1, 2).setValues([['user', messageContent]]);
      chatRange = chatSheet.getRange(2, 1, 1, 2);
    }
  }
  let requestMessage = [];
  if (systemContent) {
    requestMessage.push({'role': 'system', 'content': systemContent});
  }
  let values = chatRange.getValues();
  for(let i = 0; i < values.length; i++) {
    requestMessage.push({'role': values[i][0], 'content': values[i][1]});
  }
  return requestMessage;
}

// OpenAIのリクエストパラメータを生成する関数
function generateOpenAIParams(requestMessage) {
  const openAiHeaders = {
    'Authorization': `Bearer ${PropertiesService.getScriptProperties().getProperty('OpenAI_key')}`,
    'Content-type': 'application/json',
    'X-Slack-No-Retry': 1
  };
  const openAiPayload = {
    'model': 'gpt-3.5-turbo', // 使用するモデル
    'max_tokens': 1024, // 生成する文章の最大トークン数
    'temperature': tempRange.getValue(), // 生成された文章のランダムさを制御するパラメータ。値が高いほど、よりランダムな文章が生成される
    'messages': requestMessage // 生成する文章の元になるプロンプト
  };
  return {
    'method': 'POST',
    'headers': openAiHeaders,
    'payload': JSON.stringify(openAiPayload),
  };
}

// アシスタントの返答をスプレッドシートに格納
function setAssistantMessage(translatedMessage) {
  chatLastRow = chatSheet.getLastRow();
  chatSheet.getRange(chatLastRow + 1, 1, 1, 2).setValues([['assistant', translatedMessage]]);
}

// LINEに返信するメッセージを生成する関数
function generateLinePayload(replyToken, replyMessage) {
  return {
    'method': 'post',
    'headers': {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${PropertiesService.getScriptProperties().getProperty('Line_key')}`,
    },
    'payload': JSON.stringify({
      'replyToken': replyToken,
      'messages': [{
        'type': 'text',
        'text': replyMessage,
      }],
    }),
  };
}

// LINEにメッセージを送信する関数
function sendLineMessage(linePayload) {
  const response = UrlFetchApp.fetch('https://api.line.me/v2/bot/message/reply', linePayload);
  const responseCode = response.getResponseCode();
  if (responseCode !== 200) {
    console.error(`LINE API returned an error: ${responseCode}`);
  }
}

1行目の「スプレッドシート ID」は書き換えてくください。

やりとりの保存用と system ロールへの指示用シートの名前はそれぞれ「chat」と「system」としています。

コード作成後にウェブアプリとして公開、URL を LINE Messaging API チャンネルで Webhook として設定して完成です。


***

会話と system ロールへの指示ができると、また一段と活用法が広がる気がしますね。

無料枠を使い切った後も自分(家族)用の便利 bot として利用するのはありかもと思っています。コストとしても月に数十〜数百円程度なら許容範囲です。Bing やブラウザから ChatGPT を使うのが面倒なちょっとした時に重宝しそうです。


ちなみに今回も ChatGPT にコードの作成を手伝ってもらったのですが、ときどき存在しないメソッドを回答され混乱しました。信用しきってはだめですね(笑)。




Twitter (@nkkmd) 日々更新中です。