結論: 「送っていいか」を人の判断から手順に移す
生成AIを業務で使う時、いちばん頻繁に起きるヒヤリは高度な攻撃ではありません。取引先とのメール本文をそのままAIに貼って要約させるという、日常の1操作です。急いでいる時ほど確認は飛びます。
この記事では、貼る前に個人情報を自動で伏せ字にし、AIの出力を元の値に戻すPythonスクリプトを作ります。標準ライブラリのみ・約120行で、外部サービスに何も送りません。手順はすべてサンドボックスで実行し、検出できた情報と取りこぼした情報の両方を実出力のまま掲載します。
生成AIに社外秘の文書を渡す時の伏せ字化フローの図解
個人情報を外に出さずにAIへ渡す — 伏せ字化と復元の流れ
対応表は手元に置いたままにする。外へ出るのは伏せ字版だけ
個人情報は届かない
「AIに入れてよい情報か」を人の注意力ではなく手順で担保する
| 1. 原文(社内) | 2. 伏せ字版 |
|---|---|
| 田中太郎 様 | [NAME_1] 様 |
| 03-1234-5678 | [PHONE_1] |
| hanako@example.co.jp | [EMAIL_1] |
| 対応表(手元に保存) | 4. 復元 |
| [NAME_1] → 田中太郎 | AIの出力のトークンを |
| [PHONE_1] → 03-1234-5678 | 対応表で元の値へ戻す |
考え方はシンプルです。外に出るのは伏せ字版だけで、対応表は手元から出ません。AIは「[NAME_1]様への請求書送付メール」として文章を扱えるので、要約も校正も返信文案も従来どおり頼めます。
動作環境(2026年8月14日 実行検証)
| 項目 | 値 |
|---|---|
| Python | 3.11.1 |
| OS | Windows 11 |
| 外部パッケージ | なし(re / json / argparse / sys のみ) |
| 検証日 | 2026年8月14日 |
| 検証結果 | passed(伏せ字化→復元の往復が原文と完全一致) |
手順1: スクリプトを用意する
mask_pii.py として保存します。検出パターンは上から順に適用し、先に確定した範囲は後段のパターンでは使いません。
# -*- coding: utf-8 -*-
"""生成AIに貼り付ける前に、日本語のビジネス文書から個人情報を伏せ字化する。"""
import argparse
import json
import re
import sys
# 検出パターン。長い/曖昧さの少ないものから順に適用する。
# 順番は重要: 郵便番号(3-4桁)を先に置くと 03-1234-5678 の後半を食ってしまうため、電話を先に置く。
PATTERNS = [
("CARD", re.compile(r"(?<![\d\-])(?:\d[ \-]?){12,15}\d(?![\d\-])")),
("MYNUMBER", re.compile(r"(?<![\d\-])\d{4}[ \-]?\d{4}[ \-]?\d{4}(?![\d\-])")),
("EMAIL", re.compile(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}")),
("PHONE", re.compile(r"(?<![\d\-])(?:\+81[ \-]?\d{1,4}|0\d{1,4})[ \-]\d{1,4}[ \-]\d{3,4}(?![\d\-])")),
("POSTAL", re.compile(r"(?<![\d\-])〒?\d{3}\-\d{4}(?![\d\-])")),
("NAME", re.compile(
r"(?<![一-龥ァ-ヶ])(?:[一-龥ァ-ヶ]{1,5}[ ])?[一-龥ァ-ヶ]{1,5}(?=[ ]?(?:様|さま|さん|氏))")),
]
# 「経理部 田中太郎 様」のように部署名まで巻き込んだ時に切り落とす接尾辞
ORG_SUFFIX = ("部", "課", "係", "室", "社", "店", "所", "局", "本部", "支社", "御中")
def luhn_ok(value):
"""クレジットカード番号のチェックディジット検証。桁数だけの誤検出を落とす。"""
nums = [int(c) for c in value if c.isdigit()]
if not 13 <= len(nums) <= 16:
return False
total = 0
parity = len(nums) % 2
for i, n in enumerate(nums):
if i % 2 == parity:
n *= 2
if n > 9:
n -= 9
total += n
return total % 10 == 0
def trim_org_prefix(start, value):
"""「経理部 田中太郎」のように部署名が先頭に付いた場合、氏名だけに切り詰める。"""
parts = re.split(r"[ ]", value)
if len(parts) == 2 and parts[0].endswith(ORG_SUFFIX):
start += len(value) - len(parts[1])
value = parts[1]
return start, value
def find_hits(text):
"""(開始, 終了, ラベル, 値) のリストを返す。範囲が重なる検出は先勝ちで捨てる。"""
hits = []
taken = []
def overlaps(start, end):
return any(not (end <= s or start >= e) for s, e in taken)
for label, pattern in PATTERNS:
for m in pattern.finditer(text):
start, end = m.span()
value = m.group(0)
if overlaps(start, end):
continue
if label == "CARD" and not luhn_ok(value):
continue
if label == "MYNUMBER" and len(re.sub(r"\D", "", value)) != 12:
continue
if label == "NAME":
start, value = trim_org_prefix(start, value)
taken.append((start, end))
hits.append((start, end, label, value))
return hits
def apply_mask(text):
"""伏せ字化した本文と対応表(トークン→元の値)を返す。"""
hits = find_hits(text)
mapping = {}
token_of = {}
counters = {}
for _, _, label, value in sorted(hits):
if value in token_of:
continue
counters[label] = counters.get(label, 0) + 1
token = "[{}_{}]".format(label, counters[label])
token_of[value] = token
mapping[token] = value
# 後ろから置換するとインデックスがずれない
for start, end, _, value in sorted(hits, reverse=True):
text = text[:start] + token_of[value] + text[end:]
return text, mapping
def restore(text, mapping):
"""AIの出力に残ったトークンを元の値へ戻す。"""
for token, value in mapping.items():
text = text.replace(token, value)
return text
def main():
ap = argparse.ArgumentParser(description="生成AIに渡す前に個人情報を伏せ字化する")
ap.add_argument("infile", help="入力テキストファイル")
ap.add_argument("--map", default="mapping.json", help="対応表の保存先/読み込み元")
ap.add_argument("--restore", action="store_true", help="伏せ字を元の値へ戻す")
args = ap.parse_args()
with open(args.infile, encoding="utf-8") as f:
src = f.read()
if args.restore:
with open(args.map, encoding="utf-8") as f:
mapping = json.load(f)
sys.stdout.write(restore(src, mapping))
return
masked, mapping = apply_mask(src)
with open(args.map, "w", encoding="utf-8") as f:
json.dump(mapping, f, ensure_ascii=False, indent=2)
sys.stdout.write(masked)
counts = {}
for token in mapping:
label = token.strip("[]").rsplit("_", 1)[0]
counts[label] = counts.get(label, 0) + 1
sys.stderr.write("\n--- 検出サマリ ---\n")
for label in sorted(counts):
sys.stderr.write("{}: {}件\n".format(label, counts[label]))
sys.stderr.write("対応表: {}\n".format(args.map))
if __name__ == "__main__":
main()
手順2: 実行する
検証には、請求書送付メールを模した sample.txt を使いました。氏名・住所・電話・メール・法人カード番号・マイナンバーを意図的に混ぜてあります。
python mask_pii.py sample.txt --map mapping.json
Windowsのコマンドプロンプトで文字化けする場合は、環境変数 PYTHONUTF8=1 を付けて実行してください。
実出力(伏せ字化された本文・抜粋)
株式会社サンプル商事
経理部 [NAME_1] 様
いつもお世話になっております。株式会社テスト製作所の山田 花子でございます。
■ 送付先
[POSTAL_1]
東京都渋谷区渋谷1-2-3 サンプルビル5F
■ お問い合わせ先
担当: 山田 花子
電話: [PHONE_1]
携帯: [PHONE_2]
メール: [EMAIL_1]
代表: [PHONE_3]
■ 決済情報(社内控え。外部共有禁止)
法人カード: [CARD_1]
登録番号(マイナンバー): [MYNUMBER_1]
なお、前回ご指摘のあった納品書の型番違いについては、[NAME_2]さん経由で修正版を再送済みです。
不明点がございましたら、[NAME_3] 氏までご連絡ください。
株式会社テスト製作所
[EMAIL_2]
実出力(検出サマリ)
--- 検出サマリ ---
CARD: 1件
EMAIL: 2件
MYNUMBER: 1件
NAME: 3件
PHONE: 3件
POSTAL: 1件
対応表: mapping.json
実出力(対応表 mapping.json)
{
"[NAME_1]": "田中太郎",
"[POSTAL_1]": "〒150-0002",
"[PHONE_1]": "03-1234-5678",
"[PHONE_2]": "090-8765-4321",
"[EMAIL_1]": "hanako.yamada@example.co.jp",
"[PHONE_3]": "+81-3-1111-2222",
"[CARD_1]": "4111-1111-1111-1111",
"[MYNUMBER_1]": "1234-5678-9012",
"[NAME_2]": "佐藤",
"[NAME_3]": "鈴木一郎",
"[EMAIL_2]": "support@test-example.jp"
}
固定電話・携帯・+81表記の3種類がすべてPHONEとして検出され、「経理部 田中太郎 様」からは部署名を除いた氏名だけが取れています。
手順3: AIの出力を元に戻す
伏せ字版をAIに渡し、返ってきた文章を --restore に通すと、トークンが元の値へ戻ります。
python mask_pii.py ai_output.txt --map mapping.json --restore
検証では、伏せ字化した本文をそのまま復元にかけ、原文と完全一致することを確認しました。
実出力(往復検証)
=== 復元結果が原文と完全一致するか ===
MATCH: True
なおこの往復検証、最初は MATCH: False になりました。原因はスクリプトではなく、検証用スクリプト側で中間ファイルを書き出す際にWindowsが改行コード \n を \r\n に変換していたことでした。内容は完全に一致していて、改行コードだけが違う状態です。パイプ経由で受け渡す処理を組む時に踏みやすい罠なので、比較の前に改行を正規化してください。
検証で分かった限界(取りこぼす情報)
ここが本記事でいちばん重要な部分です。このスクリプトはすべての個人情報を検出できません。検証で確認できた取りこぼしをそのまま載せます。
実出力(限界の確認)
=== このスクリプトが取りこぼす情報(既知の限界) ===
山田 花子: 検出できず(本文に残る)
東京都渋谷区渋谷1-2-3: 検出できず(本文に残る)
| 取りこぼした情報 | 理由 | 対処 |
|---|---|---|
| 「山田 花子」(敬称なしの氏名) | 検出条件を「様・さん・氏が後に続く」に限定しているため。自社の担当者名は敬称なしで書かれることが多い | 自社メンバー名を固定リストで追加置換する |
| 「東京都渋谷区渋谷1-2-3」(住所) | 住所は表記ゆれが大きく、正規表現では過検出と未検出のどちらかに倒れる | 都道府県名リストを起点にした行単位の置換を追加する |
敬称を条件から外して漢字2〜5文字をすべて氏名扱いにすると、今度は「請求金額」「納品書」のような一般語まで伏せ字になり、AIが文脈を読めなくなります。過検出と未検出はトレードオフで、どちらに倒すかは扱う文書の種類で決めるべき設計判断です。
実務では、次の順で運用するのが現実的です。
- まずこのスクリプトで機械的に落とせるもの(連絡先・番号類)を落とす
- 自社の社員名・取引先名は固定リストで追加置換する
- 残りは人が最終確認する。ただし確認対象が「全文」から「スクリプトが残した箇所」に減っているのが要点
3番目が本質です。この仕組みの価値は完全自動化ではなく、人が見るべき範囲を絞ることにあります。
社内で運用に載せる時の注意
- 対応表(mapping.json)は個人情報そのものです。 AIに渡さないのはもちろん、共有フォルダやチャットに置かないでください。用が済んだら削除する運用が安全です。
- クレジットカード番号はLuhn検証を通しています。 桁数だけで判定すると受注番号や電話番号を誤検出するため、チェックディジットで絞り込んでいます。検証で使った
4111-1111-1111-1111はテスト用の番号です。 - このスクリプトは法令対応ではありません。 人の注意力に頼っていた確認を手順に置き換える道具です。自社が扱う情報の種類や委託関係については、必要に応じて専門家に確認してください。
伏せ字化はあくまで技術的な一手順です。社内ルールの整備から法人プラン移行までを含む全体の進め方は、ChatGPT業務活用の始め方で3ステップに整理しています。
AI活用のルール作りから相談したい場合
「どの情報ならAIに渡してよいか」の線引きは、ツール選定より先に決まっていないと現場が止まります。PENGINでは、社内でのAI利用ルールの整備から実際の業務フローへの組み込みまでを含めたAI導入・活用支援を行っています。何から手を付けるべきかの整理から相談いただけます。
よくある質問