ChatGPTのfunction callingを用いて過去の会話内容について質問する

ChatGPTと会話していて、過去に会話した内容について、質問したり話したりできたら嬉しくないですか？

[以前の記事](https://qiita.com/izaki_shin/items/44e619e5af183831efba)で、LangChainを用いて実装したのですが、今回、ChatGPTのfunction callingでも試してみました。

実際の動作例としては、以下の通りです。
```:過去の会話
【一週間前の会話 （2023-06-28 19:03:42） 】
Human: 最近暑くなってきたからさっぱりしたモノが食べたくて、冷麺を食べたよ。
AI: いいね！ちなみに、盛岡冷麺、韓国冷麺、どっち…？

【一昨日の会話 (2023-07-03 21:53:02)】
Human: 今日は友達とイタリアンに行ったよ。イカスミパスタと真鯛のカルパッチョを食べたんだけど、どっちも美味しかったぁ。
AI: いいなぁ。僕もイタリアン食べたい！！

【昨日の会話 (2023-07-04 20:11:56)】
Human: 今日の昼は中華レストランでよだれ鶏を食べんたんだ。最近暑いからか、あの酸味と辛味を欲してしまうんだよね
AI: 暑いと酸味と辛味を欲するの？なんで？

```
```:今日の会話 (2023-07-05)
Human: 昨日食べた料理が美味しかったから、自分でも作ってみたよ。何を作ったでしょうか?
AI: よだれ鶏を作ったのですね。お疲れ様でした。美味しくできましたか？

Human: 7/3に僕が食べた料理を答えてみて。
AI: 7月3日には、イカスミパスタと真鯛のカルパッチョを食べたとのことですね。どちらも美味しかったとのこと、良かったですね。
```

# 概要とまとめ
## 概要
過去の会話内容について質問したり話したりするために、__ChatGPT自身に、過去の会話内容へのアクセスの要否を判断させる__ というものです。

[以前の記事](https://qiita.com/izaki_shin/items/44e619e5af183831efba)では、LangChainの`Agent`の機能を使用して実装したのですが、今回は、OpenAI APIの【function calling】を使用して実装します。

function callingを用いることで、会話の最中に、特定の関数（ここでは「過去の会話内容へのアクセス」）を必要に応じて実行することができます。

注意点としては、以下３点です。
1. ChatGPTが関数要否を判断するため、__うまく呼び出せない場合がある__
2. __関数の実行自体はAPIを呼び出す側で実施する__ （ChatGPTから「関数実行して」と返ってくるので、指定通りに実行する必要があります）
3. API呼び出し回数が増えるので、__応答時間が長くなる__

## まとめ（頭出し）

先にまとめを書いちゃいます。
Function Callingを用いることで、所望の通り、__過去の会話内容に関連する質問に回答することができました__。

GPT-3.5、GPT-4で試してみましたが、やはり __GPT-4の方が性能が高く__、今回試した例では全て想定通り関数を呼び出すことができました。

一方で、GPT-3.5では、__過去に関する直接的な質問__ （例：「昨日は何を食べたっけ？」）では正しく動作しましたが、__間接的な質問__ （例：「昨日食べた料理を自分でも作ってみたよ。何を作ったでしょう?」）ではうまく「過去の会話へのアクセス」を呼び出せませんでした。

[「結果」](#結果)に、実際のGPT-3.5 / 4の出力を載せていますので、ご参照ください。

## ソースコード
GitHubにnotebookをあげていますので、よろしければご参照ください。

https://github.com/izaki-shin/notebooks/blob/master/nlp/ChatGPTでfunction_callingを用いて過去の会話履歴を呼び出す.ipynb

Google Colaboratoryで開く場合はこちらから。

http://colab.research.google.com/github/izaki-shin/notebooks/blob/master/nlp/ChatGPTでfunction_callingを用いて過去の会話履歴を呼び出す.ipynb


# 実装

## 環境構築
Google Colabを使用していきます。
まずは、必要なPythonパッケージをインストールします。
```
! pip install openai
```
今回はOpenAI APIを使用するので、アクセストークンを指定します。
```python
import os
os.environ['OPENAI_API_KEY'] = 'ご自身のアクセストークンに書き換えてください'
```

## 1. 会話をJSON形式で読み書きする
```python
import json
from datetime import datetime

def save_conversation_from_messages(outdir, datetime_str, messages, **kwargs):
    chat_datetime = datetime.strptime(datetime_str, '%Y-%m-%d %H:%M:%S')
    json_filepath = os.path.join(outdir, 'chat_history_{}.json'.format(chat_datetime.strftime('%Y%m%d')))
    dat = dict({'datetime': datetime_str, 'messages': messages}, **kwargs)  # 任意の情報を保存可能
    with open(json_filepath, 'w') as fout:
        json.dump(dat, fout)
    print('Saved ...', json_filepath)

def load_conversation_from_json(json_filepath):
    with open(json_filepath, 'r') as fin:
        dict_all = json.load(fin)
    return dict_all

# 日別で会話内容を保存
for datetime_str, messages in past_conversations:
    save_conversation_from_messages('./', datetime_str, messages)
```

<details><summary>（参考） past_conversations の内容 </summary><div>
```python: past_conversations
# json_file, datetime, chat_history
past_conversations = [
    [
        '2023-06-28 19:03:42',
        [
            {
                "role": "user",
                "content": "最近暑くなってきたからさっぱりしたモノが食べたくて、冷麺を食べたよ。",
            },
            {
                "role": "assistant",
                "content": "いいね！ちなみに、盛岡冷麺、韓国冷麺、どっち…？",
            },
        ]
    ],
    [
        '2023-07-03 21:53:02',
        [
            {
                "role": "user",
                "content": "今日は友達とイタリアンに行ったよ。イカスミパスタと真鯛のカルパッチョを食べたんだけど、どっちも美味しかったぁ。",
            },
            {
                "role": "assistant",
                "content": "いいなぁ。僕もイタリアン食べたい！！",
            },
        ]
    ],
    [
        '2023-07-04 20:11:56',
        [
            {
                "role": "user",
                "content": "今日の昼は中華レストランでよだれ鶏を食べんたんだ。最近暑いからか、あの酸味と辛味を欲してしまうんだよね",
            },
            {
                "role": "assistant",
                "content": "暑いと酸味と辛味を欲するの？なんで？",
            },
        ]
    ],
]
```
</div></details>

## 2. function callingを用いて、過去の会話内容を呼び出す

OpenAIの公式サイト [OpenAI Guides - Function Calling](https://platform.openai.com/docs/guides/gpt/function-calling) を参照しました。
日本語の文献も多数ありますので、以下では使用方法の概略のみ記載します。

## function callingの使用方法

### 関数定義
function callingの使用方法は簡単で、`openai.ChatCompletion.create()`を実行する際に、`functions=`に関数定義を指定するだけです。

<details><summary>（参考） 関数定義例（OpenAI公式サイトより引用） </summary><div>

```python
    functions = [
        {
            "name": "get_current_weather",
            "description": "Get the current weather in a given location",
            "parameters": {
                "type": "object",
                "properties": {
                    "location": {
                        "type": "string",
                        "description": "The city and state, e.g. San Francisco, CA",
                    },
                    "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]},
                },
                "required": ["location"],
            },
        }
    ]
```
</div></details>


### 関数実行の判定
関数が不要と判断された場合、通常通り`"content"`にモデル出力のテキストが格納されます。一方で、関数実行が必要と判断された場合には、`"content"`は null (None)となり、代わりに __`"function_call"`に実行対象の関数名とその引数__ が格納されます。

具体的には、以下のような感じです。
```:function calling の返値
# print(response['choices'][0]['message'])
{
  "role": "assistant",
  "content": null,
  "function_call": {
    "name": "recall_past_chats",
    "arguments": "{\n  \"the_date\": \"2023-07-03\"\n}"
  }
}
```

### 関数実行結果の渡し方

指定された関数を実行した後、結果をモデルに返す必要があります。
以下のように、__"role"を "function"__ としてチャット履歴に追加して、`ChatCompletion.create()`の`messages=`に指定します。

```python: 例
function_message = {
    "role": "function",
    "name": function_name,         # 関数名
    "content": function_response,  # 実行結果
}
message_history.append(function_message)  # これをChatCompletionに指定
```

## 実行用のクラス定義

まとめると、手順は以下の通りです。
1. `ChatCompletion.create()`の返値を確認し、
2. `function_call`が定義されていたら指定の関数を実行し、
3. 実行結果をチャット履歴に追加して、再度`ChatCompletion.create()`を実行

以下、function callingを実行可能なチャット機能を、クラスとして定義します。
関数`chat()`の中で、上記の３ステップを実施しています。

```python
class MyChatBot():

    SYSTEM_PROMPT = """You are a helpfule assistant.
You need to follow the following rules:
- lang:ja
- please be polite (e.g. use です, ます)
- short reply (less than 100 japanese charactors)
"""

    def __init__(self, model='gpt-4', temparature=0.0):
        self._model = model
        self._temparature = temparature
        self.reset_messages()

        # For function calling
        self._available_functions = {}
        self._function_properties = {}

    def reset_messages(self):
        self._messages = [{"role": "system", "content": MyChatBot.SYSTEM_PROMPT}]

    def get_messages(self):
        return self._messages.copy()

    def add_function(self, function, func_props):
        if not callable(function):
            raise TypeError('function must be callable.')
        func_name = func_props['name']
        self._available_functions[func_name] = function
        self._function_properties[func_name] = func_props

    @property
    def function_properties(self):
        if len(self._function_properties) == 0:
            return None
        return list(self._function_properties.values())

    # 会話用インタフェース （function calling は最大一回のみ）
    def chat(self, input, verbose=False):
        user_message = {'role': 'user', 'content': input}
        response = self._chat_completion(user_message)

        # add assistant message
        response_message = response['choices'][0]['message']
        self._messages.append(response_message)

        # Function Calling
        if response_message.get('function_call'):
            function_name = response_message['function_call']['name']

            fuction_to_call = self._available_functions[function_name]
            function_args = json.loads(response_message['function_call']['arguments'])
            function_response = fuction_to_call(**function_args)

            if verbose:
                print(f'request function: {function_name}')
                print('  args - ', function_args)

            function_message = {
                'role': 'function',
                "name": function_name,
                "content": function_response,
            }
            response = self._chat_completion(function_message)

        return response

    # API call (function calling 有無で共通化)
    def _chat_completion(self, message, enable_functions=True):
        # update messages
        self._messages.append(message)

        # for function calling
        additional_args = {}
        if enable_functions and (len(self._function_properties) > 0):
            additional_args['functions'] = list(self._function_properties.values())

        # run chat-completion
        response = openai.ChatCompletion.create(
            model=self._model,
            temperature=self._temparature,
            messages=self._messages,
            **additional_args
        )
        return response
```

## 呼び出す関数の実装
続いて、呼び出すための関数と、function calling用の関数定義です。

関数自体は通常通り定義します。ここでは、引数として「日付（YYYY-MM-DD）」を指定すると、その日付の会話内容を返す関数を定義しています。

function calling用の関数定義は、関数名（name）や用途（description）、引数情報（parameters）などを指定します。おそらく日本語で問題ないと思いますが、今回は英語で記載しています。

```python: function calling で呼び出す関数の定義
import re

def recall_past_chats(the_date):
    if re.match(r'\d{4}-\d{2}-\d{2}', the_date) is None:  # is not date format
        return None
    json_file = 'chat_history_{}.json'.format(the_date.replace('-', ''))
    chats_history = load_conversation_from_json(json_file)
    messages = chats_history['messages']
    # make output
    output = f"The following is chats on {the_date}　between the user and YOU (i.e. assistant);"
    for msg in messages:
        role = msg['role']
        content = msg['content']
        output += f"\n{role}: {content}"
    return output

func_props = {
    'name': 'recall_past_chats',
    'description': 'Access the past chats between you and the user. Today is 2023-07-05.',
    "parameters": {
        "type": "object",
        "properties": {
            "the_date": {
                "type": "string",
                "description": "Specify the date of the chats you need to access in the format 'YYYY-MM-DD'",
            },
        },
        "required": ["the_date"],
    }
}

# function callingを有効にして会話する
my_chatbot = MyChatBot()
my_chatbot.add_function(recall_past_chats, func_props)
response = my_chatbot.chat('一昨日僕が食べた料理を答えてみて。')

print(response['choices'][0]['message']['content'])
# 一昨日、あなたは友達とイタリアンに行き、イカスミパスタと真鯛のカルパッチョを食べましたね。どちらも美味しかったとのことです。
```

# 結果
それでは、実際の回答結果を見てみましょう。

ますは、__【GPT-3.5、GPT-4の両方で】__ 正しく回答できた例です。
```
input   : 一昨日僕が食べた料理を答えてみて。
GPT-3.5 : 一昨日、あなたはイカスミパスタと真鯛のカルパッチョを食べました。
GPT-4   : 一昨日、あなたは友達とイタリアンに行き、イカスミパスタと真鯛のカルパッチョを食べましたね。どちらも美味しかったとのことです。

input   : 7/4に食べた料理は何でしょうか？
GPT-3.5 : 7月4日に食べた料理は「よだれ鶏」でした。暑い日には、酸味と辛味を欲することがあるんですよね。
GPT-4   : 7月4日には中華レストランでよだれ鶏を召し上がったようですね。
```

続いて、__【GPT-4のみで】__ 正しく回答できた例です。
（GPT-3.5では、関数呼び出しを要求できませんでした…）
```
input   : 昨日食べた料理が美味しかったから、自分でも作ってみたよ。何を作ったでしょうか?
GPT-3.5 : それは素晴らしいですね！昨日食べた料理を自分でも作ってみたのですね。では、昨日食べた料理は何でしたか？
GPT-4   : よだれ鶏を作ったのですね。お疲れ様でした。美味しくできましたか？

input   : 昨日食べた料理が美味しかったから、自分でも作ってみたよ。何を作ったか当ててみて！
GPT-3.5 : それは素晴らしいですね！では、昨日作った料理は何でしょうか？楽しみにしています！
GPT-4   : よだれ鶏を作ったのではないでしょうか。
```

少し表現を変えて試してみましたが、GPT-3.5では出来ませんでした。

成功例との違いは、__間接的に過去のことについて聞いている点__、でしょうか？
GPT-3.5が成功した例では、直接的に過去のことについて質問しています。一方で、失敗ケースでは、「質問自体は過去に関するものではなく、ただ、回答するには過去の情報が必要」となっています。

Web版ChatGPTを使用していて、個人的には、GPT-3.5でも十分な性能を感じていますが、やはりGPT-4の方が文脈を正しく捉える能力が高いということがわかりますね。

# おわりに

本記事では、過去の会話内容に基づいて会話をするチャットボットを作るべく、OpenAI APIの Function Calling を試してみました。

GPT-3.5とGPT-4で実行し、両モデルとも、過去に関する質問にちゃんと答えられることを確認しました。ただし、GPT-3.5では、関数（過去の会話内容の参照）の実行要否の判断を誤る場合がありました。

function calling自体は 2023年6月に発表された機能ですので、すでに多くの日本語記事がありますが、本記事の内容が何かの参考になりましたら幸いです。

今回の記事は以上となります。
最後まで読んでいただき誠にありがとうございます。
少しでも楽しんでいただけたり、参考になる部分があったりしましたら幸いです。

それでは！(*ˊᗜˋ)ﾉｼ