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にDM振り分けルールを書かせても即反映しない:JSON化→再生試験→承認のPython実装

0
Last updated at Posted at 2026-10-18

「返金に関するDMは優先キューへ、通報済みなら担当者へ回して」とAIに頼み、設定JSONを作らせるところまでは簡単になりました。

難しいのは、そのJSONを誰が正しいと判断し、誤分類やAIサービスの障害があってもDMを止めずに運用できる形へ変えることです。AIの説明が自然であるほど、設定自体も正しいように見えてしまいます。

本稿では、AIをルールの作成補助に限定し、実行時は決定的なルールエンジンを使います。候補JSONを検証用DMへ適用し、人が差分を確認した後だけ承認済み設定に昇格させます。

結論:AIには候補を書かせ、本番反映はテストと承認で決める

今回作る流れは次のとおりです。

  1. 人が自然言語でDMの振り分け要件を書く
  2. AIが制約付きの候補JSONを生成する
  3. Pythonでスキーマ、禁止操作、優先順位を検査する
  4. 合成したDMを再生し、期待する振り分けと比較する
  5. 人が結果を確認し、承認者名とハッシュを付ける
  6. 本番アプリは承認済みJSONだけを読み込む

AIが得意なのは、曖昧な要件を設定候補へ変換し、変更内容を説明することです。一方、次の判断はAIへ委ねません。

  • 誤検知をどこまで許容するか
  • どの話題をセンシティブとして扱うか
  • 自動処理について利用者へ何を表示するか
  • 削除、ブロック、返信送信などの不可逆操作を許すか
  • 翻訳文だけを根拠にモデレーションしてよいか

この分離により、AIが利用できない時間でも、承認済みルールによるDM振り分けは継続できます。

前提:メッセージング、ルール作成、実行を分ける

Tencent RTCのSocial Messagingソリューションは、1対1チャット、グループディスカッション、コミュニティ、リッチメディア、ライブ配信ルーム内チャットなどのシナリオを扱います。

本稿では、そのうち1対1のDMを例にします。ただし、以下のPythonコードはTencent RTC SDKのAPIを再実装するものではありません。責任境界は次のように分けます。

層 担当
Tencent RTC DMの送受信とチャット体験
アプリのAdapter 受信イベントをルール入力へ正規化
ルールエンジン キュー、優先度、有人確認の判定
AIアシスタント 候補ルールの作成と説明
運用担当者 テスト結果の確認、承認、ロールバック

重要なのは、ルール作成用AIへ本番DMを直接読ませないことです。要件、項目定義、合成した例文だけでも候補は作れます。実メッセージを使った評価が必要な場合は、組織の同意、プライバシー、保持期間、アクセス権限を先に決める必要があります。

手順1:AIが出力できる範囲を固定する

今回は、実行可能な操作を次の3種類だけにします。

  • standard_queue: 通常キュー
  • priority_queue: 優先キュー
  • human_handoff: 自動判定を止めて担当者確認へ移す

AIには、返信、削除、ブロック、通報確定を表す操作を出力させません。プロンプトで禁止するだけでなく、後段のコードでも拒否します。

AIへ渡す依頼例は次のようになります。

DM振り分けポリシーの候補をJSONで作成してください。

要件:
- 通報済みDMは担当者確認へ送る
- 「返金」「請求」「詐欺」を含むDMは優先キューへ送る
- それ以外は通常キューへ送る
- 自動処理の案内に同意していない利用者は担当者確認へ送る

制約:
- actionはstandard_queue、priority_queue、human_handoffのみ
- 返信、削除、ブロック、通報確定は提案しない
- 各ルールに一意なidと整数のpriorityを付ける
- 自信がなくても制約外のactionを追加しない
- JSON以外を出力しない

ここでのAI出力は、あくまで次工程への入力です。「JSONが生成できた」ことは、「運用ポリシーが妥当である」ことを意味しません。

手順2:候補ポリシーと再生データを用意する

作業ディレクトリを作成します。

mkdir dm-policy-gate
cd dm-policy-gate
touch policy.candidate.json fixtures.json policy_gate.py

policy.candidate.jsonへ候補を保存します。

{
  "revision": "2026-10-18-01",
  "default_action": "standard_queue",
  "rules": [
    {
      "id": "reported-message",
      "priority": 10,
      "when": {
        "reported": true
      },
      "action": "human_handoff"
    },
    {
      "id": "billing-keywords",
      "priority": 20,
      "when": {
        "contains_any": ["返金", "請求", "詐欺"]
      },
      "action": "priority_queue"
    }
  ]
}

続いて、fixtures.jsonへ合成メッセージと期待結果を保存します。

[
  {
    "name": "案内への同意がない",
    "message": {
      "id": "m-001",
      "text": "配送予定を確認したいです",
      "reported": false,
      "automation_notice_accepted": false
    },
    "expected_action": "human_handoff",
    "expected_reason": "consent_missing"
  },
  {
    "name": "通報済みを最優先する",
    "message": {
      "id": "m-002",
      "text": "通常の文章",
      "reported": true,
      "automation_notice_accepted": true
    },
    "expected_action": "human_handoff",
    "expected_reason": "reported-message"
  },
  {
    "name": "返金相談を優先キューへ送る",
    "message": {
      "id": "m-003",
      "text": "返金の手続きを確認したいです",
      "reported": false,
      "automation_notice_accepted": true
    },
    "expected_action": "priority_queue",
    "expected_reason": "billing-keywords"
  },
  {
    "name": "一般的な問い合わせ",
    "message": {
      "id": "m-004",
      "text": "プロフィール画像を変更したいです",
      "reported": false,
      "automation_notice_accepted": true
    },
    "expected_action": "standard_queue",
    "expected_reason": "default"
  }
]

automation_notice_acceptedは実装例のアプリポリシーであり、普遍的な法的要件を示すものではありません。実際に必要な通知や同意は、対象地域、サービス規約、処理目的に合わせて決めてください。

コード:禁止操作を拒否し、再生試験後だけ承認する

policy_gate.pyへ次のコードを保存します。外部パッケージは使いません。

from __future__ import annotations

import argparse
import hashlib
import json
import os
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

ALLOWED_ACTIONS = {
    "standard_queue",
    "priority_queue",
    "human_handoff",
}
ALLOWED_CONDITIONS = {"reported", "contains_any"}
REVISION_PATTERN = re.compile(r"^[0-9A-Za-z._-]{1,64}$")


def load_json(path: str) -> Any:
    return json.loads(Path(path).read_text(encoding="utf-8"))


def canonical_bytes(value: Any) -> bytes:
    return json.dumps(
        value,
        ensure_ascii=False,
        sort_keys=True,
        separators=(",", ":"),
    ).encode("utf-8")


def digest_policy(policy: dict[str, Any]) -> str:
    return hashlib.sha256(canonical_bytes(policy)).hexdigest()


def require_exact_keys(
    value: dict[str, Any],
    allowed: set[str],
    location: str,
) -> None:
    unknown = set(value) - allowed
    if unknown:
        raise ValueError(
            f"{location}: 未許可の項目があります: {sorted(unknown)}"
        )


def validate_policy(policy: Any) -> None:
    if not isinstance(policy, dict):
        raise ValueError("policyはオブジェクトである必要があります")

    require_exact_keys(
        policy,
        {"revision", "default_action", "rules"},
        "policy",
    )

    revision = policy.get("revision")
    if not isinstance(revision, str) or not REVISION_PATTERN.fullmatch(revision):
        raise ValueError("revisionの形式が不正です")

    if policy.get("default_action") not in ALLOWED_ACTIONS:
        raise ValueError("default_actionが許可されていません")

    rules = policy.get("rules")
    if not isinstance(rules, list) or not rules:
        raise ValueError("rulesには1件以上のルールが必要です")

    ids: set[str] = set()
    priorities: set[int] = set()

    for index, rule in enumerate(rules):
        location = f"rules[{index}]"
        if not isinstance(rule, dict):
            raise ValueError(f"{location}はオブジェクトである必要があります")

        require_exact_keys(
            rule,
            {"id", "priority", "when", "action"},
            location,
        )

        rule_id = rule.get("id")
        if not isinstance(rule_id, str) or not REVISION_PATTERN.fullmatch(rule_id):
            raise ValueError(f"{location}.idの形式が不正です")
        if rule_id in ids:
            raise ValueError(f"ルールIDが重複しています: {rule_id}")
        ids.add(rule_id)

        priority = rule.get("priority")
        if not isinstance(priority, int) or isinstance(priority, bool):
            raise ValueError(f"{location}.priorityは整数にしてください")
        if priority in priorities:
            raise ValueError(f"priorityが重複しています: {priority}")
        priorities.add(priority)

        if rule.get("action") not in ALLOWED_ACTIONS:
            raise ValueError(
                f"{location}.actionは許可されていません: {rule.get('action')}"
            )

        conditions = rule.get("when")
        if not isinstance(conditions, dict) or not conditions:
            raise ValueError(f"{location}.whenが空です")

        unknown_conditions = set(conditions) - ALLOWED_CONDITIONS
        if unknown_conditions:
            raise ValueError(
                f"{location}.whenに未許可の条件があります: "
                f"{sorted(unknown_conditions)}"
            )

        if "reported" in conditions and not isinstance(
            conditions["reported"], bool
        ):
            raise ValueError(f"{location}.when.reportedは真偽値にしてください")

        if "contains_any" in conditions:
            terms = conditions["contains_any"]
            if (
                not isinstance(terms, list)
                or not terms
                or not all(isinstance(term, str) and term.strip() for term in terms)
            ):
                raise ValueError(
                    f"{location}.when.contains_anyには空でない文字列配列が必要です"
                )


def matches(rule: dict[str, Any], message: dict[str, Any]) -> bool:
    conditions = rule["when"]

    if "reported" in conditions:
        if bool(message.get("reported", False)) != conditions["reported"]:
            return False

    if "contains_any" in conditions:
        text = str(message.get("text", "")).casefold()
        terms = [term.casefold() for term in conditions["contains_any"]]
        if not any(term in text for term in terms):
            return False

    return True


def classify(
    policy: dict[str, Any],
    message: dict[str, Any],
) -> dict[str, str]:
    if not message.get("automation_notice_accepted", False):
        return {
            "message_id": str(message.get("id", "unknown")),
            "policy_revision": policy["revision"],
            "action": "human_handoff",
            "reason": "consent_missing",
            "notice_key": "human_review_pending",
        }

    for rule in sorted(policy["rules"], key=lambda item: item["priority"]):
        if matches(rule, message):
            return {
                "message_id": str(message.get("id", "unknown")),
                "policy_revision": policy["revision"],
                "action": rule["action"],
                "reason": rule["id"],
                "notice_key": (
                    "human_review_pending"
                    if rule["action"] == "human_handoff"
                    else "received"
                ),
            }

    return {
        "message_id": str(message.get("id", "unknown")),
        "policy_revision": policy["revision"],
        "action": policy["default_action"],
        "reason": "default",
        "notice_key": "received",
    }


def replay(policy: dict[str, Any], fixtures: Any) -> bool:
    if not isinstance(fixtures, list) or not fixtures:
        raise ValueError("fixturesには1件以上のケースが必要です")

    passed = True
    for fixture in fixtures:
        result = classify(policy, fixture["message"])
        ok = (
            result["action"] == fixture["expected_action"]
            and result["reason"] == fixture["expected_reason"]
        )
        mark = "PASS" if ok else "FAIL"
        print(
            f"[{mark}] {fixture['name']}: "
            f"action={result['action']} reason={result['reason']}"
        )
        passed = passed and ok

    return passed


def approve(policy: dict[str, Any], output_path: str) -> None:
    approver = os.environ.get("APPROVER", "").strip()
    if not approver:
        raise ValueError("承認時は環境変数APPROVERを指定してください")

    bundle = {
        "policy": policy,
        "approval": {
            "approver": approver,
            "approved_at": datetime.now(timezone.utc).isoformat(),
            "sha256": digest_policy(policy),
        },
    }
    Path(output_path).write_text(
        json.dumps(bundle, ensure_ascii=False, indent=2) + "\n",
        encoding="utf-8",
    )
    print(f"承認済みポリシーを書き出しました: {output_path}")


def verify_bundle(path: str) -> None:
    bundle = load_json(path)
    policy = bundle["policy"]
    approval = bundle["approval"]
    validate_policy(policy)

    actual = digest_policy(policy)
    if actual != approval.get("sha256"):
        raise ValueError("承認後にポリシーが変更されています")

    print(
        "承認済みポリシーを確認しました: "
        f"revision={policy['revision']} approver={approval.get('approver')}"
    )


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--policy", default="policy.candidate.json")
    parser.add_argument("--fixtures", default="fixtures.json")
    parser.add_argument("--approve", action="store_true")
    parser.add_argument("--output", default="policy.approved.json")
    parser.add_argument("--verify-approved")
    args = parser.parse_args()

    try:
        if args.verify_approved:
            verify_bundle(args.verify_approved)
            return 0

        policy = load_json(args.policy)
        fixtures = load_json(args.fixtures)
        validate_policy(policy)

        if not replay(policy, fixtures):
            print("再生試験に失敗したため承認できません", file=sys.stderr)
            return 1

        if args.approve:
            approve(policy, args.output)
        else:
            print("候補は有効です。まだ承認されていません。")
        return 0
    except (ValueError, KeyError, json.JSONDecodeError) as error:
        print(f"ERROR: {error}", file=sys.stderr)
        return 1


if __name__ == "__main__":
    raise SystemExit(main())

確認方法:正常な候補より、危険な候補を先に試す

1. 候補を再生する

python policy_gate.py

すべてのケースがPASSになっても、まだ本番用ファイルは生成されません。

[PASS] 案内への同意がない: action=human_handoff reason=consent_missing
[PASS] 通報済みを最優先する: action=human_handoff reason=reported-message
[PASS] 返金相談を優先キューへ送る: action=priority_queue reason=billing-keywords
[PASS] 一般的な問い合わせ: action=standard_queue reason=default
候補は有効です。まだ承認されていません。

2. AIが禁止操作を出した場合

候補JSONのactionをauto_deleteへ変更して実行します。

python policy_gate.py

許可リストにないため、再生前に拒否されます。AIへの指示が守られなかった場合でも、実行境界で止められます。

3. 人が承認する

出力差分と再生結果を確認した担当者が、承認者名を指定します。

APPROVER=reviewer-a python policy_gate.py --approve

生成されたファイルを検証します。

python policy_gate.py --verify-approved policy.approved.json

policy.approved.json内のルールを後から書き換えると、ハッシュ不一致で拒否されます。ただし、SHA-256は認証や権限管理の代わりではありません。本番では、承認済みファイルを認証された設定ストアへ保存し、変更履歴とロールバック先も管理してください。

手順3:Tencent RTCのDMへ接続する

SDK固有の受信イベントを直接ルールエンジンへ渡さず、アプリ側で小さな入力形式へ変換します。

Tencent RTCでDMを受信
        ↓
アプリのAdapterで正規化
  id / text / reported / automation_notice_accepted
        ↓
承認済みポリシーのハッシュを検証
        ↓
classify()を実行
        ↓
standard_queue / priority_queue / human_handoff
        ↓
アプリのキューを更新し、利用者へ状態を表示

接続時の原則は次の3点です。

  1. 候補ファイルを本番プロセスから読まない
  2. human_handoffを「処理済み」ではなく「担当者確認待ち」と表示する
  3. ルールの実行結果にpolicy_revisionとreasonを残す

特に3点目が重要です。「なぜ優先キューへ入ったのか」を後から確認できないと、誤分類をルール改訂へ反映できません。DM本文を監査ログへ複製する必要があるとは限らないため、本文の保存範囲と保持期間は別途設計します。

翻訳を加える場合の判断

TUIChatにはオンデマンドのテキストメッセージ翻訳に関する公式手順があります。対応するコンテンツ形式、言語、エディション上の条件は変更され得るため、組み込み前に公式ドキュメントを確認してください。

翻訳表示とモデレーション判定は同じ判断にしない方が安全です。例えば、翻訳文に「詐欺」が現れたという理由だけで自動措置を行うと、文脈や翻訳誤差を取り込んでしまいます。

選択肢は次のように整理できます。

用途 推奨する扱い
利用者がDMを読むための翻訳 原文へ戻れるUIを用意する
担当者が内容を把握するための翻訳 原文と翻訳文を併記する
翻訳文による優先度の補助 human_handoffへ寄せて人が確認する
翻訳文だけを根拠に削除・ブロック このルールエンジンでは許可しない

多言語対応が必要なら、まず各言語の合成fixtureを追加します。単に日本語キーワードを増やすだけでは、否定、引用、皮肉、表記揺れを十分に扱えません。

AIを使うかどうかの判断基準

AIを使う価値があるのは、ルール候補の作成頻度が高く、人がJSONを手書きする負担を下げたい場合です。反対に、ルールが数個でほとんど変わらないなら、AI連携より手動編集とコードレビューの方が単純です。

判断対象 AI利用 最終決定
自然言語要件から候補JSONを作る 向いている 人がレビュー
既存ルールの説明文を作る 向いている 人が事実確認
未知のactionを追加する 許可しない スキーマ変更として設計レビュー
DMを削除・ブロックする 任せない 明示的な運用ポリシーと権限が必要
誤分類の許容範囲を決める 任せない プロダクト・運用担当者が決定
AI障害時の本番処理 依存させない 最後の承認済み設定を利用

見えにくいコストは、AIの利用料だけではありません。fixtureの保守、言語別のテスト、レビュー担当者の確保、誤分類の調査、ロールバック訓練まで含めて判断する必要があります。

注意点と失敗しやすいケース

キーワード一致は意味理解ではない

「詐欺ではありません」という否定文や、過去のDMを引用した文章にも一致します。最初から自動措置へ使わず、優先キューや有人確認の補助として始める方が扱いやすい設計です。

fixtureが少ないと、間違ったルールも通る

最低でも次のケースを追加します。

  • 否定文
  • 引用文
  • 全角・半角や大文字・小文字の違い
  • 空文字、添付のみ、削除済みメッセージ
  • 複数ルールへ同時に一致する文章
  • 同意状態が欠落したイベント
  • 翻訳前後で意味が変わる文章
  • 通報状態とキーワード条件が競合する文章

ハッシュだけでは承認者を証明できない

このサンプルのハッシュは、承認後の意図しない変更を検出するものです。悪意ある変更や承認者のなりすましを防ぐには、認証済みデプロイ経路、アクセス制御、署名、監査ログなどが別途必要です。

有人引き継ぎを行き止まりにしない

human_handoffへ振り分けるだけでなく、担当キュー、受付時刻、現在の状態、利用者に見せる案内を定義します。「AIでは判断できませんでした」だけを表示して会話を止めないことが重要です。

リリース前チェックリスト

  • 本番プロセスは承認済みファイルだけを読む
  • 許可されるactionが列挙されている
  • 返信、削除、ブロックなどの不可逆操作が含まれていない
  • AIが未知のフィールドを追加すると検証に失敗する
  • ルールの優先順位が重複していない
  • 否定、引用、多言語、空メッセージを再生した
  • 判定結果へポリシーrevisionと理由を残す
  • 有人確認中であることを利用者へ表示できる
  • AIサービス停止中でも承認済みルールが動く
  • 直前の承認済みrevisionへ戻す手順がある

チャットで設定を作れること自体は便利です。しかし、本番投入の信頼性を作るのは、自然な回答ではなく、許可リスト、再生可能なテスト、人の承認、改変検知です。AIを本番の判断者ではなく、検査可能な候補の作成者として置くと、便利さを残しながら責任の所在を明確にできます。


関係性の開示: 筆者はTencent RTCのコンテンツ制作に関わっています。本稿はTencent RTC公式ドキュメントを実装上の参照資料として使用しました。

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?