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