AIにDMの返信案を書かせると、文章を整える時間は減らせます。一方で、読みやすい返信ほど「必要な条件が全部書かれている」と錯覚しやすくなります。
ここで気にすべきなのは、文章がAIっぽいかどうかではありません。
- 返金までの日数が抜けていないか
- 確認できていないことを断定していないか
- AI利用への同意がないDMをモデルへ渡していないか
- モデレーション対象を自動返信していないか
- 人が確認中であることを相手に見せられるか
文体の癖を禁止するだけでは、必要な情報まで短くなることがあります。そこで本記事では、AIの文章を直接採点するのではなく、返信前に満たすべき「回答義務」を人が定義し、機械的なゲートと有人承認で送信可否を決める構成を作ります。
結論:AIの文章ではなく、返信ブリーフとの差分を検査する
今回の完成形は次のとおりです。
- DMの原文を正本として保存する
- 同意とモデレーション結果を確認する
- 人または業務ルールが返信ブリーフを作る
- AIはブリーフをもとに返信案を生成する
- TypeScriptのゲートが回答義務、根拠、未確定事項を検査する
- 担当者が承認した場合だけ送信可能にする
- 承認待ちや差し戻しは、固定文言で相手に見せる
AIが得意なのは、下書き、言い換え、指定言語への表現調整です。しかし、流暢さから業務上の完全性を判断することはできません。何を必須情報とするか、どの不確実性を許容するか、最終的に送信するかは人が決めます。
前提:チャット、AI、翻訳を別の状態として扱う
Tencent RTCのSocial Messagingは、1対1チャット、グループディスカッション、コミュニティ、リッチメディアなどのソーシャルメッセージング用途を扱います。
本記事ではTencent RTCのDMをメッセージ配送層として使い、その外側にアプリケーション独自の返信ゲートを置きます。責任境界は次のように分けます。
| 層 | 担当すること | 担当しないこと |
|---|---|---|
| Tencent RTCのチャット | DMの送受信、会話体験 | AI返信の業務的な正しさの判定 |
| モデレーション | 自動処理を続行してよいかの判定 | 返信内容の作成 |
| AI | ブリーフに沿った返信案の作成 | 送信、必須条件の変更、承認 |
| 返信ゲート | 必須項目と状態の検査 | 自然言語の意味を完全に理解すること |
| 担当者 | ブリーフ作成、例外判断、送信承認 | 毎回ゼロから文章を書くこと |
| 翻訳表示 | 利用者が選んだ言語で読む補助 | 原文の上書き、別メッセージとしての再投稿 |
AIを使うかどうかの判断表
| 状況 | 推奨する処理 |
|---|---|
| 必須条件が定型化でき、低リスク | AIで下書きし、人が承認 |
| 必須条件は定型化できるが、金額や期限を含む | アンカー検査を加え、人が原文と照合 |
| 相手の意図が曖昧 | AIに返信を書かせず、担当者へ引き継ぐ |
| モデレーション結果が要確認 | 自動処理を停止して担当者へ引き継ぐ |
| AI利用への同意がない | AIへ本文を渡さず、人が対応 |
| 法的・医療的判断など高い専門性が必要 | 最初から専門担当者へ引き継ぐ |
ワークフロー:返信ブリーフを先に固定する
たとえば、利用者から次のDMが届いたとします。
注文をキャンセルしました。返金はいつになりますか?
AIへそのまま「丁寧に返信して」と依頼するのではなく、担当者または業務ルールが次のブリーフを作ります。
回答義務 A: キャンセル受付済みであること
回答義務 B: 返金処理の目安が3営業日以内であること
回答義務 C: 実際の着金日は決済会社によって異なること
未確定事項: なし
許可する根拠: 注文状態メッセージ、返金ポリシー
重要なのは、AIが回答義務を新しく決めないことです。AIは、決められた内容を文章へ変換する役割に限定します。
手順1:検証用プロジェクトを作る
Node.jsとTypeScriptで再現します。
mkdir dm-reply-gate
cd dm-reply-gate
npm init -y
npm install -D typescript tsx @types/node
mkdir src test
package.jsonへテストコマンドを追加します。
{
"scripts": {
"test": "tsx --test test/**/*.test.ts"
}
}
手順2:回答義務をデータとして持つ
src/reply-gate.tsを作成します。
export type ModerationState = "pass" | "review" | "block";
export type Obligation = {
id: string;
description: string;
// 本文に必ず現れるべき業務上の語句。
// 意味の正しさを証明するものではなく、脱落検知用。
anchors: string[];
};
export type ReplyBrief = {
conversationId: string;
originalMessageId: string;
allowedSourceMessageIds: string[];
aiUseConsented: boolean;
moderation: ModerationState;
language: string;
obligations: Obligation[];
ownerId: string;
};
export type AiDraft = {
text: string;
language: string;
coveredObligationIds: string[];
sourceMessageIds: string[];
uncertainties: string[];
};
export type Decision =
| {
status: "send_ready";
issues: [];
}
| {
status: "await_human";
issues: [];
clientNotice: string;
}
| {
status: "handoff";
issues: string[];
ownerId: string;
clientNotice: string;
};
function normalize(value: string): string {
return value.normalize("NFKC").toLocaleLowerCase().replace(/\s+/g, "");
}
export function evaluateDraft(
brief: ReplyBrief,
draft: AiDraft,
humanApproved: boolean,
): Decision {
const issues: string[] = [];
if (!brief.aiUseConsented) {
issues.push("AI利用への同意が確認できない");
}
if (brief.moderation !== "pass") {
issues.push(`モデレーション状態が${brief.moderation}`);
}
if (draft.language !== brief.language) {
issues.push(
`返信言語が不一致: expected=${brief.language}, actual=${draft.language}`,
);
}
const allowedSources = new Set(brief.allowedSourceMessageIds);
for (const sourceId of draft.sourceMessageIds) {
if (!allowedSources.has(sourceId)) {
issues.push(`許可されていない根拠: ${sourceId}`);
}
}
const normalizedText = normalize(draft.text);
const covered = new Set(draft.coveredObligationIds);
for (const obligation of brief.obligations) {
if (!covered.has(obligation.id)) {
issues.push(`回答義務の申告漏れ: ${obligation.id}`);
}
for (const anchor of obligation.anchors) {
if (!normalizedText.includes(normalize(anchor))) {
issues.push(`必須語句の欠落: ${obligation.id}/${anchor}`);
}
}
}
if (draft.uncertainties.length > 0) {
issues.push(`未確定事項あり: ${draft.uncertainties.join("、")}`);
}
if (issues.length > 0) {
return {
status: "handoff",
issues,
ownerId: brief.ownerId,
clientNotice: "内容を確認するため、担当者へ引き継ぎました。",
};
}
if (!humanApproved) {
return {
status: "await_human",
issues: [],
clientNotice: "返信内容を担当者が確認しています。",
};
}
return {
status: "send_ready",
issues: [],
};
}
このゲートで防げること
- AI利用への同意がない状態で処理を続けること
- モデレーションで要確認になったDMへの自動返信
- 回答義務IDを満たしたとAIが申告しても、本文に期限などがない状態
- 許可されていないメッセージを根拠として使うこと
- 未確定事項を残したまま送信すること
- 人の承認前に送信可能状態へ進むこと
このゲートだけでは防げないこと
3営業日以内という文字列が含まれていても、「3営業日以内ではありません」と書かれている可能性はあります。アンカー検査は意味検証ではなく、単純な脱落検知です。
したがって、金額、期限、契約条件を含む返信では、担当者が原文とブリーフを照合する必要があります。より厳密にする場合は、自由文を解析するのではなく、確認済みテンプレートのスロットへ値を埋める方式を検討します。
手順3:流暢でも不完全な返信を落とす
test/reply-gate.test.tsを作成します。
import assert from "node:assert/strict";
import test from "node:test";
import {
evaluateDraft,
type AiDraft,
type ReplyBrief,
} from "../src/reply-gate.js";
const brief: ReplyBrief = {
conversationId: "conversation-01",
originalMessageId: "message-100",
allowedSourceMessageIds: ["message-100", "policy-refund-v3"],
aiUseConsented: true,
moderation: "pass",
language: "ja",
ownerId: "support-team-a",
obligations: [
{
id: "cancelled",
description: "キャンセル受付済みと伝える",
anchors: ["キャンセル", "受付"],
},
{
id: "refund-window",
description: "返金処理の目安を伝える",
anchors: ["3営業日以内"],
},
{
id: "payment-provider",
description: "着金日は決済会社により異なると伝える",
anchors: ["決済会社", "異なります"],
},
],
};
function completeDraft(): AiDraft {
return {
language: "ja",
text:
"キャンセルを受け付けました。返金処理は3営業日以内に行います。実際の着金日は決済会社によって異なります。",
coveredObligationIds: [
"cancelled",
"refund-window",
"payment-provider",
],
sourceMessageIds: ["message-100", "policy-refund-v3"],
uncertainties: [],
};
}
test("完全な案でも、人の承認前は送信しない", () => {
const result = evaluateDraft(brief, completeDraft(), false);
assert.equal(result.status, "await_human");
});
test("期限が抜けた流暢な返信を引き継ぐ", () => {
const draft = completeDraft();
draft.text =
"キャンセルを受け付けました。返金手続きを進めます。実際の着金日は決済会社によって異なります。";
const result = evaluateDraft(brief, draft, true);
assert.equal(result.status, "handoff");
if (result.status === "handoff") {
assert.ok(
result.issues.includes("必須語句の欠落: refund-window/3営業日以内"),
);
}
});
test("AI利用への同意がなければ、人が承認しても送信しない", () => {
const result = evaluateDraft(
{ ...brief, aiUseConsented: false },
completeDraft(),
true,
);
assert.equal(result.status, "handoff");
});
test("モデレーションがreviewなら自動処理を止める", () => {
const result = evaluateDraft(
{ ...brief, moderation: "review" },
completeDraft(),
true,
);
assert.equal(result.status, "handoff");
});
test("条件を満たし、人が承認した場合だけ送信可能になる", () => {
const result = evaluateDraft(brief, completeDraft(), true);
assert.equal(result.status, "send_ready");
});
実行します。
npm test
すべて成功すれば、少なくとも次の境界を再現できています。
- AIの文章が自然でも、期限が抜けていれば送信できない
- AI処理への同意と担当者の送信承認は別々に必要
- モデレーション対象は返信生成の結果にかかわらず引き継ぐ
- 正常な返信案も、有人承認までは保留する
手順4:AIとTencent RTCのDMへ接続する
実システムでは、次の順序で既存のチャット処理へ接続します。
async function handleIncomingDm(message: {
conversationId: string;
messageId: string;
text: string;
}) {
// 1. 原文を正本として保存する
await saveCanonicalMessage(message);
// 2. 同意とモデレーション状態を確認する
const consent = await loadAiConsent(message.conversationId);
const moderation = await moderateBeforeAi(message.text);
// 3. AIへ渡してよい場合だけ返信ブリーフを作る
const brief = await buildReplyBrief({
message,
consent,
moderation,
});
if (!consent || moderation !== "pass") {
await createHumanHandoff(brief.ownerId, message.messageId);
await showApplicationNotice(
message.conversationId,
"内容を確認するため、担当者へ引き継ぎました。",
);
return;
}
// 4. AIは構造化された返信案だけを返す
const draft = await generateStructuredDraft(brief);
// 5. 送信承認前のため、ここでは必ず保留または引き継ぎになる
const decision = evaluateDraft(brief, draft, false);
await saveDraftForReview(brief, draft, decision);
if (decision.status !== "send_ready") {
await showApplicationNotice(
message.conversationId,
decision.clientNotice,
);
}
}
上記の関数名は構成を説明するためのアプリケーション側の例であり、Tencent RTC SDKのAPI名ではありません。実際のメッセージ受信と送信は、採用したプラットフォーム向けの公式SDK実装へ置き換えてください。
承認後も、保存済みの判定結果だけを信用せず、送信直前に再評価します。ブリーフや返信案が承認後に変更される可能性があるためです。
async function approveAndSend(
brief: ReplyBrief,
draft: AiDraft,
reviewerId: string,
) {
const decision = evaluateDraft(brief, draft, true);
if (decision.status !== "send_ready") {
throw new Error(`送信不可: ${decision.status}`);
}
await recordApproval({
conversationId: brief.conversationId,
originalMessageId: brief.originalMessageId,
reviewerId,
approvedText: draft.text,
});
// 既存のTencent RTCメッセージ送信処理をここから呼ぶ
await sendTextViaYourTencentRtcIntegration({
conversationId: brief.conversationId,
text: draft.text,
});
}
本番では、二重送信を防ぐためにoriginalMessageIdと承認対象の版を使った冪等キーも保存します。
翻訳は返信生成ではなく「表示」に閉じ込める
多言語DMでは、AIへ翻訳と返信判断を一度に任せたくなります。しかし、翻訳文だけを正本にすると、誤訳と回答漏れのどちらが原因なのか追えなくなります。
TUIChatには、テキストメッセージを利用者の操作に応じて翻訳する方法が案内されています。対応するコンテンツ形式、言語、エディション上の条件は公式ドキュメントで確認してください。
アプリケーション側では、原文と翻訳表示を分けて保持します。
type CanonicalMessage = {
messageId: string;
originalText: string;
originalLanguage: string;
};
type TranslationView = {
messageId: string;
locale: string;
translatedText: string;
createdAt: string;
};
TranslationViewは、新しいチャットメッセージとして再送信しません。AIへ渡す必要がある場合も、原文のIDと言語を保持し、担当者が原文へ戻れるようにします。
また、翻訳機能が利用できない形式や条件だった場合に備え、次のフォールバックを用意します。
- 原文をそのまま表示する
- 翻訳できなかったことを明示する
- 多言語対応可能な担当者へ引き継ぐ
- 未確認の機械翻訳を確定回答として送らない
確認方法:文章の印象ではなく境界条件を壊す
本番接続前に、次のチェックを行います。
同意とプライバシー
- AI利用への同意がないDM本文をモデルへ送らない
- 同意撤回後に新しい返信案を生成しない
- ログへDM全文を無条件に残さない
- AIサービスへ送るデータ範囲を説明できる
回答義務
-
期限を削除すると
handoffになる -
金額や単位を削除すると
handoffになる - AIが回答義務を満たしたと申告しても、本文のアンカーを別途検査する
- 未確定事項が1件でもあれば自動送信しない
モデレーションと有人対応
-
reviewとblockでAI生成または送信を止める - 担当者の所有者IDが必ず決まる
- 承認待ちであることを利用者に表示する
- 引き継ぎ通知はAIに自由生成させず、アプリ管理の固定文言にする
配送
- 承認前にTencent RTCの送信処理が呼ばれない
- 承認後に本文を変更した場合は再承認を要求する
- 再試行しても同じ返信を二重送信しない
- 送信失敗を「承認失敗」と混同しない
翻訳
- 原文を翻訳文で上書きしない
- 翻訳表示を新しい受信メッセージとして処理しない
- 翻訳できない場合に原文へ戻れる
- 公式ドキュメントで対応形式、言語、利用条件を確認する
注意点とトレードオフ
1. アンカーを増やしすぎると文章が不自然になる
必須語句を細かく固定すると、AIを使う意味が薄れます。アンカーは期限、金額、単位、否定条件など、脱落時の影響が大きい情報へ絞ります。
2. AIの自己申告は証拠ではない
coveredObligationIdsは、AIがどの義務を扱ったつもりかを追跡するための補助情報です。申告があるだけで合格にせず、本文検査と有人確認を組み合わせます。
3. 人の承認は万能ではない
承認画面に原文、ブリーフ、返信案を並べて表示しないと、担当者も自然な文章だけを見て承認してしまいます。画面上の優先順位は次の順が適切です。
- 未達の回答義務
- 未確定事項とモデレーション状態
- 原文と根拠
- AIの返信案
- 承認ボタン
4. AIを使わない経路も第一級の機能にする
同意がない、内容が曖昧、センシティブである、といったケースは異常系ではありません。最初から有人返信へ進める経路を、AI失敗時の暫定対応ではなく正式なワークフローとして実装します。
まとめ
DM返信で見るべきなのは、AIらしい表現が残っているかではなく、返信に必要な事実と条件が残っているかです。
- AIには返信ブリーフから下書きを作らせる
- 必須条件は人または業務ルールが定義する
- 同意、モデレーション、回答義務、有人承認を別々に検査する
- 引き継ぎ状態を利用者から見えるようにする
- 翻訳は原文を置き換えず、表示状態として扱う
この境界を作っておけば、モデルや文体が変わっても、送信可否の判断基準をアプリケーション側に残せます。
関係性の開示: 筆者はTencent RTCに関係する立場で本記事を作成しています。実装上の事実確認にはTencent RTCの公式ドキュメントを参照しました。