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-11-04

リリースを速く回したい。一方で、ユーザーの声を集めるために別のフォームやコミュニティを増やすと、今度は誰も担当しない窓口が増えてしまいます。

そこで既存のチャットをフィードバック入口にすると、次の問題が起きます。

  • 「ログインできない」だけでは、どのアプリ版の報告か分からない
  • 同じ受信イベントの再送で、Issue候補が重複する
  • AIの要約が自然なので、再現条件まで確認済みのように見える
  • 担当者へ渡したつもりでも、ユーザーからは状況が見えない
  • 翻訳文と原文が混ざり、後から意味を確認できない

この記事では、チャットを単なる投稿欄ではなく、版情報付きのフィードバック受付台帳として扱います。AIは分類候補を作るだけに限定し、担当、公開、解決判断は人が引き受ける構成です。

結論:メッセージ本文ではなく「受信時点の実行環境」を一緒に固定する

高速リリース下では、同じ文章でも版が違えば別の不具合かもしれません。逆に、表現が違っても同じ版・同じ操作で発生していれば、一つの問題として扱える可能性があります。

そのため、チャットから次の情報を一つの受付レコードに固定します。

ユーザーの原文
  + メッセージID
  + アプリ版
  + OS
  + 画面名
  + AI利用への同意
  + モデレーション結果
  + 人間の担当者
  + ユーザーに見せる受付状態

AIが改善できるのは、要約、分類候補、追加で聞く質問の下書きです。AIだけでは、実際に再現したこと、修正責任を誰が持つか、解決済みかを確定できません。

まず再現する失敗

次の2件がチャットへ届いたとします。

A: v4.8.0 / iOS / 「ログイン画面から進めません」
B: v4.8.1 / iOS / 「ログインできないです」

文章だけをAIへ渡すと、同じ問題としてまとめる可能性があります。しかし、v4.8.1で認証処理を変更していたなら、別々に調べるべきかもしれません。

さらに、ネットワーク再接続などでAの受信イベントが再配送された場合、本文だけをキーにすると次のどちらかになります。

  • 同じ報告を2件登録する
  • 同じ文章を書いた別ユーザーの報告まで重複扱いする

重複判定には本文ではなく、チャット基盤から得た一意なメッセージ識別子を使います。版をまたぐ統合は、自動処理ではなく担当者の判断に残します。

前提:チャット配信とフィードバック管理を分離する

Tencent RTCのSocial Messagingは、1対1チャット、グループ、コミュニティ、リッチメディア、ライブ配信ルーム内のチャットなどを扱うシナリオとして案内されています。

この記事ではTencent RTCのチャットをユーザーとの入口にし、次の処理はアプリ側のサービスとして実装します。

Tencent RTCのチャット
        ↓
受信アダプター
        ↓
同意・モデレーション確認
        ↓
版情報付き受付台帳
        ↓
AIの整理候補(非公開)
        ↓
人が担当・Issue化を承認
        ↓
チャットへ状態を返信

AIへチャット送信権限やIssueのクローズ権限を直接渡さないことがポイントです。

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

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

mkdir versioned-chat-feedback
cd versioned-chat-feedback
npm init -y
npm install -D typescript tsx @types/node
mkdir src test

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

{
  "type": "module",
  "scripts": {
    "test": "tsx --test test/**/*.test.ts"
  }
}

手順2:受付レコードを定義する

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

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

export type ModerationResult = "ALLOW" | "REVIEW" | "BLOCK";
export type FeedbackState =
  | "WAITING_FOR_REVIEW"
  | "WAITING_FOR_OWNER"
  | "OWNED"
  | "TRACKED"
  | "RESOLVED";

export type IncomingFeedback = {
  sourceMessageId: string;
  userId: string;
  originalText: string;
  appVersion: string;
  platform: "ios" | "android" | "web" | "unknown";
  screen: string | null;
  locale: string;
  aiConsent: boolean;
  moderation: ModerationResult;
  contextMessageIds: string[];
};

export type AiSuggestion = {
  summary: string;
  category: "bug" | "question" | "request" | "unknown";
  followUpQuestion: string | null;
  possibleClusterKey: string | null;
};

export type FeedbackRecord = {
  id: string;
  sourceMessageId: string;
  userIdHash: string;
  originalText: string;
  appVersion: string;
  platform: IncomingFeedback["platform"];
  screen: string | null;
  locale: string;
  state: FeedbackState;
  owner: string | null;
  issueUrl: string | null;
  aiSuggestion: AiSuggestion | null;
  contextMessageIds: string[];
  createdAt: string;
};

export type IntakeResult =
  | { accepted: true; duplicate: boolean; record: FeedbackRecord }
  | { accepted: false; reason: "BLOCKED" };

export interface SuggestionEngine {
  suggest(input: {
    originalText: string;
    appVersion: string;
    platform: IncomingFeedback["platform"];
    screen: string | null;
  }): Promise<unknown>;
}

function hashUserId(userId: string): string {
  return createHash("sha256").update(userId).digest("hex");
}

function parseSuggestion(value: unknown): AiSuggestion {
  if (!value || typeof value !== "object") {
    throw new Error("AI suggestion must be an object");
  }

  const v = value as Record<string, unknown>;
  const categories = ["bug", "question", "request", "unknown"];

  if (typeof v.summary !== "string" || v.summary.length > 300) {
    throw new Error("Invalid summary");
  }
  if (typeof v.category !== "string" || !categories.includes(v.category)) {
    throw new Error("Invalid category");
  }
  if (v.followUpQuestion !== null && typeof v.followUpQuestion !== "string") {
    throw new Error("Invalid follow-up question");
  }
  if (v.possibleClusterKey !== null && typeof v.possibleClusterKey !== "string") {
    throw new Error("Invalid cluster key");
  }

  return v as AiSuggestion;
}

export class FeedbackInbox {
  private readonly records = new Map<string, FeedbackRecord>();
  private readonly messageIndex = new Map<string, string>();

  ingest(input: IncomingFeedback): IntakeResult {
    const existingId = this.messageIndex.get(input.sourceMessageId);
    if (existingId) {
      return {
        accepted: true,
        duplicate: true,
        record: this.get(existingId),
      };
    }

    // BLOCKされた本文を受付台帳へ残さない方針。
    // 実運用では監査・法務要件に応じて別保管を検討する。
    if (input.moderation === "BLOCK") {
      return { accepted: false, reason: "BLOCKED" };
    }

    if (input.contextMessageIds.length > 5) {
      throw new Error("Context is limited to five explicitly selected messages");
    }

    const id = randomUUID();
    const record: FeedbackRecord = {
      id,
      sourceMessageId: input.sourceMessageId,
      userIdHash: hashUserId(input.userId),
      originalText: input.originalText,
      appVersion: input.appVersion,
      platform: input.platform,
      screen: input.screen,
      locale: input.locale,
      state:
        input.moderation === "REVIEW"
          ? "WAITING_FOR_REVIEW"
          : "WAITING_FOR_OWNER",
      owner: null,
      issueUrl: null,
      aiSuggestion: null,
      contextMessageIds: [...input.contextMessageIds],
      createdAt: new Date().toISOString(),
    };

    this.records.set(id, record);
    this.messageIndex.set(input.sourceMessageId, id);
    return { accepted: true, duplicate: false, record };
  }

  async proposeWithAi(
    id: string,
    consent: boolean,
    engine: SuggestionEngine,
  ): Promise<FeedbackRecord> {
    const record = this.get(id);

    if (!consent) throw new Error("AI consent is required");
    if (record.state === "WAITING_FOR_REVIEW") {
      throw new Error("Moderation review must finish before AI processing");
    }

    const raw = await engine.suggest({
      originalText: record.originalText,
      appVersion: record.appVersion,
      platform: record.platform,
      screen: record.screen,
    });

    // AI出力を保存前に検証する。提案だけでは状態を変更しない。
    record.aiSuggestion = parseSuggestion(raw);
    return record;
  }

  completeModerationReview(id: string): FeedbackRecord {
    const record = this.get(id);
    if (record.state !== "WAITING_FOR_REVIEW") {
      throw new Error("Record is not waiting for review");
    }
    record.state = "WAITING_FOR_OWNER";
    return record;
  }

  assign(id: string, owner: string): FeedbackRecord {
    if (!owner.trim()) throw new Error("Owner is required");

    const record = this.get(id);
    if (record.state === "WAITING_FOR_REVIEW") {
      throw new Error("Complete moderation review first");
    }

    record.owner = owner;
    record.state = "OWNED";
    return record;
  }

  track(id: string, issueUrl: string): FeedbackRecord {
    const record = this.get(id);
    if (!record.owner || record.state !== "OWNED") {
      throw new Error("A human owner must be assigned before tracking");
    }
    if (!issueUrl.startsWith("https://")) {
      throw new Error("Issue URL must use HTTPS");
    }

    record.issueUrl = issueUrl;
    record.state = "TRACKED";
    return record;
  }

  publicReceipt(id: string): string {
    const record = this.get(id);
    const ownerText = record.owner ? `担当: ${record.owner}` : "担当確認中";
    return `受付ID: ${record.id}\n状態: ${record.state}\n${ownerText}`;
  }

  get(id: string): FeedbackRecord {
    const record = this.records.get(id);
    if (!record) throw new Error("Feedback not found");
    return record;
  }
}

このモデルでは、AIが提案を返してもstateは変わりません。分類結果がもっともらしくても、担当者が決まったことにはしないためです。

手順3:AIを固定応答にしてワークフローを通す

最初から実際のLLMを接続すると、ワークフローの不具合とモデルの揺れを区別しにくくなります。まず固定応答で確認します。

src/demo.ts:

import { FeedbackInbox, type SuggestionEngine } from "./inbox.js";

const fakeAi: SuggestionEngine = {
  async suggest(input) {
    return {
      summary: `${input.appVersion}の${input.screen ?? "不明画面"}で操作不能`,
      category: "bug",
      followUpQuestion: "表示されたエラー文を教えてください",
      possibleClusterKey: `${input.platform}:login-failure`,
    };
  },
};

const inbox = new FeedbackInbox();

const intake = inbox.ingest({
  sourceMessageId: "msg-001",
  userId: "user-123",
  originalText: "更新後、ログイン画面から進めません",
  appVersion: "4.8.1",
  platform: "ios",
  screen: "login",
  locale: "ja-JP",
  aiConsent: true,
  moderation: "ALLOW",
  contextMessageIds: [],
});

if (!intake.accepted) throw new Error(intake.reason);

await inbox.proposeWithAi(intake.record.id, true, fakeAi);
inbox.assign(intake.record.id, "mobile-oncall");
inbox.track(intake.record.id, "https://github.com/example/app/issues/123");

console.log(inbox.publicReceipt(intake.record.id));

実行します。

npx tsx src/demo.ts

出力例です。

受付ID: ...
状態: TRACKED
担当: mobile-oncall

この文字列をチャットへ返信すれば、ユーザーは「送信できた」だけでなく、現在の受付状態も確認できます。内部向けAI要約は返信せず、必要なら人が編集した追加質問だけを送ります。

手順4:境界条件を自動テストする

test/inbox.test.ts:

import assert from "node:assert/strict";
import test from "node:test";
import { FeedbackInbox, type IncomingFeedback } from "../src/inbox.js";

const base: IncomingFeedback = {
  sourceMessageId: "msg-001",
  userId: "user-001",
  originalText: "ログインできません",
  appVersion: "4.8.1",
  platform: "ios",
  screen: "login",
  locale: "ja-JP",
  aiConsent: true,
  moderation: "ALLOW",
  contextMessageIds: [],
};

test("同じメッセージIDの再配送ではレコードを増やさない", () => {
  const inbox = new FeedbackInbox();
  const first = inbox.ingest(base);
  const second = inbox.ingest(base);

  assert.equal(first.accepted, true);
  assert.equal(second.accepted, true);
  if (second.accepted) assert.equal(second.duplicate, true);
});

test("同じ本文でも版が異なり、IDも異なれば別受付にする", () => {
  const inbox = new FeedbackInbox();
  const a = inbox.ingest(base);
  const b = inbox.ingest({
    ...base,
    sourceMessageId: "msg-002",
    appVersion: "4.8.2",
  });

  assert.equal(a.accepted, true);
  assert.equal(b.accepted, true);
  if (a.accepted && b.accepted) assert.notEqual(a.record.id, b.record.id);
});

test("モデレーション確認中はAIへ渡さない", async () => {
  const inbox = new FeedbackInbox();
  const result = inbox.ingest({ ...base, moderation: "REVIEW" });
  assert.equal(result.accepted, true);
  if (!result.accepted) return;

  await assert.rejects(
    inbox.proposeWithAi(result.record.id, true, {
      async suggest() {
        return {};
      },
    }),
    /Moderation review/,
  );
});

test("不正なAI出力を保存しない", async () => {
  const inbox = new FeedbackInbox();
  const result = inbox.ingest(base);
  if (!result.accepted) return;

  await assert.rejects(
    inbox.proposeWithAi(result.record.id, true, {
      async suggest() {
        return {
          summary: "ログイン障害",
          category: "緊急なので自動公開",
          followUpQuestion: null,
          possibleClusterKey: null,
        };
      },
    }),
    /Invalid category/,
  );
});

test("担当者なしではIssue追跡状態へ進めない", () => {
  const inbox = new FeedbackInbox();
  const result = inbox.ingest(base);
  if (!result.accepted) return;

  assert.throws(
    () => inbox.track(result.record.id, "https://github.com/example/app/issues/1"),
    /human owner/,
  );
});

実行します。

npm test

Tencent RTCのチャットへ接続する位置

実際の組み込みでは、利用しているTencent RTCのチャットSDKまたはUIコンポーネントから受け取ったテキストメッセージを、IncomingFeedbackへ変換します。

接続時にアプリ側で行う処理は次の4点です。

  1. 受信メッセージの識別子をsourceMessageIdへ設定する
  2. アプリのビルド情報からappVersionとplatformを付ける
  3. 現在の画面名を、個人情報を含まない列挙値へ変換する
  4. publicReceipt()の結果をチャットへ返信する

SDK固有の受信・送信メソッド名は、採用するSDKとバージョンの公式ドキュメントに合わせてください。この記事の受付サービスは、その外側に置くアプリケーション層です。

翻訳を使う場合

TUIChatには、テキストメッセージをオンデマンドで翻訳するための公式手順があります。対応するコンテンツ種別、言語、エディション上の条件は導入前に公式ページで確認してください。

受付台帳では、翻訳文でoriginalTextを上書きしないようにします。

type DisplayTranslation = {
  sourceMessageId: string;
  sourceLocale: string;
  targetLocale: string;
  translatedText: string;
};

翻訳は表示補助です。Issue化やモデレーションで意味が争点になった場合に原文へ戻れるよう、原文と翻訳文を別データとして扱います。

AIへ任せる範囲を決める

処理 AIを使うか 最終判断
長文の要約候補 使える 担当者が確認
カテゴリ候補 使える 担当者が変更可能
類似報告の候補提示 使える 版・再現条件を人が比較
追加質問の下書き 使える 送信前に人が確認
モデレーションの最終処分 単独では任せない 運用ポリシーと担当者
担当者の決定 任せない チームの責任分担
Issueの公開・クローズ 任せない 担当者
修正済みの判定 任せない 検証結果

AIエージェントが複数のツールを操作できることと、プロジェクトが完了することは別です。チャット運用で不足しやすいのは文章生成能力ではなく、誰が次の状態へ進めるかという所有権です。

確認方法:自然な要約より台帳の境界を壊す

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

  • 同じメッセージイベントを2回投入しても受付IDが増えない
  • 同じ本文でもメッセージIDが異なれば別の報告として残る
  • 異なるアプリ版をAIが勝手に一件へ統合しない
  • AI利用への同意がない報告をAIへ送らない
  • モデレーションがREVIEWならAI処理より先に止まる
  • AIが未定義カテゴリを返したら保存を拒否する
  • AIの提案だけでは状態や担当者が変更されない
  • 担当者がいないレコードをIssue追跡済みにできない
  • チャット上で受付IDと状態を確認できる
  • 翻訳表示後も原文を参照できる
  • 解決通知を送る前に、人が対象版で確認している

注意点

1. sourceMessageIdの一意性範囲を確認する

識別子が会話内でのみ一意なら、conversationId + sourceMessageIdの複合キーにします。文字列の形式や保証範囲を推測せず、利用するSDKの仕様を確認してください。

2. アプリ版はユーザー入力にしない

ユーザーに版番号を入力してもらうと、未記入や転記ミスが発生します。可能な限り、クライアントアプリが持つビルド情報から付与します。ただしWeb版のように継続デプロイされる環境では、リリース番号やコミット識別子など、自チームが追跡可能な値を別途設計する必要があります。

3. 画面名へ個人情報を入れない

URL全体や検索クエリをそのままscreenへ入れると、メールアドレスやトークンが混入する可能性があります。login、checkout、profileのような許可リストへ正規化します。

4. AIのクラスタは「統合命令」ではない

possibleClusterKeyは検索補助にすぎません。版、OS、操作順、エラー内容が異なる報告を一つへまとめると、回帰不具合を見落とす可能性があります。統合後も元の受付IDを追跡できるようにしてください。

5. チャットだけを唯一の障害窓口にしない

チャットが利用できない障害では、チャットから報告できません。サービスの性質に応じて、ステータスページや別の連絡経路を用意します。ただし入口を増やすなら、各入口から同じ受付台帳へ集約し、担当不明の別キューを作らないことが重要です。

まとめ

高速リリースとユーザー理解は両立できますが、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?