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?

チャット翻訳をデモで終わらせない:編集・連打・誤訳に耐える受理条件をTypeScriptで作る

0
Last updated at Posted at 2026-10-03

翻訳できることと、翻訳を信頼できることは別

チャット画面に「翻訳」ボタンを追加し、別言語の文章が表示されればデモは成立します。しかし運用では、次のような時間差が発生します。

  • 翻訳中に送信者がメッセージを編集した
  • 利用者が翻訳ボタンを連打し、古い結果が後から届いた
  • 翻訳対象のメッセージが削除された
  • 翻訳文だけを見たモデレーターが、原文とは異なる意図で判断した
  • 誤訳報告用に別フォームを作ったが、担当者も回答期限も決まっていない

ここで必要なのは、翻訳モデルを高度にすることよりも、どの原文に対する結果なら画面へ採用してよいかを決定論的に判定することです。

この記事では、Tencent RTCのSocial MessagingシナリオでTUIChatのメッセージ翻訳を利用する前提で、翻訳結果の受理条件と有人確認への引き継ぎをTypeScriptで実装します。

結論:翻訳結果は「メッセージID」だけで受理しない

翻訳要求時に、少なくとも以下を固定します。

messageKey + revision + sourceHash + targetLanguage + requestId

結果が返った時点で、現在のメッセージとこれらを照合します。1つでも一致しなければ、その結果は画面へ反映しません。

また、翻訳文は原文の置き換えではなく派生表示として扱います。

原文 ───────────────┐
                    ├─ 翻訳表示
                    ├─ 誤訳報告
                    └─ モデレーション・有人確認

これにより、翻訳の品質問題と、非同期処理による取り違えを別々に調査できます。

前提:TUIChatが担う範囲とアプリ側が担う範囲

TUIChatの公式ガイドでは、オンデマンドのテキストメッセージ翻訳について、対象コンテンツ、対応言語、エディション上の条件を含む導入方法が案内されています。

対応範囲や利用条件は更新される可能性があるため、実装前に利用中のプラットフォームとバージョンについて公式ドキュメントを確認してください。

この記事では、公式手順で翻訳機能を組み込む部分と、その前後に置くアプリ固有の制御を分離します。

層 主な責務
TUIChat/Social Messaging メッセージングUI、1対1・グループなどのチャット体験、公式手順に沿った翻訳機能の組み込み
アプリの翻訳コーディネーター 同意確認、重複要求の制御、編集・削除後の古い結果の破棄
運用バックエンド 誤訳報告の担当割当、状態管理、監査に必要な最小限の記録
人間の担当者 文脈依存表現、固有名詞、安全上重要な内容の最終判断

以下に登場するTranslationPortなどは、説明用に定義するアプリ独自のインターフェースです。Tencent RTC SDKのAPI名ではありません。実際の接続では、上記公式ガイドに記載された利用中プラットフォーム向けの手順へ置き換えます。

先に固定する受理ルール

実装前に、結果を採用する条件を文章にします。

  1. 利用者が対象メッセージの翻訳を明示的に要求している
  2. 対象メッセージが現在も存在する
  3. 翻訳要求時と現在のrevisionが一致する
  4. 翻訳要求時と現在の原文ハッシュが一致する
  5. 同じメッセージ・同じ対象言語に対する最新要求である
  6. 翻訳文を表示しても、原文へ戻れる
  7. 翻訳文をモデレーション判断の唯一の根拠にしない

revisionとsourceHashを両方見るのは、防御を二重化するためです。編集イベントの取り扱いに不具合があっても、本文が変わっていればハッシュ差分で止められます。

手順1:検証用プロジェクトを作る

Node.js 20以降を前提にします。

mkdir chat-translation-guard
cd chat-translation-guard
npm init -y
npm install --save-dev typescript tsx @types/node

次のコードをtranslation.tsとして保存します。

コード:古い翻訳を採用しないコーディネーター

import assert from "node:assert/strict";
import { createHash, randomUUID } from "node:crypto";

type Message = {
  messageKey: string;
  revision: number;
  text: string;
  deleted: boolean;
};

type TranslationStatus =
  | { kind: "idle" }
  | { kind: "translating"; requestId: string }
  | { kind: "ready"; translatedText: string }
  | { kind: "error"; reason: string };

type TranslationOutcome =
  | { kind: "ready"; translatedText: string; fromCache: boolean }
  | { kind: "stale"; reason: string }
  | { kind: "blocked"; reason: string }
  | { kind: "error"; reason: string };

type TranslateInput = {
  text: string;
  targetLanguage: string;
};

// アプリ独自の境界。特定SDKのAPI名ではない。
interface TranslationPort {
  translate(input: TranslateInput): Promise<string>;
}

function hashText(text: string): string {
  return createHash("sha256").update(text, "utf8").digest("hex");
}

function wait(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

class TranslationCoordinator {
  private readonly messages = new Map<string, Message>();
  private readonly latestRequests = new Map<string, string>();
  private readonly cache = new Map<string, string>();
  private readonly statuses = new Map<string, TranslationStatus>();

  constructor(private readonly port: TranslationPort) {}

  upsertMessage(message: Omit<Message, "deleted">): void {
    this.messages.set(message.messageKey, {
      ...message,
      deleted: false,
    });
  }

  deleteMessage(messageKey: string): void {
    const current = this.messages.get(messageKey);
    if (!current) return;

    this.messages.set(messageKey, {
      ...current,
      deleted: true,
    });
  }

  getStatus(messageKey: string, targetLanguage: string): TranslationStatus {
    return this.statuses.get(`${messageKey}:${targetLanguage}`)
      ?? { kind: "idle" };
  }

  async requestTranslation(input: {
    messageKey: string;
    targetLanguage: string;
    consented: boolean;
  }): Promise<TranslationOutcome> {
    if (!input.consented) {
      return {
        kind: "blocked",
        reason: "利用者が翻訳を要求していません",
      };
    }

    const message = this.messages.get(input.messageKey);
    if (!message || message.deleted) {
      return {
        kind: "blocked",
        reason: "対象メッセージが存在しないか、削除されています",
      };
    }

    const snapshot = {
      messageKey: message.messageKey,
      revision: message.revision,
      sourceHash: hashText(message.text),
      text: message.text,
      targetLanguage: input.targetLanguage,
    };

    const slotKey = `${snapshot.messageKey}:${snapshot.targetLanguage}`;
    const cacheKey = `${snapshot.sourceHash}:${snapshot.targetLanguage}`;
    const cached = this.cache.get(cacheKey);

    if (cached !== undefined) {
      this.statuses.set(slotKey, {
        kind: "ready",
        translatedText: cached,
      });
      return {
        kind: "ready",
        translatedText: cached,
        fromCache: true,
      };
    }

    const requestId = randomUUID();
    this.latestRequests.set(slotKey, requestId);
    this.statuses.set(slotKey, { kind: "translating", requestId });

    try {
      const translatedText = await this.port.translate({
        text: snapshot.text,
        targetLanguage: snapshot.targetLanguage,
      });

      const current = this.messages.get(snapshot.messageKey);
      const latestRequestId = this.latestRequests.get(slotKey);

      if (!current || current.deleted) {
        return { kind: "stale", reason: "翻訳中にメッセージが削除されました" };
      }

      if (latestRequestId !== requestId) {
        return { kind: "stale", reason: "より新しい翻訳要求があります" };
      }

      if (current.revision !== snapshot.revision) {
        return { kind: "stale", reason: "翻訳中にrevisionが変わりました" };
      }

      if (hashText(current.text) !== snapshot.sourceHash) {
        return { kind: "stale", reason: "翻訳中に原文が変わりました" };
      }

      this.cache.set(cacheKey, translatedText);
      this.statuses.set(slotKey, {
        kind: "ready",
        translatedText,
      });

      return {
        kind: "ready",
        translatedText,
        fromCache: false,
      };
    } catch (error) {
      // 古い要求の失敗で、新しい要求の表示を上書きしない。
      if (this.latestRequests.get(slotKey) === requestId) {
        this.statuses.set(slotKey, {
          kind: "error",
          reason: error instanceof Error ? error.message : "unknown error",
        });
      }

      return {
        kind: "error",
        reason: error instanceof Error ? error.message : "unknown error",
      };
    }
  }
}

// 再現テスト用。実運用では公式手順に沿った翻訳処理へ差し替える。
class DelayedFakeTranslationPort implements TranslationPort {
  async translate(input: TranslateInput): Promise<string> {
    if (input.text.includes("旧")) {
      await wait(50);
    } else {
      await wait(5);
    }

    return `[${input.targetLanguage}] ${input.text}`;
  }
}

async function main(): Promise<void> {
  const coordinator = new TranslationCoordinator(
    new DelayedFakeTranslationPort(),
  );

  // 古い原文の翻訳を開始する。
  coordinator.upsertMessage({
    messageKey: "message-1",
    revision: 1,
    text: "旧テキストです",
  });

  const oldRequest = coordinator.requestTranslation({
    messageKey: "message-1",
    targetLanguage: "en",
    consented: true,
  });

  // 翻訳完了前に編集し、新しい翻訳を要求する。
  await wait(1);
  coordinator.upsertMessage({
    messageKey: "message-1",
    revision: 2,
    text: "新しいテキストです",
  });

  const newRequest = coordinator.requestTranslation({
    messageKey: "message-1",
    targetLanguage: "en",
    consented: true,
  });

  const [oldResult, newResult] = await Promise.all([
    oldRequest,
    newRequest,
  ]);

  assert.equal(oldResult.kind, "stale");
  assert.equal(newResult.kind, "ready");

  // 翻訳中に削除されたメッセージも採用しない。
  coordinator.upsertMessage({
    messageKey: "message-2",
    revision: 1,
    text: "旧メッセージ",
  });

  const deletedRequest = coordinator.requestTranslation({
    messageKey: "message-2",
    targetLanguage: "en",
    consented: true,
  });

  coordinator.deleteMessage("message-2");
  const deletedResult = await deletedRequest;
  assert.equal(deletedResult.kind, "stale");

  // 明示要求がない場合は翻訳処理を開始しない。
  const withoutConsent = await coordinator.requestTranslation({
    messageKey: "message-1",
    targetLanguage: "en",
    consented: false,
  });
  assert.equal(withoutConsent.kind, "blocked");

  console.log({ oldResult, newResult, deletedResult, withoutConsent });
  console.log("all checks passed");
}

void main();

実行します。

npx tsx translation.ts

最後にall checks passedと表示されれば、少なくとも次の条件を再現できています。

  • 編集前の遅い翻訳が、編集後の速い翻訳を上書きしない
  • 削除済みメッセージの翻訳を採用しない
  • 利用者の明示操作なしに翻訳を開始しない

手順2:TUIChatへ接続する位置を限定する

実際のアプリでは、翻訳ボタンから直接翻訳処理を呼ぶのではなく、コーディネーターを1段挟みます。

TUIChat上の翻訳操作
  ↓
現在のmessageKey・revision・原文を取得
  ↓
TranslationCoordinator.requestTranslation()
  ↓
公式ドキュメントに沿った翻訳処理
  ↓
受理条件を再確認
  ↓
原文の下に翻訳文を表示

組み込み時のポイントは3つです。

1. revisionはアプリ側で管理する

メッセージの作成時を1とし、編集を受け取るたびに増やします。利用中の構成で編集を提供しない場合でも、本文更新やメッセージ差し替えを扱う独自処理があれば同じ規則を適用します。

2. SDK固有データをコーディネーターへ漏らしすぎない

コーディネーターへ必要なのは、一意に扱えるキー、原文、revision、対象言語です。UIコンポーネント全体やSDKのメッセージオブジェクトをそのまま渡すと、単体テストが難しくなります。

3. 翻訳結果は原文と併記する

翻訳文だけに置き換えると、誤訳を報告する利用者や確認担当者が比較できません。最低限、次の操作を用意します。

  • 原文を表示する
  • 翻訳を再実行する
  • 翻訳を閉じる
  • 誤訳または要確認として報告する

手順3:誤訳報告を「別の誰も見ない窓口」にしない

報告ボタンを追加するだけでは、運用上の引き継ぎになりません。担当チーム、状態、次の行動を持つデータとして保存します。

type TranslationReviewCase = {
  caseId: string;
  messageKey: string;
  messageRevision: number;
  sourceHash: string;
  targetLanguage: string;
  reason: "wrong_meaning" | "unsafe_ambiguity" | "proper_noun" | "other";
  reporterUserId: string;
  assignedTeam: "community-ops" | "trust-and-safety";
  status: "open" | "reviewing" | "resolved" | "rejected";
  includeMessageText: boolean;
  createdAt: string;
};

本文を運用バックエンドへ保存するかは、報告時に説明して同意を取る設計にします。includeMessageTextがfalseなら、識別子とハッシュだけを送り、担当者が権限のある既存画面から確認する構成を検討できます。

重要なのは、新しいチャットチャンネルを作ることではありません。既存の運営キューへ流し、次を決めます。

  • openを誰が所有するか
  • 安全上の問題をどのチームへ振り分けるか
  • 解決時に報告者へ何を表示するか
  • 原文が削除された場合にケースをどう閉じるか

AIや翻訳へ任せる範囲を決める

翻訳機能が示せるのは、対象テキストを別言語で理解しやすくする能力です。次の判断まで正しいと保証するものではありません。

用途 翻訳結果の扱い
日常会話の理解補助 原文へ戻れる状態で表示する
固有名詞や専門用語 利用者が誤訳を報告できるようにする
規約違反の判定 原文と翻訳を併記し、翻訳だけで確定しない
脅迫、自傷、金銭など安全上重要な文脈 権限を持つ人へ明示的に引き継ぐ
自動返信の生成 翻訳文を未検証の入力として扱い、送信前に利用者が確認する

翻訳やLLMを導入すると「人が読まなくてもよくなる」と考えがちですが、実際に減らせるのは初期理解の負担です。曖昧な表現をどの意味として採用するか、処分や送信を実行するかは別の判断です。

確認方法:正常系より時間差をテストする

実機またはステージング環境では、次の順番で確認します。

非同期処理

  • 翻訳ボタンを連打しても最後の要求だけが表示される
  • 翻訳中に編集すると古い結果が破棄される
  • 翻訳中に削除すると結果が表示されない
  • 画面を閉じた後に届いた結果が別メッセージへ付かない
  • 失敗した古い要求が、成功した新しい表示をエラーへ戻さない

UIと同意

  • 翻訳前に対象言語が利用者から確認できる
  • 翻訳後も原文を表示できる
  • 翻訳を閉じても原文は変化しない
  • 対応外のコンテンツや言語を、成功したように見せない
  • 誤訳報告で送信される情報を確認できる

運用

  • 誤訳報告に担当チームが設定される
  • 同じ報告の重複登録を検出できる
  • 原文削除後の扱いが決まっている
  • モデレーターが原文と翻訳文を区別できる
  • 翻訳結果を理由にした制裁や自動送信に、人間の確認境界がある

注意点とトレードオフ

キャッシュは本文ではなくハッシュへ結び付ける

messageKey + targetLanguageだけでキャッシュすると、編集後にも古い翻訳が再利用されます。sourceHash + targetLanguageをキーにすれば、原文が同じ場合だけ再利用できます。

一方、原文を直接キャッシュキーやログへ残すと、閲覧範囲が不必要に広がります。ログにはハッシュ、結果状態、所要区間、エラー分類など、調査に必要な最小限の情報を保存します。

自動翻訳はクリック数を減らすが、送信範囲を広げる

全メッセージを自動翻訳すれば操作は減りますが、利用者が読む予定のないメッセージまで翻訳対象になります。まずは公式ガイドが扱うオンデマンド翻訳を基準にし、自動化する場合は対象ルーム、利用者設定、データの扱いを明示して判断します。

翻訳とモデレーションを同じ結果にしない

翻訳が失敗したことと、原文が規約違反であることは別問題です。翻訳エラーを「問題なし」に変換せず、モデレーション側は原文を主データとして扱います。

SDKの対応範囲をコードから推測しない

対応するコンテンツ種別、言語、利用条件を独自コードへ思い込みで埋め込まないでください。リリース前にTUIChat message translationの最新記載と照合し、非対応時のUIを用意します。

まとめ

チャット翻訳を信頼できる機能にする鍵は、翻訳文の自然さだけではありません。

  • 要求時の原文、revision、ハッシュを固定する
  • 最新要求かつ未編集の結果だけを採用する
  • 翻訳文を原文の派生表示として扱う
  • 誤訳報告を担当者のいる既存運用へ接続する
  • 安全上重要な判断では、翻訳結果だけに実行権を持たせない

この境界を先に作れば、翻訳エンジンやUIを変更しても、編集・削除・遅延による取り違えを同じテストで検出できます。


関係性の開示: 筆者は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?