リリースを速く回したい。一方で、ユーザーの声を集めるために別のフォームやコミュニティを増やすと、今度は誰も担当しない窓口が増えてしまいます。
そこで既存のチャットをフィードバック入口にすると、次の問題が起きます。
- 「ログインできない」だけでは、どのアプリ版の報告か分からない
- 同じ受信イベントの再送で、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点です。
- 受信メッセージの識別子を
sourceMessageIdへ設定する - アプリのビルド情報から
appVersionとplatformを付ける - 現在の画面名を、個人情報を含まない列挙値へ変換する
-
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の公式ドキュメントおよび公式ソリューションページを実装時の参照資料として使用しています。