0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AI問い合わせ回答の自動送信をどこまで許すか:定型回答・人の確認・結果不明の境界

0
Posted at

定型回答の送信候補と人の確認を分ける境界

2026年10月12日確認。問い合わせAIの回答を人の確認なしに送る範囲を、エンジニア・テックリードが決めるための設計例です。公開一次資料と、架空データを使ったAI支援による個人ローカル検証を扱います。実LLM、認証・認可、メール送信、継続運用は未検証です。

結論:最初の自動化候補は、承認済みの定型回答に絞る

営業時間のような限定した問い合わせで、正本と一致する承認済み文面を選べるなら、自動送信の候補にできます。自由生成した回答、根拠が欠ける回答、根拠同士が矛盾する回答は人へ戻します。停止・宛先権限なし・送信結果不明は、別の理由で送信を止めます。

これは本稿の設計案です。以下の条件を満たせなければ、下書き支援までに留めます。

  • 問い合わせの対象範囲を確定でき、正本・文面・有効期間を管理できる。
  • 宛先と回答範囲をサーバー側で認可できる。
  • 人へ戻した仕事を担当者が引き受け、送信結果不明を照合できる。

LLMの「自信がある」「送ってよい」という出力だけでは、この条件を満たしません。

選択肢を同じ条件で比較する

方式 向く条件 見送る条件・負担
手動回答を続ける 件数が少ない、例外が多い 確認待ちが業務を止める場合は別途改善
FAQ・既製品の定型返信 正本と回答範囲を固定できる 認可・監査・停止・結果照合を要件に照らして確認できない
AI下書き+人の確認 自由記述や判断を含む レビューの担当・期限・処理能力が不足する
独自の限定自動送信 狭い範囲で定型文・宛先・根拠を確定できる 例外対応と送信状態の保守費用を負えない

定型返信で足りる場合、送信経路にLLMを追加する理由はありません。生成が必要な部分だけを下書き側に残す選択もできます。

回答生成と送信許可を分ける理由

OWASP LLM06:2025は、機能・権限・自律性の過剰付与をリスクとして挙げ、影響の大きな操作への人の承認、下流システムでの認可を勧めています。

この考え方を問い合わせ対応へ適用する本稿の設計は、次の分離です。

  1. LLMは回答案・分類候補を作る。送信資格を確定させない。
  2. サーバー側のアダプタが、問い合わせ範囲、正本の状態、宛先権限、送信履歴を確定する。
  3. 送信サービスはその情報を検査する。定型経路では、モデルの回答本文を送らず、承認済みストアの文面を取得する。

recipient_allowed=TrueをLLMや問い合わせ本文から受け取れば、検査を置いても信頼境界は成立しません。分類候補も、複数の依頼や例外を含む文章を確定的に扱えなければ人へ戻します。

ローカルで検証した送信前の判定

Python 3.11.8・標準ライブラリで、次の純粋関数と合成fixtureを実行しました。判定の分岐を調べる検証であり、本番の送信実装ではありません。

入力の各項目は、信頼されたサーバー側で確定した情報という前提です。facts=currentは、対象の正本が矛盾なく有効で、選択する定型文と一致するとアダプタが確認した状態を表します。この確認処理自体は実装・検証していません。hours-v3も架空の定型文IDです。

状態の優先順位は、入力不正 → 送信済み/結果不明 → 停止 → 宛先権限 → 対象範囲 → 根拠です。停止しても、既に起きた送信や結果不明の操作は取り消されないためです。

以下をsend_gate_check.pyへ保存し、python3 send_gate_check.pyで再現できます。

"""Synthetic, local-only verification. No LLM, network, or sending operation."""
import copy
import json
import platform

FIELDS = {"state", "halted", "recipient_allowed", "topic", "facts"}
TEMPLATES = {"office_hours": "hours-v3"}


def decide(context):
    # Only a trusted server adapter may construct this input.
    if type(context) is not dict or set(context) != FIELDS:
        return "HOLD_INVALID", None
    if any(type(context[k]) is not bool for k in ("halted", "recipient_allowed")):
        return "HOLD_INVALID", None
    if any(type(context[k]) is not str for k in ("state", "topic", "facts")):
        return "HOLD_INVALID", None
    if context["state"] not in {"new", "sent", "unknown"}:
        return "HOLD_INVALID", None
    if context["facts"] not in {"current", "missing", "conflict", "stale"}:
        return "HOLD_INVALID", None
    # Stopping future work does not cancel a completed or uncertain send.
    if context["state"] == "sent":
        return "DO_NOT_RESEND", None
    if context["state"] == "unknown":
        return "RECONCILE", None
    if context["halted"]:
        return "STOP", None
    if not context["recipient_allowed"]:
        return "DENY_RECIPIENT", None
    if context["topic"] not in TEMPLATES:
        return "HUMAN_SCOPE", None
    if context["facts"] != "current":
        return "HUMAN_EVIDENCE", None
    return "TEMPLATE_CANDIDATE", TEMPLATES[context["topic"]]


base = dict(state="new", halted=False, recipient_allowed=True,
            topic="office_hours", facts="current")
cases = [
    ("normal", {}, ("TEMPLATE_CANDIDATE", "hours-v3")),
    ("missing_evidence", {"facts": "missing"}, ("HUMAN_EVIDENCE", None)),
    ("conflicting_evidence", {"facts": "conflict"}, ("HUMAN_EVIDENCE", None)),
    ("stale_evidence", {"facts": "stale"}, ("HUMAN_EVIDENCE", None)),
    ("free_form_question", {"topic": "free_form"}, ("HUMAN_SCOPE", None)),
    ("refund_request", {"topic": "refund"}, ("HUMAN_SCOPE", None)),
    ("unauthorized_recipient", {"recipient_allowed": False}, ("DENY_RECIPIENT", None)),
    ("stopped_before_send", {"halted": True}, ("STOP", None)),
    ("already_sent", {"state": "sent"}, ("DO_NOT_RESEND", None)),
    ("timeout_result_unknown", {"state": "unknown"}, ("RECONCILE", None)),
    ("stop_after_send", {"state": "sent", "halted": True}, ("DO_NOT_RESEND", None)),
    ("stop_after_timeout", {"state": "unknown", "halted": True}, ("RECONCILE", None)),
    ("truthy_string_is_not_authorization", {"recipient_allowed": "true"}, ("HOLD_INVALID", None)),
    ("numeric_stop_flag", {"halted": 0}, ("HOLD_INVALID", None)),
    ("unrecognized_delivery_state", {"state": "failed"}, ("HOLD_INVALID", None)),
    ("unrecognized_evidence_state", {"facts": "probably_ok"}, ("HOLD_INVALID", None)),
    ("untrusted_extra_instruction", {"send_now": True}, ("HOLD_INVALID", None)),
    ("null_topic", {"topic": None}, ("HOLD_INVALID", None)),
]
results = []
for name, changes, expected in cases:
    sample = {**base, **changes}
    before = copy.deepcopy(sample)
    actual = decide(sample)
    assert actual == expected, (name, actual, expected)
    assert sample == before, name
    results.append(dict(case=name, decision=actual[0], template=actual[1]))

missing = {k: v for k, v in base.items() if k != "recipient_allowed"}
for name, sample in [("missing_field", missing), ("empty_input", {}), ("non_dict_input", [])]:
    before = copy.deepcopy(sample)
    assert decide(sample) == ("HOLD_INVALID", None), name
    assert sample == before, name
    results.append(dict(case=name, decision="HOLD_INVALID", template=None))

print(f"passed={len(results)}/21; input_unchanged=True; external_calls=0")

実行結果:

passed=21/21; input_unchanged=True; external_calls=0

21条件で期待した判定と入力非破壊を確認しました。正常fixtureだけがTEMPLATE_CANDIDATEになり、根拠の欠測・矛盾・期限切れはHUMAN_EVIDENCE、自由回答や返金依頼はHUMAN_SCOPEになりました。文字列の"true"や欠けた項目は許可へ進みません。

TEMPLATE_CANDIDATEは送信候補のIDを返すだけです。宛先認可、文面照合、停止の再確認、並行処理の排他、送信、状態保存はこのコードに含めていません。未検証の本番安全性や、AIの回答精度を21/21で保証するものではありません。

失敗後に何を残し、誰が扱うか

判定・出来事 保存する状態・次の対応 担当
人の確認が必要 回答案・参照した正本の版・理由・期限を保持 業務担当が根拠と宛先を確認
入力不正・宛先権限なし 自動送信を保留。権限を自動で広げない 開発担当が入力経路・認可を調査
停止 未送信分を保留し、復旧条件を決める 運用担当
送信済み 同じ操作の自動再送を禁止 運用担当が必要に応じ履歴確認
タイムアウト・結果不明 unknownを維持し、送信側の結果を照合 運用担当。確認まで再送しない

この表の保存・担当分担も設計案です。承認は依頼ID・宛先・本文の版に結び付け、本文や宛先が変われば以前の承認を流用しません。監査ログには必要な識別子、判定理由、根拠の版、結果を残し、機密全文や認証情報を無差別に記録しないようにします。

AWS Builders’ Libraryは、応答が失われた操作の単純な再試行が副作用を重複させ得ること、要求IDを使った冪等なAPI契約の考え方を説明しています。問い合わせ送信への適用は設計上の解釈です。利用する送信APIにも同じ契約があるとは限りません。

送信前のローカル重複チェックだけでは、並行実行や「送信成功後、記録する前に停止」を防げません。送信APIの冪等性、履歴照合、状態保存を結合テストする必要があります。メールAPIの受理と受信者への配送も別の確認です。

条件付きの推奨と次に確認すること

まず人が送る下書き支援を基準にし、定型文で完結する範囲だけを自動化候補にします。次の検証を通るまでは自動送信を有効にしません。

  • 業務担当:正常・不明・矛盾・複合依頼で、対象範囲と人へ戻す条件を確定する。
  • 開発担当:宛先認可、正本の失効、定型文の版変更、改稿後の承認失効を確認する。
  • 開発・運用担当:二重起動、送信直前の停止、応答喪失、送信後の記録失敗を結合テストする。
  • 運用担当:人の確認待ちの期限、滞留時の停止条件、結果照合と復旧の手順を決める。

費用比較にはLLM利用料だけでなく、人の確認、正本更新、例外調査、結果照合、保守の負担も含めます。今回は業務件数・単価・レビュー時間を測っていないため、削減率やROIは算出していません。

参考資料と検証範囲

公開一次資料は2026年10月12日に確認しました。

公式資料は上記原則の根拠です。具体的な自動送信条件・判定コード・責任分担は本稿の設計案、21条件の結果はAI支援による個人ローカル検証です。実LLMの品質、事実を確定するアダプタ、実送信、排他・永続化、実業務での効果は未確認です。

この記事を書いた人✏️@YushiYamamoto
ITPRODX.com代表。AI × Web × 業務自動化の開発・検証に取り組んでいます。
技術選定・品質・運用の判断材料を、検証範囲と制約を示して発信しています。
AI導入や業務自動化の技術選定・設計整理については、対応できる範囲を確認のうえご相談を承ります。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?