書類作成をAIで自動化してみた

こんにちは。AILabの堂上拓です。

今回はMicrosaas開発シリーズとして、書類の空白欄を自動的に埋めるAIツールをご紹介します。 このツールは、業務でよく見かける「記入欄が多い書類」を、CSVデータをもとに一括で自動生成できるものです。

以下のような空欄が多いWord書類、皆さんも一度は目にしたことがあるのではないでしょうか?

これらを人手で何十人分も記入するのは、非常に非効率でミスの原因にもなります。 今回開発したツールは、その作業を自動化し、圧倒的に効率化するものです。

それでは開発の経緯とツールの使い方についてご説明します。

目次

開発環境

本アプリケーションで使用した技術スタックと構成は以下のとおりです。

Azure Document Intelligence

書類内の文字情報の抽出に、OCR精度の高いAzure Document Intelligenceを使用しました。 本ツールでは、「OCRで抽出した文字列」と「CSVのキー」をマッチングし、対応する値を書類に挿入します。

Azure OpenAI

CSVのキーとOCR結果内のテキストをマッチングさせるために、Azure OpenAIを活用しました。 曖昧なテキストの対応付けや、適切な出力形式(JSON)への整形もAIによって制御しています。

Python

pandasやjsonによるcsv・jsonデータの柔軟な操作、python-docxによるWord文書の読み書き自動化ライブラリを活用することで、データのまとめやdocxファイルの置換を効率化しました。

開発プロセス

今回の開発では、特に工夫が必要だった点や、実装の中で重要なポイントとなった箇所について、以下で詳しく紹介していきます。

① 入力ファイルの準備

フォーマットのファイルとして、下記画像のように挿入したい項目部分に”(項目名)”という形でテキストを入れたdocxファイル、そのpdfファイル、項目(KEY)と具体的に入れたい値(VALUE)が入ったcsvファイルを用意してください. 先ほど示した書類の画像は空白でしたが、ここの空白部分に事前に埋めていただく必要があります。

docxファイル例

csvファイル例

② pdfにOCRをかける (document_intelligence_OCR.py)

ここで用意したpdfにOCR処理を行い、書類全体の文字情報をJSON形式で出力します。

import os
import json
from azure.ai.formrecognizer import DocumentAnalysisClient
from azure.core.credentials import AzureKeyCredential
from dotenv import load_dotenv

load_dotenv()

endpoint = os.getenv("AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT")
key = os.getenv("AZURE_DOCUMENT_INTELLIGENCE_KEY")

client = DocumentAnalysisClient(
    endpoint=endpoint,
    credential=AzureKeyCredential(key)
)

def analyze_document(file_path):
    with open(file_path, "rb") as f:
        poller = client.begin_analyze_document("prebuilt-document", document=f)
    result = poller.result()

    output = {
        "pages": []
    }

    for page in result.pages:
        page_data = {
            "page_number": page.page_number,
            "lines": [line.content for line in page.lines]
        }
        output["pages"].append(page_data)

    # JSONファイルに保存
    with open("ocr_output.json", "w", encoding="utf-8") as json_file:
        json.dump(output, json_file, ensure_ascii=False, indent=2)

    print("OCR結果を ocr_output.json に出力しました。")

if __name__ == "__main__":
    file_path = "input.pdf"
    analyze_document(file_path)

③ csvをjsonに変換 (csv_to_dict.py)

OCR結果と対応づけるために、CSVもJSONに変換します。不要な列や空欄の行を削除することで、マッチング処理がスムーズに行えるようになります。

import pandas as pd
import json

# 入力CSVのパス
csv_path = "input.csv"
json_path = "input_data.json"

# CSVを読み込み
df = pd.read_csv(csv_path)

# 「Unnamed」列や NaN の列を除去
df = df.loc[:, ~df.columns.str.contains("^Unnamed")]
df = df.dropna(axis=1, how="all")  # 全部NaNの列は削除

# NaN の値を完全に除去(レコード単位)
cleaned_data = []
for row in df.to_dict(orient="records"):
    filtered_row = {k: v for k, v in row.items() if pd.notna(v)}
    cleaned_data.append(filtered_row)

# JSONとして保存
with open(json_path, "w", encoding="utf-8") as f:
    json.dump(cleaned_data, f, ensure_ascii=False, indent=2)

print(f"complete")

④ 書類の埋めたいところとcsvのマッチング(generate_json.py)

この工程が本ツールの核となる処理です。

一見、OCRで得たテキストに対してCSVのKEYを文字列一致で探すだけに見えますが、それだけでは誤置換が発生します。

以下のように「同じ項目名が複数箇所に存在する」ケースでは、意図しない場所が置換される恐れがあります。

この問題に対しては、Azure OpenAIを活用し、次のようなプロンプトで適切なプレースホルダーのみ抽出するよう指示しました。

- 各CSVキーに対して、対応するプレースホルダーは1つだけ(最も適切なもの)を出力してください。
- 括弧つきの語をプレースホルダーとし、それ以外の類似表現は無視してください。

このAIによる出力形式は以下の通りです。

{
    "placeholder": "     ",
    "context": "契約者氏名:     様",
    "input_key": "利用者"
}

さらに、AI出力の整合性が不安定な場合も考慮し、次のように2段階でのAI補正を実装しました。

"placeholderは空白と記号の連続部分だけに限定してください"
"文字起こしデータにない文章は絶対に入れないでください"

これにより、誤った挿入や過剰な補完を防ぐことができました。

import json
import os
import re
from openai import AzureOpenAI
from dotenv import load_dotenv
import unicodedata

# Load environment variables
load_dotenv()

# Azure OpenAI クライアント初期化
client = AzureOpenAI(
    api_key=os.getenv("AZURE_OPEN_AI_KEY"),
    azure_endpoint=os.getenv("AZURE_OPEN_AI_ENDPOINT"),
    api_version=os.getenv("AZURE_API_VERSION"),
)

DEPLOYMENT_NAME = os.getenv("AZURE_DEPLOYMENT_NAME")

# Load input files
with open("ocr_output.json", "r", encoding="utf-8") as f:
    ocr_data = json.load(f)

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

# Extract paragraph texts
paragraphs = [p["content"] for p in ocr_data.get("paragraphs", [])]

# 2. マッピング抽出用プロンプトを構成
system_prompt = "あなたはPDFテンプレートの記載箇所とCSVデータの対応関係をマッピングするAIです。"
user_prompt = f"""
- 各CSVキーに対して、対応するプレースホルダーは1つだけ(最も適切なもの)を出力してください。
- 例えばCSVキーに"法人格・法人の名称"がある場合、プレースホルダーは"(法人格・法人の名称)"のように括弧がついている内容を探すようにしてください。
- 文書に同じような内容で括弧がついていないものがあった場合(例えば文書に"法人格・法人の名称"があった場合)、括弧がついていない方はプレースホルダーとしないようにしてください。
- あくまでプレースホルダーは「CSVキーの内容に括弧がついた文」です。
段落:
{json.dumps(paragraphs, ensure_ascii=False, indent=2)}

CSVデータのキー:
{json.dumps(list(input_data[0].keys()), ensure_ascii=False)}

出力形式:
[
  {{
    "placeholder": "     ",
    "context": "契約者氏名:     様",
    "input_key": "利用者"
  }},
  ...
]
"""

# 3. OpenAIに問い合わせてマッピング取得
response = client.chat.completions.create(
    model=DEPLOYMENT_NAME,
    messages=[
        {"role": "system", "content": system_prompt},
        {"role": "user", "content": user_prompt}
    ],
    temperature=0.2
)
mapping_result = response.choices[0].message.content

# 4. JSONブロックを抽出(改善版)
match = re.search(r"```json\s*(\[.*?\])\s*```", mapping_result, re.DOTALL)

# fallback: try extracting just JSON array if triple-backtick block not found
if not match:
    match = re.search(r"(\[\s*\{.*?\}\s*\])", mapping_result, re.DOTALL)

if match:
    raw_mapping_json = json.loads(match.group(1))
else:
    raise ValueError("⚠️ JSON形式が抽出できませんでした。")

def expand_placeholder_in_context(placeholder, context, window=5):
    # placeholderがcontext内のどこにあるか取得
    pos = context.find(placeholder)
    if pos == -1:
        return placeholder

    start = pos
    end = pos + len(placeholder)

    # 前方向に拡張
    for i in range(1, window + 1):
        if start - i < 0:
            break
        c = context[start - i]
        # 空白や記号なら拡張、それ以外で停止
        if re.match(r'[  \-―‐─-–—=()()<><>、。.,]', c):
            start -= 1
        else:
            break

    # 後方向に拡張
    for i in range(window):
        if end + i >= len(context):
            break
        c = context[end + i]
        if re.match(r'[  \-―‐─-–—=()()<><>、。.,]', c):
            end += 1
        else:
            break

    expanded = context[start:end]
    return expanded.strip()

# 空白だけ・意味薄プレースホルダーの補正を実行
for item in raw_mapping_json:
    placeholder = item["placeholder"]
    context = item.get("context", "")

    if re.fullmatch(r'[  ]+', placeholder) or not re.search(r'[\w一-龯ぁ-んァ-ン]', placeholder):
        expanded = expand_placeholder_in_context(placeholder, context)
        item["placeholder"] = expanded


# ✅ フィルタ:プレースホルダーが空欄・記号主体のものだけに限定
def is_valid_placeholder(text):
    # 全体の7割以上が記号・空白・記号類ならOK
    if not text:
        return False
    clean = re.sub(r'[ \s\-―‐─-–—=()()<><>]', '', text)
    return len(clean) / len(text) < 0.3

filtered_mapping_json = [
    item for item in raw_mapping_json
    if is_valid_placeholder(item["placeholder"])
]

# フィルタ:空白だけの placeholder を context から補完する
def is_meaningful_placeholder(p):
    return bool(re.search(r'[\w一-龯ぁ-んァ-ン()()]', p))

for item in raw_mapping_json:
    placeholder = item["placeholder"]
    context = item.get("context", "")
    if not is_meaningful_placeholder(placeholder):
        match = re.search(r'([^)]*?)', context)
        if match:
            item["placeholder"] = match.group(0)

# ✅ CSVのカラムに対応するinput_keyだけ、最初の1つだけ抽出
csv_keys = list(input_data[0].keys())
seen_keys = set()
mapping_json = []

for item in raw_mapping_json:
    key = item["input_key"]
    if key in csv_keys and key not in seen_keys:
        mapping_json.append(item)
        seen_keys.add(key)

# 5. input_dataごとに置き換えた形式に整形
output = []
for i, person_data in enumerate(input_data):
    person_mapping = {
        "person_index": i,
        "mapped_fields": []
    }
    for mapping in mapping_json:
        key = mapping["input_key"]
        if key in person_data:
            person_mapping["mapped_fields"].append({
                "placeholder": mapping["placeholder"],
                "suggested_field_name": person_data[key],
                "csv_column_name": key
            })
    output.append(person_mapping)

def refine_mappings_with_ai(mappings):
    refine_system_prompt = (
        "あなたはPDFの空欄(placeholder)と対応データ(suggested_field_name)のペアの整合性をチェックし、"
        "不自然なplaceholderがあればより適切な形に修正してください。"
        "placeholderは空白と記号の連続部分だけに限定してください。"
        "文字起こしデータにない文章は絶対に入れないでください。"
        "修正後のリストを以下の形式でJSONで返してください。"
        "もし修正不要ならそのまま返してください。"
    )

    # マッピングを文字列に変換(例:json.dumps)
    mappings_text = json.dumps(mappings, ensure_ascii=False, indent=2)

    user_prompt = f"""
以下のリストについてチェックしてください。

{mappings_text}

出力形式は元と同じJSON配列です。
"""

    response = client.chat.completions.create(
        model=DEPLOYMENT_NAME,
        messages=[
            {"role": "system", "content": refine_system_prompt},
            {"role": "user", "content": user_prompt},
        ],
        temperature=0.2,
    )

    content = response.choices[0].message.content
    # JSON部分だけ抽出
    match = re.search(r"```json\s*(\[.*?\])\s*```", content, re.DOTALL)
    if not match:
        match = re.search(r"(\[\s*\{.*?\}\s*\])", content, re.DOTALL)
    if not match:
        raise ValueError("⚠️ JSON形式が抽出できませんでした。")

    refined_mappings = json.loads(match.group(1))
    return refined_mappings

def fully_normalize(text):
    # 全角→半角正規化 + 空白削除
    text = unicodedata.normalize("NFKC", text)
    text = re.sub(r'[  ]', '', text)
    return text

# 6. AIで最終チェック&調整
final_output = []
for person in output:
    refined_fields = refine_mappings_with_ai(person["mapped_fields"])

    for field in refined_fields:
            field["placeholder"] = fully_normalize(field["placeholder"])

    final_output.append({
        "person_index": person["person_index"],
        "mapped_fields": refined_fields
    })

# 7. 出力
with open("output_mapping.json", "w", encoding="utf-8") as f:
    json.dump(final_output, f, ensure_ascii=False, indent=2)
print("✅ output_mapping.json を出力しました。(最終チェック済み)")

⑤ python-docxを使って書類生成 (fill_blank_dockFile.py)

最後に、python-docxを使ってWord文書のプレースホルダーをCSVの値で置換します。 OCR精度や表記ゆれを考慮し、正規化処理や空白除去を組み合わせることで、正確なマッチングを実現しています。

import json
import re
from docx import Document
import unicodedata

def normalize_text(text):
    """全角・半角などを揃えるための正規化関数"""
        # よくある記号の置き換え(必要に応じて追加)
    replacements = {
        '·': '・',
        '・': '・',
        '—': '-',  # 長いハイフン → 半角ハイフン
        '–': '-',  # enダッシュ
        '―': '-',  # 全角ハイフン
        '〇': '○'
    }
    for src, tgt in replacements.items():
        text = text.replace(src, tgt)

    return unicodedata.normalize("NFKC", text)

def remove_spaces(text):
    """空白(全角・半角)を削除"""
    return re.sub(r'[  ]', '', text)

def fully_normalize(text):
    """正規化 + 空白除去"""
    return remove_spaces(normalize_text(text))

def replace_text(doc, placeholder_array, suggested_array):
    pattern_map = dict(zip(placeholder_array, suggested_array))

    def replace_and_clean(paragraphs):
        for para in paragraphs:
            original_text = para.text
            norm_orig_text = normalize_text(original_text)
            norm_no_space_text = remove_spaces(norm_orig_text)

            # 正規化・空白除去した docx テキストにおける各文字が original_text の何番目かを記録
            mapping = []
            i_orig = 0
            i_norm = 0
            while i_orig < len(original_text):
                c = original_text[i_orig]
                c_norm = normalize_text(c)
                if re.match(r'[  ]', c):
                    i_orig += 1
                    continue
                for _ in remove_spaces(c_norm):
                    mapping.append(i_orig)
                    i_norm += 1
                i_orig += 1

            for raw_pattern, replacement in pattern_map.items():
                normalized_pattern = fully_normalize(raw_pattern)
                matches = list(re.finditer(re.escape(normalized_pattern), norm_no_space_text))
                if not matches:
                    continue

                for match in matches:
                    start_norm = match.start()
                    end_norm = match.end()
                    try:
                        start_orig = mapping[start_norm]
                        end_orig = mapping[end_norm - 1] + 1
                        original_text = original_text[:start_orig] + str(replacement) + original_text[end_orig:]
                        para.text = original_text
                        break
                    except IndexError:
                        continue

    replace_and_clean(doc.paragraphs)
    for table in doc.tables:
        for row in table.rows:
            for cell in row.cells:
                replace_and_clean(cell.paragraphs)

# 入力テンプレート
template_file_name = "input.docx"

# matching.json 読み込み
with open('output_mapping.json', 'r', encoding='utf-8') as f:
    data = json.load(f)

# 各人分の Word ファイルを生成
for person in data:
    doc = Document(template_file_name)
    placeholder_array = []
    suggested_array = []

    for field in person["mapped_fields"]:
        placeholder_array.append(field['placeholder'])
        suggested_array.append(field['suggested_field_name'])

    replace_text(doc, placeholder_array, suggested_array)

    output_file_name = f"contract_filled_{person['person_index']}.docx"
    doc.save(output_file_name)
    print(f"✅ Saved: {output_file_name}")

今後の展望

現状のシステムでは、「空白部分に括弧付きのプレースホルダーを明示的に入れておく」ことが必要なため、事前情報のない空白への推定補完などは未対応です。

そのため今後は、空白部分の周囲文脈をAIで読み取り、自動で推定値を挿入を検証したいと考えています。

また、テーブル形式の際に全体を構造的に認識し、自動でマッチング・挿入を行うことも展望として挙げられます。