ChatGPT APIを利用したChatBotを作ってみた【Ruby】

2023年3月1日（日本時間3月2日）遂にChatGPT APIが公開されました。
それ以前にも「ChatGPTを使った〜〜」みたいなサービスが続々と出ていましたが、基本的には本家のChatGPT以外は「OpenAIのAPIを使ったAIサービス」でしかありませんでした。
ここからは各サービスが、ChatGPTを利用しているサービスとして胸を張っていけますね。

というわけで今回は、[以前に作ったChatBot](https://qiita.com/maabow/items/3beba230dd3bdec8cbaa)を改良して流行りのChatGPT APIを使ったChatBotに進化させていきます。ChatBot自体の作り方に関しては[前回の記事](https://qiita.com/maabow/items/3beba230dd3bdec8cbaa)を参考にして作ってください！

## 実装していく
### いきなり寄り道
開発に取り掛かる前に、本家ChatGPTにこの記事を書くことについてどう思うか聞いてみました。
![スクリーンショット 2023-03-30 17.27.51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/268830/71d024ff-76a9-39df-d978-620969ed5c8f.png)
とのことだったので頑張って作っていきます。実装手順もついでに聞いてみたのですが、引っかかることが多かったので自力で実装していきます。

### OpenAIに登録してAPI Keyを取得する
アカウントを持っていない人は[OpenAIのアカウントを登録](https://auth0.openai.com/u/signup/identifier?state=hKFo2SBvemUwWjZzWmRkanlBTGlBb3MxTHJIUW5xUkw1T3JYaKFur3VuaXZlcnNhbC1sb2dpbqN0aWTZIEVVc0NNaVRnbG42THcwbnA5TjBIMG1PZzAzY3hlbVRTo2NpZNkgRFJpdnNubTJNdTQyVDNLT3BxZHR3QjNOWXZpSFl6d0Q)してください。その後、[Account API Keys - OpenAI API](https://platform.openai.com/account/api-keys)でAPIのKeyを取得できます。
決済情報などは登録しなくても無償で$5のクレジットが付与されており、その分は利用可能です。
![スクリーンショット 2023-03-30 13.51.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/268830/5b07c021-8960-5c41-8961-9c2cfbbf1c2d.png)

※無償分の仕様は変更される恐れがあるのでご了承ください

### コード実装
[公式ドキュメント](https://platform.openai.com/docs/api-reference/chat)を参考にコードを実装します。
ヘッダー情報にAPI KEYを入れ、対象となるmodel(使用するAPIバージョン）とメッセージを送ることで利用できます。
ChatGPTへ問い合わせする部分のソースは以下の通りです。
``` ruby
# ChatGPTへの問い合わせ
def post_chat_gpt_chat(message)
    api_key = ssm_client.get_parameter({ name: '/openai/api_key', with_decryption: true }).parameter.value
    headers = {
        'Content-Type': 'application/json',
        'Authorization': "Bearer #{api_key}"
    }
    data = {
        "model": "gpt-3.5-turbo",
        "messages": [{"role": "user", "content": message}]
    }
    response = http_post_request('https://api.openai.com/v1/chat/completions', headers, data)
    JSON.parse(response.body)['choices'][0]['message']['content']
end
```
GPT-4が既に公開されていますが、僕のアカウントではまだ利用できないので、今回は`gpt-3.5-turbo`を利用しています。

### 実験
とりあえずメッセージを送ってみたところ返事がありませんでした。何故だと思ってログを見に行ったところ`Task timed out after 3.01 seconds`と出ており、Lambdaのタイムアウト時間が短いのが原因でした。デフォルトもう少し長い認識だったのですが、かなり短かったのでお気をつけください。
タイムアウト時間を伸ばして再実行してみるとうまくいきました。
![Screenshot_20230330-175507.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/268830/4d8fb6c4-5918-8e7f-b16b-c54ec3c122ec.png)
これで完成かと思うかもしれませんが、現在の作りだと連続した会話ができません。
![Screenshot_20230331-075745.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/268830/93cf3f21-1a3b-83d9-3685-5d418a84d94c.png)
ピジン言語でプログラミングするしかないですね。

### 会話の履歴を持つようにする
連続した会話に対応するために、過去の会話の履歴を保持するように作り替えます。
DynamoDBを利用したり、S3にファイルとして持たせたりしてもいいのですが、今回は簡易的に実装するためにLambdaのインメモリを利用しました。そのため、長時間会話が保持されることはないので、本格的な実装をする場合にはAWS内部で実装するならDynamoDBあたりを使うといいかなと思います。
履歴を保持する作りに変えてメッセージを送ると、連続した会話ができるようになりました！
![Screenshot_20230331-075754.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/268830/2b6d570d-a6aa-50fb-1071-c13958566ab9.png)

Rubyで実装する場合はインスタンス変数に入れるだけでOKです。履歴が保持される期間はLambdaのインスタンスが再利用されるかどうかに依存します。ここはブラックボックスな部分なので再利用されるように祈ってください。
今回実装したコード全文は以下の通りです。
``` ruby 
require 'json'
require 'aws-sdk-ssm'

def lambda_handler(event:, context:)
    lambda_event_body = JSON.parse(event['body'])

    logger.info lambda_event_body
    
    line_event = lambda_event_body['events'][0]
    # メッセージ以外は受け取らない
    return unless line_event['type'] == 'message' && line_event['message']['type'] == 'text'
    
    line_user_id = line_event['source']['userId']
        
    # ChatGPTに問い合わせ
    return_message = post_chat_gpt_chat(line_event['message']['text'])
        
    # ラインへ返信
    push_line_message(line_user_id, return_message)
end

# ChatGPTへの問い合わせ
def post_chat_gpt_chat(message)
    api_key = ssm_client.get_parameter({ name: '/openai/api_key', with_decryption: true }).parameter.value
    headers = {
        'Content-Type': 'application/json',
        'Authorization': "Bearer #{api_key}"
    }
    # メッセージ履歴
    @cache_messages ||= []
    @cache_messages.push({ "role": "user", "content": message })
    logger.info @cache_messages
    data = {
        "model": "gpt-3.5-turbo",
        "messages": @cache_messages
    }
    response = http_post_request('https://api.openai.com/v1/chat/completions', headers, data)
    gpt_message = JSON.parse(response.body)['choices'][0]['message']['content']
    @cache_messages.push({ "role": "assistant", "content": gpt_message })
    
    gpt_message
end

# LINEへのメッセージ送信
def push_line_message(line_user_id, message)
    access_token = ssm_client.get_parameter({ name: '/line/access_token', with_decryption: true }).parameter.value
    headers = {
      'Content-Type': 'application/json',
      'Authorization': "Bearer #{access_token}"
    }
    data = {
        'to': line_user_id,
        'messages': [{ 'type': 'text', 'text': message }]
    }
    http_post_request('https://api.line.me/v2/bot/message/push', headers, data)
end

def http_post_request(api_url, headers, data)
    uri = URI.parse(api_url)
    http = Net::HTTP.new(uri.host, uri.port)
    http.use_ssl = uri.scheme === 'https'
    req = Net::HTTP::Post.new(uri.path)
    req.body = JSON.generate(data)
    req.initialize_http_header(headers)
    http.request(req)
end

def logger
    @logger ||= Logger.new(STDOUT, formatter: proc { |severity, timestamp, _, msg|
        JSON.dump({ time: timestamp, level: severity, message: msg })
    })
end

def ssm_client
    @ssm_client ||= Aws::SSM::Client.new
end
```

### 役割を指定してみる
実装自体は終わったのですが、ChatGPT APIに送っている値の`role`について簡単に説明しておきます。`role`は`user`,`assistant`,`system`の３種類存在しています。`user`は入力側のメッセージ、`assistant`はChatGPT側のメッセージを表しています。そして、今回の実装では使っていない`system`は役割を与えることができます。
「役割を与えるとは？」となったので、具体的に見ていきましょう。
`あなたは嘘をつくAIです`という役割を与えてみます。`{"role": "system", "content": "あなたは嘘をつくAIです。"}`を一番最初のメッセージに指定することで実装できます。
![1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/268830/734f0821-1352-37b0-d051-636006292641.png)
怒られました。

代わりに`あなたは賢いAIです`という役割を与えてみると以下のようになりました。
![2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/268830/d0d73f60-2faf-706e-6273-af4395f3815a.png)
役割の指定なしの場合は`2です。`としか回答されなかったので少し内容が変わっています。役割の指定はもっと活用する術がありそうです。是非探求してみてください。

## 最後に
実装自体はいたってシンプルで、特にパラメーターを作り込まずに使えるレベルだなと感じました。
AIの回答はあくまでも学習によって生成されたものなので嘘を平気でついてきます。用法用量を守って正しくお使いください。

### P.S.
記事がバズるかどうか聞いてみたところ
![Screenshot_20230331-085313.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/268830/91484b6c-c305-4f31-ff61-8d9e3f6826d7.png)
とのことでした。みなさん是非いいねしてください。