ChatGPT最新機能 Function CallingでQiitaへの理解が深いLINE Botを作る

6月13日にOpenAIのAPIである`gpt-3.5-turbo`と`gpt-4`に更新がありました。

https://openai.com/blog/function-calling-and-other-api-updates

`Function Calling`と呼ばれるその機能は非常に革新的であり、これまでのAIプロダクト開発をさらに加速させることが間違いないものでした。

**本記事ではその新機能を組み込んだLINE Botを作成し、機能の理解を深めるとともにその有用性を広く拡散したいと思います。**

## 作成したプロダクト

![スクリーンショット 2023-06-15 2.00.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2100048/7c41c664-f9bc-21d3-a3aa-02d0ef61cd39.png)

1. `こんばんは`のように従来のChatGPTで実施していた会話は問題なく実施できます
1. Qiitaに関する質問をした際は、**QiitaAPIを叩いて情報を取得**した上で、その結果に基づいた返答を返します。

本実装のGitHubはこちら

https://github.com/watanabe-tsubasa/Qiita_bot_with_function_calling

## 実行環境

> Python 3.11.3
> MacOS 12.6.6
> pyenv-venvで仮想環境を構築
> <details><summary>requirements.txt</summary>
>
>aiohttp==3.8.4
aiosignal==1.3.1
anyio==3.7.0
async-timeout==4.0.2
attrs==23.1.0
certifi==2023.5.7
charset-normalizer==3.1.0
click==8.1.3
fastapi==0.97.0
frozenlist==1.3.3
future==0.18.3
h11==0.14.0
idna==3.4
line-bot-sdk==2.4.2
multidict==6.0.4
openai==0.27.8
pydantic==1.10.9
python-dotenv==1.0.0
requests==2.28.2
sniffio==1.3.0
starlette==0.27.0
tqdm==4.65.0
typing_extensions==4.6.3
urllib3==1.26.16
uvicorn==0.22.0
yarl==1.9.2
</details>

- LINE Botを構築する上で、serverはFastAPIで構築しています

## ファイル構成

基本的な`Python`による`LINE Bot`作成はこちらの記事をお読みください。

https://qiita.com/watanabe-tsubasa/items/44ccec1763f2cfb678b9

```
.
├── README.md
├── main.py
├── requirements.txt
└── src
    └── request_openai.py
```

### サンプルコード

```python:main.py

from src.request_openai import make_completion

import logging
import os

from dotenv import load_dotenv; load_dotenv()
from fastapi import FastAPI, Request
from linebot import LineBotApi, WebhookHandler
from linebot.exceptions import InvalidSignatureError
from linebot.models import MessageEvent, TextMessage, TextSendMessage

CHANNEL_SECRET = os.environ.get('CHANNEL_SECRET') or 'CHANNEL_SECRETをコピペ'
CHANNEL_ACCESS_TOKEN = os.environ.get('CHANNEL_ACCESS_TOKEN') or 'CHANNEL_ACCESS_TOKENをコピペ'

app = FastAPI()

line_bot_api = LineBotApi(channel_access_token=CHANNEL_ACCESS_TOKEN)
handler = WebhookHandler(channel_secret=CHANNEL_SECRET)

logger = logging.getLogger(__name__)

@app.post("/webhook")
async def callback(request: Request):
    signature = request.headers['X-Line-Signature']
    
    body = await request.body()
    logger.info("Request body:" + body.decode())
    
    try:
        handler.handle(body.decode(), signature)
    except InvalidSignatureError:
        logger.warning("Invalid signature")
        return "Invalid signature"
    
    return "OK"

@handler.add(MessageEvent, message=TextMessage)
def handle_message(event):
    messages=[
    {"role": "system", "content": "You are a Qiita website engineer."},
    {"role": "user", "content": event.message.text},
    ]    
    functions=[
        {
            "name": "get_tag_info",
            "description": "Get the qiita info in a given tag",
            "parameters": {
                "type": "object",
                "properties": {
                    "tag": {
                        "type": "string",
                        "description": "Tech word, e.g. Python, JavaScript, React"
                        },
                    "unit": {
                        "type": "string",
                        "enum": ["followers_count", "items_count"]
                        }
                    },
                "required": ["tag"]
                }
            }
        ] 
    reply_text = make_completion(messages, functions)
    line_bot_api.reply_message(
        event.reply_token,
        TextSendMessage(text = reply_text)
    )

```

:::note
**ポイント１：functionsを定義する**
サンプルコードのようにChatGPTに実施してほしい関数の型のようなものをここで定義します。
:::

```python:request_openai.py
import json
import os

from dotenv import load_dotenv; load_dotenv()
import openai; openai.api_key = os.getenv('OPENAI_API_KEY')
import requests

def get_tag_info(tag: str):
    try:
        res = requests.get(f'https://qiita.com/api/v2/tags/{tag}')
        return res.json()
    except ValueError as e:
        print(e)
        
def make_completion(messages: list, functions: list):
    completion = openai.ChatCompletion.create(
        model="gpt-3.5-turbo-0613",
        messages=messages,
        functions=functions
    )
    
    if completion.choices[0].finish_reason == 'function_call':
        params = json.loads(completion.choices[0].message.function_call.arguments)
        tag = params['tag']
        function_name = completion.choices[0].message.function_call.name
        function = globals()[function_name]
        res = function(tag)
        add_dict = {"role": "function", "name": "get_tag_info", "content": json.dumps(res)}
        messages.append(add_dict)
        
        return make_completion(messages, functions)
        
    else:
        return completion.choices[0].message.content

```

:::note
**ポイント２：functionsを引数にして、openai.ChatCompletion.create**
文脈から判断して、先ほど定義したfunctionsにあった関数について、JSON形式で返してくれます
:::

上にの写真にある`Pythonのタグフォロワー数`について聞いている場合、

```
completion.choices[0].message.function_call.arguments
```

に

```json
"{\n  \"tag\": \"python\",\n  \"unit\": \"followers_count\"\n}"
```

のようなJSONにパース可能な文字列がChatGPTのAPIから返ってきています。
さらに、

```python
completion.choices[0].finish_reason == 'function_call'
```

として返ってくるので、ここでFunction Callingが実行されていることも明確にわかります。

<details><summary>参考：Jupyterでの実行結果</summary>

![スクリーンショット 2023-06-15 7.30.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2100048/eea6f85f-a930-97d5-8963-91870b41f0a4.png)

![スクリーンショット 2023-06-15 7.30.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2100048/bfcf2562-3669-b4e8-6774-78b3d52bec0f.png)

</details>

:::note
**ポイント3：ChatGPTが生成したJSONを用いてQiitaAPIを叩く**
`paramas['tag'] = 'Python'`となるようなJSONが返ってきているので、これを用いてQiitaAPIを叩く
:::

:::note
**ポイント4：QiitaAPIを叩いた結果を合わせて、再度ChatGPTのAPIを叩いて出力された結果をテキストとしてLINEに返す**
:::

今回のコードでは make_completion()の中にさらにmake_completion()を定義することで、再帰関数として実装しています。

## つまりどういうことか

　設定した条件に合致した会話となった場合、あらかじめ設定した関数の名前と引数して使えるJSONデータが返ってきます。今回のコードは、`get_tag_info`でQiitaAPIをtagについて叩くわけですが、その引数となるtagの名前にBotのメッセージ送信者のメッセージに基づくJSONデータが生成されて返ってきます。
これを利用することで、自然な会話の流れからAPIを叩いたり、スクレイピングを指示したりすることができます。**（スクレイピングさせるときは、トークン量に注意しましょう）**

　また、functionsがlistで指定していることを考えると、**複数のfunctionを指定し、文脈に応じて異なる処理を実行させることができそう**です。

例えば、**Qiitaについて聞かれた場合はQiitaAPI、食べ物について聞かれた時は食べログのAPIを実行する**といった分岐が簡単に実装できるようになりました。

本記事が少しでも皆様の理解の助けとなれば幸いです。
詳細は改めてこちらの記事を参照ください。

https://openai.com/blog/function-calling-and-other-api-updates

## 以上まとめ

楽しい

## 追記：シンプルにJSONを返させることも可能

これまで指定した色のRGBをJSONで返してほしいというプロンプトを書いても、前後に余計な文章が入ることが問題でした。
これもFunction Callingで解消できます。

```python
from dotenv import load_dotenv; load_dotenv()
import openai; openai.api_key = os.getenv('OPENAI_API_KEY')
import requests

completion = openai.ChatCompletion.create(
  model="gpt-3.5-turbo-0613",
  messages=[
    {"role": "user", "content": "What is the RGB value of pink"},
  ],
  functions=[
    {
      "name": "get_rgb_values",
      "description": "Get the rgb values of the given color",
      "parameters": {
        "type": "object",
        "properties": {
          "red": {
            "type": "number",
            "description": "value from  0 to 255"
          },
          "green": {
            "type": "number",
            "description": "value from  0 to 255"
          },
          "blue": {
            "type": "number",
            "description": "value from  0 to 255"
          },
        },
        "required": ["red", "green", "blue"]
      }
    }
  ]
)

print(completion)
```

これに対して、こんな感じで返ってきます。

```json
{
  "id": "chatcmpl-7Se3nbb4VJQRwKMYZ0nsDiDhe9Ylt",
  "object": "chat.completion",
  "created": 1687061531,
  "model": "gpt-3.5-turbo-0613",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": null,
        "function_call": {
          "name": "get_rgb_values",
          "arguments": "{\n  \"red\": 255,\n  \"green\": 192,\n  \"blue\": 203\n}"
        }
      },
      "finish_reason": "function_call"
    }
  ],
  "usage": {
    "prompt_tokens": 94,
    "completion_tokens": 30,
    "total_tokens": 124
  }
}
```

これで、ChatGPTに指定した色の値を作らせて、フルカラーLEDをその色に光らせるということも可能と思われます。