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
Last updated at Posted at 2026-08-13

多言語チャットにAI翻訳を入れると、デモでは会話が滑らかになります。しかし運用を始めると、別の緊張が生まれます。

  • 表示中の文章は、送信者の原文なのか翻訳結果なのか
  • 不適切表現の判定は、原文と誤訳のどちらに基づいたのか
  • AIが作った返信を、誰が送信すると決めたのか
  • 問題が起きたとき、担当者は原文まで確認できるのか

必要なのは翻訳精度への期待だけではありません。原文、翻訳、AI下書き、人間の判断を別の状態として残すことです。

この記事では、Tencent RTCのSocial Messagingを利用するチャットを想定し、次の最小構成をTypeScriptで再現します。

  1. 受信した原文を不変データとして保存する
  2. 翻訳はユーザー操作によるオンデマンド表示にする
  3. 翻訳結果から直接メッセージを送信できないようにする
  4. AIは返信候補の作成までに限定する
  5. モデレーションで判断できない場合は原文付きで人へ渡す

結論

実装上の要点は、1件のメッセージを単一の text として扱わないことです。

送信者が入力した原文
  ├─ 閲覧者ごとの翻訳表示
  ├─ モデレーション結果
  ├─ AI返信の下書き
  └─ 有人確認チケット

翻訳結果は原文の更新版ではなく、特定の閲覧者が要求した「派生表示」です。AI返信も送信予定メッセージではなく、承認または破棄できる下書きです。

この分離によって、モデルや翻訳結果が変わっても次の問いに答えられます。

  • 実際に送信された文章は何か
  • 誰が翻訳を要求したか
  • どの文章を根拠に制限したか
  • 最終返信を決定したのは誰か

前提

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

TUIChatには、テキストメッセージを必要に応じて翻訳するための公式ガイドがあります。

対応コンテンツ、言語、エディション上の条件は変更される可能性があるため、導入時には上記ドキュメントで対象環境を確認してください。本稿では、存在が確認できないSDK API名を仮定せず、公式手順との接続部分をアダプターとして分離します。

検証用コードはNode.js 20以降とTypeScriptを使います。

mkdir explainable-multilingual-chat
cd explainable-multilingual-chat
npm init -y
npm install -D typescript tsx vitest @types/node
npx tsc --init
mkdir -p src test

package.jsonへテストコマンドを追加します。

{
  "scripts": {
    "test": "vitest run",
    "dev": "tsx src/example.ts"
  }
}

最初に決める判断基準

翻訳、AI、モデレーションは、同じ「文章を処理する機能」でも権限が異なります。

利用目的 自動処理してよい範囲 人が判断する場面
受信文を自分の言語で読む ユーザーが要求した翻訳を表示 固有名詞や重要条件が曖昧な場合
返信文を作る AIが未送信の候補を作る 送信、約束、返金、制裁などの決定
不適切表現を検出する 審査候補と根拠を記録する 原文と翻訳で判定が食い違う場合
問い合わせを引き継ぐ 会話IDと対象メッセージをまとめる 対応内容と最終返信の決定

重要なのは、翻訳を「読むための補助」として扱うことです。翻訳文だけを根拠にアカウント制限や通報確定を行う設計は避けます。

手順1:原文を変更できないデータモデルにする

src/model.tsを作成します。

export type MessageRecord = Readonly<{
  id: string;
  conversationId: string;
  senderId: string;
  sentAt: string;
  original: Readonly<{
    text: string;
    declaredLocale?: string;
  }>;
}>;

export type TranslationView = Readonly<{
  messageId: string;
  viewerId: string;
  targetLocale: string;
  translatedText: string;
  requestedAt: string;
  status: "translated" | "failed";
}>;

export type ModerationReview = Readonly<{
  messageId: string;
  basis: "original" | "translation-assisted";
  decision: "allow" | "review" | "restrict";
  reasonCodes: readonly string[];
  decidedBy: "rule" | "model" | "human";
  reviewedAt: string;
}>;

originalの中に翻訳文を入れず、TranslationViewを別レコードにしています。同じメッセージでも、閲覧者と対象言語が違えば別の翻訳表示になるためです。

データベース上でも、次のように保存先を分けます。

messages/{messageId}
translationViews/{messageId}:{viewerId}:{targetLocale}
moderationReviews/{messageId}:{reviewId}
replyDrafts/{draftId}
handoffTickets/{ticketId}

原文の訂正機能が必要な場合も、既存レコードを上書きするのではなく、編集履歴または新しいバージョンを残す方が監査しやすくなります。

手順2:翻訳を明示的な操作にする

TUIChatの翻訳機能を組み込む画面では、少なくとも次の表示を分けます。

[送信者の原文]
Can you ship it by Friday?

[日本語に翻訳]  ← ユーザーが押す
金曜日までに発送できますか?
翻訳結果・原文を確認

自動翻訳を既定にすると、ユーザーが翻訳文を原文だと認識しやすくなります。少なくとも初回は操作を要求し、翻訳後も原文へ戻れる導線を残します。

公式SDKとの接続点を、アプリケーションロジックから隔離します。

src/translation.ts:

import type { MessageRecord, TranslationView } from "./model.js";

export interface TranslationAdapter {
  translate(input: {
    messageId: string;
    originalText: string;
    targetLocale: string;
  }): Promise<{ translatedText: string }>;
}

export async function requestTranslation(
  message: MessageRecord,
  viewerId: string,
  targetLocale: string,
  adapter: TranslationAdapter,
): Promise<TranslationView> {
  if (!viewerId || !targetLocale) {
    throw new Error("viewerId and targetLocale are required");
  }

  try {
    const result = await adapter.translate({
      messageId: message.id,
      originalText: message.original.text,
      targetLocale,
    });

    return Object.freeze({
      messageId: message.id,
      viewerId,
      targetLocale,
      translatedText: result.translatedText,
      requestedAt: new Date().toISOString(),
      status: "translated" as const,
    });
  } catch {
    return Object.freeze({
      messageId: message.id,
      viewerId,
      targetLocale,
      translatedText: "",
      requestedAt: new Date().toISOString(),
      status: "failed" as const,
    });
  }
}

TranslationAdapterの実装部分を、公式ドキュメントに従ったTUIChatの翻訳処理へ接続します。こうしておけば、UIコンポーネントやSDKの具体的な呼び出し方法が変わっても、原文固定というドメインルールは影響を受けません。

翻訳に失敗した場合は、原文を消さずに「翻訳できませんでした」と表示します。空文字や前回の翻訳を成功結果として再利用しないことも重要です。

手順3:AI返信を送信可能な本文から分離する

AIが自然な返信を作れても、そのまま送信してよいとは限りません。特に多言語チャットでは、AIが翻訳結果の曖昧さを断定的に補ってしまう可能性があります。

src/draft.ts:

export type ReplyDraft = Readonly<{
  id: string;
  conversationId: string;
  sourceMessageId: string;
  text: string;
  locale: string;
  createdBy: "ai" | "human";
  status: "pending" | "approved" | "rejected";
  warnings: readonly string[];
}>;

export function createAiDraft(input: {
  id: string;
  conversationId: string;
  sourceMessageId: string;
  text: string;
  locale: string;
  translationWasUsed: boolean;
}): ReplyDraft {
  return Object.freeze({
    id: input.id,
    conversationId: input.conversationId,
    sourceMessageId: input.sourceMessageId,
    text: input.text,
    locale: input.locale,
    createdBy: "ai",
    status: "pending",
    warnings: input.translationWasUsed
      ? ["翻訳結果を参照した下書きです。送信前に原文を確認してください。"]
      : [],
  });
}

export function approveDraft(
  draft: ReplyDraft,
  approverId: string,
): ReplyDraft & { approvedBy: string } {
  if (!approverId) throw new Error("approverId is required");
  if (draft.status !== "pending") {
    throw new Error(`draft is already ${draft.status}`);
  }

  return Object.freeze({
    ...draft,
    status: "approved" as const,
    approvedBy: approverId,
  });
}

送信処理は、status === "approved"の下書きだけを受け付けるようにします。

export function buildSendCommand(
  draft: ReplyDraft & { approvedBy?: string },
) {
  if (draft.status !== "approved" || !draft.approvedBy) {
    throw new Error("unapproved draft cannot be sent");
  }

  return {
    conversationId: draft.conversationId,
    text: draft.text,
    metadata: {
      draftId: draft.id,
      approvedBy: draft.approvedBy,
    },
  };
}

AIに任せているのは、文章候補の生成までです。「この内容で相手に約束してよいか」「このユーザーを制限してよいか」は、人間または明示的な業務ルールの責任として残します。

手順4:モデレーションの根拠を残す

翻訳文はモデレーションの補助にはなりますが、原文そのものではありません。そこで、判定に使った対象を記録します。

import type { ModerationReview } from "./model.js";

export function recordAutomatedReview(input: {
  messageId: string;
  usedTranslation: boolean;
  reasonCodes: string[];
}): ModerationReview {
  return Object.freeze({
    messageId: input.messageId,
    basis: input.usedTranslation
      ? "translation-assisted"
      : "original",
    decision: input.usedTranslation ? "review" : "allow",
    reasonCodes: input.reasonCodes,
    decidedBy: "model",
    reviewedAt: new Date().toISOString(),
  });
}

この例では、翻訳を利用した自動判定だけでは即時制限に進まず、reviewへ送っています。実際の制限条件はサービスのポリシーに合わせて決める必要がありますが、少なくとも次のケースは有人確認へ回すのが安全です。

  • 原文の言語を判定できない
  • 固有名詞、俗語、皮肉が含まれる
  • 原文と翻訳で危険度が変わる
  • アカウント停止など影響の大きい処置につながる
  • AIやルールが根拠コードを返せない

手順5:原文付きの有人引き継ぎを作る

「担当者に確認します」というメッセージだけでは、引き継ぎは完了していません。担当者が原文、参考翻訳、判定理由へ到達できるデータを作ります。

src/handoff.ts:

import type {
  MessageRecord,
  ModerationReview,
  TranslationView,
} from "./model.js";

export type HandoffTicket = Readonly<{
  id: string;
  conversationId: string;
  sourceMessageId: string;
  originalText: string;
  referenceTranslation?: {
    locale: string;
    text: string;
  };
  reasonCodes: readonly string[];
  status: "waiting_human" | "resolved";
  createdAt: string;
}>;

export function createHandoff(input: {
  id: string;
  message: MessageRecord;
  translation?: TranslationView;
  review: ModerationReview;
}): HandoffTicket {
  return Object.freeze({
    id: input.id,
    conversationId: input.message.conversationId,
    sourceMessageId: input.message.id,
    originalText: input.message.original.text,
    referenceTranslation:
      input.translation?.status === "translated"
        ? {
            locale: input.translation.targetLocale,
            text: input.translation.translatedText,
          }
        : undefined,
    reasonCodes: input.review.reasonCodes,
    status: "waiting_human",
    createdAt: new Date().toISOString(),
  });
}

管理画面では、翻訳文より先に原文を表示します。

要確認:翻訳を利用した自動判定

原文:
Can you ship it by Friday?

参考翻訳:
金曜日までに発送できますか?

判定理由:
- delivery_commitment

[返信を作成] [問題なし] [専門担当へ移管]

ユーザー側にも、「AIが対応中」と曖昧に表示するのではなく、「担当者による確認待ち」など現在の状態を見せます。

確認方法

test/chat.test.tsを作成します。

import { describe, expect, it } from "vitest";
import { requestTranslation } from "../src/translation.js";
import { approveDraft, createAiDraft } from "../src/draft.js";
import { createHandoff } from "../src/handoff.js";
import type { MessageRecord, ModerationReview } from "../src/model.js";

const message: MessageRecord = Object.freeze({
  id: "m-1",
  conversationId: "c-1",
  senderId: "u-1",
  sentAt: "2026-08-13T00:00:00.000Z",
  original: Object.freeze({
    text: "Can you ship it by Friday?",
    declaredLocale: "en",
  }),
});

describe("multilingual chat boundaries", () => {
  it("翻訳しても原文を変更しない", async () => {
    const view = await requestTranslation(
      message,
      "viewer-1",
      "ja",
      {
        translate: async () => ({
          translatedText: "金曜日までに発送できますか?",
        }),
      },
    );

    expect(message.original.text).toBe("Can you ship it by Friday?");
    expect(view.translatedText).toBe("金曜日までに発送できますか?");
    expect(view.viewerId).toBe("viewer-1");
  });

  it("翻訳失敗時も原文を残す", async () => {
    const view = await requestTranslation(message, "viewer-1", "ja", {
      translate: async () => {
        throw new Error("translation unavailable");
      },
    });

    expect(view.status).toBe("failed");
    expect(message.original.text).not.toBe("");
  });

  it("AI下書きには翻訳利用の警告を付ける", () => {
    const draft = createAiDraft({
      id: "d-1",
      conversationId: "c-1",
      sourceMessageId: "m-1",
      text: "確認して折り返します。",
      locale: "ja",
      translationWasUsed: true,
    });

    expect(draft.status).toBe("pending");
    expect(draft.warnings).toHaveLength(1);
  });

  it("承認者を記録する", () => {
    const draft = createAiDraft({
      id: "d-2",
      conversationId: "c-1",
      sourceMessageId: "m-1",
      text: "担当者が確認します。",
      locale: "ja",
      translationWasUsed: false,
    });

    const approved = approveDraft(draft, "operator-1");
    expect(approved.status).toBe("approved");
    expect(approved.approvedBy).toBe("operator-1");
  });

  it("有人引き継ぎには必ず原文を含める", () => {
    const review: ModerationReview = {
      messageId: "m-1",
      basis: "translation-assisted",
      decision: "review",
      reasonCodes: ["ambiguous_translation"],
      decidedBy: "model",
      reviewedAt: "2026-08-13T00:01:00.000Z",
    };

    const ticket = createHandoff({
      id: "h-1",
      message,
      review,
    });

    expect(ticket.originalText).toBe("Can you ship it by Friday?");
    expect(ticket.status).toBe("waiting_human");
  });
});

実行します。

npm test

SDK接続後は、単体テストに加えて実機で次を確認します。

検証チェックリスト

  • 翻訳前に原文が表示される
  • 翻訳操作をした閲覧者だけに翻訳結果が表示される
  • 翻訳後もワンタップで原文へ戻れる
  • 翻訳失敗時に原文が消えない
  • AI作成文に「AI下書き」などの表示がある
  • 未承認のAI下書きを送信APIへ渡せない
  • 翻訳ベースのモデレーション結果に根拠区分が残る
  • 有人担当者が原文と参考翻訳を区別できる
  • ユーザーが有人確認中であることを認識できる
  • 対応外のコンテンツ形式や言語で安全に失敗する

注意点とトレードオフ

1. 翻訳履歴を保存すると説明しやすいが、保管対象が増える

翻訳結果を保存すれば、後から「当時どの表示を見たか」を確認できます。一方で、保存期間、削除要求、アクセス権限を設計する必要があります。監査が不要なら短期キャッシュに限定する選択肢もあります。

2. オンデマンド翻訳は一手間増える

自動翻訳より操作数は増えます。しかし、原文と翻訳の区別をユーザー自身が認識しやすくなります。頻繁に利用するユーザー向けに設定を追加する場合も、常に原文を確認できるUIは残します。

3. AI下書きは返信を補助できるが、責任者にはならない

AIが文章を自然に整えられることと、契約、制裁、安全に関する判断が正しいことは別です。モデル性能の比較より先に、誰が送信を承認するか、どの条件で有人対応へ移すかを決める必要があります。

4. 翻訳文だけで検索・公開用コンテンツを作らない

チャット翻訳は会話理解の補助です。固有名詞、商品名、地域名、ニュアンスが重要な公開コンテンツへ転用する場合は、原文との突合と編集者による確認を別工程にします。

5. 対応条件を実装前に確認する

TUIChatメッセージ翻訳には、対応するコンテンツ、言語、利用条件があります。対象外ケースを推測で補完せず、公式ドキュメントを確認したうえで、翻訳ボタンの表示条件と失敗時UIを決めてください。

まとめ

多言語チャットでAIを信頼できる形にするには、「高精度だから自動化する」ではなく、処理の由来と決定権を見えるようにします。

  • 原文は不変データとして残す
  • 翻訳は閲覧者が要求する派生表示にする
  • AI返信は未送信の下書きとして扱う
  • モデレーションには判断根拠を記録する
  • 曖昧なケースは原文付きで人へ渡す

Tencent RTCのSocial MessagingとTUIChat翻訳をチャット基盤として使いつつ、この境界をアプリケーション側で保持すれば、翻訳エンジンや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?