自作したChatGPT規格のプラグインをFunction callingを使って呼び出す方法

# まえがき

前回、ChatGPT規格のプラグイン開発に触れてみました。
プラグインの呼び出し元である**AIオーケストレーション**にはLangchainを採用して色々と試していましたが、エラーになる確率の方が高かったです、、。

https://qiita.com/sakue_103/items/30a6d170285327d6f73d

Microsoftが掲げる**Copilot stack × Plugins**の全体像に対して、前回行った内容を当てはめると以下のようなイメージでしょうか。

- 自作のプラグインを作成
- Langchainから自作のプラグインを呼び出す
- プラグイン連携はOpenAPI仕様に基づく

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/bc61db29-7cb0-0883-6c7d-d5eb855c518b.png)

もう少し具体的な図で示すと以下のようなイメージです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/c4fca9b9-4a45-d898-544f-0601b3d47587.png)




>※プロンプトチューニングなどはしていないので精度向上の余地は十分に残されています。
具体的には、リクエスト先のURLが間違っていることが多かったですね。
そこを調整してあげればなんとかなりそうな印象でした。

プラグインとして2つの機能を用意していました。

- ToDoリストの表示
- ToDoの追加

これらの単純な機能を実現するのにあまりプロンプトチューニングはしたくないし、Langchainのソースコードを読むのも疲れるし。(実装の参考にしたのはLangchainなので結局ソースコード読んでるんですけどね、、)
といった背景から、「 **Function callingで自分で実装するかあ** 」という結論に至りました。
単純なタスクであれば意外と簡単に実装できます。


# 今回やることの全体像

先に全体像をまとめると、以下のようなイメージです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/e047266a-8652-f399-1b39-952d06501753.png)

ざっくりとした流れは以下の通りです。

- ①プラグインの機能やユースケースに関する情報を取得
- ②プラグイン仕様(```openapi.yaml```)を取得
- ③取得したプラグイン仕様に基づき、Function calling用の関数を生成
- ④Function calling実行

①に関しては今回未実装です。実装するとなると②と同じような処理内容となるかと思います。
```ai-plugin.json```の内容を取得し、「 **こんな感じのプラグインだよ** 」ということを基盤モデルに教えてあげるイメージです。
③は、```openapi.yaml```を入力するとFunction calling用の関数に変換してくれるようなプログラムで完結するので、別プラグインとの連携も楽になります。

以降では、②以降の処理についてコア部分をまとめていきます。



# a. プラグイン仕様の取得

Langchainの実装を参考にしました。
やっていることは比較的単純で、プラグイン公開サーバから```openapi.yaml```を取得してくるだけですね。

```py:プラグイン仕様の取得
import requests
import yaml

base_url = "http://localhost:5000"
openapi_url = f"{base_url}/openapi.yaml"
response = requests.get(openapi_url)
openapi_yaml = response.text
openapi_data = yaml.safe_load(openapi_yaml)
```

# b. プラグイン仕様を基にFunction calling用の関数を生成

ここが肝だと思っています。
取得したプラグイン仕様のデータを用いて、Function callingで使用可能な関数にしていきます。

Function callingを実装する上で、以下の2つの情報を定義することが必要となります。

- **Function callingで使用したい関数**
    - 実際の処理内容
- **関数のメタデータ**
    - 関数の名前、関数実行時の引数、いつ関数を使うかなどの情報
    - OpenAI APIを実行する際、```functions=```に指定する情報です
    - （メタデータと呼ぶのか分かりませんが、この記事では関数のメタデータと呼びます。）

私は実装する際、Function callingで使用したい関数をクラスとして記述しています。
LangchainのツールやSemantic Kernelのスキル(プラグイン)の実装がそうなっており、取り回しがよいため真似しています。
ただ、ここの実装はFunction callingを実行できれば何でも大丈夫です。

<details>
<summary>クラスで定義した際の実装例</summary>

```py:
# Function callingで使用したい機能を定義するクラス
class GetTodo:
    # 関数のメタデータを定義
    metadata = {
        "name": "getTodos",
        "description": "Get the list of todos",
        "parameters": {
        "type": "object",
        "properties": {
            "url": {
            "type": "string",
            "enum": ["http://localhost:5000/todos"],
            "description": "リクエスト先のURL"
            },
            "method": {
            "type": "string",
            "enum": ["GET"],
            "description": "HTTPリクエストのメソッド種別"
            }
        },
        "required": ["url", "method"]
        }
    }
    
    # 関数を定義
    def run(self, url:str, method:str):
        # 実際には適切な処理内容を記述
        return "getTodo results"
        

# Function callingで利用可能な状態にする
get_todo = GetTodo()
functions_metadata = [get_todo.metadata]
functions_callable = {get_todo.metadata["name"]: get_todo.run}

# システムのプロンプト
SYSTEM_PROMPT = """
あなたはユーザを助けるアシスタントです。
ユーザの入力に正しく回答を出力するために、ステップバイステップで慎重に考えることができます。
まずはゴール達成のためになにが必要かを考え、自分の思考と行動を説明します。
"""

# メッセージ
messages = [
    {"role":"system", "content": SYSTEM_PROMPT},
    {"role":"user", "content": "ToDoリストを表示したい"},
]

# Azure OpenAIの設定
openai.api_type = "azure"
openai.api_key = os.getenv("AZURE_OPENAI_API_KEY")
openai.api_base = os.getenv("AZURE_OPENAI_ENDPOINT")
openai.api_version = os.getenv("AZURE_OPENAI_API_VERSION")

# Function callingの実行
response = openai.ChatCompletion.create(
    engine = os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME"),
    messages = messages,
    functions=functions_metadata,
    function_call="auto",
    temperature=0,
)

# 関数の実行
msg = response["choices"][0]["message"]
func_name = msg["function_call"]["name"]
func_args = json.loads(msg["function_call"]["arguments"])
func_result = functions_callable[func_name](**func_args)

print(func_result)
```

</details>



以上を踏まえ、クラスを動的に生成することがこの手順のゴールです。
```openapi.yaml```を基に動的にクラス生成できれば、様々なプラグインを利用できるFunction callingが出来上がります。

大まかな処理内容は以下の通りです。

- ベースとなるクラスを用意
- ```openapi.yaml```から必要な情報を抜き出す
- Function calling用のクラスを生成


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/8c3226a5-20e2-be15-3872-ac9fb576832f.png)


## ベースとなるクラスを用意

ベースとなるクラスには、以下の2つの情報を定義しておきます。

- **Function callingで使用したい関数**
    - 実際の処理内容
- **関数のメタデータのベース**
    - 関数の名前、関数実行時の引数、いつ関数を使うかなどの情報

ChatGPT規格のプラグインの利用はAPI経由で行われます。
そのため、HTTPリクエストを送信できる関数(```run()```)を定義しておきます。
引数は以下3つとしています。

- **url**
    - APIエンドポイント。
    - ```enum```を使用することで、必ず```openapi.yaml```に記載のあるエンドポイントになるように。
- **method**
    - HTTPメソッド。
- **body**
    - リクエストボディ。

```py:ベースとなるクラス
# function calling用関数のベースとなるクラス
class BaseClass:
    # 関数のメタデータのベース
    metadata_base = {
        "name": "",
        "description": "",
        "parameters": {
            "type": "object",
            "properties": {
                "url": {
                    "type": "string",
                    "description": "URL of the API endpoint",
                    "enum": []
                },
                "method": {
                    "type": "string",
                    "description": "HTTP method",
                    "enum": []
                },
                "body": {},
            },
            "required": [],
        },
    }

    def __init__(self):
        pass

    # ベースクラスに共通のrunメソッドを追加
    def run(url: str, method: str, body: dict = None) -> dict:
        if method == "GET":
            response = requests.get(url)
        elif method == "POST":
            response = requests.post(url, json=body)
        else:
            raise Exception("Not supported method")

        response_body = response.json()
        return response_body
```


このベースとなるクラスを利用して、Function calling用のクラスを生成していきます。




## openapi.yamlから必要な情報を抜き出す

関数のメタデータを埋めるために、```openapi.yaml```から必要な情報を抜き出します。
ベースクラス内に定義したメタデータは以下のようになっています。

```json:メタデータのベース
{
    "name": "",
    "description": "",
    "parameters": {
        "type": "object",
        "properties": {
            "url": {
                "type": "string",
                "description": "URL of the API endpoint",
                "enum": []
            },
            "method": {
                "type": "string",
                "description": "HTTP method",
                "enum": []
            },
            "body": {},
        },
        "required": [],
    },
}
```

上記JSONの空の部分を埋めるために必要な情報を```openapi.yaml```から引っ張ってきます。
今回、```openapi.yaml```は以下のように定義しています。

<details>
<summary>openapi.yamlの定義</summary>

```yaml:
openapi: 3.0.1
info:
  title: TODO Plugin
  description: A plugin that allows the user to create and manage a TODO list using ChatGPT.
  version: 'v1'
servers:
  - url: http://localhost:5000
paths:
  /todos:
    get:
      operationId: getTodos
      summary: Get the list of todos
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/getTodoResponse'

    post:
      operationId: postTodo
      summary: Add a todo to the list
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/postTodoRequest'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/postTodoResponse'

components:
  schemas:
    getTodoResponse:
      type: object
      properties:
        todos:
          type: array
          items:
            type: string
          description: The list of todos
    postTodoRequest:
      type: object
      properties:
        todo:
          type: string
          description: The todo to add
    postTodoResponse:
      type: object
      properties:
        todos:
          type: array
          items:
            type: string
          description: The list of todos
```

</details>


- **/todos**
    - ```GET```：現在のToDoリストを表示
    - ```POST```：ToDoリストに項目を追加



メタデータを埋めることができるのであれば、処理内容は何でも大丈夫です。一例を以下に示します。


- メタデータを作成するための関数を作成
    - ```エンドポイント, メソッドの種類, メソッドの定義情報, スキーマ情報```を用いてメタデータ生成
- リクエストパスごとに複数のHTTPメソッドがあるので、関数呼び出し元はループ処理

```py:メタデータの作成
# openapi.yamlを元にFunction calling用のメタデータを作成する関数
def create_function_metadata_from_openapi(
        endpoint: str, 
        method: str, 
        method_info: dict, 
        schemas: dict
    ) -> dict:

    # メタデータのベースをコピー
    metadata = dict(BaseClass.metadata_base)

    # メタデータ作成に必要なデータを取得
    name = method_info.get("operationId")
    description = method_info.get("summary")
    method = method.upper()
    if method == "POST":
        schema_ref = method_info.get("requestBody", {}).get("content", {}).get("application/json", {}).get("schema", {}).get("$ref", "")
        schema_name = schema_ref.split("/")[-1]
        body = schemas.get(schema_name, {})
        required_properties = ["url", "method", "body"]
    else:
        body = {}
        required_properties = ["url", "method"]

    # メタデータの作成
    metadata["name"] = name
    metadata["description"] = description
    metadata["parameters"]["properties"]["url"]["enum"] = [endpoint]
    metadata["parameters"]["properties"]["method"]["enum"] = [method]
    if body == {}: # bodyが空の場合は削除
        metadata["parameters"]["properties"].pop("body")
    else:
        metadata["parameters"]["properties"]["body"] = body
    metadata["parameters"]["required"] = required_properties

    return metadata


# openapi.yamlを指定したURLからダウンロード
base_url = "http://localhost:5000"
openapi_url = f"{base_url}/openapi.yaml"
response = requests.get(openapi_url)
openapi_yaml = response.text
openapi_data = yaml.safe_load(openapi_yaml)


# openapi.yamlを入力としてクラスを動的に生成
for path, methods in openapi_data.get("paths", {}).items():
    for method, method_info in methods.items():
        metadata = create_function_metadata_from_openapi(
            endpoint=servers[0] + path, 
            method=method,
            method_info=method_info,
            schemas=openapi_data.get("components", {}).get("schemas", {}),
        )

        print(metadata)
```


## Function calling用のクラスを生成

上記手順までで、```openapi.yaml```に定義されているリクエストパスのメソッドごとにメタデータの作成ができました。

- **/todos**
    - ```GET```：現在のToDoリストを表示　   →  getTodosとしてメタデータ生成
    - ```POST```：ToDoリストに項目を追加  　→  postTodoとしてメタデータ生成

<details>
<summary>openapi.yamlの定義</summary>

```yaml:
openapi: 3.0.1
info:
  title: TODO Plugin
  description: A plugin that allows the user to create and manage a TODO list using ChatGPT.
  version: 'v1'
servers:
  - url: http://localhost:5000
paths:
  /todos:
    get:
      operationId: getTodos
      summary: Get the list of todos
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/getTodoResponse'

    post:
      operationId: postTodo
      summary: Add a todo to the list
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/postTodoRequest'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/postTodoResponse'

components:
  schemas:
    getTodoResponse:
      type: object
      properties:
        todos:
          type: array
          items:
            type: string
          description: The list of todos
    postTodoRequest:
      type: object
      properties:
        todo:
          type: string
          description: The todo to add
    postTodoResponse:
      type: object
      properties:
        todos:
          type: array
          items:
            type: string
          description: The list of todos
```

</details>





<details>
<summary>作成されたメタデータ</summary>

```json:
[
  {
    "name": "getTodos",
    "description": "Get the list of todos",
    "parameters": {
      "type": "object",
      "properties": {
        "url": {
          "type": "string",
          "description": "URL of the API endpoint",
          "enum": ["http://localhost:5000/todos"]
        },
        "method": {
          "type": "string",
          "description": "HTTP method",
          "enum": ["GET"]
        }
      },
      "required": ["url", "method"]
    }
  },
  {
    "name": "postTodo",
    "description": "Add a todo to the list",
    "parameters": {
      "type": "object",
      "properties": {
        "url": {
          "type": "string",
          "description": "URL of the API endpoint",
          "enum": ["http://localhost:5000/todos"]
        },
        "method": {
          "type": "string",
          "description": "HTTP method",
          "enum": ["POST"]
        },
        "body": {
          "properties": {
            "todo": {
              "description": "The todo to add",
              "type": "string"
            }
          },
          "type": "object"
        }
      },
      "required": ["url", "method", "body"]
    }
  }
]
```

</details>



これらのメタデータとベースクラスを利用して、各クラスを作成します。
Pythonの組み込み関数```type()```を使用し、BaseClassを継承したクラスを辞書に格納しておきます。
その後、Function callingで利用可能な状態に整えます。

```py:
# openapi.yamlを入力としてクラスを動的に生成
for path, methods in openapi_data.get("paths", {}).items():
    for method, method_info in methods.items():
        metadata = create_function_metadata_from_openapi(
            endpoint=servers[0] + path, 
            method=method,
            method_info=method_info,
            schemas=openapi_data.get("components", {}).get("schemas", {}),
        )
        
        # Function calling用のクラスを個々に作成し、辞書に格納
        function_classes[metadata["name"]] = type(
            metadata["name"],
            (BaseClass,),
            {
                # copy()だと参照渡しになって上書きされてしまうので、deepcopy()を使用してコピー
                "metadata": copy.deepcopy(metadata),  
            },
        )


# Function callingで使用可能な状態にする
functions_metadata = [function_class.metadata for function_class in function_classes.values()]
functions_callable = {function_class.metadata["name"]: function_class.run for function_class in function_classes.values()}
```



# c. Function callingを実行する

この手順はChatGPT規格のプラグインだからなにか変わるかと言われるとそうではなく、Function callingの仕様に合わせて実行していきます。


ポイントは以下の通りです。

- 使用したい関数群(```functions_metadata```)を指定し、OpenAI APIを呼び出す
- 関数実行が必要だと判断された場合、関数実行に必要な情報を取得する (```func_name, func_args```)
- ```functions_callable```の中から該当する関数を実行
- 繰り返し同じ関数が実行されないように実行結果を```messages```に追加することで進捗状況を保持

```py:実装例
# Azure OpenAIの設定
openai.api_type = "azure"
openai.api_key = os.getenv("AZURE_OPENAI_API_KEY")
openai.api_base = os.getenv("AZURE_OPENAI_ENDPOINT")
openai.api_version = os.getenv("AZURE_OPENAI_API_VERSION")


# システムのプロンプト
SYSTEM_PROMPT = """
あなたはユーザを助けるアシスタントです。
ユーザの入力に正しく回答を出力するために、ステップバイステップで慎重に考えることができます。
まずはゴール達成のためになにが必要かを考え、自分の思考と行動を説明します。
"""

messages = [
    {"role":"system", "content": SYSTEM_PROMPT},
]


def exec_function_calling(user_input:str):
    # ユーザの入力をメッセージに追加
    messages.append({"role": "user", "content": user_input})

    # 推論実行
    response = openai.ChatCompletion.create(
        engine = os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME"),
        messages = messages,
        functions=functions_metadata,
        function_call="auto",
        temperature=0,
    )

    # 関数の呼び出し有無を確認
    while response["choices"][0]["message"].get("function_call"):
        msg = response["choices"][0]["message"]

        # 関数の呼び出し情報を取得
        func_name = msg["function_call"]["name"]
        func_args = json.loads(msg["function_call"]["arguments"])

        # 関数を呼び出し
        print(f"関数名： {func_name}")
        print(f"引数 ： {func_args}\n\n")
        func_result = functions_callable[func_name](**func_args)
        
        # 関数の実行結果をメッセージに追加
        status_msg = "関数:{}を実行\n実行結果:{}".format(func_name, func_result)
        messages.append(
            {
                "role": "function",
                "name": func_name, 
                "content": status_msg
            }
        )

        # 再度、推論実行
        response = openai.ChatCompletion.create(
            engine = os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME"),
            messages = messages,
            functions=functions_metadata,
            function_call="auto"
        )

    # 結果をメッセージに追加する
    result = response["choices"][0]["message"]["content"]
    messages.append({"role": "assistant", "content": result})    
    
    return result


# Function callingの実行
user_input = "Todoリストに「Qiitaに記事を書く」がない場合は追加したい"
result = exec_function_calling(user_input)
print(result)
```


私の環境では、実行結果は以下の通りとなりました。

```sh:
### デバッグ用の出力 ###
関数名： getTodos
引数 ： {'url': 'http://localhost:5000/todos', 'method': 'GET'}


関数名： postTodo
引数 ： {'url': 'http://localhost:5000/todos', 'method': 'POST', 'body': {'todo': 'Qiitaに記事を書く'}}


### 実行結果 ###
まず、Todoリストを取得するためにgetTodos関数を実行します。
取得した結果、以下のようになりました。

\```
{
  "todos": [
    "todo1",
    "todo2",
    "todo3"
  ]
}
\```

次に、新しいTodoである「Qiitaに記事を書く」をTodoリストに追加するために、postTodo関数を実行します。
実行後のTodoリストは以下の通りです。

\```
{
  "todos": [
    "todo1",
    "todo2",
    "todo3",
    "Qiitaに記事を書く"
  ]
}
\```

これで、「Qiitaに記事を書く」が正しくTodoリストに追加されました。
```

プラグイン公開サーバのログも見てみます。よさそうですね。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393221/173cbe87-72e9-5ce5-3b7f-510142912604.png)



# まとめ

ChatGPT規格のプラグインをFunction callingから呼び出す機能を実装してみました。
「 **他のプラグインを使いたい** 」となった場合は今回実装した処理内容を変える必要はなく、プラグイン公開サーバのURLを変えるだけで済みます。
規格が統一されていることの強みですね。様々なプラグインを利用しやすくなります。

今回、```.well-known/ai-plugin.json```は考慮せずにプラグインを利用する構成となっています。
Langchainではその辺りも含めて実装されていたので、参考にしつつ色々試していこうかと思います。

ソースコードは以下に置いています。

https://github.com/is0356xi/openai-plugins/tree/main



# おまけ

今回取り扱ったFunction calling用のクラスをChatGPTに生成してもらうためのプロンプトを遊びで作っていました。それも共有しようかと思います。精度はイマイチですね、、

ゴールは、「 **openapi.yamlをユーザが入力したら、それに基づいてFunction calling用のクラスに該当するソースコードを生成する** 」ことです。

- ```入力```：openapi.yaml
- ```出力```：Function calling用のクラスを記載したソースコード


以下のプロンプトをChatGPTにコピペして試してみてください:point_down:

<details>
<summary>プロンプト</summary>

```sh:
 - # ゴール
    - openapi.yamlの内容から、Function calling用のメタデータを作成する
 - # 出力例
    - class AddTodo:
          metadata = {
              "name": "postTodo",
              "description": "Add a todo to the list",
              "parameters": {
                  "type": "object",
                  "properties": {
                      "url": {
                          "type": "string",
                          "enum": ["http://localhost:5000/add-todo"],
                          "description": "リクエスト先のURL"
                      },
                      "method": {
                          "type": "string",
                          "enum": ["POST"],
                          "description": "HTTPリクエストのメソッド種別"
                      },
                      "body": {
                          "type": "object",
                          "properties": {
                              "todo": {
                                  "type": "string",
                                  "description": "The todo to add"
                              }
                          },
                          "required": ["todo"]
                      }
                  },
                  "required": ["url", "method", "body"]
              }
          }
      
      
          def run(self, url:str, method:str, body:dict) -> dict:
              if method == "POST":
                  # リクエストの送信 (bodyはapplication/json形式で送信する)
                  response = requests.post(url, json=body)
                  
                  # レスポンスの取得
                  response_body = response.json()
                  return response_body
              else:
                  raise Exception("Not supported method")
 - # 実行のプロセス
    - 1. ユーザにopenapi.yamlの定義を尋ねる
    - 2. openapi.yamlの仕様を理解し、内容をユーザに確認する
    - 3. 各pathごとに成果物を作成する。
    - 4. フィードバックループに入る
 - # フィードバックループ
    - ユーザに成果物をチェックしてもらう
    - ユーザとの対話を通じて成果物を修正する
 - # 成果物
    - 出力例に示したようなFunction calling用のメタデータ
    - 出力はJSON形式で、name, description, parametersのフィールドを持ちます。
    - propertiesには、必ずurl, methodを含める
 - それでは、実行のプロセスに従って始めましょう。
```

</details>