😁

契約書・説明書のチェックをAIを使って効率化してみた

に公開

はじめに

こんにちは、安西晴哉です。

AILabでは、AIを活用して文書管理業務の効率化を目指し、様々なツールを開発しています。中でも、契約書や重要事項説明書といった書類において「最低限の不備がないか」を確認する作業は、上長や法務のレビュー前の重要なステップとなっています。

特に介護福祉業界のように、行政監査や指導の対象になりやすい分野では、形式的な記載ミスや法令違反につながる記入漏れが業務リスクとなる可能性があります。

本記事では、そうした書類の記入ミスをAIによって事前にチェックする「書類確認AI」の仕組みと実装方法についてご紹介します。

この内容は書類の整合性検証をAIで効率化してみたの内容に追加で実装をおこなったものになります。

開発環境

Python

PDFのPNG変換、文字列処理、CSV出力、Azure API連携などを効率良く行うため、開発言語にPythonを採用しました。豊富なライブラリにより、実装の大幅な簡素化が可能です。

Azure AI Document Intelligence

AzureのDocument Intelligenceを活用し、PDF内の文字起こし・選択肢へのチェック・表データなどの構造情報を取得します。
記述内容だけでなく、選択マークや表構造の正確な抽出が可能なため、書類全体の理解度を高めることができます。

Azure OpenAI Service

抽出された情報とともに、記入前後の書類画像をAzure OpenAIへ渡し、整合性チェックを行います。
形式不備や矛盾、記入漏れなどを指摘し、JSON形式で結果を出力します。

実装フロー

本プロジェクトは以下のディレクトリ構成で行いました。


project_root/
├── src/
│   ├── main.py
│   ├── encoder/
│   │   └── encode.py
│   ├── extracter/
│   │   ├── extract_problem.py
│   │   └── extract_table.py
│   └── converter/
│       ├── pdf_to_png.py
│       └── txt_to_json.py
├── output_csv/
├── img/
├── json/
├── table/
├── prompt/
│   └── 業界.txt
└── pdf/
    ├── 書類_記入なし.pdf
    └── 書類_記入済み.pdf


以前との変更点

①表などの記入漏れ

今回は書類のチェック項目や表の記入漏れなども指摘する必要があるのですが、そのまま書類をAIに与えるだけでは表の記入漏れを認識がうまくいきませんでした。

そのため以下の2つをAIに与えることで記入漏れをAIに認識してもらえるようにしました。

・書類
・そのページにある表をJSONファイルにしたもの

このようにすることでAIに正しい認識をしてもらうことを目指しました。

②業界を絞らずに書類の確認ができるようにする

今までのプロンプトでは様々な業界に対応させるためにわざわざプロンプトを書き直す必要がありました。

そこで、どの業界にも共通するプロンプトをコードないに書き込み、業界ごとの規程などのプロンプトは別のtxtファイルとして作成し、それを読み込ませることで様々な業界に対応できるようにしました。

使用方法

ここから、本ツールで使用するコードの説明をします。

① PDFをPNGに変換

pdf_to_png.py

pypdfium2を用いることでPDFをPNGに変換し、保存まで行います。


import os
import pypdfium2 as pdfium

def convert_pdf_to_png(pdf_file, output_folder):
    os.makedirs(output_folder, exist_ok=True)
    pdf_name = os.path.splitext(os.path.basename(pdf_file))[0]
    pdf = pdfium.PdfDocument(pdf_file)
    output_paths = []
    for page_num in range(len(pdf)):
        page = pdf[page_num]
        image = page.render(scale=4.0).to_pil()
        output_path = os.path.join(output_folder, f"{pdf_name}_page_{page_num + 1:03}.png")
        image.save(output_path)
        output_paths.append(output_path)
    print(f'Converted {pdf_file} to PNG files.')
    return output_paths


② Document Intelligence による書類情報の抽出

Document Intelligenceを用いて、PDFから文字列、選択肢、表構造などを抽出します。

Document Intelligenceでは以下のような情報が得られます:

・全文テキスト
・チェックマーク(選択肢)
・テーブルの構造と中身

open AIとDocument Intelligenceの呼び出し : extract_problem.py

以前のコードにテーブル構造を取得するコードの追加や業界ごとの規程をまとめたtxtファイルの呼び出しを行う処理を加えました。


def extract_text_and_selection(client, filename, file_path, index, table_folder):
    base_dir = os.path.dirname(__file__)
    json_dir = os.path.join(base_dir, "../..", "json")
    os.makedirs(json_dir, exist_ok=True)

    output_path = os.path.join(json_dir, f"{filename}_page_{index + 1}.json")

    result_messages = []

    # レイアウト解析&JSON保存
    with open(file_path, "rb") as f:
        poller = client.begin_analyze_document(
            model_id="prebuilt-layout",
            body=f
        )
        result = poller.result()

    result_dict = result.as_dict()
    output = {
        "status": "succeeded",
        "analyzeResult": result_dict
    }

    with open(output_path, "w", encoding="utf-8") as f:
        json.dump(output, f, ensure_ascii=False, indent=2)
    print(f"解析結果をJSONに保存しました。")

    with open(output_path, "r", encoding="utf-8") as f:
        data = json.load(f)

    transcripted_text = data["analyzeResult"]["content"]
    selection_marks = []
    pages = data["analyzeResult"].get("pages", [])
    for page in pages:
        selection_marks.extend(page.get("selectionMarks", []))
        tables = read_tables_from_image(file_path)
        page_table_path = os.path.join(table_folder, f"{filename}_page_{index + 1}_tables.json")
        save_tables_to_json(tables, page_table_path)

    if not selection_marks:
        print(f"ページ{index + 1}に選択肢情報はありません。")
        return transcripted_text, "選択肢情報はありません。"
    
    mark_idx = 1  # 選択マークの連番
    for page in pages:
        words = page.get("words", [])

        for mark in selection_marks:
            if mark.get("state") != "selected":
                continue

            mark_center = center(mark["polygon"])

            closest_word = None
            min_dist = float("inf")

            for word in words:
                text = word.get("content", "").strip()
                polygon = word.get("polygon", [])
                if not text or not polygon:
                    continue

                word_center = center(polygon)
                dist = distance(mark_center, word_center)

                if dist < min_dist:
                    min_dist = dist
                    closest_word = text

            result_messages.append(
                f"[選択マーク {mark_idx}] → 最も近い文字: 「{closest_word}」 距離: {round(min_dist, 2)} px"
            )
            mark_idx += 1

    return transcripted_text, "\n".join(result_messages)


def extract_problem(client, deployment, image_before_url, image_after_url, user_input, text, selection, prompt_path, page_tables = None):

    with open(prompt_path) as f:
        embedded_prompt = f.read()
        
    table_info = " "

    if page_tables:
        table_info = f"""
        {json.dumps(page_tables, ensure_ascii=False, indent=2)}
        """

    prompt = f"""
        あなたは画像を読み取り、記入内容を確認して**誤り**を指摘するアシスタントです。
        今日の日付は{now_date}です。

        渡す画像:

        1枚目:原本(記入前,記入例)

        2枚目:記入後の書類

        以下の業界の規程を厳密に遵守しているかを確認し、画像2枚目の**記入後の書類**に対して整合性を確認して指摘を行ってください。
        -----------業界固有の規程----------
        {embedded_prompt}
        ---------------------------------

        -------記入後画像内のテキスト--------
        {text}
        ---------------------------------

        -------記入後画像内の選択肢情報------
        {selection}
        ---------------------------------

        ------記入後画像内のテーブル情報-----
        {table_info}
        ---------------------------------
        
        あなたは記入後の書類をもとに以下のステップで取り組んでください。
        1. 一度全文を文字に起こし、その後不備のある箇所を探してください。

        2.記入後の書類の内容に対して、明らかに不適切または不備のある箇所を全て確認し、以下の観点から判断してください:

        - 書類内における日付の整合性
        - 存在しない年号や日付  **ただし、丸をつける際などは昭は昭和、平は平成など省略されている場合があることに注意してください。**(例) 平成40年、大正35年
        - 記入漏れ
        - 数字や、記載内容の形式不備や不自然な表記 (例)1000,00円

        ただし、以下のルールを厳密に遵守してください:

        - **画像内に記号(例:●)が含まれている場合、その記号が明示的にどの選択肢に付されているかに基づいて、常識や一般的な文脈を用いた解釈は行わず、記号が示す内容をそのまま文字として読み起こすものとする。仮にその内容が事実として不自然・誤りであるように見えても、目に見える情報を忠実に再現することを優先する**。
        - **画像内に表示された情報はあくまで**目視で確認できる文字や記号の通りに忠実に読み取り・評価すること**。
        - 原本は記入前の状態の画像であり、文字起こし結果は記入後のものであることに留意すること。
        - 記載された年・月・日の数字が不自然であっても、それを改変・補正せず、**「記載されているとおりに」読み取ること**。
        - 日付や数値が常識的に成立しない場合でも、**不備や誤りとして指摘するが、読み取り自体は原文通り行うこと**。
        - 未来の日付ならばすぐに誤りにするのではなく、それが現実的かどうかという観点も含め、それが正しいかどうか判断すること。 (正しい例) 令和7年8月4日 (誤り例) 令和100年
        - 日付として明らかにおかしいものは未来のもので現実的であっても誤りとしてよい
        - 項目名称の欄に記入事項が記載されている場合はその指示に従うものとする
        - 書類内での矛盾がある場合には、どの要素との整合性が取れていないかを示すこと
        - csvにはselectedやunselectedは表示しないこと。
        - 文字起こし結果における不要なスペースは問題としないこと。

        3. 出力は以下の形式の**JSON配列**で、厳密に準拠し、問題のある箇所のみを記述してください。

        4. 番号の重複・欠番を確認してください

        5. **明確な誤りのみ**を指摘し、正しい記載は指摘しないでください

        [
            {{
                "{csv_columns[0]}" : "[該当文言]",
                "{csv_columns[1]}": "指摘箇所",
                "{csv_columns[2]}": "指摘の分類(形式不備 / 内部矛盾 / 運営基準違反 / 人員基準違反 / 誤字・表記揺れ)"

            }},
            ...
        ]
    """

    response = client.chat.completions.create(
        model=deployment,
        messages=[
            {"role": "system", "content": prompt},
            {
                "role": "user",
                "content": [
                    {"type": "text", "text": user_input},
                    {"type": "image_url", "image_url": {"url":image_before_url}},
                    {"type": "image_url", "image_url": {"url":image_after_url}}
                ]
            }
        ],
        max_tokens=4096,
        temperature=0.5,
        top_p=1.0
    )
    return response



表などの読み込み : extract_table.py


import os
import json
from azure.ai.documentintelligence import DocumentIntelligenceClient
from azure.core.credentials import AzureKeyCredential
from dotenv import load_dotenv


load_dotenv()

def read_tables_from_image(image_path):

    endpoint = os.getenv("...")
    key = os.getenv("...")
    
    client = DocumentIntelligenceClient(
        endpoint=endpoint,
        credential=AzureKeyCredential(key)
    )
    
    with open(image_path, "rb") as f:
        poller = client.begin_analyze_document("prebuilt-layout", body=f)
        result = poller.result()
    
    tables = []
    
    if hasattr(result, 'tables') and result.tables:
        for table in result.tables:
            table_data = extract_table_data(table)
            tables.append(table_data)
    

    return tables

def extract_table_data(table):


    cells = {}
    for cell in table.cells:
        cells[(cell.row_index, cell.column_index)] = cell.content.strip()
    

    rows = []
    for row in range(table.row_count):
        row_data = []
        for col in range(table.column_count):
            content = cells.get((row, col), "")
            row_data.append(content)
        rows.append(row_data)
    
    table_data = {
        "row_count": table.row_count,
        "column_count": table.column_count,
        "rows": rows
    }
    
    return table_data

def save_tables_to_json(tables, output_path):

    os.makedirs(os.path.dirname(output_path), exist_ok=True)
    
    with open(output_path, "w", encoding="utf-8") as f:
        json.dump(tables, f, ensure_ascii=False, indent=2)
    
    print(f"テーブルを保存しました: {output_path}")

③ JSON形式の抽出

Open AIからのレスポンスに対して、大括弧内を抽出します。


import re
import json

def convert_to_json(text):
    text = re.sub(r'^json\s*', '', text)
    text = re.sub(r'\s*$', '', text)
    start = text.find("[")
    end = text.rfind("]")
    if start == -1 or end == -1 or end < start:
        return None
    array_str = text[start:end+1]
    array_str = re.sub(r'\\(?!["\\/bfnrtu])', '', array_str)
    return json.loads(array_str)

main.pyでの呼び出し

今までのコードをmain.pyから呼び出せるようにしました。


from encoder.encode import encode_image_base64
from extracter.extract_problem import extract_text_and_selection, extract_problem, save_as_csv
from converter.pdf_to_png import convert_pdf_to_png
from dotenv import load_dotenv
from openai import AzureOpenAI
from azure.core.credentials import AzureKeyCredential
from azure.ai.documentintelligence import DocumentIntelligenceClient
import os
import glob
import unicodedata
import json

# .envファイルを読み込む
load_dotenv()

# 環境変数の取得
openai_endpoint = os.getenv("AZURE_ENDPOINT")
deployment = os.getenv("AZURE_DEPLOYMENT")
subscription_key = os.getenv("AZURE_API_KEY")
api_version = os.getenv("API_VERSION")
doc_intelligence_endpoint = os.getenv("AZURE_DOC_ENDPOINT")
doc_intelligence_key = os.getenv("AZURE_DOC_KEY")

# Azure OpenAIクライアント作成
openai_client = AzureOpenAI(
    api_version=api_version,
    azure_endpoint=openai_endpoint,
    api_key=subscription_key,
)

# Azure Document Intelligenceクライアント作成
doc_intelligence_client = DocumentIntelligenceClient(
    endpoint=doc_intelligence_endpoint,
    credential=AzureKeyCredential(doc_intelligence_key)
)

def load_page_tables(table_folder, filename, page_index):
    table_path = os.path.join(table_folder, f"{filename}_page_{page_index + 1}_tables.json")
    if os.path.exists(table_path):
        try:
            with open(table_path, "r", encoding="utf-8") as f:
                return json.load(f)
        except Exception as e:
            print(f"テーブルファイル読み込みエラー: {e}")
            return None
    return None

def normalize_filename(filename):
    return unicodedata.normalize("NFD", filename)

base_filename = normalize_filename("書類名")

# ここで質問内容を定義
user_input = "記入内容で記入形式のミスや整合性に矛盾が見つかる部分をリストアップして下さい。"

before_filename = f"{base_filename}_記入なし"
after_filename = f"{base_filename}_記入済み"

def find_page_pairs(img_folder):
    before_glob = os.path.join(img_folder, f"{before_filename}_page_*.png")
    after_glob  = os.path.join(img_folder, f"{after_filename}_page_*.png")
    before_imgs = sorted(glob.glob(before_glob))
    after_imgs  = sorted(glob.glob(after_glob))
    
    before_map = {page_from_filename(p): p for p in before_imgs}
    after_map  = {page_from_filename(p): p for p in after_imgs}
    pages = sorted(set(before_map.keys()) & set(after_map.keys()))
    print([(before_map[p], after_map[p], p) for p in pages])
    return [(before_map[p], after_map[p], p) for p in pages]

def page_from_filename(fname):
    return os.path.splitext(fname)[0].split("_")[-1]

if __name__ == "__main__":
    pdf_folder = "../pdf"
    img_folder = "../img"
    table_folder = "../table"
    output_dir = "../output_csv"
    prompt_path = "../prompt/介護業界.txt"
    selection = ""
    first_page = True

    output_csv = os.path.join(output_dir, "check.csv")

    pdf_files = glob.glob(os.path.join(pdf_folder, "*.pdf"))
    
    for pdf_file in pdf_files:
        output_path = convert_pdf_to_png(pdf_file, img_folder)
        print(output_path)

    page_pairs = find_page_pairs(img_folder)

    for index, (before_path, after_path, page) in enumerate(page_pairs):
        print(f"解析中: {before_path}, {after_path}")

        image_before_url = encode_image_base64(before_path)
        image_after_url = encode_image_base64(after_path)

        transcripted_text, selection = extract_text_and_selection(doc_intelligence_client, after_filename, after_path, index, table_folder)
        page_tables = load_page_tables(table_folder, after_filename, index)
        response = extract_problem(openai_client, deployment, image_before_url, image_after_url, user_input, transcripted_text, selection, prompt_path, page_tables)

        save_as_csv(response, output_csv, write_header=first_page, index=index)
        first_page = False
        print(f"ページ{index + 1}の解析結果をcsvに保存しました。")

###スクリプトの実行

スクリプトは以下のコマンドで実行できます。


python main.py

実行結果

今回は介護の業界に絞って実行してみました。

例として介護業界のチェックリストは以下のように指定しました。

介護業界.txt

# 介護福祉用具貸与事業所 チェックリスト

## A. 重要事項説明書・契約書 共通のチェック項目
* **事業者情報**: 事業者名、代表者名、所在地、連絡先は正しく記載されているか?
* **契約者情報**: 利用者(または代理人)の氏名、続柄などは正しく記載されているか?
* **日付**: 契約日や説明年月日は、今日の日付に対して妥当か?未来の日付や遠い過去の日付になっていないか?
* **料金**:
    * 利用者負担割合(1割〜3割)の記載は正しいか?
    * 月途中での利用開始・終了時の料金計算方法は明記されているか?
    * 交通費など、保険外費用の定めは明確か?
* **個人情報保護**: 個人情報の取り扱い、秘密保持に関する同意取得の記載はあるか?

## B. 運営規程・重要事項説明書に特有のチェック項目
* **事業の目的・運営方針**: 事業の目的と運営の方針は記載されているか?
* **営業日時・実施地域**: 営業日、営業時間、通常の事業実施地域は明確に記載されているか?
* **人員基準**:
    * 常勤の管理者が1名以上配置されていることになっているか?(例:常勤0名は違反)
    * 福祉用具専門相談員が常勤換算で2名以上配置されていることになっているか?
* **記録の保存**: 記録の保存年限は正しく指定されているか?
* **緊急時・事故発生時の対応**: 緊急時の連絡方法、事故発生時の連絡・記録・賠償に関する手順は記載されているか?
* **苦情処理**: 苦情を受け付ける窓口、担当者、手順は明確に記載されているか?

## C. 指摘の分類カテゴリ
指摘する際は、以下のいずれかのカテゴリに分類すること。
- 形式不備(記入漏れ、日付の誤りなど)
- 内部矛盾(書類内での情報の食い違い)
- 運営基準違反
- 人員基準違反
- 誤字・表記揺れ

今後の展望

今回は各業界の規程をチェックを行えるようにしました。
現状ではどの業界に対して行うかは手動で決定しなくてはならない状態です。
そこで今後はAIを用いて書類がどの業界のものかを判断し、その業界にあった規程を自動で決定してくれるようにすることに挑戦したいです。

Discussion