僕「OpenAI、JSON形式で返して！」、OpenAI「```JSON```(md形式のJSONを返す音)」

**僕「...(md形式のJSONだとパースできへんやん)」**

# はじめに
ということで、Hubbleでバックエンドエンジニアをしている @power3812 です。オブジェクト指向大好きマンで、神クラスを作れないかと模索の日々です:innocent:

今回は契約書のバージョン管理サービスを提供している弊社が、OpenAIを使用してみた結果について書こうと思います！

# 経緯
弊社では、契約書のバージョン管理サービスを提供していて、その中に[契約書情報(契約書名、契約相手方、契約開始日、契約終了日等)を自動で抽出する機能](https://help.hubble-docs.com/ja/articles/5688587-%E3%83%89%E3%82%AD%E3%83%A5%E3%83%A1%E3%83%B3%E3%83%88%E3%83%AA%E3%82%B9%E3%83%88%E3%81%AE%E8%87%AA%E5%8B%95%E4%BD%9C%E6%88%90%E6%A9%9F%E8%83%BD-%E8%A9%B3%E7%B4%B0%E6%83%85%E5%A0%B1%E3%81%AE%E8%87%AA%E5%8B%95%E5%8F%8D%E6%98%A0%E6%A9%9F%E8%83%BD)があります。

この機能は、OpenAI等の生成AIを用いずに正規表現で取得していました。しかし、正規表現では契約書の意味を理解して抽出しているわけではないので、契約書のフォーマットが変わると情報を抽出できない問題点がありました。

この問題に対してGPT-4がAzureにて使用可能になったのでかなり正確に情報を抽出できそうだということになり実装するに至りました。

# 結果
OpenAIの詳細の実装に関しては先人たちの記事に任せるとして実際に実装してみて、ある程度のプロンプトのチューニングは必要でしたが、正規表現よりはかなり正確に取ることができるようになりました。

しかし、ここでタイトルの話になります。
プロンプトでJSON形式でresponseを返すように指定しているのに、JSONはJSONでも、md形式のJSONをランダムで返すことが起きるようになりました。

- 期待値
```JSON
{
  "contractName": "業務委託契約書",
  "clinetName": "株式会社Hubble",
  "contractStart": "2024-01-01",
  "contractEnd": "2024-12-31"
}
```
- エラー値
```
```JSON
{
  "contractName": "業務委託契約書",
  "clinetName": "株式会社Hubble",
  "contractStart": "2024-01-01",
  "contractEnd": "2024-12-31"
}```
```
上記のように期待値とエラー値がランダムで返ってくるとパースできるときとできないときがあり、手を拱いていました。

# 解決策
問題を解決すべく以下の2つを試してみました。
## その1 Function callingを使用する
OpenAIには[Function calling](https://platform.openai.com/docs/guides/function-calling)という機能があります。
OpenAIにRequestした際、学習モデルはある地点までの情報しか持っていないため、リアルタイム情報(明日の天気等)を返すことができません。

その際にこのFunction callingに天気API等をcallしてリアルタイム情報を取得できるように設定できる機能です。

Function callingを使用すると、OpenAIは正しいJSON形式で返してくれるようになります。そのためこの副次効果を利用して、確実にJSON形式にしてもらおうとしました。

このFunction callingに使用するAPIは自作でも良いため以下のように定義しました。
```Python
from openai import AzureOpenAI

tools = [
    {
        "type": "function",
        "function": {
            "name": "get_contract_properties",
            "description": "契約書の情報を抽出する。",
            "parameters": {
                "type": "object",
                "properties": {
                    "contractName": {
                        "type": "string",
                        "default": None,
                        "description": "契約書のタイトルを抽出してください。該当項目がない場合はnullにしてください。半角スペースがある場合は削除してください。"
                    },
                    "clientName":  {
                        "type": "string",
                        "default": None,
                        "description": "取引相手の名前を抽出してください。該当項目がない場合はnullにしてください。"
                    },
                    "contractStart": {
                        "type": "string",
                        "default": None,
                        "description": "契約開始日を抽出してください。日付はISO8601の形式にしてください(e.g. 2022-06-30)。該当項目がない場合はnullにしてください。"
                    },
                    "contractEnd": {
                        "type": "string",
                        "default": None,
                        "description": "契約終了日を抽出してください。契約期間が「締結日から1年間」といった表記の場合、終了日を計算して正確に抽出してください（例:締結日が2023年4月1日の場合、終了日は2024年3月31日）。日付はISO8601の形式にしてください(e.g. 2022-06-30)。該当項目がない場合はnullにしてください。"
                    }
                },
                "required": [
                    "contractName",
                    "clientName",
                    "contractStart",
                    "contractEnd",
                ],
            },
        },
    }
]


azure_open_ai = AzureOpenAI(
    api_key="OPENAI_API_KEY",
    azure_endpoint="OPENAI_API_BASE",
    api_version="OPENAI_API_VERSION",
)

response = azure_open_ai.chat.completions.create(
    model=settings.OPENAI_DEPLOYMENT_ID,
    messages=[prompt_user_message],
    tools=tools,
    tool_choice={
        "type": "function",
        "function": {"name": "get_contract_properties"},
    },
    temperature=1.0,
    max_tokens=800,
    top_p=0.0,
    frequency_penalty=0,
    presence_penalty=0,
    stop=None,
)

content = response.choices[0].message.tool_calls[0].function.arguments

print(content)

```
これで一旦正規のJSONしか返さなくなりましたが、これだと以下の問題点が発生しました。
### 精度が下がる
通常の**openai.ChatCompletion.create()** で対話型のOpenAIと比較した際に精度が落ちるという事象が発生しました。
例えば、**openai.ChatCompletion.create()** には以下のプロンプトを渡した場合には正確に情報を抽出できていました。
```Python
prompt = (
    "この契約書の以下を抽出してください。"
    "契約書名:契約書のタイトルを「contractName」(型はstring)として抽出し、半角スペースがある場合は削除してください。"
    "取引相手:取引相手の名前を「clientName」(型はstring)として抽出してください"
    "契約開始日と契約終了日:契約開始日を「contractStart」(型はstring)、契約終了日を「contractEnd」(型はstring)として抽出してください。契約期間が「締結日から1年間」といった表記の場合、終了日を計算して正確に抽出してください（例:締結日が2023年4月1日の場合、終了日は2024年3月31日）。"
    "返答はJSON形式でバッククォートもjsonタグも使用なしでマークダウン記法も使用しないで、中括弧のみのJSON形式で表現してください。各項目は一つだけを含むようにし、該当項目がない場合は型がstringのものはnull、型がbooleanのものはをfalseとしてください。日付はISO8601の形式にしてください(e.g. 2022-06-30) 変換できないものはnullにしてください。"
)
```
しかし、Fuction callingにほぼ同じプロンプトを渡しているのに、同じ契約書で取引相手を取ることができなかったり、契約書名を空白で区切ったりと精度がかなり落ちました。

### requiredを指定しても必須項目にならない
これはそこまでクリティカルな問題ではないのですが、toolsで使用するプロパティに**function.parameters.required**というプロパティがあるのですが、これを指定すると必須で返してくれる仕様なのですが、該当項目がない場合はnullではなくそのkey自体を返してくれないので以下のようにPython側でnullにする必要がありました。

```Python
_contract_properties = json.loads(contract_properties)

return SchemaContractProperty(
    contract_name=_contract_properties.get("contractName", None),
    client_name=_contract_properties.get("clientName", None),
    contract_start=_contract_properties.get("contractStart", None),
    contract_end=_contract_properties.get("contractEnd", None),
)
```

これについては[公式コミュニティでも議論](https://community.openai.com/t/problem-with-required-property-when-function-calling/273635/14)されていますが、解決はしていないようです。

## その2 response_formatでjson_objectを指定する
その1の事象を踏まえて最終的に解決した方法になります。
Function callingは、元々別OpenAI外のAPIをCallしてOpenAIで処理するための物で結果JSONを返す機能でしたが、[こちらは完全にJSONを返すためのプロパティ](https://platform.openai.com/docs/guides/text-generation/json-mode)になります。
設定は簡単で、**openai.ChatCompletion.create()** で以下のようにプロパティを指定するのとmessagesにJSONの単語を入れるだけです。
```Python
prompt_system_message = "あなたはJSONをレスポンスとして返すAPIサーバーです。"
prompt_user_message = "以下のテキストから情報抽出してください。\n-------------\n業務委託契約書\nxxxxxxxxxx"

response = open_ai.ChatCompletion.create(
    deployment_id="OPENAI_DEPLOYMENT_ID",
    messages=[prompt_system_message, prompt_user_message],
    response_format={"type": "json_object"}
    temperature=1.0,
    max_tokens=800,
    top_p=0.0,
    frequency_penalty=0,
    presence_penalty=0,
    stop=None,
)

content = response.choices[0].message.content

return content
```

これによって不正なJSONがランダムで返らなくなりました。

# まとめ
今回はOpenAIを利用した開発で手詰まった所について記事を書きました！

最終的な解決方法はほぼ1行足すだけでしたが、OpenAIはまだまだ最新技術で日本語文献も少ないのはもちろんの事、そこまで踏み入った情報が無いことや突然の新機能追加など、解決に紆余曲折ありました。
しかし、そこが最新技術の醍醐味でありトライ＆エラーが楽しい所でもあります。
この記事が同じ悩みの方の助けになれれば嬉しいです！