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に「違う」と言った後を放置しない:OpenAI/Gemini共通の会話修復フローをRailsで作る

0
Last updated at Posted at 2026-10-24

モデル比較が盛り上がるほど、「OpenAIとGeminiのどちらが賢いか」に目が向きます。しかし、音声コンパニオンを運用する側の本当の緊張は別の場所にあります。

利用者が「違う」と言った後、会話をどう立て直し、その失敗を誰が改善するのか。

単に同じ入力をLLMへ再送すると、同じ誤解を言い換えて返したり、謝罪だけを繰り返したりします。一方、失敗報告用のフォームやSlackチャンネルを増やしても、誰も継続して見なければ改善につながりません。

この記事では、リアルタイム音声AIの失敗を次の流れで扱います。

  1. 利用者が「違う」ボタンまたは訂正発話で修復を開始する
  2. 聞き取り・回答内容・操作・人格のどこが違ったかを選んでもらう
  3. 訂正内容を使って、LLMに1回だけ再回答させる
  4. 解決しなければ、既存のプロダクトバックログへOutbox経由で送る
  5. OpenAI/Geminiは、通常回答だけでなく修復結果でも比較する

結論:モデルの再実行ではなく「会話修復」を独立した業務フローにする

音声AIの修復では、LLMに任せる範囲と、人間が決める範囲を分けます。

判断 担当
訂正内容を踏まえた回答候補の生成 LLM
修復を開始するか 利用者
修復できたか 利用者の明示確認
再試行回数 アプリの固定ルール
失敗分類と担当先 プロダクト責任者
バックログへ登録するか アプリの決定的な処理
モデルの採用・切り替え 人間による評価

LLMは訂正された条件から回答を作り直せます。しかし、「ユーザーが本当に納得したか」「どのチームが改善すべきか」まで確実に判断できるわけではありません。

そのため、無言になったことを成功と見なさず、画面上に短い確認を出します。

  • 直った
  • まだ違う
  • 会話を終了する

この明示操作を修復結果の一次情報にします。

前提:RTC、音声認識、LLM、修復台帳を分離する

Tencent Conversational AIは、リアルタイム音声対話と複数のLLMプロバイダーを組み合わせるシナリオを扱います。全体像は公式ドキュメントで確認できます。

LLM設定の公式資料では、OpenAI互換モデルやエージェント基盤との接続、リクエスト識別子を使ったルーティング・観測が説明されています。本稿では、Tencent RTC固有のAPI名を仮定せず、アプリケーション側に置く修復ロジックを実装します。

構成は次のように分離します。

利用者の音声
  ↓
RTCによるメディア経路
  ↓
音声認識
  ↓
Rails: Conversation Repair Coordinator
  ├─ 通常ターン → OpenAI/Gemini Adapter
  ├─ 修復ターン → 訂正条件付きで1回再生成
  └─ 未解決     → Outbox → 既存バックログ
  ↓
音声合成
  ↓
利用者へ再生

Geminiを利用する場合も、アプリ内部ではOpenAIと同じProviderAdapter契約へ正規化します。実際の認証方法、リクエスト形式、利用可能なモデルは各プロバイダーの利用時点の公式仕様を確認してください。

今回作る会話修復ワークフロー

修復は自由なチャットではなく、状態を持つ短いフローとして扱います。

通常会話
  ↓ 「違う」または修復ボタン
AWAITING_DETAIL
  ↓ 分類と訂正内容を入力
READY
  ↓ 1回だけ再回答
RETRYING
  ├─ 「直った」   → RESOLVED
  └─ 「まだ違う」 → ESCALATED → 既存バックログ

分類は最初から増やしすぎず、次の4種類に限定します。

category 意味 主な確認先
asr 発話の聞き取りが違う 音声認識・辞書設定
answer 質問は合っているが回答が違う Prompt・検索・LLM
action 依頼していない操作を提案した 権限・確認フロー
persona 口調や距離感が不適切 キャラクター設計

この分類自体をLLMだけに決めさせないのが重要です。利用者が選べない場合はanswerへ勝手に寄せず、unknownとして人が確認できる設計にします。

手順1:Rails APIプロジェクトを作る

rails new voice-repair-lab --api -d sqlite3
cd voice-repair-lab
bin/rails db:create

次に、修復台帳とOutboxを作成します。

bin/rails generate migration CreateConversationRepairs
bin/rails generate migration CreateOutboxEvents

マイグレーションを次のように編集します。

# db/migrate/XXXXXXXXXXXXXX_create_conversation_repairs.rb
class CreateConversationRepairs < ActiveRecord::Migration[8.0]
  def change
    create_table :conversation_repairs do |t|
      t.string  :conversation_id, null: false
      t.string  :turn_id, null: false
      t.string  :provider, null: false
      t.string  :state, null: false, default: "awaiting_detail"
      t.string  :category
      t.text    :correction
      t.string  :attempt_id
      t.integer :retry_count, null: false, default: 0
      t.string  :owner_key, null: false
      t.json    :context_snapshot, null: false, default: {}
      t.datetime :resolved_at
      t.timestamps
    end

    add_index :conversation_repairs,
              [:conversation_id, :turn_id],
              unique: true,
              name: "idx_repairs_on_conversation_and_turn"
  end
end
# db/migrate/XXXXXXXXXXXXXX_create_outbox_events.rb
class CreateOutboxEvents < ActiveRecord::Migration[8.0]
  def change
    create_table :outbox_events do |t|
      t.string :topic, null: false
      t.string :deduplication_key, null: false
      t.json :payload, null: false
      t.datetime :delivered_at
      t.timestamps
    end

    add_index :outbox_events, :deduplication_key, unique: true
  end
end
bin/rails db:migrate

conversation_idとturn_idの組を一意にすることで、ボタン連打や同じイベントの再配送による重複登録を防ぎます。

手順2:担当不明の修復を作れないようにする

「改善用チャンネルを作ったが誰も見ない」を防ぐには、修復レコードに担当先を必須化します。

# app/models/conversation_repair.rb
class ConversationRepair < ApplicationRecord
  STATES = %w[
    awaiting_detail
    ready
    retrying
    resolved
    escalated
  ].freeze

  CATEGORIES = %w[asr answer action persona unknown].freeze

  validates :conversation_id, :turn_id, :provider, :owner_key, presence: true
  validates :state, inclusion: { in: STATES }
  validates :category, inclusion: { in: CATEGORIES }, allow_nil: true
end
# app/models/outbox_event.rb
class OutboxEvent < ApplicationRecord
  validates :topic, :deduplication_key, :payload, presence: true
end

担当先は新しい窓口ではなく、すでに運用されているバックログの識別子へ対応させます。

# config/repair_owners.yml
asr: voice-platform
answer: ai-product
operation: safety-review
persona: conversation-design
unknown: ai-product

起動時に未設定を検査して、本番で担当なしの機能が有効にならないようにします。

# config/initializers/repair_owners.rb
REPAIR_OWNERS = Rails.application.config_for(:repair_owners).stringify_keys.freeze

required = %w[asr answer operation persona unknown]
missing = required - REPAIR_OWNERS.keys

raise "Missing repair owners: #{missing.join(', ')}" if missing.any?

コード:修復を1回に制限し、未解決ならOutboxへ残す

中心となるCoordinatorを実装します。

# app/services/repair_coordinator.rb
class RepairCoordinator
  MAX_RETRIES = 1

  def open!(conversation_id:, turn_id:, provider:, context_snapshot:)
    ConversationRepair.create!(
      conversation_id: conversation_id,
      turn_id: turn_id,
      provider: provider,
      owner_key: REPAIR_OWNERS.fetch("unknown"),
      context_snapshot: context_snapshot
    )
  rescue ActiveRecord::RecordNotUnique
    ConversationRepair.find_by!(
      conversation_id: conversation_id,
      turn_id: turn_id
    )
  end

  def add_detail!(repair_id:, category:, correction:)
    raise ArgumentError, "unknown category" unless
      ConversationRepair::CATEGORIES.include?(category)

    ConversationRepair.transaction do
      repair = ConversationRepair.lock.find(repair_id)
      raise "repair is not waiting for detail" unless
        repair.state == "awaiting_detail"

      repair.update!(
        category: category,
        correction: correction.to_s.strip,
        owner_key: REPAIR_OWNERS.fetch(category),
        state: "ready"
      )

      repair
    end
  end

  def begin_retry!(repair_id:)
    ConversationRepair.transaction do
      repair = ConversationRepair.lock.find(repair_id)
      raise "repair is not ready" unless repair.state == "ready"
      raise "retry limit exceeded" if repair.retry_count >= MAX_RETRIES

      attempt_id = SecureRandom.uuid

      repair.update!(
        state: "retrying",
        retry_count: repair.retry_count + 1,
        attempt_id: attempt_id
      )

      {
        request_id: attempt_id,
        provider: repair.provider,
        category: repair.category,
        correction: repair.correction,
        context: repair.context_snapshot
      }
    end
  end

  def accept_llm_result?(repair_id:, request_id:)
    repair = ConversationRepair.find(repair_id)

    repair.state == "retrying" &&
      ActiveSupport::SecurityUtils.secure_compare(
        repair.attempt_id.to_s,
        request_id.to_s
      )
  end

  def complete!(repair_id:, accepted_by_user:)
    ConversationRepair.transaction do
      repair = ConversationRepair.lock.find(repair_id)
      raise "repair is not retrying" unless repair.state == "retrying"

      if accepted_by_user
        repair.update!(state: "resolved", resolved_at: Time.current)
      else
        repair.update!(state: "escalated")

        OutboxEvent.create!(
          topic: "existing_product_backlog",
          deduplication_key: "repair:#{repair.id}:escalated",
          payload: {
            repair_id: repair.id,
            owner_key: repair.owner_key,
            category: repair.category,
            provider: repair.provider,
            conversation_id: repair.conversation_id,
            turn_id: repair.turn_id
          }
        )
      end

      repair
    end
  end
end

accept_llm_result?は、修復開始前に送っていた古い回答や、別の再試行結果を読み上げないための受理条件です。LLMのキャンセルに成功したかどうかではなく、現在のattempt_idに一致する結果だけを採用します。

LLMへ渡す修復入力

再回答時に会話履歴全体をそのまま渡すのではなく、必要な情報だけを共通形式へ変換します。

{
  "request_id": "4dd8...",
  "category": "answer",
  "correction": "場所ではなく、営業時間を聞いています",
  "context": {
    "recognized_user_text": "今日の営業時間は?",
    "previous_answer_summary": "店舗の所在地を案内した"
  }
}

OpenAI/Gemini用のAdapterは、この共通形式を各プロバイダーの正式なリクエストへ変換します。

class ProviderAdapter
  def generate_repair(_envelope)
    raise NotImplementedError
  end
end

class RepairRunner
  def initialize(coordinator:, adapters:)
    @coordinator = coordinator
    @adapters = adapters
  end

  def call(repair_id:)
    envelope = @coordinator.begin_retry!(repair_id: repair_id)
    adapter = @adapters.fetch(envelope.fetch(:provider))
    result = adapter.generate_repair(envelope)

    return { accepted: false, reason: "stale_result" } unless
      @coordinator.accept_llm_result?(
        repair_id: repair_id,
        request_id: result.fetch(:request_id)
      )

    {
      accepted: true,
      spoken_reply: result.fetch(:spoken_reply),
      request_id: result.fetch(:request_id)
    }
  end
end

ここでのクラス名はアプリ側の抽象化であり、Tencent RTC SDKや各LLMプロバイダーの実在API名ではありません。

手順3:音声UIへ接続する

修復開始の入口は2つ用意します。

  1. 画面上の「違う」ボタン
  2. 音声認識で得た「違う」「そうじゃない」などの明示表現

ただし、音声表現だけに依存すると誤検出があります。まずボタンを正とし、発話検出時には「回答を直しますか?」という確認を挟むほうが安全です。

RTC・音声認識側からRailsへ渡すイベントは、少なくとも次を含めます。

{
  "conversation_id": "conv-123",
  "turn_id": "turn-18",
  "event": "repair_requested",
  "provider": "gemini",
  "recognized_user_text": "今日の営業時間は?",
  "previous_answer_summary": "店舗の所在地を案内した"
}

eventなどは本稿で定義するアプリ内契約です。Tencent RTCのコールバック名ではありません。実際の接続では、利用するSDK・構成の公式ドキュメントに沿ってイベントを変換してください。

修復中の音声案内はLLMに自由生成させず、短い固定文にできます。

どこが違いましたか?
「聞き取り」「回答内容」「操作」「話し方」から選べます。

LLMやネットワークが失敗しても、この固定案内とテキスト入力への切り替えはアプリ側だけで提供できます。

確認方法:正常回答ではなく「直し方」を壊して試す

1. 同じ修復要求を2回送る

期待結果:

  • 修復レコードは1件だけ
  • 同じconversation_idとturn_idで重複しない
  • 既存レコードが返る

2. 再回答中に古いLLM結果を返す

期待結果:

  • request_idが一致しない結果は読み上げない
  • 修復状態は変化しない

3. 利用者が「まだ違う」を選ぶ

期待結果:

  • 2回目の自動再回答を行わない
  • 状態がescalatedになる
  • Outboxに1件だけ保存される

4. バックログ連携先を停止する

期待結果:

  • 修復結果はOutboxに残る
  • 音声セッションの応答とバックログ送信を同じHTTP処理で待たない
  • 復旧後に同じdeduplication_keyで再配送できる

5. 担当設定を削除する

期待結果:

  • アプリ起動時に設定不足を検出する
  • 「担当不明だが、とりあえず記録する」という状態を作らない

6. 訂正内容に個人情報を含める

期待結果:

  • context_snapshotへ会話全文を保存しない
  • バックログへ送る前に、アプリのプライバシーポリシーに従ってマスキングする
  • 音声AIによる分析・保存について利用者が確認できる

OpenAIとGeminiをどう比較するか

通常質問への回答だけを比較すると、音声体験で重要な「誤解から戻れるか」が抜けます。候補モデルごとに、少なくとも次を同じ定義で記録します。

観点 算出例
修復受理率 resolved / repair_started
未解決率 escalated / repair_started
同一カテゴリの再発 同じ会話で同分類が再度発生した割合
修復所要時間 修復開始から利用者の明示確認まで
古い応答の拒否 不一致request_idを正しく破棄できたか

これらに普遍的な合格値はありません。言語、キャラクター、利用目的、音声認識、ネットワーク条件を固定し、自分たちの受入条件を決めます。

判断順序は次のようにすると整理しやすくなります。

  1. 権限や安全条件を破る候補は不採用
  2. 修復しても同じ誤りを繰り返す候補を除外
  3. 残った候補で修復所要時間と運用コストを比較
  4. 実際の未解決例を人が読み、Prompt・検索・ASR・LLMのどこを直すか決める

モデル名の新しさは、最後の参考情報です。

注意点

利用者の沈黙を成功にしない

音声AIでは、利用者が諦めて黙った可能性があります。無発話タイムアウトをresolvedとして記録せず、abandonedなど別の状態で扱うべきです。

LLMに失敗分類と担当者決定を丸投げしない

LLMによる分類候補は補助に使えますが、バックログの担当先は設定ファイルや管理画面で人が決めます。担当変更の履歴も残してください。

修復台帳へ会話全文を保存しない

改善に必要なのは、失敗したターン、分類、訂正内容、採用したプロバイダー、結果です。目的なく音声や全履歴を保存しないよう、保持期間と削除手順を決めます。

自動再試行を増やしすぎない

再試行を重ねるほど良くなるとは限りません。同じ前提で生成を繰り返すと、利用者に訂正を何度も強いることになります。本稿では1回に制限し、その後はテキスト入力、有人対応、会話終了など利用者が選べる出口を用意しました。

LLMの能力と音声体験を混同しない

回答品質が高いモデルでも、音声認識の誤り、遅延した古い回答、TTS再生、割り込み処理は別の問題です。モデル比較だけで音声AI全体の採否を決めないでください。

最終チェックリスト

  • 「違う」を通常発話とは別の修復イベントとして扱っている
  • 修復カテゴリを利用者が確認できる
  • 自動再回答の回数を固定している
  • request_idが異なる古い回答を再生しない
  • 成功を利用者の明示操作で記録している
  • 未解決時の担当先が事前に決まっている
  • 新しい報告チャンネルではなく既存バックログへ接続している
  • バックログ停止中もOutboxへ記録が残る
  • OpenAI/Geminiを同じ修復指標で比較できる
  • 会話全文や不要な個人情報を保存していない

モデル選定への不安は、「一番賢いモデルが分からない」ことだけから生まれるわけではありません。失敗したときに戻れる道筋と、改善責任の所在が見えないことも大きな原因です。

先に会話修復の契約を固定すれば、OpenAIやGeminiを変更しても、利用者に同じ失敗対応を提供しながら比較できます。


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