Google Chat Bot に構造化ログとフィードバックダイアログを実装してKB改善サイクルを回す

Google Chat Bot に構造化ログとフィードバックダイアログを実装してKB改善サイクルを回す

Google Chat Bot に構造化ログとフィードバックダイアログを実装し、Cloud Logging → BigQuery → Looker Studio で KB 改善サイクルを回す仕組みを構築しました。Workspace Add-ons 固有のイベント形式の違いや、Gemini 構造化出力によるフォールバック検知についても解説します。
2026.07.10

はじめに

前回の記事では、Vertex AI RAG Engine のナレッジベース(KB)更新時の同期の落とし穴について紹介しました。

KB を運用し始めると、次に気になるのは「ボットの回答品質をどう継続的に改善するか」です。ユーザーがどんな質問をしていて、どれだけ満足しているのか、どの質問に答えられていないのか — これらが見えないと、KB のどこを改善すべきか分かりません。

今回は、Google Chat Bot に構造化ログフィードバックダイアログを実装し、Cloud Logging → BigQuery → Looker Studio で KB 改善サイクルを回す仕組みを構築した話を紹介します。

やりたいこと

実現したいのは以下の改善サイクルです。

google-chat-bot-structured-logging-feedback-dialog-kb-cycle

具体的には 3 つのシグナルを捕捉します。

シグナル 意味 捕捉方法
回答成功 ユーザーの質問に回答できた usage イベント(is_fallback=false
フォールバック KB に該当情報がなく回答不可 usage イベント(is_fallback=true, severity=WARNING)
ユーザー評価 回答が役に立ったか feedback イベント(vote + 理由カテゴリ + コメント)

構成

項目 選択
ランタイム Cloud Functions 第2世代(Cloud Run ベース)
言語 Python 3.14
ログ基盤 Cloud Logging(Cloud Run 標準出力から自動収集)
分析基盤 BigQuery(ログシンクで転送)
可視化 Looker Studio(BigQuery に接続)

構造化ログの設計

なぜ構造化ログか

Cloud Run では、標準出力に JSON を書き出すだけで Cloud Logging が自動的にパースしてくれます。severity フィールドでログレベルを制御でき、logging.googleapis.com/trace フィールドでリクエストトレースとの紐付けも可能です。

外部ライブラリは不要で、Python の json.dumps + print だけで実現できます。

ログモジュールの実装

bot/log_events.py として薄いモジュールを作成しました。stdlib の logging との名前衝突を避けるため log_events.py としています。

bot/log_events.py
import json
import os
import threading
from typing import Any

_local = threading.local()
_PROJECT_ID = os.environ.get("GCP_PROJECT_ID", "")

def set_request_context(*, trace_header: str = "") -> None:
    """リクエストごとのトレースコンテキストを設定する。"""
    trace = ""
    if trace_header and _PROJECT_ID:
        trace_id = trace_header.split("/")[0]
        trace = f"projects/{_PROJECT_ID}/traces/{trace_id}"
    _local.trace = trace

def log_event(severity: str, event_type: str, **fields: Any) -> None:
    """構造化 JSON ログエントリを stdout に書き出す。"""
    entry: dict[str, Any] = {
        "severity": severity,
        "event_type": event_type,
        **fields,
    }
    trace = getattr(_local, "trace", "")
    if trace:
        entry["logging.googleapis.com/trace"] = trace

    print(json.dumps(entry, ensure_ascii=False, default=str), flush=True)

ポイントは以下の通りです。

  • threading.local() でスレッドごとにトレース ID を保持。バックグラウンドスレッドで処理するボットの構造に合わせている
  • X-Cloud-Trace-Context ヘッダーからトレース ID を抽出し、Cloud Logging のトレース相関に対応
  • ensure_ascii=False で日本語をエスケープせずそのまま出力

イベントスキーマ

2 種類のイベントを定義しました。

usage イベント(クエリごとに 1 件):

{
  "severity": "INFO",
  "event_type": "usage",
  "user_id": "users/123456",
  "space_id": "spaces/AAAA",
  "message_id": "spaces/AAAA/messages/BBBB",
  "query_text": "有給の申請方法は?",
  "query_length": 10,
  "rag_confidence": 0.35,
  "rag_sources_count": 3,
  "response_text": "有給休暇の申請は、社内ポータルの「勤怠管理」から...",
  "response_length": 520,
  "latency_ms": 3200,
  "is_fallback": false,
  "error": null
}

feedback イベント(ユーザーが評価ボタンを押したとき):

{
  "severity": "INFO",
  "event_type": "feedback",
  "user_id": "users/123456",
  "space_id": "spaces/AAAA",
  "message_id": "spaces/AAAA/messages/BBBB",
  "vote": "down",
  "reason_category": "inaccurate",
  "comment_text": "参照先が古い"
}

message_id が両イベントの結合キーです。BigQuery で JOIN すれば、どの質問にどんなフィードバックがあったかを紐付けられます。

フォールバック(KB に情報がなく回答できないケース)は usage イベントの is_fallback=true で捕捉し、severity を WARNING に上げています。Cloud Logging 上で目立つので見落としにくくなります。

フィードバック UX の設計

業界のベストプラクティス: 段階的フィードバック

ChatGPT や Copilot など主要なチャットボットのフィードバック UX を調査したところ、段階的(tiered)フィードバックが標準パターンでした。

段階 内容 フリクション 回答率
第1段階 👍 / 👎 ボタン ゼロ 15-30%
第2段階 理由カテゴリ(👎 のみ) 第1段階の約50%
第3段階 自由テキスト(任意) 第2段階の10-20%

重要な設計判断として、👍 はワンクリックで完了、👎 のみダイアログを表示します。「なぜ良かったか」のデータは改善アクションに直結しにくいですが、「なぜ悪かったか」のデータは KB のどこを直せばいいかの具体的なヒントになるためです。

Google Chat のダイアログ API

Google Chat API はボタンクリックからダイアログ(モーダルフォーム)を開く機能を提供しています。ボタンの onClick.action"interaction": "OPEN_DIALOG" を設定すると、レスポンスで返したカード本文がダイアログとして表示されます。

フィードバックフロー

実装したフローは以下の通りです。

google-chat-bot-structured-logging-feedback-dialog-feedback-flow

実装

フィードバックボタンの変更

既存のフィードバックボタンを修正し、ラベルの日本語化と 👎 ボタンへの OPEN_DIALOG の追加を行いました。

SCR-20260710-lugz (1)

bot/cards.py
def _build_feedback_section(message_name, endpoint_url):
    msg_id = message_name or ""
    function_url = endpoint_url or ""
    return {
        "widgets": [{
            "buttonList": {
                "buttons": [
                    {
                        "text": "参考になった",
                        "onClick": {
                            "action": {
                                "function": function_url,
                                "parameters": [
                                    {"key": "action", "value": "feedback"},
                                    {"key": "vote", "value": "up"},
                                    {"key": "message_id", "value": msg_id},
                                ],
                            }
                        },
                    },
                    {
                        "text": "改善が必要",
                        "onClick": {
                            "action": {
                                "function": function_url,
                                "interaction": "OPEN_DIALOG",  # ダイアログを開く
                                "parameters": [
                                    {"key": "action", "value": "feedback"},
                                    {"key": "vote", "value": "down"},
                                    {"key": "message_id", "value": msg_id},
                                ],
                            }
                        },
                    },
                ]
            }
        }]
    }

"interaction": "OPEN_DIALOG"onClick.action の直下に配置します。これを設定すると、ボタンクリック時に Google Chat が CARD_CLICKED イベントを送信し、ボットのレスポンスをダイアログとして表示してくれます。

ダイアログのカード構築

👎 クリック時に返すダイアログのカード本文です。

SCR-20260708-pinz (1)

bot/cards.py
def build_feedback_dialog(message_id, endpoint_url):
    function_url = endpoint_url or ""
    msg_id = message_id or ""
    return {
        "action": {
            "navigations": [{
                "pushCard": {
                    "sections": [{
                        "header": "フィードバック",
                        "widgets": [
                            {
                                "textParagraph": {
                                    "text": "回答の改善のため、詳細を教えてください。",
                                }
                            },
                            {
                                "selectionInput": {
                                    "type": "DROPDOWN",
                                    "label": "理由",
                                    "name": "reason_category",
                                    "items": [
                                        {"text": "不正確", "value": "inaccurate", "selected": True},
                                        {"text": "関連性が低い", "value": "irrelevant"},
                                        {"text": "不完全", "value": "incomplete"},
                                        {"text": "その他", "value": "other"},
                                    ],
                                }
                            },
                            {
                                "textInput": {
                                    "label": "コメント(任意)",
                                    "type": "MULTIPLE_LINE",
                                    "name": "comment_text",
                                }
                            },
                            {
                                "buttonList": {
                                    "buttons": [
                                        {
                                            "text": "送信",
                                            "onClick": {
                                                "action": {
                                                    "function": function_url,
                                                    "parameters": [
                                                        {"key": "action", "value": "feedback"},
                                                        {"key": "vote", "value": "down"},
                                                        {"key": "message_id", "value": msg_id},
                                                        {"key": "submit", "value": "true"},
                                                    ],
                                                }
                                            },
                                        },
                                        {
                                            "text": "スキップ",
                                            "onClick": {
                                                "action": {
                                                    "function": function_url,
                                                    "parameters": [
                                                        {"key": "action", "value": "feedback"},
                                                        {"key": "vote", "value": "down"},
                                                        {"key": "message_id", "value": msg_id},
                                                        {"key": "submit", "value": "true"},
                                                        {"key": "skipped", "value": "true"},
                                                    ],
                                                }
                                            },
                                        },
                                    ]
                                }
                            },
                        ]
                    }]
                }
            }]
        }
    }

重要: Workspace Add-ons(HTTP エンドポイント方式)では、ダイアログのレスポンス形式が通常の Chat API と異なります。通常の Chat API ドキュメントでは actionResponse.type = "DIALOG"dialogAction.dialog.body と記載されていますが、Add-ons では action.navigations[{pushCard}] 形式を使います。この違いについては後述の「ハマりポイント」で詳しく説明します。

ダイアログ内の送信ボタンには {"key": "submit", "value": "true"} パラメータを付与しています。これはダイアログ送信と最初のダウンボートクリックを区別するために必要です(理由は後述)。

ルーティングの拡張

ボタンクリックイベントは chat.buttonClickedPayload の有無で判定します。さらに、ダイアログ内の送信ボタンクリックは submit パラメータで区別します。

main.py
chat = body.get("chat", {})
common_event = body.get("commonEventObject", {})
params = common_event.get("parameters", {})

host = request.headers.get("X-Forwarded-Host") or request.headers.get("Host", "")
scheme = request.headers.get("X-Forwarded-Proto", "https")
service = os.environ.get("K_SERVICE", "")
endpoint_url = f"{scheme}://{host}/{service}" if host else ""

if "buttonClickedPayload" in chat:
    if params.get("action") != "feedback":
        return {}

    # ダイアログ送信(送信/スキップボタン)
    if params.get("submit") == "true":
        return feedback.handle_dialog_submit(body)

    # カードボタンクリック(参考になった / 改善が必要)
    return feedback.handle_card_click(body, endpoint_url=endpoint_url)

Workspace Add-ons では invokedFunction は空文字列になるため、ルーティングには使えません。代わりに buttonClickedPayload キーの有無で判定します。endpoint_url もリクエストヘッダーから組み立てる必要があります(詳細は後述の「ハマりポイント」参照)。

フィードバックハンドラの分岐

フィードバック送信後、元の回答カードを丸ごと置換してしまうと、ユーザーが回答を参照できなくなります。そこで GET-then-PATCH 方式を採用し、messages.get で現在のカード内容を取得した上で、フィードバックボタンのセクションだけを「ありがとう」テキストに差し替えます。

bot/feedback.py
_THANK_YOU_TEXT = "フィードバックありがとうございます!"

def _build_thank_you_body(current_message):
    """既存セクションを保持し、フィードバックボタンだけを「ありがとう」に差し替える。"""
    cards_v2 = current_message.get("cardsV2", [])
    if not cards_v2:
        return _fallback_thank_you_card()

    card = cards_v2[0].get("card", {})
    sections = list(card.get("sections", []))
    if not sections:
        return _fallback_thank_you_card()

    last_section = sections[-1]
    last_widgets = last_section.get("widgets", [])

    # 冪等性ガード: 既に「ありがとう」テキストがあればスキップ
    if (len(last_widgets) == 1
            and "textParagraph" in last_widgets[0]
            and _THANK_YOU_TEXT in last_widgets[0]["textParagraph"].get("text", "")):
        return None

    # フィードバックボタンのセクションを除去
    if any("buttonList" in w for w in last_widgets):
        sections = sections[:-1]

    sections.append({
        "widgets": [{"textParagraph": {"text": _THANK_YOU_TEXT}}]
    })
    return {
        "cardsV2": [{
            "cardId": cards_v2[0].get("cardId", "progressive-card"),
            "card": {"sections": sections},
        }]
    }

def _replace_feedback_with_thanks(client, message_id):
    """GET → 加工 → PATCH でフィードバックボタンを「ありがとう」に差し替える。"""
    current_message = client.get_message(message_id)
    body = _build_thank_you_body(current_message)
    if body is not None:
        client.patch_message(message_id, body, "cardsV2")

_build_thank_you_body は API レスポンスからカードのセクション一覧を取得し、最後のセクションが buttonList を含む場合は除去、「ありがとう」テキストセクションを末尾に追加して返します。既にありがとうテキストが存在する場合は None を返し(冪等性ガード)、パッチをスキップします。

ハンドラからは共通ヘルパー _replace_feedback_with_thanks を呼びます。

bot/feedback.py
def handle_card_click(event_body, endpoint_url=""):
    vote, message_id, user_id, space_id = _extract_feedback_params(event_body)

    if vote == "up":
        log_event("INFO", "feedback",
                  user_id=user_id, space_id=space_id,
                  message_id=message_id, vote=vote,
                  reason_category=None, comment_text=None)
        try:
            client = ChatApiClient()
            _replace_feedback_with_thanks(client, message_id)
        except Exception:
            logger.warning("Failed to patch message after upvote", exc_info=True)
        return {}

    # vote == "down": ダイアログを開く(この時点ではログしない)
    return build_feedback_dialog(message_id, endpoint_url)

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

    vote, message_id, user_id, space_id = _extract_feedback_params(event_body)
    skipped = params.get("skipped", "false") == "true"

    reason_category = None
    comment_text = None
    if not skipped:
        reason_raw = form_inputs.get("reason_category", {})
        reason_values = reason_raw.get("stringInputs", {}).get("value", [])
        reason_category = reason_values[0] if reason_values else None

        comment_raw = form_inputs.get("comment_text", {})
        comment_values = comment_raw.get("stringInputs", {}).get("value", [])
        comment_text = comment_values[0] if comment_values else None

    log_event("INFO", "feedback",
              user_id=user_id, space_id=space_id,
              message_id=message_id, vote=vote,
              reason_category=reason_category, comment_text=comment_text)

    # 元のメッセージのフィードバックボタンを「ありがとう」に差し替え
    try:
        client = ChatApiClient()
        _replace_feedback_with_thanks(client, message_id)
    except Exception:
        logger.warning("Failed to patch message after feedback", exc_info=True)

    # ダイアログを閉じる
    return {
        "action": {
            "navigations": [{
                "endNavigation": {"action": "CLOSE_DIALOG"},
            }],
            "notification": {
                "text": _THANK_YOU_TEXT,
            },
        }
    }

ダイアログのフォームデータは commonEventObject.formInputs に入っています。各ウィジェットの name をキーにして stringInputs.value から値を取得します。

ダイアログを閉じるには action.navigations[{endNavigation: {action: "CLOSE_DIALOG"}}] を返します。notification.text でトースト通知も表示できます。ただし、これだけでは元のメッセージのフィードバックボタンは残ったままになるため、_replace_feedback_with_thanks で GET-then-PATCH しています。

usage イベントの発行

パイプライン完了後(成功・失敗問わず)に usage イベントを発行します。

bot/worker.py
def _emit_usage_event(start_time, user_name, space_name, message_name,
                      user_text, contexts, state, error_str, is_fallback):
    latency_ms = int((time.monotonic() - start_time) * 1000)
    best_score = contexts[0]["score"] if contexts else None
    severity = "ERROR" if error_str else ("WARNING" if is_fallback else "INFO")
    log_event(
        severity, "usage",
        user_id=user_name, space_id=space_name,
        message_id=message_name,
        query_text=user_text, query_length=len(user_text),
        rag_confidence=best_score, rag_sources_count=len(contexts),
        response_text="\n".join(state.content_paragraphs),
        response_length=sum(len(p) for p in state.content_paragraphs),
        latency_ms=latency_ms, is_fallback=is_fallback, error=error_str,
    )

is_fallback は呼び出し元で事前に判定し、パラメータとして渡します(判定ロジックは後述の「フォールバック検知の落とし穴」で解説)。

severity の使い分けがポイントです。

  • INFO: 通常の回答成功
  • WARNING: フォールバック(KB ギャップ)— Cloud Logging 上で目立つ
  • ERROR: パイプライン実行エラー

Cloud Logging だけでは足りない理由

ここまでで構造化ログとフィードバックの仕組みは完成しました。Cloud Logging にきれいな JSON が並んでいます。しかし、いざ「KB 改善に活かそう」とすると、いくつかの壁にぶつかります。

確認するたびにクエリを手入力

Cloud Logging でフィードバックを確認するには、毎回こうしたフィルタクエリを手入力する必要があります。

resource.type="cloud_run_revision"
AND resource.labels.service_name="your-bot-name"
AND jsonPayload.event_type="feedback"
AND jsonPayload.vote="down"

「未回答の質問を見たい」→ クエリを書き換え。「今週の利用件数は?」→ また書き換え。保存クエリ機能もありますが、目的別にクエリを管理・共有する仕組みとしては貧弱です。

SCR-20260708-pnzm (4)

JSON を一件ずつ展開して読む

検索結果は JSON エントリの一覧です。query_textreason_category を確認するには、各エントリを展開して jsonPayload の中身を読む必要があります。10 件程度ならまだしも、「先月の低評価を全部確認したい」となると現実的ではありません。

集計・クロス分析ができない

Cloud Logging は検索ツールであって分析ツールではありません。以下はすべて不可能です。

  • 週次の利用件数やフォールバック率の推移
  • 低評価の理由カテゴリ別の集計
  • usage イベントと feedback イベントの JOIN(「どの質問にどんな理由で低評価がついたか」)

特に JOIN ができないのは致命的です。構造化ログで message_id を結合キーとして設計しましたが、Cloud Logging 上では 2 つのクエリを実行して message_id を目視で照合するしかありません。

30 日でデータが消える

Cloud Logging のデフォルト保持期間は 30 日です。KB の改善サイクルを回すには月次・四半期のトレンドを追いたいですが、30 日前のデータには二度とアクセスできません。

BigQuery への転送

これらの課題をすべて解決するのが、Cloud Logging → BigQuery のログシンクです。構造化ログを自動的に BigQuery テーブルに転送する仕組みで、設定は 2 コマンドで完了します。

# BigQuery データセットを作成
bq mk --dataset --location=asia-northeast1 YOUR_PROJECT:chat_analytics

# ログシンクを作成(usage と feedback イベントのみ転送)
gcloud logging sinks create kb-analytics-sink \
  bigquery.googleapis.com/projects/YOUR_PROJECT/datasets/chat_analytics \
  --log-filter='jsonPayload.event_type=("usage" OR "feedback")'

ログシンク作成時に表示されるサービスアカウントに、BigQuery データセットへの「BigQuery データ編集者」ロールを付与すれば、以降のログが自動的に BigQuery に流れます。ログシンクに TTL はなく、BigQuery テーブルにもデフォルトの有効期限はないため、データは永続的に保持されます。

コストも心配不要です。ボットの利用量(1 日数十件程度)では BigQuery のストレージ・クエリ共に無料枠内に収まり、月額 $1 未満で運用できます。

BigQuery ビューでクエリを簡潔に

Cloud Logging から転送されたテーブルは jsonPayload.query_text のようにネストが深く、毎回書くのは面倒です。ビューを作成してフラット化しておくと、分析が格段に楽になります。

SCR-20260709-jqxp (2)

-- usage イベント用ビュー
CREATE OR REPLACE VIEW `chat_analytics.usage_events` AS
SELECT
  timestamp,
  jsonPayload.user_id,
  jsonPayload.space_id,
  jsonPayload.message_id,
  jsonPayload.query_text,
  jsonPayload.response_text,
  jsonPayload.rag_confidence,
  jsonPayload.rag_sources_count,
  jsonPayload.latency_ms,
  jsonPayload.is_fallback,
  jsonPayload.error
FROM `chat_analytics.YOUR_TABLE`
WHERE jsonPayload.event_type = 'usage';

-- feedback イベント用ビュー
CREATE OR REPLACE VIEW `chat_analytics.feedback_events` AS
SELECT
  timestamp,
  jsonPayload.user_id,
  jsonPayload.space_id,
  jsonPayload.message_id,
  jsonPayload.vote,
  jsonPayload.reason_category,
  jsonPayload.comment_text
FROM `chat_analytics.YOUR_TABLE`
WHERE jsonPayload.event_type = 'feedback';

ビューを使えば、Cloud Logging では不可能だった分析がシンプルな SQL で即座にできます。

未回答の質問(KB ギャップ)を頻度順に表示:

SELECT query_text, COUNT(*) as count
FROM `chat_analytics.usage_events`
WHERE is_fallback = true
GROUP BY query_text
ORDER BY count DESC

繰り返し出現するクエリが、KB に追加すべき記事の候補です。

低評価の回答を質問・回答・理由付きで一覧表示:

SELECT u.query_text, u.response_text,
       f.reason_category, f.comment_text
FROM `chat_analytics.usage_events` u
JOIN `chat_analytics.feedback_events` f
  ON u.message_id = f.message_id
WHERE f.vote = 'down'
ORDER BY f.timestamp DESC

Cloud Logging では 2 つのクエリを実行して message_id を目視照合していた作業が、JOIN 一本で完了します。「どの質問に対してどんな理由で低評価がついたか」が一目で分かり、KB のどの記事を改善すべきかの判断材料になります。

Looker Studio でダッシュボード化

BigQuery で SQL を使った分析ができるようになりましたが、まだ「SQL を書ける人しか使えない」という課題が残ります。KB を改善するのはエンジニアだけではありません。運用担当者やナレッジ管理者が自分で状況を確認できるのが理想です。

ここで Looker Studio(旧 Google Data Studio)の出番です。BigQuery のビューに接続してダッシュボードを作成でき、完全無料で利用できます。

ダッシュボード項目 内容 改善アクション
週次利用件数 ボットの利用状況の推移 利用促進の効果測定
フォールバック率 回答できなかった質問の割合 KB カバレッジの KPI
未回答クエリ一覧 KB に追加すべき質問のリスト KB 記事の追加
低評価の回答 質問・回答・理由を一画面で確認 既存 KB 記事の改善
評価比率 高評価/低評価の割合推移 改善施策の効果測定

ダッシュボードは URL をブックマークして開くだけです。GCP コンソールの操作も SQL の知識も不要で、データは自動的に最新のログを反映します。

Cloud Logging / BigQuery / Looker Studio の役割分担

最終的な構成は「ログ収集 → 分析基盤 → 可視化」の 3 層になります。

項目 Cloud Logging BigQuery Looker Studio
役割 リアルタイムのログ検索・デバッグ データ分析・集計 ダッシュボード・可視化
対象ユーザー エンジニア SQL が書ける人 誰でも
データ確認 毎回クエリを手入力 SQL で保存・再利用可能 URL を開くだけ
集計・分析 不可 GROUP BY, COUNT, JOIN 等 グラフ・表で自動可視化
トレンド分析 不可 可能 グラフで一目瞭然
データ保持 30 日 無期限 (BigQuery に依存)
コスト 無料(Cloud Run 付属) ほぼ無料(~$0/月) 無料

Cloud Logging を否定しているわけではありません。リアルタイムのデバッグやエラー調査には Cloud Logging が最適です。ただし、KB 改善サイクルという目的には、構造化ログを BigQuery に流して Looker Studio で可視化するパイプラインが必要でした。

ハマりポイント: Workspace Add-ons と Chat API のイベント形式の違い

今回最も苦労したのは、Workspace Add-ons(HTTP エンドポイント方式)のイベント形式が、通常の Chat API(Apps Script や Cloud Functions 用)のドキュメントとは異なる点です。Google の公式ドキュメントは主に通常の Chat API を前提としており、Add-ons 固有の挙動は記載が少なく、実機デバッグで発見していく必要がありました。

以下、遭遇した問題と解決策をまとめます。

1. invokedFunction が空文字列

問題: 公式ドキュメントでは commonEventObject.invokedFunction にボタンの function URL が入ると記載されています。しかし Add-ons では、このフィールドは常に空文字列 "" です。

# ❌ 動かない(空文字列は falsy)
if common_event.get("invokedFunction"):
    ...

解決策: ボタンクリックの判定には chat.buttonClickedPayload キーの有無を使います。

# ✅ 正しい判定方法
if "buttonClickedPayload" in chat:
    ...

また、invokedFunction が空のため、ダイアログ内のボタンに設定する function URL も自分で組み立てる必要があります。

host = request.headers.get("X-Forwarded-Host") or request.headers.get("Host", "")
scheme = request.headers.get("X-Forwarded-Proto", "https")
service = os.environ.get("K_SERVICE", "")
endpoint_url = f"{scheme}://{host}/{service}" if host else ""

2. レスポンスベースのカード更新が効かない

問題: 通常の Chat API では、ボタンクリックへのレスポンスで {"actionResponse": {"type": "UPDATE_MESSAGE"}, "cardsV2": [...]} を返せばカードを更新できます。Add-ons ではこの形式が無視されます

# ❌ Add-ons では効果なし
return {"actionResponse": {"type": "UPDATE_MESSAGE"}, **thank_you_card}

解決策: Chat API の messages.get で現在のカード内容を取得し、フィードバックボタンのセクションだけを差し替えた上で patch_message で更新します。レスポンスは空 {} を返します。

# ✅ GET-then-PATCH で元の回答を保持しつつボタンだけ差し替え
client = ChatApiClient()
current_message = client.get_message(message_id)
body = _build_thank_you_body(current_message)  # ボタン除去 + ありがとう追加
if body is not None:
    client.patch_message(message_id, body, "cardsV2")
return {}

単純にカード全体を「ありがとう」カードに置換すると、元の回答コンテンツが消失してしまいます。messages.get で現在のカード構造を取得し、フィードバックボタンのセクションのみを除去することで、ステータスステップや回答テキストを保持できます。

3. ダイアログのレスポンス形式が異なる

問題: 通常の Chat API ドキュメントではダイアログを開くレスポンスとして以下の形式が記載されています。

# ❌ 通常の Chat API 形式(Add-ons では動かない)
return {
    "actionResponse": {
        "type": "DIALOG",
        "dialogAction": {
            "dialog": {"body": {"sections": [...]}}
        }
    }
}

Add-ons ではこの形式を返してもダイアログが一瞬表示されて即座に閉じてしまいます。

解決策: Add-ons では action.navigations 形式を使います。

# ✅ Add-ons でダイアログを開く
return {
    "action": {
        "navigations": [{
            "pushCard": {"sections": [...]}
        }]
    }
}

# ✅ Add-ons でダイアログを閉じる
return {
    "action": {
        "navigations": [{
            "endNavigation": {"action": "CLOSE_DIALOG"}
        }],
        "notification": {"text": "完了しました"},
    }
}

4. dialogEventType が送信されない

問題: 通常の Chat API では、ダイアログ内のボタンをクリックすると dialogEventType: "SUBMIT_DIALOG" フィールドがイベントに含まれます。しかし Add-ons ではこのフィールドが送信されません

つまり、ダイアログ内の送信ボタンクリックは通常のカードボタンクリックと同じ buttonClickedPayload イベントとして届き、イベント構造からは区別できません

# ❌ Add-ons では dialogEventType が存在しない
if body.get("dialogEventType") == "SUBMIT_DIALOG":
    ...

この結果、ダウンボートボタンのクリック(ダイアログを開く)とダイアログ内の送信ボタンのクリック(ダイアログを閉じる)が同じハンドラにルーティングされ、送信ボタンを押すたびにダイアログが再度開いてしまうという問題が発生しました。

解決策: ダイアログ内のボタンに独自の submit パラメータを追加して区別します。

# ダイアログ内の送信ボタンに submit=true パラメータを付与
{"key": "submit", "value": "true"}

# ルーティングで判定
if params.get("submit") == "true":
    return feedback.handle_dialog_submit(body)  # ダイアログ送信
return feedback.handle_card_click(body, ...)     # カードボタンクリック

5. フォールバック検知の落とし穴: キーワードマッチングから構造化出力へ

問題 1 — デッドコード: 当初、フォールバック判定をベクトル距離スコアに基づいて行っていました。しかし retrieve_context() が RAG Engine API に vector_distance_threshold=0.6 を渡しているため、API がサーバーサイドで距離 > 0.6 のチャンクを除外して返します。クライアント側で同じ閾値を再チェックするコードはデッドコードでした。

実際に起きた問題: 「Boxの利用方法について教えて」に対し、KB に該当記事がないのに、RAG Engine がベクトル空間上で近い(しかし無関係な)チャンクを返しました。距離スコアは ≤ 0.6 なのでフォールバック判定をすり抜け、usage ログの is_fallbackfalse のままでした。

問題 2 — キーワードマッチングの脆さ: 距離ベースのチェックを削除し、生成後のテキストマッチングに切り替えました。システムプロンプトで「該当する情報が見つかりませんでした」と回答するよう指示し、そのフレーズの有無で判定する方式です。しかし Gemini はプロンプトの指示に関わらず表現を言い換えてしまいます。「情報や手順は見つかりませんでした」「ナレッジベースには登録されておりません」など、バリエーションが次々と出現し、キーワードを追加するいたちごっこになりました。

解決策: Gemini の 構造化出力(Structured Output)を使い、LLM 自身に「回答できたか」を明示的に分類させます。

bot/prompts.py
from pydantic import BaseModel, Field

class RagAnswer(BaseModel):
    answer: str = Field(description="ナレッジベースの情報に基づく回答(Markdown形式)")
    is_answerable: bool = Field(
        description="ナレッジベースに質問に該当する情報があり回答できた場合はtrue、"
        "該当情報がなく回答できなかった場合はfalse",
    )
bot/rag.py
from google import genai
from google.genai import types

def generate_answer(question: str, contexts: list[dict]) -> RagAnswer:
    # ...
    response = client.models.generate_content(
        model=MODEL_ID,
        contents=prompt,
        config=types.GenerateContentConfig(
            system_instruction=SYSTEM_PROMPT,
            response_schema=RagAnswer,          # Pydantic モデルを直接渡せる
            response_mime_type="application/json",
        ),
    )
    return response.parsed  # バリデーション済みの RagAnswer インスタンス
bot/worker.py
result = generate_answer(user_text, contexts)
is_fallback = not result.is_answerable  # キーワードマッチング不要

このアプローチが機能する理由:

  • response_schema で JSON スキーマを強制するため、Gemini は必ず {answer, is_answerable} の構造で返す
  • is_answerable は boolean なので表現の揺れが発生しない
  • google-genai SDK は Pydantic モデルをネイティブサポートし、response.parsed でバリデーション済みインスタンスを返す
  • キーワードの追加・メンテナンスが不要になり、検知精度が 100% になった

教訓: RAG パイプラインのフォールバック検知では、リトリーバルスコアもキーワードマッチングも脆弱です。LLM の構造化出力で明示的に分類させるのが最も堅牢なアプローチです。

まとめ表

項目 通常の Chat API Workspace Add-ons
ボタンクリック判定 invokedFunction buttonClickedPayload キーの有無
カード更新 レスポンスで UPDATE_MESSAGE messages.getpatch_message(GET-then-PATCH)
ダイアログを開く actionResponse.DIALOG.dialogAction.dialog.body action.navigations[{pushCard}]
ダイアログを閉じる actionResponse.DIALOG.dialogAction.actionStatus action.navigations[{endNavigation}]
ダイアログ送信の判定 dialogEventType: "SUBMIT_DIALOG" 独自パラメータ(submit=true)で判定
function URL invokedFunction から取得 リクエストヘッダーから組み立て

Workspace Add-ons でダイアログを実装する際は、通常の Chat API のドキュメントをそのまま参考にすると動かないケースが多いため、注意が必要です。

まとめ

Google Chat Bot に構造化ログとフィードバックダイアログを実装し、KB 改善サイクルの基盤を構築しました。

項目 実装内容
構造化ログ JSON を stdout に書き出すだけで Cloud Logging が自動パース
usage イベント クエリごとに query_text, RAG メトリクス, レイテンシ等を記録
フォールバック検知 Gemini の構造化出力(is_answerable boolean)で LLM 自身に「回答不可」を分類させ、is_fallback=true + severity=WARNING で KB ギャップを可視化
フィードバック UX 👍 はワンクリック、👎 はダイアログで理由 + コメントを収集
データ分析 Cloud Logging → BigQuery ログシンク → ビューでフラット化 → Looker Studio で可視化

実装にあたって学んだポイントをいくつか共有します。

  • Cloud Run の構造化ログは外部ライブラリ不要json.dumps + print で十分
  • Google Chat のダイアログは interaction: "OPEN_DIALOG" をボタンに設定するだけで開ける。ただし、ダイアログを閉じるレスポンスで元のメッセージは更新されないため、messages.get で現在のカードを取得し patch_message でフィードバックボタンだけを差し替える GET-then-PATCH 方式が必要。元の回答コンテンツを保持しつつボタンのみ更新できる
  • Workspace Add-ons のイベント形式は通常の Chat API と異なるinvokedFunction は空、dialogEventType は送信されない、レスポンス形式も action.navigations 方式を使う。公式ドキュメントは通常の Chat API 前提で書かれているため、Add-ons 固有の挙動は実機デバッグで確認する必要がある
  • フィードバック設計では「ダイアログを閉じた = キャンセル」として扱い、「スキップ」ボタンで理由なし投票を可能にするのが自然な UX
  • RAG のフォールバック検知はリトリーバルスコアやキーワードマッチングでは不十分。リトリーバルスコアはサーバーサイドフィルタと重複してデッドコードになり、キーワードマッチングは LLM の表現の揺れに対応しきれない。Gemini の構造化出力(response_schema + Pydantic)で is_answerable boolean を返させるのが最も堅牢
  • 構造化ログは「出す」だけでは不十分。Cloud Logging はリアルタイムデバッグには最適だが、集計・JOIN・トレンド分析はできない。BigQuery ログシンク(2 コマンドで設定、ほぼ無料)→ ビューでフラット化 → Looker Studio(無料)で可視化という 3 層構成にすることで、非エンジニアも含めた KB 改善サイクルが回せるようになる

参考

この記事をシェアする

関連記事