こんにちは。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で読み取り、自動で推定値を挿入を検証したいと考えています。
また、テーブル形式の際に全体を構造的に認識し、自動でマッチング・挿入を行うことも展望として挙げられます。