OpenAIのプライバシーフィルターでスケーラブルなWebアプリを構築する方法
- ドキュメントのプライバシー・エクスプローラー:PDFまたはDOCXを投入すると、すべてのPIIスパンがその場にハイライトされた状態でドキュメントを読み返せます。
- 画像匿名化:画像をアップロードすると、氏名、メールアドレス、口座番号の上に黒いマスキングバーをかけた状態で返されます。この画像はキャンバス上で編集もできるため、ダウンロード前に自分自身で注釈を追加できます。
- SmartRedact Paste(スマート・リダクト・ペースト):機微なテキストを貼り付けると、リダクト版を返す公開URLを共有できます。さらに、自分だけが利用する非公開のリビール(復元)リンクも保持できます。
この3つはいずれも gradio.Server をベースに構築されています。これにより、Gradioのキューイング、ZeroGPUの割り当て、gradio_client SDKと組み合わせて、カスタムのHTML/JSフロントエンドを使えます。これらのアプリでは、gradio.Server が同じバックエンドの役割を担っており、この一貫性こそが、まさに強力さの理由です。
モデル
プライバシーフィルターは、5,000万アクティブパラメータを持つ15億パラメータのモデルで、Apache 2.0 のもとで許容的にライセンスされています。PIIカテゴリは private_person、private_address、private_email、private_phone、private_url、private_date、account_number、secret です。コンテキストは128,000トークンです。PII-Masking-300kベンチマーク で最先端の性能を達成しています。詳細な数値と手法は 公式リリースブログ に掲載されています。
1. ドキュメント・プライバシー・エクスプローラー
ysharma/OPF-Document-PII-Explorer でお試しください。
ユーザーの課題。 PII が多いドキュメント(契約書、履歴書、エクスポートしたチャットログなど)を読みたいとします。検出されたあらゆる対象範囲をカテゴリごとにハイライトし、サイドバーにフィルターを用意し、上部には要約ダッシュボードを表示します。読み心地は、フォームではなく通常のドキュメントのようであるべきです。
ここでプライバシー・フィルターが行うこと。 ファイル全体が単一の 128k コンテキストのフォワードパスで処理されるため、分割(チャンク化)やつなぎ合わせ(ステッチング)はありません。また、スパンのオフセットはレンダリングされたテキストにそのまま対応します。BIOES のデコードによって、長く曖昧な連続範囲でもスパン境界がきれいに保たれます。
ここで gr.Server が行うこと。 Blocks で gr.HighlightedText とサイドバーを組み合わせれば、この構成に配線することもできます。私たちが望んだ読み取り体験(セリフ体の本文、カテゴリフィルターはモデルを再実行せずクライアント側で CSS クラスを切り替えること、ページの再レンダーを強制しないサマリーダッシュボード)は、組み合わせて作るよりも、手で作り込む方が簡単でした。gr.Server を使うと、リーダー表示を 1 つの HTML ファイルとして配信し、モデルは 1 つのキュー付きエンドポイントの背後で公開できます。
import gradio as gr
from fastapi.responses import HTMLResponse
from gradio.data_classes import FileData
server = gr.Server()
@server.get("/", response_class=HTMLResponse)
async def homepage():
return FRONTEND_HTML # reader view; see app.py
@server.api(name="analyze_document")
def analyze_document(file: FileData) -> dict:
text = extract_text(file["path"]) # PyMuPDF / python-docx
source_text, spans = run_privacy_filter(text) # single 128k pass
return {
"text": source_text,
"spans": spans, # [{start, end, label}, ...]
"stats": compute_stats(source_text, spans),
}
デコレーターに注目してください:プレーンな @server.post ではなく @server.api(name="analyze_document") です。これが、ハンドラーを Gradio のキューに接続する部分であり、同時アップロードが直列化されます。@spaces.GPU は ZeroGPU 上で正しく合成され、同じエンドポイントにブラウザと gradio_client の両方から複製コードなしで到達できます。ブラウザは Gradio JS クライアントでこれを呼び出します:
<script type="module">
import { Client, handle_file } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
const client = await Client.connect(window.location.origin);
async function uploadFile(file) {
const result = await client.predict("/analyze_document", { file: handle_file(file) });
renderResults(result.data[0]); // { text, spans, stats }
}
</script>
2. 画像アノニマイザー
こちらでお試しください。 ysharma/OPF-Image-Anonymizer.
ユーザーの課題。 PII の上に黒い帯を重ねた状態で、画像やスクリーンショット(Slack のスレッド、領収書、Stripe のダッシュボードなど)を共有したい。帯のオン/オフを切り替えたい、位置をドラッグで調整したい、モデルが見逃したもののために手描きで帯を 1 つ追加したい。そして結果を書き出したい。
ここでの Privacy Filter の動き。 Tesseract が OCR を実行し、単語ごとの境界ボックスを返す。バックエンドは char-offset(文字オフセット)を box map に紐づけながら全文を再構成し、その全テキストに対して Privacy Filter を 1 回だけ実行する。検出された文字範囲を word map に照合し、行ごとにピクセル矩形として結合する。
ここでの gr.Server の役割。 gr.ImageEditor はレイヤー構造の注釈をサポートしており、画像の秘匿(redaction)の妥当な出発点になる。私たちが作りたかったワークフロー(帯ごとのカテゴリのメタデータ、カテゴリ内の全帯を一括でトグル、自然な解像度でサーバー往復なしにクライアント側で PNG 書き出し)は、カスタムの <canvas> フロントエンドのほうがよりきれいに実装できた。gr.Server は、キューに入れた 1 つのエンドポイントからピクセル矩形を返すだけにして、それ以外はキャンバスにすべて任せる:
@server.api(name="anonymize_screenshot")
def anonymize_screenshot(image: FileData) -> dict:
img = Image.open(image["path"]).convert("RGB")
full_text, char_to_box = ocr_image(img) # 単語ごとのボックス + 文字マップ
spans = run_privacy_filter(full_text)
boxes = spans_to_pixel_boxes(spans, char_to_box)
return {
"image_data_url": pil_to_base64(img),
"width": img.width,
"height": img.height,
"boxes": boxes, # [{x, y, w, h, label, text}, ...]
}
フロントエンドは client.predict("/anonymize_screenshot", { image: handle_file(file) }) で呼び出す。これは上と同じパターンである。トグル、ドラッグ、新しい帯の描画、PNG の書き出しはすべてブラウザ内で行われ、編集内容がサーバーに往復することはない。
3. SmartRedact Paste
ysharma/OPF-SmartRedact-Paste で試してみてください。
ユーザーの課題。 共有する前に秘匿してくれる pastebin が欲しい。ログの 1 行、メールアドレス、サポートチケットを貼り付ける。すると 2 つの URL が返ってくる。公開用の URL は、<PRIVATE_PERSON>、<PRIVATE_EMAIL>、<ACCOUNT_NUMBER> のプレースホルダーを使って秘匿版を提供し、これは 公式ブログの例 にある秘匿規約に従う。非公開用の URL は、保持しておくトークンでゲートされており、スパンがハイライトされた状態で元のテキストを表示する。
ここで Privacy Filter が行うこと。 保存した貼り付け(paste)の各検出スパンを、<CATEGORY> プレースホルダーに置き換える。これが秘匿ステップのすべてである。多言語テキスト(スペイン語、フランス語、中国語、ヒンディー語など。モデルカードの例に含まれるその他の言語)も、変更なしで同じ呼び出しを通る。
ここでの gr.Server の役割。 このアプリは同じ paste ID に対して、2 つの異なる GET ルート(1 つは公開、もう 1 つはトークンで制限)を必要とする。そして URL の形は重要だ。なぜなら、公開前に開示(reveal)する URL が、保持しておく“鍵”だからである。gr.Server がここで機能するのは、その中で FastAPI アプリとして動いているからだ。だからこそ、同じプロセス内で @server.api と素の @server.get を並べて配置できる。注:これは gr.Blocks() でも FastAPI を使ってカスタムルートをマウントすることで構築できる:
# モデル呼び出し → キューに入るエンドポイント。ブラウザから
# client.predict("/create_paste", { text, ttl }) 経由でヒット。
@server.api(name="create_paste")
def create_paste(text: str, ttl: str = "never") -> dict:
source_text, spans = run_privacy_filter(text)
redacted = redact(source_text, spans) # <CATEGORY> プレースホルダー
pid, reveal_token = secrets.token_urlsafe(6), secrets.token_urlsafe(22)
PASTES[pid] = Paste(pid, reveal_token, source_text, redacted, spans,
expires_at=_ttl(ttl)) # app.py を参照
return {
"view_path": f"/view/{pid}",
"reveal_path": f"/view/{pid}?token={reveal_token}",
}
返却形式: {"translated": "翻訳されたHTML"}# ページを表示 → プレーンなFastAPIのGET。モデルも、キューも不要で、
# 実際に欲しいのは、キュー付きエンドポイントでは提供できない
# 独自のURL形状 `/view/{pid}?token=...` です。
@server.get("/view/{pid}", response_class=HTMLResponse)
async def view_paste(pid: str, token: str | None = None):
p = _store_get(pid) # ストアはapp.pyを参照
if p is None:
return HTMLResponse(_not_found(), status_code=404)
revealed = bool(token) and secrets.compare_digest(token, p.reveal_token)
return HTMLResponse(_render_view(p, revealed))
デーモンスレッドが、期限切れのペーストを30秒ごとに削除(evict)します。保存先を含むサービス全体は、すべてが1つのプロセスにあるため、アプリケーションコードがおよそ200行です。
gradio.Server が提供するもの
3つのアプリすべてにまたがって分割の方針は同じです。つまり、モデルに触れるものはすべて @server.api を通り、その他はすべてプレーンなFastAPIルートのままです:
| アプリ | キュー付き計算(@server.api) |
プレーンなFastAPIルート |
|---|---|---|
| Document Privacy Explorer | analyze_document — 抽出、検出、統計 |
GET / はカスタムのリーダービューを提供 |
| Image Anonymizer | anonymize_screenshot — OCR、検出、スパン → ピクセルの枠 |
GET / + GET /examples/* でキャンバスUIとプリロードされたサンプルを提供 |
| SmartRedact Paste | create_paste — 検出、マスキング、IDの発行 |
GET / はページを構成、GET /view/{pid}?token=... は公開+トークン制御のビュー、GET /api/paste/{pid} はJSONルックアップ |
@server.api により、Gradioのキュー(シリアライズされたリクエスト、ZeroGPUでの正しい @spaces.GPU の合成、進捗イベント)が使えます。そしてそれが、ブラウザが @gradio/client を通じて叩くものです。同じエンドポイントは、gradio_client のPythonユーザーが叩くものでもあります。つまり、1つの関数で2つのSDKを賄え、重複したコードはありません。プレーンな @server.get/@server.post は、静的なサーフェス(HTMLページ、ファイルの参照、安価な辞書読み取り)用に予約されています。これは gradio.Server intro post での経験則であり、UIがまったく異なっていても、この3つのアプリが一貫した感じになるのはそのためです。
試してみる
履歴書を投入する、Slackスレッドのスクリーンショットを投入する、トークンが入ったログ行を投入する。面白いのは、あなたが本当に気にしているテキストに対して、Privacy Filterがどこまで(そしてときにはどこを)拾ってくれるのかを見ることです。




