App Store と Google Play のレビュー返信をまとめて処理する日は、1セッションで30〜40件を続けて書きます。App Store 側は送信のたびに8秒ほど待たされますので、40件だと待ち時間だけで5分を超えます。その5分のあいだに、同じ趣旨の投稿を2度も3度も読むことになります。
壁紙アプリで一番多いのは「保存したはずの画像が見つからない」という内容です。手順はヘルプに書いてあります。私自身が書きました。それでも、月をまたいでまた同じ文面が届きます。
書いていないから届くのではありません。書いてあるのに届いていないのです。この違いを、感覚ではなく機械で切り分けたいと思うようになりました。
「書いてあるか」と「辿り着けるか」は別の問題です
ドキュメントの点検というと、たいていは網羅性の話になります。あの機能の説明が抜けている、この設定の項目がない、といった欠落を埋める作業です。
けれども個人開発で実際に困るのは、欠落よりも到達不能のほうです。ヘルプの見出しは開発者の語彙で書かれます。「ダウンロード先の変更」と書いた項目を、利用者は「保存できない」と検索します。語が一致しない限り、そのページは存在しないのと同じ扱いになります。
つまり点検すべき問いは「この項目は書いてあるか」ではなく、「利用者の言い方で来た質問に、この資料だけで答えられるか」です。後者は人手で網羅するには数が多すぎますが、モデルには向いた作業です。
ちょうど 2026年8月10日から、Gemini in Classroom が対象を広げ、学生が授業のコースマテリアルを選ぶだけで練習クイズやフラッシュカードを作れるようになりました。資料を渡して問いを作らせるという、この構造がそのまま流用できます。教育向けの機能に見えますが、中身は「資料から到達可能な問いを列挙する装置」です。
資料から問いを作り、その資料だけで答えさせます
設計は二段構えにします。
生成パス : ヘルプ本文を渡し、利用者が投げそうな質問を複数生成させます
判定パス : 生成された各質問に対し、同じヘルプ本文だけを根拠として回答を試みさせ、答えられたかどうかを三値で返させます
判定結果は answered / partial / missing の3つに分けます。partial は「根拠となる記述はあるが、1箇所では完結せず複数のセクションを跨ぐ」状態です。この中間状態を用意するかどうかで、後の打ち手がまったく変わってきます。
ここで大事なのは、生成と判定を同じ呼び出しにまとめないこと です。1回の呼び出しで「質問と回答を作って」と頼むと、モデルは自分が答えられる質問しか作りません。答えられない問いを見つけるのが目的ですから、それでは装置として成立しません。呼び出しを分け、判定側には質問の出自を知らせない状態で本文だけを渡します。
質問を作らせる実装
Google AI Python SDK と構造化出力を使います。スキーマを固定して、後段が壊れない形で受け取ります。
# pip install google-genai pydantic
from google import genai
from pydantic import BaseModel
from typing import List
client = genai.Client( api_key = "YOUR_API_KEY" )
MODEL = "gemini-3.6-flash"
class Question ( BaseModel ):
question_id: str # 判定パスと突き合わせるための固定ID
text: str # 利用者の言い方で書かれた質問
user_words: List[ str ] # その質問に含まれる利用者側の語彙
class QuestionSet ( BaseModel ):
questions: List[Question]
GEN_PROMPT = """あなたはこのアプリを初めて使う利用者です。
以下のヘルプ本文を読み、あなたが実際にサポートへ送りそうな質問を8件書いてください。
制約:
- ヘルプ本文の見出し語をそのまま使わないでください。日常の言い方に置き換えてください
- 「できない」「消えた」「見つからない」のような、症状から入る言い方を半分以上含めてください
- 本文に答えが無さそうな質問も、思いついたなら遠慮せず含めてください
- question_id は q01 から q08 までの連番にしてください
ヘルプ本文:
---
{doc}
---"""
def generate_questions (doc_text: str ) -> QuestionSet:
res = client.models.generate_content(
model = MODEL ,
contents = GEN_PROMPT .format( doc = doc_text),
config = {
"response_mime_type" : "application/json" ,
"response_schema" : QuestionSet,
},
)
return QuestionSet.model_validate_json(res.text)
# 期待する出力(抜粋):
# questions=[Question(question_id='q01',
# text='保存したはずの壁紙がアルバムに見当たりません',
# user_words=['保存', '見当たらない', 'アルバム']), ...]
「見出し語をそのまま使わない」という一行が効きます。これを外すと、生成される質問が本文の目次を言い換えただけのものになり、判定パスがすべて answered を返して何も見つかりません。
質問の種として、実際のレビュー本文を数件だけ添えるやり方も有効でした。利用者が本当に使っている語彙は、こちらで想像するより素っ気ないものです。
本文だけで答えられるかを判定する実装
判定側には、質問と本文だけを渡します。外部知識や一般常識で補うことを明示的に禁じ、根拠となる引用を必ず返させます。引用を必須にすると、モデルが曖昧に answered へ倒す挙動がかなり減ります。
class Verdict ( BaseModel ):
question_id: str
verdict: str # "answered" | "partial" | "missing"
quote: str # 本文からの逐語引用。無ければ空文字
reason: str # 60字以内
class VerdictSet ( BaseModel ):
verdicts: List[Verdict]
JUDGE_PROMPT = """以下の資料本文だけを根拠として、各質問に答えられるかを判定してください。
判定基準:
- answered: 本文の一箇所を読めば答えが分かる
- partial: 根拠はあるが複数の箇所を突き合わせないと答えに届かない
- missing: 本文に根拠が無い
規則:
- 資料の外にある知識を使ってはいけません
- answered と partial には、本文からの逐語引用を quote に入れてください
- 質問と同じ question_id を必ず返してください
資料本文:
---
{doc}
---
質問一覧:
{questions} """
def judge (doc_text: str , qs: QuestionSet) -> VerdictSet:
listed = " \n " .join( f " { q.question_id } : { q.text } " for q in qs.questions)
res = client.models.generate_content(
model = MODEL ,
contents = JUDGE_PROMPT .format( doc = doc_text, questions = listed),
config = {
"response_mime_type" : "application/json" ,
"response_schema" : VerdictSet,
"system_instruction" : "あなたは資料の校閲者です。資料に書かれていないことを補ってはいけません。" ,
},
)
vs = VerdictSet.model_validate_json(res.text)
# 対応漏れの検出。ID が欠けたら再試行対象にします
got = {v.question_id for v in vs.verdicts}
missing_ids = [q.question_id for q in qs.questions if q.question_id not in got]
if missing_ids:
raise ValueError ( f "verdict missing for: { missing_ids } " )
return vs
question_id の突き合わせを省くと、8件の質問に対して7件の判定が返ってきたときに黙って1件が消えます。件数が合わない呼び出しは、再試行に回すか、その資料の判定ごと捨てるほうが安全です。構造化出力が本番で静かに崩れる挙動については、Gemini API の構造化出力が本番でスキーマから外れるとき と Gemini API のレスポンスを Schema 検証で守る設計 にまとめてあります。
返信を書きながら気づいたのは、足りないのは説明ではなく言葉でした
この仕組みを考え始めたきっかけは、レビュー返信の作業そのものでした。
繰り返し届く質問について、私は最初「説明が足りないのだろう」と考え、ヘルプに段落を足しました。ところが問い合わせは減りませんでした。次の月も、同じ言い回しの投稿が届きます。
返信を1件ずつ書いていると、理由がだんだん分かってきます。利用者が使う語と、ヘルプの見出しに書いた語が、そもそも重なっていないのです。「保存先」と書いた項目を、利用者は「どこに行った」と表現します。説明を足しても、その説明に到達する経路が増えていなければ、状況は何も変わりません。
だからこの装置の価値は、missing を見つけることよりも partial を見つけることにあります。partial は「根拠はあるのに一度で届かない」という状態で、これは加筆ではなく、見出しの言い換えと集約で直る種類の欠陥です。手を動かす量が少なく、効き目が早い側です。
私はこの点検を、新機能を出す前ではなく、レビュー返信をまとめて処理した直後に回すやり方を好みます。利用者の語彙が頭に残っているうちに、生成された質問を読めるからです。
多言語で返信を運用している場合の温度感の揃え方については、Google Play 多言語レビュー返信を Gemini と作る運用設計 に別途書いてあります。
1問ずつ判定すると、入力トークンが7倍になります
呼び出しの組み方で費用がはっきり変わります。ヘルプ40本、1本あたり本文1,800字相当、1本につき質問8件という前提で概算します。日本語はおよそ1文字を1トークンとみなし、料金は Gemini 3.6 Flash の入力 $1.50 / 1M トークン、出力 $7.50 / 1M トークンで計算しました。
判定の組み方 入力トークン 出力トークン 1回あたりの概算費用
1問ごとに本文を送る 716,800 51,200 約 $1.46
同一資料の8問をまとめて送る 170,800 51,200 約 $0.64
差は入力側だけで約 7.0 倍です。判定パスでは資料本文が支配的で、1問ごとに送ると同じ本文を8回繰り返し課金されます。まとめて送れば本文は1回で済みます。
ただし、まとめ判定には副作用があります。直前の判定に引きずられて、同じ資料内の後半の質問が一様に answered へ寄る傾向が出ます。私は次の2点で抑えています。
まとめるのは同一資料内の質問だけ にします。資料をまたいで詰め込まない
question_id を必ず返させ、順序ではなく ID で突き合わせます
同じ本文を何度も送る構成がどうしても避けられない場合は、コンテキストキャッシュのほうが素直です。判断材料は Gemini API のコンテキストキャッシュでコストを抑える方法 に整理してあります。
台帳にして、変わった資料だけ回します
点検は一度きりでは意味がありません。ヘルプを直したあとに、同じ質問が answered へ変わったかどうかまで見て初めて閉じます。
import hashlib
import json
from pathlib import Path
LEDGER = Path( "docs_answerability.jsonl" )
def doc_fingerprint (text: str ) -> str :
return hashlib.sha256(text.encode( "utf-8" )).hexdigest()[: 16 ]
def load_ledger () -> dict :
if not LEDGER .exists():
return {}
rows = {}
for line in LEDGER .read_text( encoding = "utf-8" ).splitlines():
if line.strip():
row = json.loads(line)
rows[row[ "doc" ]] = row
return rows
def run (doc_paths):
ledger = load_ledger()
out = []
for path in doc_paths:
text = Path(path).read_text( encoding = "utf-8" )
fp = doc_fingerprint(text)
prev = ledger.get(path)
if prev and prev[ "fingerprint" ] == fp:
continue # 内容が変わっていない資料は課金対象から外します
qs = generate_questions(text)
vs = judge(text, qs)
gaps = [v for v in vs.verdicts if v.verdict != "answered" ]
out.append({
"doc" : path,
"fingerprint" : fp,
"total" : len (vs.verdicts),
"gaps" : [
{ "id" : v.question_id, "verdict" : v.verdict, "reason" : v.reason}
for v in gaps
],
"prev_gap_count" : len (prev[ "gaps" ]) if prev else None ,
})
with LEDGER .open( "a" , encoding = "utf-8" ) as f:
for row in out:
f.write(json.dumps(row, ensure_ascii = False ) + " \n " )
return out
# 期待する出力(抜粋):
# [{'doc': 'help/ja/save-wallpaper.md', 'fingerprint': '9f1c...', 'total': 8,
# 'gaps': [{'id': 'q03', 'verdict': 'partial', 'reason': '保存先の記述が別節にあります'}],
# 'prev_gap_count': 3}]
prev_gap_count を持たせておくと、直したつもりの資料で穴が減っていないケースがすぐ分かります。私の運用では、月に更新する資料は5本前後です。まとめ判定の前提で見積もると1か月あたり約 $0.08 で、費用を理由に止める要素はありません。
指紋照合を挟まずに毎回40本すべて回すと、変更が無い資料にも同額を払い続けます。ここは省かないほうが良い場所です。
どこまでを機械に任せるかの線引き
この装置が返すのは「答えられなかった」という事実だけです。何をどう書き直すかは、こちらの判断に残ります。私は次のように分けています。
機械に任せる : 質問の生成、到達可能性の判定、資料ごとの穴の集計、前回との差分
自分で決める : 見出しの言い換え、セクションの統合、そもそも仕様のほうを直すかどうか
missing が並んだときに、資料へ書き足す前に一度立ち止まる価値もあります。ヘルプでしか回避できない操作は、多くの場合アプリ側の設計に無理があります。個人開発だと、文章を足すほうが安く見えて、実際には毎月のレビュー返信という形で払い続けることになります。
まず1本のヘルプで試してみてください
最初から全資料に回す必要はありません。問い合わせが一番多い1本を選び、生成された8件の質問を自分の目で読むところから始めてください。その8件が「利用者が本当に書きそうな文」に見えなければ、生成プロンプトの制約が緩すぎます。そこを直すのが最初の作業になります。
私自身、まだ点検の粒度を決めかねているところがあります。同じように運用されている方の工夫があれば、学ばせていただきたいと思っております。最後までお読みいただき、ありがとうございました。