星ひとつのレビューに、深夜、返信を書きかけたことがあります。
「起動して設定を開くと必ず落ちる」。心当たりのある不具合でした。気づくと指が「次のアップデートで修正します」と打っていて、送信ボタンの手前でようやく止まりました。その時点で、次の版に間に合う保証はどこにもありませんでした。
個人開発でアプリを出していると、レビューへの返信も当然ひとりの作業になります。返信の文面そのものは、Gemini に下書きさせれば数秒で出てきます。速くなるほど、送ってしまった後の取り返しがつかなくなる作業でもあります。ストアの返信は公開されますし、書いた約束は次のバージョンが出るまで読まれ続けます。
そこで私は、下書きを賢くする前に、送る前に止める側を先に作りました。処理としては地味で、コードも短いものです。ただ実際に走らせてみると、間違えていたのは下書きではなく検査の側でした。その顛末まで含めて置いておきます。
返信するかどうかを、書き始める前に決める
レビューが届いた順に返信していくと、返信すべきものに時間が残りません。App Store と Google Play で文面の作法は変わりますが、振り分けの基準そのものはどちらでも同じで構いません。文章を作らせる前に、機械で三つに分けます。
| 振り分け | 条件 | その後の扱い |
|---|---|---|
priority | ★2以下で、再現手順らしき記述がある | 下書きを作り、内容も自分で読む |
ack_only | ★3以下で、再現手順がない | 受け止めだけを短く返す |
no_reply | ★4以上 | 返信枠を使わない |
高評価に返信しないのは冷たく見えるかもしれません。ただ、返信を読んで態度が変わるのは、困っている人のほうです。限られた時間をそちらへ寄せる、という判断です。
import re
def triage(stars: int, review: str) -> str:
repro = re.search(r"(すると|したら|開くと|押すと|落ちる|クラッシュ|固まる|表示されない|保存できない)", review)
if stars >= 4:
return "no_reply" # 返信枠を使わない
if stars <= 2 and repro:
return "priority" # 再現手順があるので最優先
return "ack_only" # 受け止めだけを返す判定に使っているのは星の数と、症状を表す語だけです。感情の強さでは分けていません。強い言葉で書かれた不満より、淡々と手順を書いてくれたレビューのほうが、直せる情報を含んでいることが多いためです。
下書きは、文章ではなく構造で受け取る
Gemini に「返信を書いて」と頼むと、返ってくるのは一続きの文章です。これを検査に回すと、どこが謝罪でどこが約束なのかを毎回また解析することになります。
最初から分けて受け取るほうが、あとの工程が楽になります。
from google import genai
from google.genai import types
client = genai.Client(api_key="YOUR_API_KEY")
REPLY_SCHEMA = {
"type": "object",
"properties": {
"acknowledgement": {"type": "string"}, # 受け止め
"status": {"type": "string"}, # 今わかっている事実だけ
"next_step": {"type": "string"}, # 相手にお願いすること
},
"required": ["acknowledgement", "status"],
}
def draft(review: str, mode: str) -> dict:
prompt = (
"アプリのレビューへの返信案を作ってください。敬体で書き、"
"実施が確定していない修正時期は書かないでください。\n"
f"振り分け: {mode}\nレビュー本文: {review}"
)
res = client.models.generate_content(
model="gemini-3.7-flash",
contents=prompt,
config=types.GenerateContentConfig(
response_mime_type="application/json",
response_schema=REPLY_SCHEMA,
),
)
return res.parsedstatus を独立させているのが要点です。「原因を調査しています」は事実ですが、「修正します」は約束です。同じ段落に混ぜて書かせると、後から切り分けられなくなります。スキーマの書き方でつまずいたときは、Gemini API の Structured Output でバリデーションエラーが返るときの原因と対処に典型的な失敗例をまとめてあります。
なお、プロンプトで「修正時期を書かないで」と指示しても、書かれるときは書かれます。だから次の検査が要ります。
送信前の三つの検査
止めたいものは三つだけです。守れない約束(A)、連絡先の混入(B)、文体の崩れと長さ(C)。
MAX_CHARS = 350 # ストアの上限とは別に、読ませる長さとして自分で決めた値です
PROMISE = [r"次の(アップデート|バージョン)で", r"必ず(修正|対応)", r"すぐに(修正|対応)",
r"\d+\s*(日|週間|月)以内に", r"来週(まで)?に", r"今月中に", r"予定です"]
POLITE_END = re.compile(r"(です|でした|ます|ました|ません|ましょう|ください|ございます)。$")
PLAIN_END = re.compile(r"(だ|である|した|ない|いる|する|よ|ね)。$")
def mask(draft: str) -> str:
"""メールと URL を先に伏せる。伏せないと @ の検出が同じ箇所で二重に鳴ります"""
d = re.sub(r"[\w.+-]+@[\w-]+\.[\w.]+", " ", draft)
return re.sub(r"https?://\S+", " ", d)
def check(draft: str) -> list:
hits = []
for p in PROMISE:
if re.search(p, draft):
hits.append(("A_promise", p))
for pattern, target in ((r"[\w.+-]+@[\w-]+\.[\w.]+", draft),
(r"https?://\S+", draft),
(r"@[A-Za-z0-9_]{3,}", mask(draft)),
(r"0\d{1,4}-\d{1,4}-\d{3,4}", mask(draft))):
m = re.search(pattern, target)
if m:
hits.append(("B_contact", m.group(0)))
for s in re.split(r"(?<=。)", draft):
s = s.strip()
if s and not POLITE_END.search(s) and PLAIN_END.search(s):
hits.append(("C_plain", s))
break
if len(draft) > MAX_CHARS:
hits.append(("C_length", f"{len(draft)}字"))
return hitsB に連絡先を入れているのは、個別対応へ誘導する文面が公開の場に出ることを避けたいからです。サポート窓口へ誘導したい気持ちは分かるのですが、公開返信にアドレスを書くと、そのアドレスは以後ずっとそこに残ります。
走らせたら、間違えていたのは検査の側でした
サンプルのレビューと下書きを6組用意して、そのまま実行しました。最初の版の出力がこれです。
★ 振り分け 判定 検出
1 priority STOP A_promise:次の(アップデート|バージョン)で
1 ack_only STOP B_contact:support@example.com / B_contact:@example
2 ack_only OK —
2 priority STOP C_plain:保存が完了しない事象を確認しました。
5 no_reply — 返信枠を使わない
3 ack_only STOP C_plain:ご意見に感謝する。
下書き6件 → 返信対象5件 / 通過1件 / 差し戻し4件 / 対象外1件差し戻し4件のうち、2件が検査側の誤りでした。
ひとつめは2行目です。support@example.com に対して B_contact が二度鳴っています。メールアドレスの中の @example を、SNS のハンドル名を探すパターンが拾っていました。同じ一箇所を二重に数えていたわけです。
ふたつめは4行目で、こちらは実害があります。「保存が完了しない事象を確認しました。」は敬体です。それを常体として止めていました。文末の活用形を した。 で拾う書き方にしていたため、ました。 がそこに含まれてしまったのです。敬体の判定は、常体の形を探すのではなく、先に敬体の語尾を確認してから常体を疑う順序にしないと成立しません。
修正は2箇所です。メールと URL を伏せてからハンドル名を探すこと。敬体語尾(です ました ません ください など)を先に判定すること。書き直して再実行した結果が次です。
1 priority STOP A_promise:次の(アップデート|バージョン)で
1 ack_only STOP B_contact:support@example.com
2 ack_only OK —
2 priority OK —
5 no_reply — 返信枠を使わない
3 ack_only STOP C_plain:ご意見に感謝する。
下書き6件 → 返信対象5件 / 通過2件 / 差し戻し3件 / 対象外1件| 項目 | 修正前 | 修正後 |
|---|---|---|
| 差し戻し | 4件 | 3件 |
| うち誤検出 | 2件(二重検出1・敬体の誤判定1) | 0件 |
| 通過 | 1件 | 2件 |
止まった3件は、いずれも本当に止めるべきものでした。確定していない修正時期、公開の場に出したくないアドレス、常体のまま出てきた一文です。
誤検出が残ったまま運用に入ると、検査を信じなくなります。そして信じなくなった検査は、いずれ外されます。数字が小さいうちに一度手で並べて確かめておくと、その道を通らずに済みます。
何を任せ、何を手元に残すか
私は、返信文の下書きと、送信前の機械的な検査までを自動化の範囲にしています。送信ボタンは自分で押します。
線引きの理由は単純で、返信は約束を含むからです。「調査しています」までは事実の報告ですが、「直します」は開発計画への合意です。それを決められるのは、次に何に手をつけるかを知っている人だけです。文面の巧拙とは別の話だと考えています。
同じ考え方で、レビューの内容そのものへの判断も手元に残しています。返信するかどうかは振り分けの結果を参考にしますが、最終的には自分でレビューを読みます。読まずに返す返信は、読まれたときに分かってしまいます。
多言語で返信を回す場合は、語彙の統一や返信件数の分散など、ここで扱っていない論点が加わります。実際に複数言語で運用した記録はGoogle Play 多言語レビュー返信を Gemini と作るにまとめてありますので、返信の本数が増えてきた段階で覗いてみてください。
次の一手
まずは PROMISE の配列に、自分が過去に書いてしまった言い回しを1つ足すところから始めてみてください。汎用の禁止語リストより、自分の癖を1つ潰すほうが確実に効きます。私の場合は「予定です」でした。
小さな検査ですが、送信ボタンの手前に一段だけ挟まっていると、深夜に書いた文章を朝の自分が確認できます。お読みいただきありがとうございました。