Function callingで複雑なJson形式を抽出する

背景

OpenAI APIのChat APIにFunction calling機能がリリースされました。
名称的にもサンプルコード的にも、Chat APIでPluginsのようなツールを使うための方法のようです。

ですが、「Jsonを安定して出せる」ことが何よりの価値だと感じます。

この記事でもテキストからJson形式で抽出する方法について書きましたが、
安定してJsonを出力する部分で少し苦労しています。
これをアップデートしたいなということで、まずは勉強しました。

本日リリースですでにいくつも使い方の記事がみつかります。(本当にスピードの早い世の中。。)
ただ、見える範囲では、ネストされたJsonを出力するものは見つからなかったので、試してみました。

実装

Import & Load API key

まずはいつも通り必要なパッケージをインポートして、APIキーをセットします。

import openai,os,json
from dotenv import load_dotenv, find_dotenv
_ = load_dotenv(find_dotenv())
openai.api_key = os.environ['OPENAI_API_KEY']

Example from OpenAI Document

まずはOpenAIのドキュメントにある例を動作確認します。

def get_current_weather(location, unit="fahrenheit"):
    weather_info = {
        "location": location,
        "temperature": "72",
        "unit": unit,
        "forecast": ["sunny", "windy"],
    }
    return json.dumps(weather_info)

`get_current_weather(location)`で`location`の天気を出力するダミー関数を定義しておきます。

def run_conversation(input):
    response = openai.ChatCompletion.create(
        model="gpt-3.5-turbo-0613",
        messages=[{"role": "user", "content": input}],
        functions=[
            {
                "name": "get_current_weather",
                "description": "指定した場所の現在の天気を取得",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "location": {
                            "type": "string",
                            "description": "都市と州",
                        },
                        "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]},
                    },
                    "required": ["location"],
                },
            }
        ],
        function_call="auto",
    )
    message = response["choices"][0]["message"]
    print("message>>>\n", message, "\n\n")

    if message.get("function_call"):
        function_name = message["function_call"]["name"]

        function_response = get_current_weather(
            location=message.get("location"),
            unit=message.get("unit"),
        )

        second_response = openai.ChatCompletion.create(
            model="gpt-3.5-turbo-0613",
            messages=[
                {"role": "user", "content": input},
                message,
                {
                    "role": "function",
                    "name": function_name,
                    "content": function_response,
                },
            ],
        )
        return second_response

print("response>>>\n", run_conversation("ボストンの天気はどうですか?")["choices"][0]["message"]["content"], "\n\n")

先程の関数とその入力を定義し、`functions`に入力するという仕様です。
最初の入力の応答に、`function_call`があった場合、関数名と入力を受け取って関数を実行し、次の入力にします。
計2回のAPI呼び出しで、応答を得られます。

    message>>>
     {
      "role": "assistant",
      "content": null,
      "function_call": {
        "name": "get_current_weather",
        "arguments": "{\n  \"location\": \"Boston\"\n}"
      }
    } 
    
    
    response>>>
     ボストンの現在の天気は晴れで、風も強いようです。気温は72度です。 

結果を見ると、いい感じにJsonが得られ、それをもとに自然な回答を得られています。

デモ

次に、リストや辞書が含まれる、より複雑なJsonの場合を試してみます。(Json的にはarrayとobject?)

まずは、ChatGPTのUI版で適当なレシピテキストを作ってもらいました。ここからレシピを抽出します。

recipe_text = """\
シンプルなトマトパスタのレシピです。
材料: パスタ(お好みの種類): 100g、トマト缶: 60g、にんにく: 1かけ、オリーブオイル: 大さじ1、塩: 適量、こしょう: 適量、パルメザンチーズ(お好みで): 適量
鍋にお湯を沸かし、パスタを袋の表示通りに茹でます。
別のフライパンにオリーブオイルを熱し、みじん切りにしたにんにくを加えて弱火で炒めます。
トマト缶を加え、塩とこしょうで味を調えます。
トマトソースを弱火で加熱し、少しとろみがつくまで煮込みます。
茹で上がったパスタをソースに加え、全体がよく絡まるように混ぜます。
お皿に盛り付け、お好みでパルメザンチーズをかけて完成です。\
"""

以下のようなフォーマットを定義しました。リストや辞書を含む形です。

{
    "name": "string: レシピ名。",
    "appliances": ["string: 使用機器"],
    "ingredients": [
        {"name": "string: 材料名","amount": "string: 分量"}
    ],
    "instructions": ["string: 手順"]
}

このフォーマットを`parameters`に入力すると、以下のようになりました。
ドキュメントにあった以下のリンクをもとにJsonを勉強しました。

Understanding JSON Schema — Understanding JSON Schema 2020-12 documentation

かなり長くなりますね。。

response = openai.ChatCompletion.create(
    model="gpt-3.5-turbo-0613",
    messages=[{"role": "user", "content": recipe_text}],
    functions=[
        {
            "name": "get_recipe",
            "description": "レシピデータをJson形式で返す",
            "parameters": {
                "type": "object",
                "properties": {
                    "name": {
                        "type": "string", "description": "レシピ名"
                    },
                    "appliances": {
                        "type": "array", "description": "使用機器のリスト",
                        "items": {
                            "type": "string", "description": "使用機器"
                        }
                    },
                    "ingredients": {
                        "type": "object", "description": "材料のリスト",
                        "properties": {
                            "name": {
                                "type": "string", "description": "材料名"
                            },
                            "amount": {
                                "type": "string", "description": "材料の分量。例:200g, 大さじ1, 2個, etc",
                            }
                        }
                    },
                    "instructions": {
                        "type": "array", "description": "調理手順のリスト",
                        "items": {
                            "type": "string", "description": "調理手順"
                        }
                    },
                },
                "required": ["name","appliances","ingredients","instructions"],
            },
        }
    ],
    function_call="auto",
)
message = response["choices"][0]["message"]
print(json.dumps(json.loads(message['function_call']['arguments']),indent=2,ensure_ascii=False))

出力がこちらです。いい感じでJson抽出できました。

{
  "name": "トマトパスタ",
  "appliances": [
    "鍋",
    "フライパン"
  ],
  "ingredients": [
    {
      "name": "パスタ",
      "amount": "100g"
    },
    {
	  "name": "トマト缶",
	  "amount": "60g"
	},
	{
	  "name": "にんにく",
	  "amount": "1かけ"
	},
	{
	  "name": "オリーブオイル",
	  "amount": "大さじ1"
	},
	{
	  "name": "塩",
	  "amount": "適量"
	},
	{
	  "name": "こしょう",
	  "amount": "適量"
	},
	{
	  "name": "パルメザンチーズ(お好みで)",
	  "amount": "適量"
	}
  ],
  "instructions": [
    "鍋にお湯を沸かし、パスタを袋の表示通りに茹でます。",
	"別のフライパンにオリーブオイルを熱し、みじん切りにしたにんにくを加えて弱火で炒めます。",
	"トマト缶を加え、塩とこしょうで味を調えます。",
	"トマトソースを弱火で加熱し、少しとろみがつくまで煮込みます。",
	"茹で上がったパスタをソースに加え、全体がよく絡まるように混ぜます。",
	"お皿に盛り付け、お好みでパルメザンチーズをかけて完成です。"
  ]
}

疑問

まだ、`required`の扱いがわかりません。

こちらで使うには、`required`を初期から使うわけにはいかないが、なにか問題が起こるのか。。今後試してみます。

また、Function callingという名称について、
普通に考えると、「Output Parserです」って出したほうが汎用性的にもいいと思うんですが、なぜFunction calling(「関数の入力を作成し、関数を使えるようにしました」)だったんでしょう。

本当に関数を使うだけの機能なら、API呼び出しを2回に分けないほうが自然ですし。

用途を限定してわかりやすく、インパクトを大きくするという意図なのでしょうか。それとも何か他に意図や願望があるのか。うーん。

まとめ

新しく出たFunction callsについてサンプルコードより複雑なJsonの出力方法をお試しました。

少し前に試していたLangChainのOutput Parserだと、ネストされたJsonのスキーマを与える方法がわからず、仕方なく自分でプロンプトを書いていました。(Pydanticだとdescriptionを日本語で書くと文字化けするんです。回避方法もありそうですが。。)

これで、より色々なところで使えそうですね。
LangChainを使うのかopenaiのみを使って自前実装するのか、難しくなっていきそうですね。「LangChainの実装はわかっておきながら自前実装する」が最適解ですかね。

参考

ChatGPTでURLから任意のJson形式でデータ抽出を行う|harukary
GPT function calling - OpenAI API
Understanding JSON Schema — Understanding JSON Schema 2020-12 documentation

サンプルコード

https://github.com/harukary/llm_samples/blob/main/OpenAI/funtion_calling.ipynb

この記事が気に入ったらサポートをしてみませんか?