SNS で質問している人への返信の下書きを LLM に作らせ、人が1本ずつ承認してから送る運用をしていました。ところが、承認待ちの間に候補が古くなって、見送りの半分近くが「間に合わなかった」ものでした。
そこで承認をやめて自動で送ることにしました。ただ、人の目の代わりになる検査が要ります。いちばん怖いのは、それらしいけれど確かめていない事実を言い切ることです。この記事は、LLM に「台帳に無い主張」を列挙させて、1つでもあれば送らない関門の作り方です。
問題:既存の検査は「知っている誤り」しか止められない
検査はすでに3つありました。
- 文面の検査:長さ・宣伝の語・URL・決まり文句など
- 言い切りの検査:過去に実際に出した誤り(例:条件付きのものを「確実」と言う)を正規表現で止める
- 型の検査:相手の質問に具体的に答えているか
それでも、承認制の間に人が止めていた下書きには、どれにも当たらないものがありました。
| 下書きの主張(要旨) | 人が止めた理由 |
|---|---|
| 大手の解約金と転出手数料の金額を言い切る | 事実台帳に書いていない(確かめていない) |
| 別の会社では、ある通話の無料枠が有料のオプションになる | 他社の仕様で、台帳に無い |
| ある手続きに対応しているので、番号の発行なしで進められる | 手続きの対応状況が台帳に無い |
どれも正しいかもしれません。ただ、確かめていないことを言い切っていたのが問題でした。正規表現で書けるのは、一度踏んだ誤りだけです。
手順1:事実台帳を「出典付きの Markdown」で持つ
料金・条件・手続きなど、返信で言ってよい事実を1つのファイルにまとめます。行ごとに出典と確認日を書きます。
| 項目 | 内容 | 出典 |
|---|---|---|
| 12/1 からの料金 | 40GB まで 3,135円 / 60GB まで 4,125円 / 120GB まで 5,115円 | 公式ページ(2026-10-09 確認) |
手順2:LLM に「台帳に無い主張」を列挙させる
判定は「正しいか」ではなく、**「台帳から言えるか」**にします。モデルの知識で正誤を判断させると、確かめていない知識で通してしまうからです。
FACT_RUBRIC = """あなたは事実確認の係です。下の「事実台帳」だけを根拠に、「返信文」の中の事実の言い切り
(料金・容量・手数料・期間・条件・手続き・サービスの仕様・他社の料金など、確かめられる主張)を1つずつ調べてください。
- 台帳に書いてある、または台帳から明らかに言える → supported
- 台帳に無い・台帳と食い違う → unsupported
- 「公式の地図で確かめる」のような一般的な手順の勧めや、相手の発言の言い換え・感想は主張に数えない
出力は JSON 1行だけ: {"unsupported": ["台帳に無い主張", ...]}(無ければ空配列)
=== 事実台帳 ===
"""
def facts_check(text: str) -> list[str] | None:
"""台帳に無い主張の一覧。判定できなければ None(呼び出し側は送らない)。"""
prompt = FACT_RUBRIC + FACTS.read_text(encoding="utf-8") + "\n\n=== 返信文 ===\n" + text
try:
p = subprocess.run("claude -p --disallowedTools Edit,Write,Bash", input=prompt,
capture_output=True, text=True, encoding="utf-8", timeout=600, shell=True)
m = re.search(r"\{.*\}", p.stdout or "", re.S)
return list(json.loads(m.group(0)).get("unsupported", [])) if m else None
except Exception:
return None
ここでは Claude Code の CLI(claude -p)を、編集系のツールを禁止して呼んでいます。台帳はおよそ27KBで、そのままプロンプトに入れています。
手順3:関門は fail-closed でつなぐ
LLM の判定は、上限や停止で返ってこないことがあります。返ってこなかったときに「問題なし」と読むと、関門が無いのと同じです。
def gates(text: str) -> str | None:
"""落ちた理由(通れば None)。どれか1つでも落ちたら送らない。"""
if re.search(r"https?://", text):
return "URL が入っている"
why = reject_reason(text) # 文面の検査
if why:
return f"reject_reason: {why}"
bad = check_texts({"t": text}) # 過去の誤りの正規表現
if bad:
return f"claim_facts_gate: {bad[0]}"
un = facts_check(text) # 台帳との照合
if un is None:
return "事実の照合ができない(LLM 停止・上限の可能性)" # ← 判定不能は「送らない」
if un:
return f"事実台帳に無い主張: {' / '.join(un)}"
return None
安い検査から順に並べ、LLM は最後に1回だけ呼びます。
確かめ方:通すべき例と止めるべき例を両方流す
関門は、止めすぎてもエラーにならず、送る数が減るだけで終わります。弾きすぎに気づけないので、通すべき例も必ず入れます。承認制の時代に人が「送った」もの・「止めた」ものが、そのまま正解の例になりました。
tests = {
"通すべき(人が承認して送った)": "安さで決めるなら、先に自宅と職場が公式のエリアマップで…",
"通すべき(台帳の数字だけ)": "11/1時点で契約中なら、12/1から40GBの上限が自動で入って…",
"止めるべき(台帳に無い)": "大手なら今は解約金も転出手数料も…なので…", # 金額の言い切り
"止めるべき(他社の仕様)": "…の通話無料は…だと別料金のオプションになる点だけ注意かな…",
"止めるべき(手続きの対応)": "…に対応してるので、…すれば番号の発行なしで進められますね…",
}
for k, t in tests.items():
print(k, "→", gates(t) or "通過")
結果は5件とも期待どおりでした。止めた3件には、台帳に無い主張が具体的に列挙されました(例:解約金と転出手数料の金額の言い切りが2つ)。
通すべき(人が承認して送った) → 通過
通すべき(台帳の数字だけ) → 通過
止めるべき(台帳に無い) → 事実台帳に無い主張: (解約金の金額の言い切り) / (転出手数料の金額の言い切り) / …
止めるべき(他社の仕様) → 事実台帳に無い主張: …では5分以内の通話無料が別料金のオプションになる(台帳に記載なし)
止めるべき(手続きの対応) → 事実台帳に無い主張: …に対応している / …
まとめ
- 「正しいか」ではなく「台帳から言えるか」で判定させる。台帳に無ければ、正しくても止める
- LLM の判定が返らないときは送らない(fail-closed)
- 安い検査を先に、LLM は最後に1回
- 承認制で人が「送った・止めた」記録は、そのまま評価セットになる。通すべき例も必ず入れる