GASでOpenAI APIを使用して、履歴をスプレッドシートに記録する

# 目的
GASでOpenAI APIを使用して、履歴をスプレッドシートに記録する。

# 背景
最近はやりのChatGPTを、自作のLINE botでも使いたいと思ったので、OpenAI APIをたたくクラスを作ってみた。
また、参考にした[記事](https://auto-worker.com/blog/?p=7459)に、以下の工夫が書かれていたので取り入れてみた。
1. 過去のやり取りは直前〇回までと設定
1. APIのmax_tokensのパラメータ上限を設定
1. 日本語ではなく英語に翻訳してAPIリクエスト

# 事前準備
スプレッドシート作成方法や、OpenAI APIのAPIキー取得方法は、ChatGPTに聞いてみる。またはググるなどして、用意するとよい。
ChatGPTに質問する際の例文は以下の通り。
「スプレッドシートの作り方教えて」
「OpenAIのAPIキーを取得する方法教えて」
※ChatGPTに「ChatGPTの回答を再配布してもよいか」聞いてみたところダメとの回答だったので、質問する例文を示した。

# 使用法
スプレッドシートを作成し、見出し行を作る。(3列にする)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/201344/8b48d3fc-3c5e-f3fc-2133-7abbbd875192.png)
スプレッドシートのURLは、以下のフォーマットになっているので、XXXXXXXの部分がスプレッドシートIDになる。
https://docs.google.com/spreadsheets/d/XXXXXXX/edit

以下のスクリプトプロパティを登録する。
1. CHAT_GPT_MESSAGE_SPREADSHEET_ID にスプレッドシートのIDを定義する。
1. CHAT_GPT_MESSAGE_SHEET_NAME にシート名を定義する。
1. APIKEY_OPENAI にOpenAIのAPIキーを定義する。

必要に応じて、以下の定義を変更する。
1. NUMBER_OF_CHAT_HISTORY_TO_SEND に過去のやり取り何回分を送るか指定する。
1. MAX_TOKENS にAPIのmax_tokensのパラメータ上限を指定する。

作成したのはChatGPTMessagesクラスです。
addJPContent()メソッドで質問文を登録する。
requestChatCompletion()メソッドでOpenAI APIをたたく。
getJPContent()メソッドで回答文を取得する。
テストコードがmyChatGPTMessagesTest()にあるので、デバッグして動作を確認するとわかりやすいと思う。

```javascript
const CHAT_GPT_MESSAGE_SPREADSHEET_ID = PropertiesService.getScriptProperties().getProperty('CHAT_GPT_MESSAGE_SPREADSHEET_ID');
const CHAT_GPT_MESSAGE_SHEET_NAME = PropertiesService.getScriptProperties().getProperty('CHAT_GPT_MESSAGE_SHEET_NAME');
const USER_STR = 'user';
const ASSISTANT_STR = 'assistant';
const APIKEY_OPENAI = PropertiesService.getScriptProperties().getProperty('APIKEY_OPENAI');
const NUMBER_OF_CHAT_HISTORY_TO_SEND = 5
const MAX_TOKENS = 1024

function myChatGPTMessagesTest(){
  let messages = new ChatGPTMessages();
  console.log(messages);
  messages.addJPContent('"Qiita"記事のテンプレートを作って。見出しは"目的"、"環境"、"セットアップ"、"使い方"、"おわりに"、"関連リンク"です');
  console.log(messages.getENMessages());
  messages.requestChatCompletion();
  console.log(messages.getJPContent());
  messages.addJPContent('"目的"の次に、"背景"を入れて');
  console.log(messages.getENMessages());
  messages.requestChatCompletion();
  console.log(messages.getJPContent());
  messages.addJPContent('"関連リンク"に、小見出し"ドキュメント"を入れて');
  console.log(messages.getENMessages());
  messages.requestChatCompletion();
  console.log(messages.getJPContent());
  messages.addJPContent('"ドキュメント"の前に"参考サイト"を入れて');
  console.log(messages.getENMessages());
  messages.requestChatCompletion();
  console.log(messages.getJPContent());
  // スプレッドシートのデータをクリアする
  messages.clear();
}

(function(global){
  let ChatGPTMessages = function() {
    this.spreadsheet = SpreadsheetApp.openById(CHAT_GPT_MESSAGE_SPREADSHEET_ID);
    this.sheet = this.spreadsheet.getSheetByName(CHAT_GPT_MESSAGE_SHEET_NAME);
    let _values = this.sheet.getDataRange().getValues();
    //一行目は見出しとする。見出し読み込み。
    this.header = new ChatGPTMessage(_values[0]);
    _values.shift(); //ヘッダーを削除
    //二行目以降はデータ。データ読み込み
    this.lastColumn = this.sheet.getDataRange().getLastColumn();
    this.messages = [];
    for(let i = 0; i < _values.length; i++){
      this.messages[i] = new ChatGPTMessage(_values[i]);
    }
  };

  // ChatGPTへの質問登録用
  ChatGPTMessages.prototype.addJPContent = function(content_jp){
    let content_en = LanguageApp.translate(content_jp, '', 'en');
    let message = new ChatGPTMessage([USER_STR, content_jp, content_en]);
    this.messages.push(message);
    this.updateSheet();
  };
  
  // ChatGPTからの返答登録用
  ChatGPTMessages.prototype.addENContent = function(content_en){
    let content_jp = LanguageApp.translate(content_en, '', 'ja');
    let message = new ChatGPTMessage([ASSISTANT_STR, content_jp, content_en]);
    this.messages.push(message);
    this.updateSheet();
   };

  // ChatGPTからの返答取得
  ChatGPTMessages.prototype.getJPContent = function(){
    let content_jp = this.getJPMessages(1).pop().content;
    return content_jp;
  };

  // ChatGPTとの過去のやり取りを全て取得する
  ChatGPTMessages.prototype.getValues = function(){
    let values = []
    for(let message of this.messages){
      values.push(message.getRow());
    }
    return values;
  };

  // ChatGPTとの過去のやり取りを全てクリアする
  ChatGPTMessages.prototype.clear = function(){
    this.messages = [];
    this.updateSheet();
  };

  // スプレッドシートに反映
  ChatGPTMessages.prototype.updateSheet = function(){
    // 一旦クリア
    this.sheet.clearContents();
    // 見出し反映
    let values = [];
    values.push(this.header.getRow());
    // データの反映
    for(let value of this.getValues()){
      values.push(value);
    }
    //データ範囲の最終行と最終列を求める
    let column = this.header.getRow().length;
    let row = values.length;
    //書き出し
    this.sheet.getRange(1, 1, row, column).setValues(values);
  };

  // ChatGPTへの質問リスト取得
  ChatGPTMessages.prototype.getENMessages = function(size=NUMBER_OF_CHAT_HISTORY_TO_SEND){
    let enMessages = [];
    let count = 0;
    let startRow = 0;
    if(this.messages.length > size) {
      startRow = this.messages.length - size;
    }
    for(let i = startRow; i < this.messages.length; i++){
      enMessages.push({'role': this.messages[i].role, 'content': this.messages[i].content_en});
    }
    return enMessages;
  };

  // ChatGPTからの返答リスト取得
  ChatGPTMessages.prototype.getJPMessages = function(size=1){
    let jpMessages = [];
    let count = 0;
    let startRow = 0;
    if(this.messages.length > size) {
      startRow = this.messages.length - size;
    }
    for(let i = startRow; i < this.messages.length; i++){
      jpMessages.push({'role': this.messages[i].role, 'content': this.messages[i].content_jp});
    }
    return jpMessages;
  };

  // ChatGPTに英文でリクエストする
  // エラー時はresponse.choicesが無く、response.error.messageがある。
  ChatGPTMessages.prototype.requestChatCompletion = function() {
    //ChatGPTのAPIのエンドポイントを設定
    const apiUrl = 'https://api.openai.com/v1/chat/completions';
    //OpenAIのAPIリクエストに必要なヘッダー情報を設定
    const headers = {
      'Authorization':'Bearer '+ APIKEY_OPENAI,
      'Content-type': 'application/json',
      'X-Slack-No-Retry': 1
    };
    //ChatGPTモデルやトークン上限、プロンプトをオプションに設定
    const options = {
      'muteHttpExceptions' : true,
      'headers': headers, 
      'method': 'POST',
      'payload': JSON.stringify({
        'model': 'gpt-3.5-turbo',
        'max_tokens' : MAX_TOKENS,
        'temperature' : 0.9,
        'messages': this.getENMessages(),
        })
    };
    //OpenAIのChatGPTにAPIリクエストを送り、結果を変数に格納
    const response = JSON.parse(UrlFetchApp.fetch(apiUrl, options).getContentText());
    if(response.choices){
      this.addENContent(response.choices[0].message.content);
    }else{
      console.log(response.error.messages);
    }
  };

  global.ChatGPTMessages = ChatGPTMessages;
})(this);

(function(global){
  let ChatGPTMessage = function(record) {
    [this.role,
    this.content_jp,
    this.content_en] = record;
  };

  ChatGPTMessage.prototype.getRow = function(){
    return [this.role, this.content_jp, this.content_en];
  };

  global.ChatGPTMessage = ChatGPTMessage;
})(this);
```

# 結論
いくつか微妙な点があり、作ったはいいが、使わないかもしれない。トークンは抑えられそうなので、興味がある方はどうぞ。
1. 日英翻訳で往復すると、微妙に単語が変わったり、日本語に訳されない箇所がある。
1. 半角の文字が全角に変わることがある。

微妙な結果を受けて、モチベーションが下がってしまったため、トークンが本当に抑えられたかどうか、測定は是非どなたかやっていただきたい。


# 関連リンク
## 参考
- [GASでChatGPTのAPIリクエスト方法！文脈に沿った応答のサンプルコードを解説](https://auto-worker.com/blog/?p=7438)
- [ChatGPTのAPIの利用料金を詳しく解説！使い方次第でトークン増大で高額費用も](https://auto-worker.com/blog/?p=7459)
- [Google Apps Script(GAS)で翻訳する方法！英訳や和訳の実装(translateメソッド)](https://auto-worker.com/blog/?p=2416)

## ドキュメント
- [OpenAIドキュメント](https://platform.openai.com/docs/introduction)