毎週、複数のアプリに届くレビューとクラッシュレポートをまとめて読み、優先度を付ける作業をしています。個人開発でいくつものアプリを長く運用してきて、届くレビューの量が増えるにつれ、手作業で全部に目を通すのが現実的でなくなりました。そこで App Store Connect と Google Play、それに Firebase Crashlytics から本文を引き出し、Claude API に渡して「不具合報告か、要望か、ただの感想か」を仕分けし、深刻そうなものだけを自分の手元に残す仕組みを組みました。
回し始めて最初に気づいたのは、レビュー本文に思った以上に個人情報が混ざっているという事実でした。「返金してほしいので ○○@gmail.com に連絡ください」と書く方がいます。クラッシュのカスタムキーに、デバッグ目的で入れていたユーザーの識別子がそのまま乗っていることもありました。これらを無加工で外部 API に送り続けるのは、たとえ正規の事業者であっても、私の感覚では気持ちのいいものではありません。預かったものを丁寧に扱うという姿勢は、ユーザーが残してくれた言葉に対しても同じだと考えています。
この記事は、その前処理パイプラインをどう設計したかの記録です。単に伏せるだけでなく、Claude の出力から元のレビューへ正確に辿り直せる「可逆マスキング」をどう作るか。そして本番で実際に踏んだ落とし穴を、実装コードと合わせてまとめます。
どの情報を「個人情報」として扱うか — 検出対象の線引き
最初に決めるべきは、何をマスクするかの範囲です。広げすぎると本文が穴だらけになってモデルの判断精度が落ち、狭めると肝心の情報が漏れます。私は「外部 API に送りたくないか」「単体で個人を特定しうるか」の二軸で優先度を付けました。
高優先で必ず伏せるのは、メールアドレス、電話番号、本文中に書かれた氏名、そして自分のアプリが内部で振っているユーザー識別子やデバイス識別子です。これらは正規表現か内部 ID の形式で機械的に拾えます。中優先は、住所の断片や、まれに貼られるカード番号らしき数字列です。低優先、というより基本的に伏せないのが、アプリ名・OS バージョン・端末名といった、分析にむしろ必要なメタ情報です。
ここで大事なのは、伏せる対象を「ブロックリスト」ではなく、可能な範囲で「アローリスト」で考えることでした。特に Crashlytics のカスタムキーのような構造化データは、送ってよいキーをこちらで列挙し、それ以外は丸ごと落とす方針にしています。自由記述のレビュー本文は列挙ができないので検出器に頼りますが、構造化された部分はアローリストの方が事故が起きにくいです。
なぜ単純な [REDACTED] 置換では足りないのか
最初に書いたのは、検出したものを片端から [REDACTED] に置き換える素朴なコードでした。送信前のデータは確かに綺麗になります。ところが、いざ Claude の仕分け結果を受け取って「では、このメールアドレスを書いた人に個別対応しよう」と思った瞬間に手が止まりました。出力には [REDACTED] としか残っておらず、どのレビューの誰だったのかを辿る手段がありません。
import re
EMAIL = re.compile( r " [ A-Za-z0-9._%+- ] + @ [ A-Za-z0-9.- ] + \. [ A-Za-z ] {2,} " )
def naive_redact (text: str ) -> str :
# 検出したものを一律 [REDACTED] へ。送信は安全だが復元不能
text = EMAIL .sub( "[REDACTED]" , text)
return text
masked = naive_redact( "返金希望です user@example.com まで連絡を" )
# => "返金希望です [REDACTED] まで連絡を"
# この出力をモデルが返してきても、誰のことか二度と分からない
もう一つの問題は、同じ値が複数箇所に出てきたときに区別が消えることです。一通のレビューに同じメールアドレスが二度出てきても、別々のレビューに別人のアドレスが出てきても、すべてが同じ [REDACTED] に潰れます。モデルに「同じ人物が二度言及されている」と読ませたい場面で、その手がかりを自分の前処理で消してしまっていました。
つまり必要なのは、伏せると同時に、後から安全に戻せる「鍵」を残す仕組みでした。
可逆マスキングの設計 — トークン形式の選び方
可逆マスキングの考え方はシンプルです。検出した個人情報を、その場限りの一意なトークンに置き換え、トークンと元の値の対応表をリクエストごとにメモリ上だけで保持します。Claude にはトークン入りの本文を送り、戻ってきた出力に同じトークンが残っていれば、対応表を引いて元の値に復元します。
ここで地味に重要なのが、トークンをどんな文字列にするかです。私は最初、見栄えを優先して «EMAIL_1» のようにギユメ記号で囲みました。これが本番で裏目に出ます。モデルは出力を整形する過程で、こうした珍しい記号を別の引用符に正規化したり、英語で返すよう指示したときに記号ごと訳語に巻き込んだりすることがありました。トークンが少しでも変形すると、復元時の完全一致に失敗します。
# Before: 見た目はよいが、モデルが正規化・翻訳で壊しやすい
token = f "« { kind } _ { n } »" # « » が別の引用符に化けることがある
# After: ASCII の英数字とアンダースコアだけ。モデルがそのまま素通ししやすい
token = f "__PII_ { kind } _ { n } __" # 区切りに記号を使わず、原形保持されやすい
英数字とアンダースコアだけで構成し、前後を二重アンダースコアで挟む形式に変えてから、トークンの破損はほぼ起きなくなりました。さらに「同じ値には同じトークンを割り当てる」ことで、本文内の言及の一貫性も保てます。下が中心となるマスカーの実装です。
import re
from dataclasses import dataclass, field
@dataclass
class Masker :
# 検出器: 種別名 -> 正規表現
detectors: dict[ str , re.Pattern]
_forward: dict[ str , str ] = field( default_factory = dict ) # 元の値 -> トークン
_reverse: dict[ str , str ] = field( default_factory = dict ) # トークン -> 元の値
_counts: dict[ str , int ] = field( default_factory = dict )
def _token_for (self, kind: str , value: str ) -> str :
if value in self ._forward:
return self ._forward[value] # 同じ値には同じトークン
n = self ._counts.get(kind, 0 ) + 1
self ._counts[kind] = n
token = f "__PII_ { kind } _ { n } __"
self ._forward[value] = token
self ._reverse[token] = value
return token
def mask (self, text: str ) -> str :
for kind, pattern in self .detectors.items():
text = pattern.sub( lambda m: self ._token_for(kind, m.group( 0 )), text)
return text
def restore (self, text: str ) -> str :
# 自分が発行したトークンだけを戻す。未知のトークンは触らない
for token, value in self ._reverse.items():
text = text.replace(token, value)
return text
対応表をリクエストごとに作り、処理が終わったら破棄するのが肝です。プロセス全体で使い回す永続的な対応表を持つと、それ自体が個人情報の集積になってしまい、伏せた意味が薄れます。
検出器の実装 — 正規表現と固有表現抽出の使い分け
検出は二段構えにしています。形式が決まっているものは正規表現で高精度に拾い、形式が決まらない氏名のようなものは、必要なときだけ固有表現抽出(NER)で補います。
形式の決まったものは正規表現で
メールアドレス、電話番号、URL、そして自分のアプリ固有の識別子は、正規表現が最も確実です。識別子は自分で形式を決めているので、誤検知をほぼゼロにできます。
DETECTORS = {
"EMAIL" : re.compile( r " [ A-Za-z0-9._%+- ] + @ [ A-Za-z0-9.- ] + \. [ A-Za-z ] {2,} " ),
"PHONE" : re.compile( r "0 \d {1,4} [ - \s] ? \d {1,4} [ - \s] ? \d {3,4} " ),
"URL" : re.compile( r "https ? :// [ ^ \s ] + " ),
# 自分のアプリが振る識別子。形式が固定なので誤検知は起きにくい
"UID" : re.compile( r " \b USR- [ 0-9A-F ] {12} \b " ),
}
氏名のような曖昧なものは NER で、ただし慎重に
氏名は正規表現では拾えません。ここで NER を使いますが、注意点があります。NER は再現率と精度のトレードオフがあり、閾値を下げると一般名詞まで人名と誤判定して本文が穴だらけになります。私はレビュー分析では、固有表現抽出を「人名ラベルかつ信頼度が高いものだけ」に絞り、判断に迷う候補は伏せない側に倒しました。レビューの仕分けという用途では、たまに氏名が一つ残るリスクより、本文が壊れて誤判定が増えるリスクの方を重く見ています。
検出の順序にも落とし穴があります。URL の中にメールらしき文字列が含まれる場合など、検出器同士が食い合うので、より長く・より具体的な形式から先に当てるのが安全です。私は URL を最優先で当て、その後にメール、電話番号、識別子という順で処理しています。
Claude に渡して結果を復元する — 往復の実装
ここまでの部品をつなぐと、往復の流れができます。マスクした本文を Claude に渡し、仕分け結果を受け取り、出力に残ったトークンを復元します。大量のレビューを安く回したいので、モデルは Haiku を使っています。
import anthropic
client = anthropic.Anthropic( api_key = "YOUR_ANTHROPIC_API_KEY" )
def triage_review (raw_text: str ) -> dict :
masker = Masker( detectors = DETECTORS )
masked = masker.mask(raw_text) # 送信前にマスク
resp = client.messages.create(
model = "claude-haiku-4-5-20251001" ,
max_tokens = 300 ,
system = (
"あなたはアプリレビューの仕分け担当です。"
"本文中の __PII_...__ という文字列はマスク済みの個人情報です。"
"この文字列は変形・翻訳せず、そのまま引用してください。"
"種別(bug/request/praise/other)と要約をJSONで返してください。"
),
messages = [{ "role" : "user" , "content" : masked}],
)
out = resp.content[ 0 ].text
restored = masker.restore(out) # 必要な箇所だけ復元
return { "masked_input" : masked, "restored_output" : restored}
ポイントは、システムプロンプトでトークンの扱いを明示することでした。「変形・翻訳せず、そのまま引用して」と一言添えるだけで、出力にトークンが原形で残る確率が体感で大きく上がります。それでもモデルがトークンを言い換えたり、英語化のときに崩したりする可能性はゼロにはなりません。だからこそ復元側を「自分が発行したトークンだけを戻す」設計にしてあります。モデルが架空のトークンを生成しても、対応表に無いものは無視され、勝手に元データが復元されることはありません。
本番運用で踏んだ落とし穴
設計どおりに動いても、運用の現場では別の問題が出ます。実際に詰まった三つを挙げます。
一つ目は、ログ出力からの漏れでした。前処理は安全でも、例外が起きたときのスタックトレースやデバッグログに、マスク前の生本文がそのまま出ていることがあります。エラー調査のためにログレベルを上げた瞬間、伏せたはずの情報が平文でログに残るという、本末転倒な状態になりました。対処として、生本文を引数に取る関数では例外を握って種別だけを記録し、本文そのものは決してログに出さない方針へ統一しました。マスク処理は入口だけでなく、ログの出口でも意識する必要があります。
二つ目は、復元の二重適用です。同じ Masker インスタンスで restore を二度呼んだり、復元済みの文字列をさらに別の対応表に通したりすると、トークンが既に消えているのに戻そうとして崩れます。復元は出力に対して一度だけ、というルールをコードのレベルで守るようにしました。
三つ目は、トークンが本文の意味を変えてしまう稀なケースです。たとえば「私の名前は PII_NAME_1 です」のように、文の流れに対してトークンが文法的に浮いて見えると、モデルが「これは伏字だ」と気を利かせて別の表現に書き換えることがありました。システムプロンプトでの明示と、ASCII トークンへの変更で頻度は大きく下がりましたが、ゼロにはなりません。重要な処理では、出力に発行済みトークンが全て残っているかを検証し、欠けていたらそのレビューだけ人手に回す安全弁を入れています。
どこまでやるかの判断 — 個人開発での現実解
個人開発で全てを完璧にやろうとすると、前処理だけで時間が溶けます。私は優先度の高い情報源から順に守ることをお勧めします。具体的には、メールアドレス・電話番号・自前の識別子という「機械的に高精度で拾えて、かつ漏れたら影響が大きいもの」をまず確実に塞ぎ、氏名のような曖昧なものは精度の高い検出だけに留める、という順序です。
効果を数字で見ておくと、私の環境では正規表現ベースの検出だけで主要な個人情報の大半を捕捉でき、自前識別子の誤検知率は0.5%未満に収まっています。NER を全件に掛けるのをやめて高優先の正規表現に絞ったことで、前処理の所要時間が以前の半分以下、おおよそ2倍以上の速度で回るようになりました。収益レポートを読むときと同じで、全部を完璧に追うより、影響の大きいところから手堅く押さえる方が、個人開発では続けやすいと感じています。
それでも、外部 API にデータを渡すという行為そのものには常に責任が伴います。利用規約とプライバシーポリシーで、収集したデータをどう扱うかを正直に書いておくことも、技術的な前処理と同じくらい大切だと考えています。
検出漏れを測る — ゴールデンコーパスで回帰を防ぐ
前処理で一番こわいのは、コードを直したつもりが別の漏れを生むことです。検出器の正規表現を一つ調整しただけで、これまで拾えていたパターンを取りこぼすことは珍しくありません。私は、過去に実際に届いたレビューから個人情報を含むものを少数選び、手で「ここがメール」「ここが識別子」と印を付けた小さなゴールデンコーパスを用意しています。本物の本文はそのまま置いておくと管理対象が増えるので、構造だけ残した擬似データに置き換えてあります。
# 期待値つきの検証ケース。原文は擬似化し、印だけを真実とする
CASES = [
{
"text" : "返金希望 user@example.com 端末はUSR-0A1B2C3D4E5F" ,
"expect_kinds" : { "EMAIL" : 1 , "UID" : 1 },
},
{
"text" : "とても良いアプリです。星5にしました" ,
"expect_kinds" : {}, # 個人情報なし。何も伏せてはいけない
},
]
def test_detection ():
for c in CASES :
m = Masker( detectors = DETECTORS )
m.mask(c[ "text" ])
got = {k: v for k, v in m._counts.items() if v}
assert got == c[ "expect_kinds" ], f "mismatch: { got } != { c[ 'expect_kinds' ] } "
print ( "detection OK" )
二つ目のケースが地味に効きます。個人情報を含まない普通のレビューを、検出器が誤って伏せていないかを毎回確かめられるからです。前処理の品質は「漏らさないこと」と「伏せすぎないこと」の両輪で、後者を測る仕組みがないと、いつのまにか本文が穴だらけになってモデルの判定精度が静かに落ちていきます。
このテストを変更のたびに走らせるようにしてから、検出器の調整を怖がらずにできるようになりました。本番に出す前の小さな安全弁ですが、預かったデータを扱う処理だからこそ、ここは省かない方針にしています。
Claude に渡す前に伏せる理由——漏れるのは自分側の経路です
前処理をどこに置くかは、精度を詰めるより先に決めておきたい設計判断です。Claude API のトラフィックは、Anthropic のポリシー上、既定ではモデル学習に使われないと明記されています。それでも本文が通る経路は、想像以上に自分のシステム側へ伸びています。
運用していて実際に肝を冷やしたのは、次の三つの経路でした。
ステージングへのリプレイ : 本番リクエストをサンプリングして再現テストに回す運用は、回帰を見つけるのにとても有効です。ただし生の個人情報がそのまま検証担当の手元に届くため、データが VPC の外に出ていなくても運用ポリシー違反になり得ます。
例外のスタックトレース : Python でも TypeScript でも、例外オブジェクトが messages 配列をまるごと __repr__ してエラーログに吐いてしまう事故は、驚くほど簡単に起こります。
キャッシュキー : プロンプトキャッシュやレスポンスキャッシュのキーに生の本文を使うと、その個人情報は Redis のスローログ・スナップショットのダンプ・取得済みのバックアップの全てに残ります。
三つとも Claude ではなく自分のシステム側の問題です。だからこそマスキングは API 呼び出しの手前、できれば SDK ラッパーの最も内側で行うのが原則になります。応答後のフィルタは最後の砦としては有効ですが、第一関門にしてはいけません。
次の一歩
まずは自分のパイプラインで、Claude に渡している本文を一度だけ標準出力に書き出してみてください。実際のレビューやクラッシュログに、どれだけ個人情報が混ざっているかを自分の目で確かめるところから始めると、どの検出器を優先すべきかが具体的に見えてきます。検証が済んだら、その出力は必ず消すこと。それが最初の一歩です。
最後までお付き合いいただき、ありがとうございました。同じようにユーザーの声を預かって扱っている方の、設計の参考になれば嬉しいです。