Google Chat Bot に聞き返しウィジェットを実装してみた — cardsV2 のインラインフォーム活用

Google Chat Bot に聞き返しウィジェットを実装してみた — cardsV2 のインラインフォーム活用

Google Chat Bot で曖昧なクエリに対しインタラクティブな聞き返しカードを表示する仕組みを実装しました。Pydantic 構造化出力で曖昧性を判定し、cardsV2 の ButtonList + TextInput でインラインフォームを構築、無限ループ防止やエスカレーション文の強制付与も含めた実装を紹介します。
2026.07.11

はじめに

前回の記事では、Google Chat Bot に cardsV2 のプログレッシブ更新 UX を実装し、RAG パイプラインの進捗をリアルタイムで表示できるようにしました。

今回は、そのボットに インタラクティブな聞き返しウィジェット を追加した話です。ユーザーが曖昧なクエリ(例:「マクロ」の一語だけ)を送ったとき、いきなり回答を生成するのではなく、「どの内容についてお探しですか?」と聞き返し、選択肢ボタンと自由入力欄を表示します。

SCR-20260710-nhcl (1)

合わせて、フォールバック応答(ナレッジベースに情報がない場合)と機密情報拒否(パスワード等)に エスカレーション案内文を確実に付与する 仕組みも実装しました。

構成

項目 選択
ランタイム Cloud Functions 第2世代
言語 Python 3.14
LLM Gemini 2.5 Flash(google-genai SDK)
構造化出力 Pydantic BaseModel + response_schema
カード UI cardsV2(ButtonList + TextInput)

課題: 曖昧なクエリに即回答してしまう

RAG ボットの運用を続けていると、ユーザーから曖昧なクエリが届くケースが頻繁にありました。

ユーザー: 「マクロ」
ボット:   Excel のマクロについて説明します...(ユーザーは Outlook のマクロを聞きたかった)

ナレッジベースに「Excel のマクロ」「Outlook のマクロ」「Google スプレッドシートのマクロ」といった複数のトピックがある場合、LLM はどれか1つを選んで回答してしまいます。ユーザーの意図とずれた回答を返すのは、回答しないよりも悪い体験です。

理想的なフローはこうです:

ユーザー: 「マクロ」
ボット:   「マクロ」について、どの内容をお探しですか?
          [Excel のマクロ] [Outlook のマクロ] [スプレッドシートのマクロ]
          [自由入力欄] [送信]
ユーザー: [Excel のマクロ] をクリック
ボット:   Excel のマクロの設定手順は以下の通りです...

実装1: Pydantic 構造化出力で曖昧性を検出する

RagAnswer スキーマの拡張

まず、LLM の出力スキーマに聞き返し用のフィールドを追加します。google-genai SDK は Pydantic の BaseModelresponse_schema に直接渡せるため、型安全に構造化出力を定義できます。

from pydantic import BaseModel, Field

class RagAnswer(BaseModel):
    """Structured output from Gemini for RAG responses."""

    answer: str = Field(
        description="ナレッジベースの情報に基づく回答(Markdown形式)"
    )
    is_answerable: bool = Field(
        description="ナレッジベースに該当する情報があり回答できた場合はtrue"
    )
    needs_clarification: bool = Field(
        default=False,
        description="クエリが曖昧で意図を特定できない場合はtrue。"
        "trueの場合、answerに聞き返しの文を入れ、"
        "clarification_optionsに選択肢を入れる",
    )
    clarification_options: list[str] = Field(
        default_factory=list,
        description="聞き返す場合の選択肢(最大3つ)。"
        "needs_clarificationがtrueの場合のみ使用",
    )

システムプロンプトへの指示追加

スキーマだけでは LLM は聞き返しをしてくれません。システムプロンプトに明確な判断基準を記載します。

## 曖昧なクエリの処理
- ユーザーの質問が曖昧で、ナレッジベースの検索結果が複数の異なるトピックに該当する場合:
  - needs_clarification を true に設定する
  - answer に聞き返しの文を入れる
  - clarification_options に検索結果に基づく選択肢を最大3つ入れる
- 質問が一語のみでも、検索結果が1つのトピックに集中している場合は聞き返さず即回答する
- 検索結果が0件の場合は聞き返さず、is_answerable を false にする

ポイントは 「一語 = 曖昧」ではない という点です。「VPN」のように検索結果がすべて同じトピック(VPN 接続トラブルシューティング)を指している場合は、一語でも即回答するのが正しい体験です。曖昧かどうかは検索結果の多様性で判断させます。

生成呼び出し

google-genai SDK での呼び出しは通常の構造化出力と同じです。

from google import genai
from google.genai import types

client = genai.Client(vertexai=True, project=PROJECT_ID, location=LOCATION)

response = client.models.generate_content(
    model="gemini-2.5-flash",
    contents=prompt,
    config=types.GenerateContentConfig(
        system_instruction=SYSTEM_PROMPT,
        response_schema=RagAnswer,
        response_mime_type="application/json",
    ),
)

result = response.parsed  # → RagAnswer インスタンス

response.parsed が直接 Pydantic モデルのインスタンスを返してくれるので、パース処理を書く必要がありません。result.needs_clarificationTrue なら聞き返しフローに分岐します。

実装2: cardsV2 で聞き返しカードを構築する

カードの構造

聞き返しカードは以下の要素で構成されます:

  1. textParagraph — LLM が生成した聞き返しの文(例:「マクロについて、どの内容をお探しですか?」)
  2. buttonList — 最大3つの選択肢ボタン
  3. textInput — 自由入力欄(ボタンの選択肢にないケース用)
  4. buttonList — 送信ボタン
def _build_clarification_section(clarification_text, options,
                                 message_name, endpoint_url,
                                 space_name, thread_name, user_name,
                                 original_query=""):
    function_url = endpoint_url or ""
    base_params = [
        {"key": "action", "value": "clarify"},
        {"key": "message_name", "value": message_name or ""},
        {"key": "space_name", "value": space_name or ""},
        {"key": "thread_name", "value": thread_name or ""},
        {"key": "user_name", "value": user_name or ""},
        {"key": "original_query", "value": original_query or ""},
    ]

    widgets = [
        {"textParagraph": {"text": clarification_text}},
    ]

    # 選択肢ボタン(最大3つ)
    if options:
        buttons = []
        for option in options[:3]:
            buttons.append({
                "text": option,
                "onClick": {
                    "action": {
                        "function": function_url,
                        "parameters": base_params + [
                            {"key": "query", "value": option},
                        ],
                    }
                },
            })
        widgets.append({"buttonList": {"buttons": buttons}})

    # 自由入力欄
    widgets.append({
        "textInput": {
            "label": "上記以外はこちらに入力",
            "type": "SINGLE_LINE",
            "name": "clarify_text",
        }
    })

    # 送信ボタン(自由入力用)
    widgets.append({
        "buttonList": {
            "buttons": [{
                "text": "送信",
                "onClick": {
                    "action": {
                        "function": function_url,
                        "parameters": base_params + [
                            {"key": "query", "value": ""},
                        ],
                    }
                },
            }]
        }
    })

    return {"widgets": widgets}

textInput がインラインカードで使えるのか?

実装前に気になったのが、Google Chat の textInput ウィジェットです。ドキュメントでは主にダイアログ(renderActions で表示するモーダル)の文脈で紹介されていますが、インラインのメッセージカードでも問題なく動作します

ユーザーがテキストを入力して送信ボタンを押すと、CARD_CLICKED イベントの commonEventObject.formInputs にフォームデータが含まれます。

{
  "commonEventObject": {
    "formInputs": {
      "clarify_text": {
        "stringInputs": {
          "value": ["ユーザーが入力したテキスト"]
        }
      }
    },
    "parameters": {
      "action": "clarify",
      "query": ""
    }
  }
}

パラメータ設計の注意点

Google Chat の action.parametersすべて文字列型 です。数値やブール値は渡せません。そのため、message_name(既存メッセージのリソース名)や thread_name も文字列として渡し、空文字列を「なし」として扱います。

また、選択肢ボタンには query パラメータにボタンのテキストをそのまま入れ、送信ボタンでは query を空文字列にしています。これにより、クリックハンドラ側で「ボタンクリックか自由入力か」を簡単に判別できます。

実装3: CARD_CLICKED イベントのハンドリング

ルーティング

ボタンクリック時に Google Chat が送信する CARD_CLICKED イベントを処理します。前回の記事で実装したフィードバックボタンと同じイベント形式なので、parameters.action で分岐します。

main.py
if "buttonClickedPayload" in chat:
    action = params.get("action")

    if action == "clarify":
        return handle_clarify(body, endpoint_url=endpoint_url)

    if action == "feedback":
        # 既存のフィードバック処理
        ...

クリックハンドラ

bot/clarify.py
import threading
from bot.worker import process_message

def handle_clarify(event_body, endpoint_url=""):
    common_event = event_body.get("commonEventObject", {})
    params = common_event.get("parameters", {})
    form_inputs = common_event.get("formInputs", {})

    # ボタンクリック → params["query"] にテキストが入っている
    # 自由入力 + 送信ボタン → params["query"] は空、formInputs にテキスト
    query = params.get("query", "")
    if not query:
        text_input = form_inputs.get("clarify_text", {})
        values = text_input.get("stringInputs", {}).get("value", [])
        query = values[0].strip() if values else ""

    if not query:
        return {}  # 空クエリは無視

    # 元のクエリと聞き返し応答を結合して文脈を保持
    original_query = params.get("original_query", "")
    if original_query and query != original_query:
        query = f"{original_query}{query}について)"

    message_name = params.get("message_name", "")
    space_name = params.get("space_name", "")
    thread_name = params.get("thread_name", "")
    user_name = params.get("user_name", "")

    user = event_body.get("chat", {}).get("user", {})
    sender = user.get("displayName", "")

    thread = threading.Thread(
        target=process_message,
        args=(space_name, query, sender),
        kwargs={
            "endpoint_url": endpoint_url,
            "thread_name": thread_name or None,
            "user_name": user_name,
            "message_name": message_name or None,
            "is_clarification": True,
        },
    )
    thread.start()

    return {}

ポイントは以下の4つです。

ボタンの query を優先する

ユーザーが選択肢ボタンをクリックしたとき、textInput に何か入力されていても params["query"] に非空の文字列が入っています。ボタンクリックの意図が明確なので、こちらを優先します。送信ボタンの場合は query が空なので、formInputs から取得します。

original_query で元の質問の文脈を保持する

聞き返しカードの base_params に元のクエリ(original_query)を埋め込んでいます。ユーザーが聞き返しに応答したとき、この元のクエリとユーザーの応答を結合してからパイプラインを再実行します。

例えば、元のクエリが「ツールの申請方法を教えて」で、ユーザーが自由入力欄に「kintone」と入力した場合、最終的なクエリは ツールの申請方法を教えて(kintoneについて) になります。これにより、「申請方法」という文脈を保ったまま KB 検索できます。

なぜこれが必要かは後述の「ハマったポイント」で詳しく説明します。

message_name を渡して既存カードをパッチする

通常の MESSAGE イベントでは process_message が新しいメッセージカードを作成しますが、聞き返しからの再実行では元の聞き返しカードを 上書き したいです。message_name(既存メッセージのリソース名)を渡すことで、ワーカーは create_message ではなく patch_message を使います。

bot/worker.py
# process_message 内
if message_name is None:
    message_name = chat_client.create_message(space_name, body, thread_name=thread_name)
else:
    chat_client.patch_message(message_name, body, "cardsV2")

return {} で即座にレスポンスを返す

CARD_CLICKED に対して {} を返すと、Google Chat は何も変更しません(元のカードがそのまま残る)。バックグラウンドスレッドが Chat API 経由でカードをパッチするため、ユーザーには聞き返しカードが回答カードにシームレスに切り替わる体験になります。

実装4: 無限ループの防止

聞き返しの結果、再び「曖昧だ」と判断されたらどうなるでしょうか?

ユーザー: 「マクロ」
ボット:   聞き返しカード → [Excel のマクロ]
ボット:   また聞き返しカード → 無限ループ!

これを防ぐため、is_clarification=True で実行された場合は、LLM が needs_clarification=True を返しても強制的に False に上書きします。

bot/worker.py
# 無限ループ防止
if is_clarification and result.needs_clarification:
    result = RagAnswer(
        answer=result.answer,
        is_answerable=result.is_answerable,
        needs_clarification=False,
        clarification_options=[],
    )

1回の聞き返しで十分な情報が得られなかった場合は、持っている情報で最善の回答をするほうが、再度聞き返すよりもユーザー体験として良いです。

実装5: エスカレーション文の確実な付与

聞き返しとは別に、フォールバック応答(ナレッジベースに情報がない場合)や機密情報の拒否時に、担当部署へのエスカレーション案内が欠落する問題もありました。

システムプロンプトでの指示

## 制約
- パスワードやアカウント情報は直接回答しない
  (手順のみ案内し、回答の末尾に必ず次の一文を追加する):
  「YOUR_ESCALATION_TEXTにお問い合わせください。」
- ナレッジベースに該当情報がない場合は、回答の末尾に必ず次の一文を追加する:
  「YOUR_ESCALATION_TEXTにお問い合わせください。」

安全策: 後処理での強制付与

LLM は指示を守らないことがあります。特にエスカレーション文のような定型文は、モデルが「不要」と判断して省略してしまうケースがありました。

そこで、LLM の出力を後処理でチェックし、is_answerable=False なのにエスカレーション文が含まれていなければ強制的に追加します。

ESCALATION_TEXT = "YOUR_ESCALATION_TEXTにお問い合わせください。"

def _ensure_escalation(result: RagAnswer) -> RagAnswer:
    """Append escalation text to non-answerable responses if missing."""
    if result.needs_clarification:
        return result  # 聞き返し中はスキップ
    if not result.is_answerable and ESCALATION_TEXT not in result.answer:
        return RagAnswer(
            answer=result.answer.rstrip() + "\n" + ESCALATION_TEXT,
            is_answerable=False,
        )
    return result

needs_clarification=True の場合はスキップしている点がポイントです。聞き返しは「まだ回答していない」状態なので、エスカレーション文を追加するのは不適切です。

この関数を generate_answer() のすべての return パスに適用します。

def generate_answer(question, contexts, history=None):
    if not contexts:
        return RagAnswer(answer=NO_RESULT_MESSAGE, is_answerable=False)

    # ... LLM 呼び出し ...

    try:
        if isinstance(response.parsed, RagAnswer):
            return _ensure_escalation(response.parsed)
        if response.text is not None:
            return _ensure_escalation(RagAnswer.model_validate_json(response.text))
    except (ValueError, TypeError):
        logger.warning("Failed to parse structured response")
    return RagAnswer(answer=NO_RESULT_MESSAGE, is_answerable=False)

全体のフロー

最終的な聞き返しフローの全体像です。

google-chat-bot-cardsv2-clarification-widget-flow

Phase 1(初回クエリ): ユーザーが「マクロ」と送信すると、RAG パイプラインが KB を検索し、複数トピックを検出した場合に needs_clarification=True を返します。聞き返しカード(選択肢ボタン + テキスト入力 + 送信)が Chat API 経由でパッチされます。

Phase 2(聞き返し応答): ユーザーがボタンをクリックまたはテキストを入力すると、CARD_CLICKED イベントから handle_clarify() がクエリを抽出します。カードパラメータに埋め込まれた original_query(元のクエリ)と結合し、文脈を保持したクエリで is_clarification=True のパイプラインを再実行します。2回目の聞き返しは強制 OFF にされ、既存カードが patch_message で回答に上書きされます。

ハマったポイント

formInputs はインラインカードでも送られてくる

Google Chat のドキュメントでは formInputs をダイアログの文脈で説明していますが、インラインのメッセージカードに textInput を配置しても、ボタンクリック時に formInputs が正しく送られてきます。ダイアログを使う必要はありません。

パラメータはすべて文字列

action.parametersvalue は文字列型のみです。message_namethread_name など、再実行に必要な情報はすべて文字列として渡します。空文字列 "" を「未設定」として扱い、ハンドラ側で or None に変換しています。

聞き返し応答で元のクエリの文脈が消える

これが一番厄介な問題でした。以下のシナリオで発生します。

ユーザー: 「ツールの申請方法を教えて」
ボット:   聞き返しカード → [kintone] [Salesforce] [Microsoft 365] + 自由入力
ユーザー: 自由入力に「kintone」と入力して送信
ボット:   kintone の一般的な説明を回答 ← 「申請方法」の文脈が消えている!

原因は、聞き返しが発生した場合に元のクエリが会話履歴に保存されないことです。通常のフォローアップ質問では Firestore の会話履歴を使ってクエリを再構成しますが、聞き返し時は回答を返していないため save_turn() が呼ばれません。結果、聞き返し応答の「kintone」だけで KB 検索してしまい、「申請方法」という文脈が完全に失われます。

解決策として、聞き返しカードの action.parameters に元のクエリ(original_query)を埋め込む 方式を採用しました。

# cards.py — base_params に original_query を追加
base_params = [
    {"key": "action", "value": "clarify"},
    {"key": "original_query", "value": original_query or ""},
    # ...
]

# clarify.py — 結合してからパイプラインを再実行
original_query = params.get("original_query", "")
if original_query and query != original_query:
    query = f"{original_query}{query}について)"

Firestore に保存する方式も検討しましたが、カードパラメータ方式のほうが以下の点で優れています。

  • 決定的 — LLM による再構成が不要で、追加のレイテンシやコストがかからない
  • Firestore 非依存 — カードウィジェット自体に文脈が埋め込まれるため、Firestore 障害時も動作する
  • 履歴汚染なし — 聞き返しの不完全なやり取りを会話履歴に保存する必要がない

聞き返し時は会話履歴に保存しない

聞き返しのやり取り(「どれについてですか?」→ボタン選択)はメタ会話であり、本来の Q&A ではありません。会話履歴に保存すると、後続のフォローアップ質問で文脈がおかしくなります。needs_clarification=True の場合は save_turn() をスキップしています。

ボタンの query と formInputs の優先順位

選択肢ボタンをクリックしたとき、ユーザーが textInput に何か入力していた場合、両方のデータが CARD_CLICKED イベントに含まれます。ボタンの parameters に非空の query が入っていればそちらを優先し、送信ボタン(query="")の場合のみ formInputs を参照する設計にしました。

まとめ

  • Pydantic 構造化出力 で LLM に曖昧性の判断を委ねることで、ルールベースでは難しい「このクエリは曖昧か?」の判定を自然に実現できました
  • cardsV2 の ButtonList + TextInput でインタラクティブな聞き返し UI を構築。ダイアログなしでインラインカードだけで完結します
  • CARD_CLICKED{} + バックグラウンドスレッド のパターンで、既存カードのシームレスな上書きが可能
  • original_query のカードパラメータ埋め込み で、聞き返し応答時に元の質問の文脈(例:「申請方法」)を保持。会話履歴に依存しない決定的な方式
  • is_clarification フラグ による無限ループ防止は必須。1回の聞き返しで最善の回答を返すポリシーがシンプルで効果的
  • _ensure_escalation() の後処理パターン は、LLM の構造化出力に定型文を確実に含めたいケースで汎用的に使えます

Google Chat の cardsV2 は、ダイアログを使わなくても textInput でフォームデータを受け取れるため、チャットボットのインタラクティブな体験を実現する手段として十分実用的です。

参考


国内企業 AI活用実態調査2026 配布中

クラスメソッドが独自に行なったAI診断調査をもとに、企業のAI活用の現在地を調査レポートとしてまとめました。企業規模別の活用度傾向に加え、規模を超えてAI活用を進める企業に共通する取り組みまで、自社の現在地を捉えるためのヒントにぜひ。

国内企業 AI活用実態調査2026

無料でダウンロードする

この記事をシェアする

関連記事