GAS × ChatGPT API で「前の会話内容を引き継げるLINE bot」を作る

・みんな大好き ChatGPT API を使用した LINE bot を何番煎じかわかりませんが、作りました。
・一問一答だけではなく、**投稿時に右矢印（「→」）を行頭につけることで、前の内容を引き継いで会話をつないでいくことができます。**
・逆に「→」をつけずに投稿する場合は、前の内容と無関係な質問を新たに投稿したことになります。

![linesim2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2611121/a1ab9378-77ff-d65f-7a18-e257be6bd6a3.png)
（投稿の先頭に「→」をつけることで会話を引き継げる）

+ 会話継続のやり方はいろいろ考えられます。デフォルトでは会話を続けるようにして「終わり」と打ったら終わるようにしてもよいかもしれません。API 消費が怖いので、ここでは会話を続けるときは明示的に指定する方法としました。

+ キャラクターの形成が目的ではなく、あくまで１つの質問における文脈の連続が目的なので、要約によるアプローチはなじまないと判断しました。ただしやりとりが長大になる場合は、要約アプローチも一定の意義があるかもしれません・

+ 他の実装の例：[リンク](https://qiita.com/yshimizu22/items/150c1c38c36c48b283be) 
+ + →こちらは自分より後に公開された記事ですが、「保存するメッセージの個数を最新の一定個数に制限する」というアプローチです。
会話を始める際にわざわざ「→」を打つ必要もなく、一般的な使い方では1つ前の内容を言及すれば十分なことがほとんどであるためこちらの方が便利かもしれません。

+  PropertyService と CacheService には以下の違いがあります。

|  | PropertyService | CacheService |
|:-----------|:------------:|:------------:|
|保持期間|永久|6時間(最大)|
|１キーに保存できる<br>値の最大サイズ|9KB|**100KB**|



+ + ChatGPTからの回答サイズを制限しない場合、9KBを超えるユースケースがありえたため、CacheService を使用することとしました。
 
## 1. Chat GPT API KEY の取得
APIKEY の取得については、
[ChatGPT の API キーを取得する手順](https://qiita.com/Qnoir/items/8c580b51880c5899c4ab)
を参照。

## 2.GAS のスクリプトプロパティに、API KEYとLINEトークンを保存
ChatGPT の API_KEY や LINE MESSAGING API のトークンは、ソースコード中に直接書くのではなく、安全のためスクリプトプロパティに保存しましょう。

### (1) Google スプレッドシートを開き、メニューから拡張機能 -> Apps Script を選択

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2611121/7705f0ec-2b1b-fed6-67bc-0c5b2ecb1eef.png)


### (2) エディタ左側に縦に並んでいるアイコンのうち、歯車のアイコン（プロジェクトの設定）をクリック

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2611121/b0a09f25-4c24-ce87-3ae6-b98480e48a0d.png)


### (3) 設定画面を一番下までスクロールして、「スクリプトプロパティを追加」をクリック

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2611121/0290b5fa-bde4-613b-0b1c-d32a99f7cd23.png)





+ LINE Messsaging APIのトークンを「LINE_TOKEN」という名前で保存。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2611121/ce5ecce8-d8ad-2c7e-7946-7b3bfd1d12e6.png)
↓
「スクリプトプロパティを追加」をもう一度クリック。
↓

+ [OpenAI のサイトから取得した、ChatGPT の APIKEY](https://qiita.com/Qnoir/items/8c580b51880c5899c4ab) を設定する。
プロパティには、大文字半角で「APIKEY」、
値には、[Open AIのサイトから発行した API キー](https://qiita.com/Qnoir/items/8c580b51880c5899c4ab) を入力する
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2611121/44fc5150-e1ae-9311-4dff-6a09a5869ca6.png)
↓
「スクリプトプロパティを保存」をクリック。

## 3. GAS のスクリプトエディタでコードを書く。

 Google スプレッドシートを開き、メニューから拡張機能 -> Apps Script を選択

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2611121/7705f0ec-2b1b-fed6-67bc-0c5b2ecb1eef.png)

エディタに下記のコードを書きます。
※コード.gs、メッセージ.gs、ロガー.gsと3つのスクリプトに分けていますが、全部コード.gs に記述しても問題ありません。

コード.gs
```js
const properties = PropertiesService.getScriptProperties().getProperties();
const LINE_TOKEN = properties['LINE_TOKEN'];
const APIKEY = properties['APIKEY'];
const cache = CacheService.getDocumentCache();

// API 消費を抑えるため回答文字数を制限する。適宜好みに応じて調整。
const postscript = '（50文字位で）';


function doPost(e) {
  try {
    return doPostProxy(e);
  } catch (err) {
    log(err.stack);
  }
}


function doPostProxy(e) {
  const event = JSON.parse(e.postData.contents).events[0];
  const replyToken = event.replyToken;
  const inputText = event.message.text;

  if (inputText == null) 
    return replyFromLinebot(replyToken, '文章を入力してください');
  
  if (inputText.length < 5) 
    return replyFromLinebot(replyToken, '入力文字数が少なすぎます。５文字以上入力してください。');
  
  if (inputText[0] !== '→') {
    // 先頭に「→」がない投稿の場合は、前の会話内容を引き継がず新たな会話を開始する。
    initializeMessages();
  }
  // messages 配列にユーザーの入力を追加
  const messages = updateMessages('user', inputText + postscript);
  
  // ChatGPT API に最新の質問を含む会話の配列を渡して、応答を得る。
  const answer = getAnswer(messages);
  
  // messages 配列に ChatGPT API からの応答を追加。
  updateMessages('assistant', answer);
  
  return replyFromLinebot(replyToken, answer);
}


// ChatGPT API に最新の質問を含む会話の配列を渡して、最新の質問に対する応答を得る。
function getAnswer(messages){
  const openai_api_endpoint = 'https://api.openai.com/v1/chat/completions';
  const options = {
    method: 'POST',
    headers: {
      'content-type': 'application/json',
      authorization: 'Bearer ' + APIKEY
    },
    muteHttpExceptions: true,
    payload: JSON.stringify({
      model: 'gpt-3.5-turbo',
      // max_tokens: 200,  // 応答内容が途中で切れるため使わない。
      temperature : 0.5,
      messages }),
  }

  const response = UrlFetchApp.fetch(openai_api_endpoint, options);
  if (response.getResponseCode() !== 200) {
    const text = JSON.parse(response.getContentText());
    const errorMessage = `エラ－：${text.error.message} (statusCode：${response.getResponseCode()})`;
    log(errorMessage);
    return errorMessage;
  }
  const json = JSON.parse(response.getContentText());
  return json['choices'][0]['message']['content'].trim();
}


// LINE bot Messaging API でユーザーの画面に ChatGPT の応答内容を表示。
function replyFromLinebot(replyToken, message) {
  const line_api_endpoint = 'https://api.line.me/v2/bot/message/reply';
  const options = {
    headers: {
      'content-type': 'application/json; charset=UTF-8',
      authorization: 'Bearer ' + LINE_TOKEN,
    },
    muteHttpExceptions: true,
    method: 'POST',
    payload: JSON.stringify({
      replyToken,
      messages: [{ type: 'text', text: message, }],
    }),
  };

  const response = UrlFetchApp.fetch(line_api_endpoint, options);
  if (response.getResponseCode() !== 200) {
    const text = response.getContentText();
    const errorMessage = `エラ－：${text} (statusCode：${response.getResponseCode()})`;
    throw new Error(errorMessage);
  }
}


```

メッセージ.gs
```js
// 初期状態の message を返す。（system）
function getInitialMessages() {
  return [{ role: "system", content: "あなたは優秀なアシスタントです。質問に対して丁寧に回答してください。" }];
}

// キャッシュに含まれる messages 配列を返す。
function getMessages() {
  const mes = cache.get("messages");
  if (mes == null) {
    // キャッシュが揮発または初期化されている場合：
    return getInitialMessages();
  }
  return JSON.parse(mes);
}

// キャッシュ中の messages を初期化(消去）する。
function initializeMessages() {
  cache.remove("messages");
}

// キャッシュに message を追加 ＆ 追加後の messages 配列を返す。
function updateMessages(role, content) {
  const messages = getMessages();
  messages.push({ role, content });
  cache.put("messages", JSON.stringify(messages), 21600);
  return messages;
}
```

ロガー.gs
※ Web アプリでデプロイしたときにデバッグを行いやすくするため。
```js

function log(message) {
  const logss = SpreadsheetApp.getActive();
  let logsh = logss.getSheetByName('log');
  if (logsh == null) {
    logsh = logss.insertSheet();
    logsh.setName('log');
  }

  logsh.appendRow([
    Utilities.formatDate(new Date(), 'Asia/Tokyo', 'yyyy/MM/dd HH:mm:ss'),
    message
  ]);
}

```
上記のコードを保存したら、GASの「デプロイ」→「新しいデプロイ」 で Webアプリとしてデプロイし、デプロイURLを発行。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2611121/d15ed529-4757-721e-c3fb-6650bdfdcb7a.png)

↓
LINE Develpers にログインし、チャンネルの Webhook URL にデプロイURL を設定しましょう。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2611121/9a52d7c4-9520-cc4f-d22b-3deeb0127809.png)
「Webhookの利用」をオンにします。
「検証」ボタンを押して。「成功」というメッセージが出れば OK。


---
以上で問題なければLINEbot上でChatGPT APIとやり取りできます。

---

・このコードの肝はメッセージ.gs に書いた部分です。
「前の内容を引き継いで会話していく」には、前の入力内容とAPIからの応答内容を、messages という配列に入れてChatGPT APIに渡す必要があります。
```js
messages : [
 {
  "role": "system",
  "content": "あなたは優秀なアシスタントです。質問に対して丁寧に回答してください。"
 },
 {
  "role": "user",
  "content": "リスキリングについて教えて。（50文字位で）"
 },
 {
  "role": "assistant",
  "content": "リスキリングとは、現在の職場や業界で必要とされるスキルや知識を身につけるための再教育や研修のことです。"
 },
 {
  "role": "user",
  "content": "→もう少し詳しく。（50文字位で）"
 },
 {
  "role": "assistant",
  "content": "リスキリングは、急速に変化するビジネス環境に対応するため、自己成長のための取り組みや、新しい職種や分野への転職のためのスキルアップが必要とされる時代に重要な取り組みです。"
 }
]
```
上記のような構造を動的に作るためのしくみがメッセージ.gs の内容です。
投稿の先頭に「→」がある場合は、GASのユーザーキャッシュに順次追記するようにしています。

+ さらに回答の文字数を絞ってAPI 消費量を少なくするために、毎回「XX文字くらいで」という後書きをつけて ChatGPT の APIに渡しています。maxtokens で制限すると回答内容が途中で切れるため。

### API消費量について
会話を継続するたびに前の内容と合わせて投稿するため、長く継続するとAPI消費量は雪だるま式に膨らみます。
上記のやりとりだけでトータル 372 token を消費していますので、あまり長い会話をしない方が良いかもしれません。（投稿時の先頭に「→」をつけなければ会話はリセットされます）

### system
**system** メッセージは Asistant（Chat GPT API の Chat Completion における仮想的な話し相手） の振る舞いにある程度影響を及ぼします。
```js
// 初期状態の message を返す。（system）
function getInitialMessages() {
  return [{ role: "system", content: "あなたは優秀なアシスタントです。質問に対して丁寧に回答してください。" }];
}
```

架空のキャラクターや歴史上の人物など、いろいろ変えてみても面白いかもしれません。