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?

Tencent RTCのDMで、AIっぽさより「回答漏れ」を検査する:返信ブリーフと有人承認をTypeScriptで作る

0
Last updated at Posted at 2026-11-02

AIにDMの返信案を書かせると、文章を整える時間は減らせます。一方で、読みやすい返信ほど「必要な条件が全部書かれている」と錯覚しやすくなります。

ここで気にすべきなのは、文章がAIっぽいかどうかではありません。

  • 返金までの日数が抜けていないか
  • 確認できていないことを断定していないか
  • AI利用への同意がないDMをモデルへ渡していないか
  • モデレーション対象を自動返信していないか
  • 人が確認中であることを相手に見せられるか

文体の癖を禁止するだけでは、必要な情報まで短くなることがあります。そこで本記事では、AIの文章を直接採点するのではなく、返信前に満たすべき「回答義務」を人が定義し、機械的なゲートと有人承認で送信可否を決める構成を作ります。

結論:AIの文章ではなく、返信ブリーフとの差分を検査する

今回の完成形は次のとおりです。

  1. DMの原文を正本として保存する
  2. 同意とモデレーション結果を確認する
  3. 人または業務ルールが返信ブリーフを作る
  4. AIはブリーフをもとに返信案を生成する
  5. TypeScriptのゲートが回答義務、根拠、未確定事項を検査する
  6. 担当者が承認した場合だけ送信可能にする
  7. 承認待ちや差し戻しは、固定文言で相手に見せる

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と言語を保持し、担当者が原文へ戻れるようにします。

また、翻訳機能が利用できない形式や条件だった場合に備え、次のフォールバックを用意します。

  1. 原文をそのまま表示する
  2. 翻訳できなかったことを明示する
  3. 多言語対応可能な担当者へ引き継ぐ
  4. 未確認の機械翻訳を確定回答として送らない

確認方法:文章の印象ではなく境界条件を壊す

本番接続前に、次のチェックを行います。

同意とプライバシー

  • AI利用への同意がないDM本文をモデルへ送らない
  • 同意撤回後に新しい返信案を生成しない
  • ログへDM全文を無条件に残さない
  • AIサービスへ送るデータ範囲を説明できる

回答義務

  • 期限を削除するとhandoffになる
  • 金額や単位を削除するとhandoffになる
  • AIが回答義務を満たしたと申告しても、本文のアンカーを別途検査する
  • 未確定事項が1件でもあれば自動送信しない

モデレーションと有人対応

  • reviewとblockでAI生成または送信を止める
  • 担当者の所有者IDが必ず決まる
  • 承認待ちであることを利用者に表示する
  • 引き継ぎ通知はAIに自由生成させず、アプリ管理の固定文言にする

配送

  • 承認前にTencent RTCの送信処理が呼ばれない
  • 承認後に本文を変更した場合は再承認を要求する
  • 再試行しても同じ返信を二重送信しない
  • 送信失敗を「承認失敗」と混同しない

翻訳

  • 原文を翻訳文で上書きしない
  • 翻訳表示を新しい受信メッセージとして処理しない
  • 翻訳できない場合に原文へ戻れる
  • 公式ドキュメントで対応形式、言語、利用条件を確認する

注意点とトレードオフ

1. アンカーを増やしすぎると文章が不自然になる

必須語句を細かく固定すると、AIを使う意味が薄れます。アンカーは期限、金額、単位、否定条件など、脱落時の影響が大きい情報へ絞ります。

2. AIの自己申告は証拠ではない

coveredObligationIdsは、AIがどの義務を扱ったつもりかを追跡するための補助情報です。申告があるだけで合格にせず、本文検査と有人確認を組み合わせます。

3. 人の承認は万能ではない

承認画面に原文、ブリーフ、返信案を並べて表示しないと、担当者も自然な文章だけを見て承認してしまいます。画面上の優先順位は次の順が適切です。

  1. 未達の回答義務
  2. 未確定事項とモデレーション状態
  3. 原文と根拠
  4. AIの返信案
  5. 承認ボタン

4. AIを使わない経路も第一級の機能にする

同意がない、内容が曖昧、センシティブである、といったケースは異常系ではありません。最初から有人返信へ進める経路を、AI失敗時の暫定対応ではなく正式なワークフローとして実装します。

まとめ

DM返信で見るべきなのは、AIらしい表現が残っているかではなく、返信に必要な事実と条件が残っているかです。

  • 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?