Pythonista上でChatGPTのfunction callingを使って音声対話エージェントを作ってみた

2023/06/13にOpenAIからChatGPTの[Function calling](https://openai.com/blog/function-calling-and-other-api-updates)の機能がアナウンスされました。
以前、プラグイン機能が出た時にそれがAPIでもできると良いなと思っていたので、ついに来たか！と思い早速試してみました。

# ChatGPT Funciton callingとは？

OpenAPIのフォーマット(JSON)で説明付きで関数を定義しておくと、ChatGPTがチャットの会話の中で呼び出しが可能な時に教えてくれる機能だと解釈しています。

例えば、下記のように関数のインターフェースを定義しておくと、会話の中で呼び出しすべきタイミングに、この関数に対してこの引数を渡せばいいよと教えてくれます。

##### インターフェース

```json
[
    {
        "name": "add",
        "description": "Take the sum of two numbers",
        "parameters": {
            "type": "object",
            "properties": {
                "a": {"type": "integer", "description": "The integer value from add"},
                "b": {"type": "integer", "description": "The integer value to add"},
            },
        },
        "required": ["a", "b"],
    },
]
```

##### 入力

> 1足す2の結果は

##### レスポンス

```json
{
    "content": null,
    "function_call": {
        "arguments": "{\n\"a\": 1,\"b\": 2\n}",
        "name": "add"
    },
    "role": "assistant"
}
```

# 何がすごい？

自然言語を入力として**引数付きで関数を呼び出せる**というところが凄いのかなと考えています。
自然言語を入力にして何か関数を呼ぶといえばSiriやGoogle Homeなどが思い浮かびますが、それを独自で簡単に実装できそうですね。

iOSでPythonを動かせるPythonistaというアプリでもopenaiのライブラリが入ったということなので、Pythonista上で対話できるアプリケーションを作ってみました

# 音声対話エージェントを作ってみた

コード類はGitHub([pythonista_agent](https://github.com/hiko2msp/pythonista_agent/tree/main))で公開しています。

## 全体像

```plantuml
@startuml

actor User
package OpenAI_API {
    () Audio
    () ChatCompletion
}
package iOS {

    package Pythonista {
        () agent_py
        () agent_pyui
        () functions
        package python_library {
            () sound
            () speech
        }
    }
    storage Files {
        () audio_wav
    }
}

agent_py <-up-> functions : 処理
agent_py <--> Audio : 音声文字起こし
agent_py <--> ChatCompletion : 自然言語の解釈
agent_py <--> sound
agent_py --> speech : 処理結果
agent_py --> agent_pyui
agent_py --> audio_wav : 音声保存


User --> sound : 音声入力
speech --> User : 音声出力
agent_pyui -down-> User : 画面表示

@enduml

```

コンポーネント名が異なりますが、シーケンス図だとこんなイメージ

```plantuml
@startuml

actor User
boundary Device
participant sound
participant speech
participant function
database data

User --> Device : 開始ボタンを押す
Device --> sound : 録音開始。マイクを許可
User --> Device : 音声入力
User --> Device : 停止ボタンを押す
Device --> sound : 録音を停止。ファイルを保存
sound --> data : .wavファイルを保存
Device <-- data : データの取得
Device <--> AudioAPI : in:音声データ,out:文字起こしされたテキスト
Device <--> ChatCompletionAPI : in:文字,out:呼び出す関数と引数
Device <--> function : in:引数,out:結果の文字列
Device <--> speech : in:結果の文字列,out: 音声データ
User <-- Device : 音声

@enduml
```

では、詳細に実装内容を説明していきます

# 文字起こし

openaiのAudioAPIを使って実現できます。

```python
import openai
openai.api_key = ""

def transcribe(filename):
    with open(filename, "rb") as f:
        res = openai.Audio.transcribe("whisper-1", f)
        return res["text"]
```

音声の録音は、pythonistaのsoundライブラリを利用します。下記のようにすると録音した内容を文字起こしすることができます

```python
from sound import Recorder
import time

filename = 'audio.wav'

recorder = Recorder(filename)
recorder.record()
time.sleep(10) # 10秒録音
recorder.stop()

text = transcribe(filename)
print(text)
```

# 関数へのマッピング

Function callingの機能を使って、テキストを関数にマッピングします。下記の例では、get_weather(天気の取得)とadd(加算)の二つの関数を定義しています。自然言語を入力として、関数を呼び出せるときは教えてくれます。

例えば、`今日の東京の天気は`という入力に対しては`get_weather("東京")`という呼び出し、`10足す20は`という入力に対しては`add(10,20)`という呼び出しを提案してくれます。ChatCompletionのレスポンスから引数と関数名を取り出し実行しています。

呼び出せる関数がない場合は、レスポンスの中に`function_call`というキーが入ってこないので、それを使って`すみません。よくわかりません`と回答させています。

```python
def get_weather(region):
    res = requests.get("http://weather.tsukumijima.net/api/forecast/city/130010").json()
    return res["forecasts"][0]["detail"]["weather"]

def add(a, b):
    return a + b


functions = [
    {
        "name": "get_weather",
        "description": "Get the weather information for a given location",
        "parameters": {
            "type": "object",
            "properties": {
                "region": {"type": "string", "description": "The name of city. like Tokyo, Osaka "}
            },
        },
        "required": ["region"],
    },
    {
        "name": "add",
        "description": "Take the sum of two numbers",
        "parameters": {
            "type": "object",
            "properties": {
                "a": {"type": "integer", "description": "The integer value from add"},
                "b": {"type": "integer", "description": "The integer value to add"},
            },
        },
        "required": ["a", "b"],
    },
]


def function_mapping(text):
    messages = [
        {
            "role": "user",
            "content": text,
        }
    ]
    res = openai.ChatCompletion.create(
        model="gpt-3.5-turbo-0613",
        messages=messages,
        functions=functions,
        function_call="auto",
    )
    response_message = res["choices"][0]["message"]
    if response_message.get("function_call"):
        func_map = {
            "get_weather": get_weather,
            "add": add,
        }
        func = func_map.get(response_message["function_call"]["name"], None)

        if func is None:
            return "多分ここにくることはない"
        
        args = json.loads(response_message["function_call"]["arguments"])
        result = str(func(**args))
        return result
    else:
        return "すみません。よくわかりません。"

```

# 考察

単純な例で音声対話エージェントを作ってみたのですが、functionsの中身は動的に変えることができるのでもっと色々なことができるのではないかな？という気がしています。

+ 対話の文脈に応じてfunctionsの中身を変更する
+ chatGPT自身にfunctionsの定義や中身を考えさせて自動的に処理できることを増やす
+ たくさんあるfunctionsのうちいくつかを抽象化させて、functionsをモジュール化させる

少し実験してみたのですが、


`次の三つの関数のうち、統合できるものは一つに統合して関数二つ分にしてください`とchatGPTに聞くと、

```
def get_tweet(query) -> str:
	result = search_tweet(query)
	return result

def get_instagram(query) -> str:
	result = search_instagram(query)
	return result

def save_text(text, filename):
	with open(filename, 'w') as f:
		f.write(text)
```

下記の内容が返ってきました。

```python
def search_social_media(query: str, platform: str) -> str:
    if platform.lower() == "twitter":
        result = search_tweet(query)
    elif platform.lower() == "instagram":
        result = search_instagram(query)
    else:
        raise ValueError(f"Unsupported platform: {platform}")
    return result

def save_text(text: str, filename: str) -> None:
    with open(filename, 'w') as f:
        f.write(text)
```

似たようなことをやっている関数をまとめてくれています。これを使えば、functionsの中身を抽象化して少なくすることができそうです。

逆に、`次の三つの関数のうち、統合できるものは統合してください。統合しない方が設計上好ましい場合はその旨を教えてください`という質問をした場合

```python
def take_photo():
	# 写真を撮影する

def speech(text):
	# 文章を音声で流す

def save_text(text):
	# 文章をファイルに保存する
```

下記の回答が返ってきて、異なる役割の関数は無理に抽象化しないことがわかりました。

```
こちらの3つの関数はそれぞれ異なるタスクを実行しており、機能的には統合することは可能ですが、設計上の観点から見ると、それぞれ独立していた方が適切と考えられます。
理由としては、各関数は単一の責任原則（Single Responsibility Principle）に従っています。つまり、各関数はそれぞれ一つのタスク（写真を撮る、文章を音声化する、文章を保存する）を実行します。これらを統合すると、新たに作成する関数は複数の責任を持つことになり、理解やメンテナンスが難しくなる可能性があります。
また、それぞれの関数は独立して使用することが可能です。たとえば、写真を撮るだけで、音声化や保存をしないといったケースです。これらを一つの関数に統合してしまうと、特定のタスクだけを実行する際に不便になる可能性があります。
したがって、これらの関数はそれぞれ独立して保つほうが設計上好ましいと考えられます。
```

これができれば、対話を通してシステムの設計や実装も任せられるような気がしてきますね・・。

この、音声対話エージェントをベースに色々と実験してみたいと思います。

# まとめ

今回は、ChatGPTのAPIを用いて音声対話エージェントを作成するプロセスを共有し、その可能性を模索しました。提案したアイデアや具体的な実装方法が皆様のご活用に役立てば幸いです。私たちの技術と知識は常に成長し、それは皆様の新たな視点やアイデアからも刺激を受けています。この記事を読んで何か新しい閃きや提案があればぜひ教えてください。最後までご覧いただきありがとうございました。