MNTSQ Techブログ

リーガルテック・カンパニー「MNTSQ(モンテスキュー)」のTechブログです。

ChatGPTとPydanticでかんたん契約書解析

MNTSQ Tech Blog TOP > 記事一覧 > ChatGPTとPydanticでかんたん契約書解析

こんにちは、MNTSQでエンジニアをやっている平田です。 MNTSQでは自然言語処理を使って契約書を解析したり検索したりする機能を開発しています。

契約書解析には、次のようなタスクがあります。

  • 秘密保持契約等の契約類型に分類
  • 契約締結日や契約当事者等の基本情報を抽出
  • 条項(第1条, 第2条, ...)単位で分解

本稿では、これらの契約書解析タスクをGPT-4oに解かせてどんな結果になるか見てみます。

ざっくりやり方

GPT-4oのAPIを呼び出すところ

ここではAzure OpenAIのGPT-4oを使います。Microsoftのサンプルコードほぼそのままですが、一応貼り付けておきます。

from openai import AzureOpenAI

client = AzureOpenAI(
    api_version="2023-05-15",
    azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT"),
    api_key=os.getenv("AZURE_OPENAI_API_KEY"),
)
response = client.chat.completions.create(
    model="gpt-4o",
    messages=[{"role": "user", "content": "ここにプロンプトを入れる"}],
    temperature=0,
)
completion = response.choices[0].message.content

解析結果をJSON形式で出力させるプロンプトテンプレート

解析結果をソフトウェアで扱いやすくするために、JSON形式で出力させます。 JSON形式で出力させるテクニックはいくつかありますが、ここではプロンプトでJSONスキーマを渡します。 プロンプトのテンプレートは次のようなイメージです。

prompt_template = """\
{content}

Transform the above text into a JSON according to the following JSON schema. Output in a code block, and do not output anything else.
```json
{json_schema}
```
"""

PydanticでJSONスキーマを生成するところ

PydanticはJSONスキーマの生成とバリデーションができるので、今回やりたいことにぴったりです。

docs.pydantic.dev

Pydanticを使わずにJSONスキーマを直接書いてもよいのですが、JSONスキーマは複雑すぎて少なくとも私は読みたくないですし、JSONスキーマ自体のバリデーションが必要になるのでPythonで書いてmypyに静的解析させるほうが楽だと思います。

Pydanticで次のようなデータモデルを定義します。

from pydantic import BaseModel, Field


class ClauseResponse(BaseModel):
    number: str | None = Field(default=None, description="条番号")
    heading: str | None = Field(default=None, description="条見出し")


class ContractResponse(BaseModel):
    document_name: str | None = Field(default=None, description="ドキュメントのタイトル")
    is_contract: bool = Field(description="契約書のテキストかどうか")
    execution_date: str | None = Field(default=None, description="契約を締結した日")
    effective_date: str | None = Field(default=None, description="契約の効力が発生した日")
    renewal_term: str | None = Field(default=None, description="契約の自動更新期間")
    notice_to_terminate_renewal: str | None = Field(default=None, description="契約の自動更新を終了するための条件")
    governing_law: str | None = Field(default=None, description="契約の準拠法")
    parties: list[str] = Field(default_factory=list, description="契約の当事者名リスト")
    clauses: list[ClauseResponse] = Field(default_factory=list, description="契約の条項リスト")

JSONスキーマContractResponse.model_json_schema() で生成できます。

{
  "$defs": {
    "ClauseResponse": {
      "properties": {
        "number": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "条番号",
          "title": "Number"
        },
        "heading": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "条見出し",
          "title": "Heading"
        }
      },
      "title": "ClauseResponse",
      "type": "object"
    }
  },
  "properties": {
    "document_name": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "ドキュメントのタイトル",
      "title": "Document Name"
    },
    "is_contract": {
      "description": "契約書のテキストかどうか",
      "title": "Is Contract",
      "type": "boolean"
    },
    "execution_date": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "契約を締結した日",
      "title": "Execution Date"
    },
    "effective_date": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "契約の効力が発生した日",
      "title": "Effective Date"
    },
    "renewal_term": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "契約の自動更新期間",
      "title": "Renewal Term"
    },
    "notice_to_terminate_renewal": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "契約の自動更新を終了するための条件",
      "title": "Notice To Terminate Renewal"
    },
    "governing_law": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "契約の準拠法",
      "title": "Governing Law"
    },
    "parties": {
      "description": "契約の当事者名リスト",
      "items": {
        "type": "string"
      },
      "title": "Parties",
      "type": "array"
    },
    "clauses": {
      "description": "契約の条項リスト",
      "items": {
        "$ref": "#/$defs/ClauseResponse"
      },
      "title": "Clauses",
      "type": "array"
    }
  },
  "required": [
    "is_contract"
  ],
  "title": "ContractResponse",
  "type": "object"
}

GPT-4oが出力したJSON文字列を抽出する

プロンプトで Output in a code block と指示しているので、GPT-4oはコードブロックで囲われたJSONを出力します。 ここでは正規表現を使ってコードブロックからJSON文字列を抽出します。

import re

json_pattern = re.compile(r"```json\n(.+?)\n```", re.DOTALL)
if m := json_pattern.search(completion):
    json_text = m.group(1)

PydanticでJSON文字列をバリデーションするところ

Pydanticを使うとJSON文字列がJSONスキーマに従っているかバリデーションできます。

json_object = ContractResponse.model_validate_json(json_text)

もしバリデーションでエラーになった場合は、次のようなテンプレートでエラーメッセージをプロンプトに追加するとうまく修正してくれることがあります。(まあGPT-4oだとほぼ完璧なJSONを返してくれるので使う機会のないテクニックですが...)

"""\
The following errors occurred, fix it.
```
{errors}
```
"""

実際にやってみた

MNTSQにたくさんある契約書サンプルの1つを使って実験してみます。

契約書サンプル(先頭と末尾のページ)

上記契約書からOCRで抽出したテキストを入力すると、GPT-4oから次のような出力が返ってきます。

```json
{
  "document_name": "金銭消費貸借契約書",
  "is_contract": true,
  "execution_date": "2021-04-20",
  "effective_date": "2021-04-30",
  "renewal_term": null,
  "notice_to_terminate_renewal": null,
  "governing_law": null,
  "parties": [
    "株式会社フォイエルバッハ商事",
    "MNTSQ株式会社",
    "板谷隆平"
  ],
  "clauses": [
    {
      "number": "1",
      "heading": "消費貸借"
    },
    {
      "number": "2",
      "heading": "借入条件"
    },
    {
      "number": "3",
      "heading": "連帯保証"
    },
    {
      "number": "4",
      "heading": "期限の利益の喪失"
    },
    {
      "number": "5",
      "heading": "届出義務"
    },
    {
      "number": "6",
      "heading": "反社会的勢力の排除"
    },
    {
      "number": "7",
      "heading": "公正証書の作成"
    },
    {
      "number": "8",
      "heading": "費用負担"
    },
    {
      "number": "9",
      "heading": "管轄"
    }
  ]
}
```

GPT-4oの出力からJSON文字列を抽出し、Pydanticのデータモデルでバリデーションしたところ、エラーなく読み込めました。 解析結果の値も完璧です 🚀

実は execution_date (締結日)と effective_date (発効日)って別物なのですが、これらをちゃんと区別できていますね。すごい!

感想

ちょっと前まで、契約書解析は次のような工程で開発していて、1機能リリースするのに数ヶ月かかることが通常でした。

  1. アノテーション
  2. モデリング
  3. テスト
  4. リリース

ChatGPTを使った開発では、アノテーションモデリングの代わりにプロンプトエンジニアリングを行います。 これにより次のような嬉しさ(開発のスケールアウト)があります。

GPT-4でも同様の開発はできるのですが、APIの料金が高く選択肢に入りにくい状況でした。 一方、GPT-4oはGPT-4に比べて破壊的に安く、選択肢に入るビジネスモデルが大幅に増えたのではないかと思います。

Azure OpenAI Serviceの価格表(執筆時点)

こうやってできることが増えていくとワクワクしますね 🙌

残る課題

これでAIが仕事を奪ってくれて明日から遊べるぞ!!!!とはならないのが残念なのですが、実際に契約書解析のプロダクトでGPT-4oを使う上でどんな課題があるか、いくつか挙げてみます。主に契約書というデータが長過ぎることに起因するものです。

  • GPT-4oのトークン制限数
    • GPT-4oの入力トークン数は最大128kです。やばいですね。一方、契約書のトークン数はだいたい1ページ500トークンです。MNTSQには数百ページの契約書が入ってきたりするのですが、仮に300ページとすると150kトークン必要なのでそのままプロンプトに入力するにはちょっと足りないのです。
    • また、GPT-4oの出力トークン数は最大4kです。条項数が100を超える契約書もあるので、本稿で紹介した解析結果を得るには心許ない数字です。
  • Lost in the Middle
    • こちらの論文にもあるように、プロンプトの中間にある情報は忘れられがちです。契約書は長いのでこの問題が結構クリティカルです。
  • GPT-4oの料金
    • いくら安くなったとはいえ、128kトークン全部使い切るようなプロンプトを入力すると1回のリクエストで100円くらいかかるので、サービスのプライシングロジックに配慮した戦略が必要です。
  • GPT-4oのターンアラウンドタイム
    • 爆速!と言われたGPT-4oですが、60ページくらいの契約書データを使ってプロンプト入力からJSON出力の時間を測ると1分くらいでした。サービスで提供したい体験によっては許容できない可能性があります。

他にもありますが、MNTSQではこのような課題と向き合いながらLLMを活用したプロダクト開発を行っています。

もしこの記事に興味をお持ちいただいて、「他のデータだとどうなの?」とか「課題は解決したの?」とか聞いてみたい方がいらっしゃいましたらお気軽にDM*1等でお問い合わせください。

この記事を書いた人

Takumi Hirata

MNTSQのアルゴリズムエンジニア。流離のなんでも屋