LINE Botで作ったキャラクターにChatGPT+画像文字起こしを加えてみた

## はじめに

だいぶ前にLINE Botの勉強がてら作ったキャラクターに、ChatGPTを組み合わせていろんな会話を楽しめるようにしてみました。

ただChatGPTを使ってAIと会話するだけでは物足りないと思い、画像からの文字起こしを組み合わせて、その画像（文章）についての会話もできるようにしてみました。


## 完成品

取り合えず、こんなのができました。

<img width="400" alt="画像1" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/617410/708960fa-f5cc-4257-84db-7de706e166fa.jpeg">

<img width="400" alt="画像2" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/617410/97292632-92b3-b5f1-258b-81c0f4338dd0.jpeg">

ChatGPTのsystemにこのようなキャラクターを演じるように設定を入れているわけですが、キャラクター(謎の自作キャラ「たんたん」)についてのツッコミはナシでお願いします。

マクドナルド公式ページで確認できるメニューを画像にして与えてみました。
どうやらこの子は1人で3つのハンバーガーを食べる気でいるようです。
(ちなみに食いしん坊キャラのような設定は加えておりません。)


### 使い道

普通のChatGPTとしての使い方もできますし、キャラクターとの会話を楽しむみたいなことができます。

- 食事のメニュー画像を送って、その中からを選んでもらう。
    - ただし、systemに設定したキャラクターの気分や好みに左右される。
- マンガや小説のワンシーンを読ませて、感想とかを聞いてみる。理解不能なシーンを解説してもらう。
- 文章を要約してもらう。
- 英語を和訳してもらう。
- よくわからない看板や警告文とかを写真で撮って意味を聞いてみる。

など。

注意点として、食事の好み等の人間の感覚に近い会話に対しては、ChatGPTのsystemでしっかりと振る舞いやキャラクターを定義付けをしておかないと「私は実際に食事を食べることはできませんが...」の様な応答が返ってきてしまいます。


### ポイント

ChatGPTが画像から文字起こしをしたことを認識している点です。

個人的にはここが思い付いた、工夫ポイントでした。
また、トークン数の上限に引っかかるため、内部的には過去3回分までしか追っかけていません。

## 構成

- LINE
    - LINE Developers : LINE Botを作るのに必須。
- GCP
    - GAS (JavaScript) : メインロジック。
    - Cloud Vision API : 文字起こしに使っています。
    - スプレッドシート : 過去のやりとりの保存として使っています。

### LINE Developers

LINE Botを作るために必要です。（ここでは特に言及しません。）

### GAS

LINE Botの中身の振る舞いを全てコントロールしています。
主なロジックや分岐としては以下になります。

- 固定メッセージの返却
    - ChatGPTを介するまでもない簡単な返事(挨拶など)
- ランダムメッセージの返却
    - 想定していないイベントへの応答(スタンプ等)
- 画像からの文字起こしとその結果の返却
    - 画像を受け取った時のみ文字起こしを実行する
- ChatGPTを用いた対話生成と返却
    - 固定メッセージで返せない文言は全てここで受ける

### Cloud Vision API

GCPのサービスの力を借りて画像から文字起こしをします。
LINE Botといえば、一時期文字起こしが流行りましたね。それを参考にさせていただきました。

### スプレッドシート

ChatGPTが過去のやりとりを認識できるようにするために用いています。

あくまでも個人用アプリとしてやりとりがわかりやすいようにスプレッドシートを用いているだけなので、実用的なシステムではやりとりが丸わかりになるのでよろしくないかなと思います。


スプレッドシートの中は以下のようになっています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/617410/bc83a7b7-c5c7-530e-efda-c3cdb96687c2.png)

- userId : LINEユーザを区別するユニーク値。個人用ですが、念の為ユーザのやりとりが混ざらないように区別できるようにしています。
- q : LINEから送られてきたメッセージ。画像の場合は「今送った画像を文字起こししてください。」という固定文字が入ります。
- a : LINE botが返却した回答。ChatGPTが生成した文言や、文字起こしの結果が入ります。

ここがポイントですね。

ChatGPTが対話を生成する際、スプレッドシートを参考に過去を遡ると

- 「画像起こしをしてください」という依頼を受けて
- 画像から文字を起こした結果を返却した

ということになっているため、ChatGPTは「自分が画像から文字起こしをしたんだな」と認識してくれます。これにより、この先の会話が成立します。

例えば、「さっきの画像は〜」と送信すると、「この画像は〜」のように返してくれるようになります。

この応用・機能拡張で、音声、動画、PDF等の組み合わせもいけそうですね。

## コード

コードは以下のようになっています。
抜粋して掲載しています。

``` javascript
const CHANNEL_ACCESS_TOKEN = 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX';
const SHEET_ID = 'XXXXXXXXXXXXXXXXXXXXXXXXXXXX';
const SHEET_NAME = '0';
const TEMPERATURE = 0.5;
const MAX_TOKENS = 4096;
const N = 1;
const TOP_P = 1;
const SYSTEM_ROLE_DEFILE = `
　(ここにキャラクターの特徴を記述します。)
`;

// スプレッドシートの取得
// count は 過去に遡るやりとりの数。デフォルト3回分にした。
function getSheet(userId = "XXXXXXXXXXXXXX", count = 3) {
  const sheetId = SHEET_ID;
  const sheetName = SHEET_NAME;
  const sheet = SpreadsheetApp.openById(sheetId).getSheetByName(sheetName);

  const lastRow = sheet.getLastRow();

  // データ範囲を指定 (1行目はヘッダーなので除外)
  const dataRange = sheet.getRange(2, 1, lastRow, 4); 
  const dataValues = dataRange.getValues();

  const interactions = [];
  let foundCount = 0;
  for (let i = dataValues.length - 1; i >= 0; i--) {

    const row = dataValues[i];
    const rowUserId = row[0];
    const user = row[1];
    const assistant = row[2];

    if (rowUserId === userId) {
      interactions.unshift({ user, assistant });
      foundCount++;
    }

    if (foundCount >= count) {
      break;
    }
  }

  return interactions;
}


// スプレッドシートへの書き込み
function writeSheet(userId, question, answer){
  var sheetId = SHEET_ID;
  var sheetName = SHEET_NAME;
  var sheet = SpreadsheetApp.openById(sheetId).getSheetByName(sheetName);
  if (userId === undefined){
    userId = 'undefined_user';
  }
  if (question === undefined){
    messageId = 'undefined_question';
  }
  if (answer === undefined){
    answer = "undefined_answer";
  }
  sheet.getRange(
    sheet.getRange(1,1).getNextDataCell(SpreadsheetApp.Direction.DOWN).getRow() + 1,
    1
  ).setValue(String(userId));
  sheet.getRange(
    sheet.getRange(1,2).getNextDataCell(SpreadsheetApp.Direction.DOWN).getRow() + 1,
    2
  ).setValue(String(question));
  sheet.getRange(
    sheet.getRange(1,3).getNextDataCell(SpreadsheetApp.Direction.DOWN).getRow() + 1,
    3
  ).setValue(String(answer));
  sheet.getRange(

  return;
}


async function ask_chatgpt(prompt, pastInteractions) {
    const api_key = "sk-XXXXXXXXXXXXXXXXXXXXXX";
    const model = "gpt-3.5-turbo";
    const url = "https://api.openai.com/v1/chat/completions";

    let id = "";
    let output_text = "";

    const headers = {
      "Content-Type": "application/json",
      "Authorization": `Bearer ${api_key}`,
    };
    
    const messages = [
      { role: "user", content: prompt },
    ];
    
    // 過去のやりとりを追加
    for (let i = pastInteractions.length - 1; i >= 0 && i >= pastInteractions.length - 3; i--) {
      const interaction = pastInteractions[i];
      messages.unshift({ role: "assistant", content: interaction.assistant });
      messages.unshift({ role: "user", content: interaction.user });
    }
    messages.unshift({ role: "system", content: SYSTEM_ROLE_DEFILE });
    
    const payload = {
      messages: messages,
      model: model,
      n: N,
      top_p: TOP_P,
      max_tokens: MAX_TOKENS,
      temperature: TEMPERATURE,
    };
    
    // ChatGPTへリクエストを送信する
    try{
      const options = {
        method: "post",
        headers: headers,
        payload: JSON.stringify(payload),
      };
      const response = UrlFetchApp.fetch(url, options);
      const data = JSON.parse(response.getContentText());
    
      id = data["id"];
      output_text = data.choices[0];
    
    } catch (e) {
      if (e.code === 429) {
        output_text = `ごめんたん。ちょっとトラブったたん。。。時間を置いて再度メッセージを送って欲しいたん。`;
      } else {
        output_text = `ごめんたん。ちょっとトラブったたん。。。何回かメッセージを送り直してみて欲しいたん。`;
      }
    }
  
  return { id, output_text };
}

// LINEでメッセージが送られてきた時に実行される
function doPost(e) {
  // 送信者の情報
  var user = JSON.parse(e.postData.contents).events[0].source.userId;
  // メッセージ種別
  var type = JSON.parse(e.postData.contents).events[0].message.type;
  // 返信用のトークン
  var reply_token= JSON.parse(e.postData.contents).events[0].replyToken;
  // メッセージ本文（メッセージ種別がtextの時のみ取得可能)
  var text = JSON.parse(e.postData.contents).events[0].message.text;
  
  if (typeof e === 'undefined'){
    // 未定義のメッセージ種別だった場合は何も反応しない
    return;

  } else if (type==='image'){
    // 画像を受けた場合の処理
    push(user,'わかったたん♪\n頑張って文字起こしするたん！');

    var messageId = JSON.parse(e.postData.contents).events[0].message.id;
    // 画像を取得
    var blob = getLineContent(user, messageId);
    // 画像をVision APIに送信
    var result = imageAnnotate(user, blob);

    // スプレッドシートに記録する
    writeSheet(user, "今送った画像を文字起こししてください。", result)
    
    // 結果のテキストをLINEで返す
    message_post(reply_token,result);

  } else if (type==='text'){
    // テキストの時に返事をする

    if (text === '元気？'){
        message_post(reply_token,"ありがとたん♪元気たん♪");
    }
  
    // 
    // ... 中略 (その他固定メッセージへの応答部分) ...
    //

    } else {
      // ChatGPTで返信する
      var messageId = JSON.parse(e.postData.contents).events[0].message.id;
      const pastInteractions = getSheet(user, 3);

      ask_chatgpt(text, pastInteractions)
        .then((response) => {
          // 結果のテキストをLINEで返す
          message_post(reply_token, response.output_text);
          // スプレッドシートに記録する       
          writeSheet(user, text, response.output_text);
        })
        .catch((error) => {
          console.error("エラーが発生しました:", error);
        });
    }
  } else {
    //上記以外のパターン

    // 
    // ... 中略 (ランダムメッセージの返却部分) ...
    //
  }
  return;
}
```

以上です！