【文脈理解（履歴の記憶）に対応！】ChatGPT x GoogleAppsScript（GAS）でLINEbotを作ってみた

# はじめに
本記事は、以下の記事の続編です。
こちらの記事で説明している内容については本記事では省略しますので、初めての方はまずはこちらをご確認ください。

https://qiita.com/CurryPrince/items/3bd03ff6ae1388eb0a3b

# こんなことができます
以下の要件を満たしたLINEbotが作れます。

- ボットに人格設定可能
- 音声入力にも対応
- スタンプの意味も解釈して返答可能
- <b>前の会話の文脈を踏まえて回答（←New!）</b>

![IMG_1800.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2956515/c3549640-a767-f7b2-1824-4b5e0d1496a8.png)


# 実装方針
記憶を持たせるということは、会話の履歴をどこかに保存しておく必要があります。
元記事ではすでに会話履歴をスプレッドシートに記録する仕組みを作成しているので、これを利用します。
また、「最大でどれくらいの記憶を持たせるか」、「いつまでの記憶を持たせるか」という点についても考慮していきます。

## 最大でどれくらいの記憶を持たせるか
ここでは、記憶として最大2,000トークン（約2,000文字）を持たせることにします。
API側の制限は、回答を合わせて約4,000トークンです。トークン数がAPIの上限を突破するとエラーになってしまいます。
LINE側の文字数制限が1メッセージ500文字なので、以下の枠でトークンを割り当てる想定です。

- 記憶：2,000トークン
- キャラ設定：1,000トークン
- userから受け取ってOpenAIのAPIに渡すメッセージ：500トークン
- OpenAIのAPIから受け取るメッセージ：500トークン

キャラ設定に1,000トークン使うことは稀かと思いますが、ある程度のバッファを持たせて設定しています。
なお、厳密には文字数とトークンはイコールではないので、気になる場合はTokenizerを使う等して処理したほうがベターかと思います。

## いつまでの記憶を持たせるか
ChatGPTであれば、テーマごとに新しいチャットを立てて会話するので気にする必要がないのですが、LINEの場合は1つのチャットで全てのやり取りをするので、この点を考慮する必要があります。
本記事では、1時間ごとに120分超経過しているログをアーカイブする処理を入れているため、記憶時間は120〜180分となっています。
この処理を入れることで、

- 過去のログを可能な範囲で毎回取得することで、消費トークンが膨大になってしまう
- ログデータが膨大になっていき処理コストがどんどん増えていく

という問題を解決しています。

# 1. スプレッドシートを加工する
## 1.1 送信メッセージの消費トークンを記録する列を追加する
APIから取得する`promptTokens`には、キャラ設定や、記憶として送信するトークン数も含まれています。そのため、記憶として含める2,000トークンを計算するために、「送信メッセージのみ」に係る消費トークン数を計算する必要があります。
`log`シートの一番右の`J1`セルに、`userMessageCharacters`という見出しを追加します。

![スクリーンショット 2023-04-02 18.30.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2956515/db60f6a1-cb6e-82f1-4c22-a97fa28623ea.png)

## 1.2 アーカイブ用のシートを追加する
`archived_log`というシートを新たに作成し、`log`シートの`A1`〜`J1`の範囲をコピーして、`archived_log`にペーストします（同じ見出しを使用）。
一定時間が経過したやりとりは、こちらのシートにアーカイブされていく仕組みです。

![スクリーンショット 2023-04-02 18.35.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2956515/d1581c9b-19b5-befd-4810-999befc1a6f1.png)

# 2. スクリプトを修正する
スクリプトを以下の通り修正します。

```javascript:main.gs
//スプレッドシートからAIのキャラクター設定を取得する
function getAiSettings(){
  try{
    const ss = SpreadsheetApp.getActiveSpreadsheet();
    const aiSettings = ss.getRangeByName("ai_settings").getValue();
    return aiSettings;
  } catch(e){
    return null;
  }
}

//スプレッドシートから指定したuserIdの過去のやり取り一覧を取得する
function getLog(userId){
  try{
    const ss = SpreadsheetApp.getActiveSpreadsheet();
    const sheet = ss.getSheetByName("log");
    const values = sheet.getDataRange().getValues();
    const [headers, ...records] = values;
    const objects = records.map(record => Object.fromEntries(record.
      map((value, i) => [headers[i], value])
    ));

    const log = objects.filter((record) => {
      return (record.userId === userId);
    });

    return log;
    
  } catch(e){
    return null;
  }
}

//promptの内容をキャラクター設定されたAI（ChatGPT API）に投げて回答を取得する
function getGptReply(userId, prompt){
  const OPENAI_API_KEY = PropertiesService.getScriptProperties().getProperty("OPENAI_API_KEY");
  const MAX_LOG_TOKENS = 2000; //新規送受信で各500、キャラ設定で1,000の枠を見て2,000に設定（合計MAX4,000）。
  const characterSettings = getAiSettings();
  const log = getLog(userId);

  let messages = [{
    "role": "user",
    "content": prompt
  }];

  //MAX_LOG_TOKENSの範囲内で、会話履歴を取得してMessagesに追加
  if(log != null){
    let logTokens = 0;
    for(let i = log.length - 1; i >= 0; i--){
      
      //botメッセージの取得
      if(logTokens + log[i].completionTokens <= MAX_LOG_TOKENS){
        messages.unshift({
          "role": "assistant",
          "content": log[i].botMessage
        }); 
        logTokens += log[i].completionTokens;
      } else {
        break;
      }

      //userメッセージの取得。なおpromptTokensにはsystemやログのデータ入っているため、トークンは文字数で概算
      if(logTokens + log[i].userMessageCharacters <= MAX_LOG_TOKENS){
        messages.unshift({
          "role": "user",
          "content": log[i].userMessage
        }); 
        logTokens += log[i].userMessageCharacters;
      } else {
        break;
      } 
    }
  }
  
  if(characterSettings != null){
    messages.unshift({
      "role": "system",
      "content": characterSettings
    });
  }

  const payload = {
    "model": "gpt-3.5-turbo",
    "temperature" : 0.5, //0〜1で設定。大きいほどランダム性が強い
    "max_tokens": 500, //LINEのメッセージ文字数制限が500文字なので、それに合わせて調整
    "messages": messages
  };
  
  const requestOptions = {
    "method": "post",
    "headers": {
      "Content-Type": "application/json",
      "Authorization": "Bearer "+ OPENAI_API_KEY
    },
    "payload": JSON.stringify(payload)
  };

  const response = UrlFetchApp.fetch("https://api.openai.com/v1/chat/completions", requestOptions);

  const responseText = response.getContentText();
  const json = JSON.parse(responseText);
  const retObj = {
    "payload": payload,
    "message": json.choices[0].message.content,
    "usage": json.usage
  };
  return retObj;
}

//file（音声ファイル）の内容をWhisperAPIを利用して文字列として取得する
function speechToText(file){
  const OPENAI_API_KEY = PropertiesService.getScriptProperties().getProperty("OPENAI_API_KEY");

  const payload = {
    "model": "whisper-1",
    "temperature" : 0,
    "language": "ja", //日本語以外にも対応する場合はこのプロパティは外す
    "file": file
  };
  
  const requestOptions = {
    "method": "post",
    "headers": {
      "Authorization": "Bearer "+ OPENAI_API_KEY
    },
    "payload": payload
  };
  try{
    const response = UrlFetchApp.fetch("https://api.openai.com/v1/audio/transcriptions", requestOptions);

    const responseText = response.getContentText();
    const json = JSON.parse(responseText);
    const text = json.text
    return text;
  } catch(e){
    return e.message;
  }
}

//LINEでユーザーから送られてきた音声ファイルを取得する
function getContentByUser(messageId){
  const LINE_ACCESS_TOKEN = PropertiesService.getScriptProperties().getProperty("LINE_ACCESS_TOKEN");
  const url = `https://api-data.line.me/v2/bot/message/${messageId}/content`;
  const requestOptions = {
    'headers': {
      'Authorization': 'Bearer ' + LINE_ACCESS_TOKEN,
    },
    'method': 'get'
  };
  const response = UrlFetchApp.fetch(url, requestOptions);
  return response.getBlob().setName(`${messageId}.m4a`); //拡張子を指定しないとWhisperAPI側でエラーになるので注意
}

//LINEでユーザーから送られてきたメッセージ（スタンプや音声含む）を文字列に変換する
function convertMessageObjToText(messageObj){
  let result = "";
  const messageType = messageObj.type;
  switch(messageType){
    case "text": //文字列
      result = messageObj.text;
      break;
    case "sticker": //スタンプ。キーワードが設定されていればそれを取得する
      if(messageObj.keywords === undefined){
        result = "？？？";
      } else{
        result = messageObj.keywords.join(",");
      }
      break;
    case "image": //画像
      result = "この画像が分かりますか？";
      break;
    case "video": //動画
      result = "この動画が分かりますか？";
      break;
    case "audio": //音声。文字起こしする
      if(messageObj.contentProvider.type === "line"){
        const audioFile = getContentByUser(messageObj.id);
        const transcriptedText = speechToText(audioFile);
        result = transcriptedText;
      } else{
        result = "この音声が聞こえますか？";
      }
      break;
    case "file": //ファイル
      result = "このファイルは見られますか？";
      break;
    case "location": //位置情報
      let locationInfo = messageObj.title ? messageObj.title + "\n" : "";
      locationInfo += messageObj.address ? messageObj.address + "\n" : "";
      locationInfo += `latitude:${messageObj.latitude}\n`;
      locationInfo += `longitude:${messageObj.longitude}`;
      result = "ここはどんな場所ですか？\n" + locationInfo;
      break;
    default: //その他
      result = "？？？";
  }
  return result;
}

//logシートにログを出力する
function appendLog(logArray){
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const logSheet = ss.getSheetByName("log");
  logSheet.appendRow(logArray);
}

//リクエストが送られるとこの関数が実行される
function doPost(e) {
  const LINE_ACCESS_TOKEN = PropertiesService.getScriptProperties().getProperty("LINE_ACCESS_TOKEN");
  const events = JSON.parse(e.postData.contents).events;
  const url = 'https://api.line.me/v2/bot/message/reply';

  const event = events[0];
  const replyToken = event.replyToken;
  const userId = event.source.userId;
  const userMessage = convertMessageObjToText(event.message);
  const gptReply = getGptReply(userId, userMessage);
  const botMessage = gptReply.message;
  const requestOptions = {
    'headers': {
      'Content-Type': 'application/json; charset=UTF-8',
      'Authorization': 'Bearer ' + LINE_ACCESS_TOKEN,
    },
    'method': 'post',
    'payload': JSON.stringify({
      'replyToken': replyToken,
      'messages': [{
        'type': 'text',
        'text': botMessage,
      }]
    })
  };

  const response = UrlFetchApp.fetch(url, requestOptions);
  
  const timestamp = Utilities.formatDate(new Date(), "Asia/Tokyo", "yyyy/MM/dd HH:mm:ss");
  const messageType = event.message.type;
  let errorMessage = "";
  if(response !== {}){
    errorMessage = response.message;
  }
  const promptTokens = gptReply.usage.prompt_tokens;
  const completionTokens = gptReply.usage.completion_tokens;
  const totalTokens = gptReply.usage.total_tokens;
  const userMessageCharacters = userMessage.length;

  appendLog([
    timestamp,
    userId,
    messageType,
    userMessage,
    botMessage,
    promptTokens,
    completionTokens,
    totalTokens,
    errorMessage,
    userMessageCharacters
  ]);

  return ContentService.createTextOutput(JSON.stringify({"content": "success"})).setMimeType(ContentService.MimeType.JSON);
}

//古いログをアーカイブに移す
//トリガーで1時間ごとに実行する
function archiveLog(){
  const HEADER_SIZE = 1; //logシートの見出し行数
  const ARCHIVE_THRESHOLD_MINUTE = 120; //この時間数以上経過しているレコードをアーカイブする
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const logSheet = ss.getSheetByName("log");
  const archiveSheet = ss.getSheetByName("archived_log");
  const values = logSheet.getDataRange().getValues();
  const colSize = values[0].length;

  if(values.length < 2){ //レコードがない場合
    return;
  }

  //logシートのデータをオブジェクトとして取得
  const [headers, ...records] = values;
  const objects = records.map(record => Object.fromEntries(record.
    map((value, i) => [headers[i], value])
  ));

  const now = new Date();
  
  //アーカイブしない直近のログが最初に見つかる位置を返す
  const dontArchiveRecordIndex = objects.findIndex(({timestamp}) => !shouldArchive(timestamp, now, ARCHIVE_THRESHOLD_MINUTE));

  let archiveRecordCount = 0;

  if(dontArchiveRecordIndex > 0){
    archiveRecordCount = dontArchiveRecordIndex;
  } else if(dontArchiveRecordIndex == 0){
    return;
  } else if(dontArchiveRecordIndex == -1){
    archiveRecordCount = objects.length;
  }

  const archiveRange = logSheet.getRange(HEADER_SIZE + 1, 1, archiveRecordCount, colSize);
  const archiveValues = archiveRange.getValues();
  const archiveLastRow = archiveSheet.getLastRow();
  const pasteRange = archiveSheet.getRange(archiveLastRow + 1, 1, archiveValues.length, archiveValues[0].length);
  pasteRange.setValues(archiveValues);

  logSheet.deleteRows(HEADER_SIZE + 1, archiveRecordCount);
}

//データをアーカイブすべきか判定する
function shouldArchive(timestamp, standardTime, thresholdMinute){
  const elapsedTime = (standardTime - timestamp)//ミリ秒
  if(elapsedTime > thresholdMinute * 1000 * 60){
    return true;
  } else {
    return false;
  }
}
```

## トークン数の算出について
送信メッセージ単体のトークン数`userMessageCharacters`は、便宜的に文字数で概算しています。
厳密性を求めるなら、Tokenizer等を利用して記録する方が良さそうです。

## 記憶の時間について
`ARCHIVE_THRESHOLD_MINUTE`が有効な記憶の時間なので、この変数を調整していただければ調整可能です。サンプルコードでは`120`分となっています。

# 3. トリガーを追加する
記憶をリフレッシュするため、1時間おきに自動で指定した関数が実行されるようにします。
エディタ画面左の時計マーク`トリガー`を選択し、画面右下の`トリガーを追加`から、以下のとおりトリガーを設定します。

![スクリーンショット 2023-04-02 18.19.58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2956515/e296af5a-c36b-720b-d8fc-54ef48351b3e.png)

# 4. 再度デプロイする
エディタ右上の`デプロイ`ボタンから再度デプロイを実行し、新しいURLを取得します。

# 5. LINE DevelopersでWebhookURLを更新する
以下からLINE Developersにログインし、`Messaging API設定`から`Webhook URL`を更新します。

https://developers.line.biz/console/

# あとがき
やっぱり前の会話の文脈を引き継いで会話できると楽しさが段違いですね！
いよいよbot側にも音声をつけたくなってきました・・・