0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Chatworkシリーズ #18】Chatwork × Dify × GASで問い合わせ回答を自動提案する

0
Last updated at Posted at 2026-03-24

FAQ429件、抽出しただけで満足していた

前回の#9で、Chatwork 8ルームの全メッセージからFAQ429件を自動抽出した。

やった、これでFAQ完成だ。そう思っていた。

でも冷静に考えると、スプレッドシートに429件並んでいるだけだ。問い合わせが来たら、担当者はそのスプレッドシートを開いて、Ctrl+Fで似た質問を探して、回答をコピペして……やるわけがない。誰もやらない。

持っているだけのFAQは、存在しないのと同じだ。

抽出はゴールじゃない。使われて初めて意味がある。じゃあどうするか。問い合わせが来た瞬間に、FAQから回答案を自動で出せばいい。

ということで、Chatwork × Dify × GASで自動提案パイプラインを設計した。この記事では設計と実装方針を書く。まだ本番環境では動かしていない。動かしたら続編を書く。

設計したもの

Chatwork新着メッセージ
  ↓ Webhook
GAS doPost()
  ↓ HTTP POST
Dify Chat API(RAG検索: FAQ429件 + 過去回答)
  ↓ 回答案
Chatworkに「[自動提案]」付きで投稿

構成要素は3つだけ。Chatwork、Dify、GAS。新しいものは何もない。組み合わせるだけ。

ポイントはDifyの使い方。FAQ429件をナレッジとして食わせて、RAG(Retrieval-Augmented Generation)で検索させる。ベクトル検索で類似質問を引っ張り、回答案を生成する。

人間は提案を見て「これでOK」ならそのまま送る。ダメなら自分で書く。全自動ではなく、半自動。ここが設計上のポイントだと思っている。全自動は怖い。「AIが勝手に間違った回答を送った」は取り返しがつかない。

DifyのRAGセットアップ

ナレッジの投入

Difyにはナレッジ(Knowledge)機能がある。ドキュメントを投入すると、自動でチャンク分割→embedding→ベクトルDB格納まで走る。

FAQ429件はAPI経由で一括投入する想定。こう書く。

import requests
import json
import time

DIFY_API_KEY = "app-xxxxxxxxxxxxxxxx"  # 実際のキーは環境変数から
DATASET_ID = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
BASE_URL = "https://api.dify.ai/v1"

headers = {
    "Authorization": f"Bearer {DIFY_API_KEY}",
    "Content-Type": "application/json"
}

# スプレッドシートから読み込んだFAQリスト
# faq_list = [{"id": 1, "question": "...", "answer": "..."}, ...]

for faq in faq_list:
    payload = {
        "name": f"FAQ-{faq['id']:04d}",
        "text": f"質問: {faq['question']}\n回答: {faq['answer']}",
        "indexing_technique": "high_quality"
    }

    res = requests.post(
        f"{BASE_URL}/datasets/{DATASET_ID}/document/create_by_text",
        headers=headers,
        json=payload
    )

    if res.status_code == 200:
        print(f"FAQ-{faq['id']:04d} 投入完了")
    else:
        print(f"FAQ-{faq['id']:04d} 失敗: {res.status_code}")

    # レートリミット対策。急ぐ必要はない
    time.sleep(0.5)

過去の実回答もCSVアップロードで投入する予定。Difyの管理画面からKnowledge > Add Data Source > Upload FilesでCSVを放り込むだけでいけるはず。

チャンク分割の設計判断

ここは事前に調べて方針を決めた。複数のFAQを1つのドキュメントにまとめると、embeddingの精度が落ちる。「送料はいくらですか」と聞いたのに、同じチャンクに入っていた「返品ポリシー」の回答が混ざって返ってくる——という話はRAGの定番の罠。

1FAQ = 1ドキュメント = 1チャンクで組む。

FAQの質問と回答は意味的に1セット。これを分割したり、別のFAQと混ぜたりしない。429件なら429ドキュメント。数が多くてもDifyは問題なく捌けるはずだ。

Embedding Modelの選択

DifyはデフォルトでOpenAIのtext-embedding-ada-002を使う。日本語のFAQなら、まずこれで十分だろう。

text-embedding-3-smallのほうがコストは下がるので、新規で組むならこっちを試す。Dify側の設定はSettings > Model Providerから。APIキーを入れるだけで切り替わる。

カスタムプロンプト

RAGで検索した結果をそのままLLMに渡すと、当然ながらAI臭い回答が返ってくる。「〜することが可能です」「〜と考えられます」の嵐。事務局の回答としてはありえない。

Difyのアプリ設定で、システムプロンプトをこう組む。

あなたは事務局の担当者です。
以下のルールに従って回答案を作成してください。

## ルール
- 丁寧語を使う(です/ます調)
- 3行以内で簡潔に回答する
- 「〜することが可能です」「〜と考えられます」等のAI定型句は禁止
- 具体的な手順がある場合は箇条書きで示す
- 該当するFAQが見つからない場合は「該当するFAQが見つかりませんでした」とだけ返す
- 推測で回答を補完しない。FAQに書かれていないことは答えない

## コンテキスト
{context}

## 質問
{query}

{context}にRAGの検索結果が入り、{query}にユーザーの質問が入る。Difyの変数展開をそのまま使える。

「推測で補完しない」が一番大事だと思っている。FAQにない質問に対して、LLMが勝手にそれっぽい回答を作る問題。これをプロンプトで潰しておかないと、半自動の意味がなくなる。

GAS側の実装

Webhook受信(doPost)

ChatworkのWebhook設定でGASのURLを登録する。メッセージが投稿されるたびにdoPostが呼ばれる。この仕組み自体は#10でさんざんやった。

const DIFY_API_KEY = PropertiesService.getScriptProperties().getProperty("DIFY_API_KEY");
const CW_API_TOKEN = PropertiesService.getScriptProperties().getProperty("CW_API_TOKEN");
const BOT_ACCOUNT_ID = PropertiesService.getScriptProperties().getProperty("BOT_ACCOUNT_ID");

function doPost(e) {
  try {
    const data = JSON.parse(e.postData.contents);
    const event = data.webhook_event;

    // Webhookイベントの種類チェック
    if (data.webhook_event_type !== "message_created") return;

    // ★ 自分自身の投稿は無視(無限ループ防止)
    if (String(event.from_account_id) === BOT_ACCOUNT_ID) return;

    // メッセージ本文を取得
    const message = event.body;

    // 短すぎるメッセージはスキップ(スタンプや「了解」等)
    if (message.length < 10) return;

    // リプライやTo付きメッセージから本文だけ抽出
    const cleanMessage = extractBody(message);

    // Difyに問い合わせ
    const result = askDify(cleanMessage);

    // 信頼度が閾値以上なら回答案を投稿
    if (result.confidence >= 0.7) {
      const proposal = formatProposal(result);
      postToChatwork(event.room_id, proposal);
    }

  } catch (err) {
    console.error("doPost error:", err);
  }
}
/**
 * Chatworkのメッセージからメンション・引用を除去して本文だけ抽出
 */
function extractBody(message) {
  // [To:xxxx]Name を除去
  let clean = message.replace(/\[To:\d+\][^\n]*/g, "");
  // [引用 aid=xxxx ...]...[/引用] を除去
  clean = clean.replace(/\[引用[^\]]*\][\s\S]*?\[\/引用\]/g, "");
  // [info]...[/info] を除去
  clean = clean.replace(/\[info\][\s\S]*?\[\/info\]/g, "");
  // 先頭末尾の空白を除去
  return clean.trim();
}

extractBodyが地味に重要。Chatworkのメッセージには[To:xxxx][引用]タグが大量に含まれる。これをそのままDifyに渡すと、タグの文字列にembeddingが引きずられて検索精度が落ちる。このへんはChatwork APIを触り続けてきた経験から、最初から織り込んで設計した。

Dify Chat API呼び出し

function askDify(question) {
  const url = "https://api.dify.ai/v1/chat-messages";

  const options = {
    method: "post",
    headers: {
      "Authorization": "Bearer " + DIFY_API_KEY,
      "Content-Type": "application/json"
    },
    payload: JSON.stringify({
      inputs: {},
      query: question,
      response_mode: "blocking",
      user: "chatwork-webhook-bot"
    }),
    muteHttpExceptions: true
  };

  const res = UrlFetchApp.fetch(url, options);
  const statusCode = res.getResponseCode();

  if (statusCode !== 200) {
    console.error("Dify API error:", statusCode, res.getContentText());
    return { text: "", confidence: 0 };
  }

  const result = JSON.parse(res.getContentText());

  return {
    text: result.answer,
    confidence: extractConfidence(result),
    sources: extractSources(result)
  };
}

response_mode: "blocking"にしているのは、GASのdoPostが同期処理だから。Difyにはstreaming modeもあるが、GASでは使えない。レスポンスが返るまで待つ形になる。

FAQ検索なら通常1〜3秒で返ってくるはずなので、タイムアウトの心配はほぼないだろう。

信頼度の抽出

Dify Chat APIのレスポンスには、RAGの検索結果にscoreが含まれる。これを信頼度として使う設計。

function extractConfidence(result) {
  // metadata.retriever_resourcesから最も高いscoreを取得
  const resources = result.metadata?.retriever_resources;
  if (!resources || resources.length === 0) return 0;

  // 最上位の検索結果のスコアを信頼度とする
  const topScore = Math.max(...resources.map(r => r.score || 0));
  return topScore;
}

function extractSources(result) {
  const resources = result.metadata?.retriever_resources;
  if (!resources || resources.length === 0) return [];

  return resources.slice(0, 3).map(r => ({
    name: r.document_name,
    score: r.score
  }));
}

回答案のフォーマット

function formatProposal(result) {
  const sources = result.sources
    .map(s => `${s.name}${Math.round(s.score * 100)}%)`)
    .join("");

  return [
    "[info]",
    "[title]自動提案[/title]",
    result.text,
    "",
    `参照元: ${sources}`,
    `一致度: ${Math.round(result.confidence * 100)}%`,
    "[/info]"
  ].join("\n");
}

Chatworkの[info]タグで囲むことで、通常のメッセージと視覚的に区別できる。参照元のFAQ番号と一致度を添えて、担当者が「どのFAQに基づいた提案か」を判断できるようにする。

Chatworkへの投稿

function postToChatwork(roomId, message) {
  const url = `https://api.chatwork.com/v2/rooms/${roomId}/messages`;

  UrlFetchApp.fetch(url, {
    method: "post",
    headers: {
      "X-ChatWorkToken": CW_API_TOKEN
    },
    payload: {
      body: message,
      self_unread: 0  // 自分の未読にしない
    }
  });
}

self_unread: 0を忘れると、ボットの投稿が自分の未読に入り続けて通知地獄になる。#12で書いた既読制御の知識がここで活きる。

設計段階で潰しておくべき罠3つ

1. 無限ループ

Webhook系のボットで一番怖いやつ。ボットが回答案を投稿する→Webhookがそれを拾う→Difyに問い合わせる→また回答案を投稿する→以下無限。

#10のWebhook実装でこの手の問題は身をもって学んでいるので、最初からfrom_account_idのチェックを入れた。

if (String(event.from_account_id) === BOT_ACCOUNT_ID) return;

型が違う罠もある。Chatwork APIはアカウントIDを数値で返すことがあるので、String()で揃えている。ここをサボると「IDは同じなのに一致しない」という地獄が待っている。これも過去にやらかした。

2. AI臭い回答問題

Difyのデフォルトプロンプトのまま動かすと、こういう回答が返ってくる。

お問い合わせいただきありがとうございます。ご質問の件について回答させていただきます。配送料につきましては、全国一律で設定させていただいております。詳細につきましては、以下をご確認いただけますと幸いです。

長い。丁寧すぎる。何も言っていない。事務局の人間はこんな回答は書かない。

カスタムプロンプトで「3行以内」「AI定型句禁止」を指定すれば、こうなるはずだ。

配送料は全国一律XXX円です。
〇〇円以上のご注文で送料無料になります。

これなら使える。プロンプトの調整は地味だが、おそらく一番効果が大きい部分。

3. 信頼度の閾値設定

ここは正直、動かしてみないと分からない。ただ方針は決めている。

閾値は最初から高めに設定する。0.7スタート。低い閾値で精度の怪しい提案を乱発すると、担当者が「またハズレか」と思って提案自体を見なくなる。一度信用を失うと、精度を上げても見てもらえない。

「出さないほうがマシ」なラインを見極めるのが一番難しいはず。 最初から高めにして「提案が出たら信頼できる」という印象を植え付けるほうが戦略的に正しいと踏んでいる。

閾値の最適値は、実データで検証しないと語れない。ここは動かしてから続編で書く。

Chatwork独特のタグ問題

extractBodyのところで書いたが、もう少し掘り下げる。Chatworkのメッセージ形式はかなり独特で、RAGとの相性が悪い。

[To:12345678]太田さん
[引用 aid=87654321 time=1234567890]
前回の件ですが...
[/引用]
送料について教えてください。

本当に聞きたいのは最後の1行だけ。なのにメッセージの大半がタグで占められている。これをそのままembeddingに通すと、「12345678」とか「aid=87654321」みたいな数字列がベクトルに影響する。

extractBodyで全部剥がしてからDifyに渡す。この前処理をサボると、検索精度はまず出ない。17記事分Chatwork APIを触り続けてきた経験が、こういうところで効いてくる。

全体のコード構成

GASのファイル構成はこう設計した。

chatwork-dify-bot/
├── main.gs          # doPost + メインロジック
├── dify.gs          # Dify API呼び出し
├── chatwork.gs      # Chatwork API呼び出し
├── utils.gs         # extractBody, formatProposal等
└── config.gs        # 定数定義(スクリプトプロパティ経由)

GASでも適度にファイルを分けたほうがいい。1ファイルに全部書くと、Webhook処理のデバッグ中にDify側のコードを延々スクロールする羽目になる。

これから検証すること

設計はできた。コードも書いた。だが、まだ動かしていない。

これから実際にDifyにFAQ429件を投入し、パイプラインを組んで、本番のChatworkルームで動かす。そこで初めて分かることがいくつかある。

1. 閾値の最適値。0.7スタートと決めたが、実際のメッセージでどの程度のカバレッジと精度が出るかは未知数。高すぎれば提案がほとんど出ないし、低すぎればゴミが出る。ここは実データで調整するしかない。

2. カバレッジの拡張。回答案が出なかった問い合わせを蓄積して、人間が書いた回答とセットでDifyに再投入する。学習データが増えれば、カバーできる範囲は自然に広がるはず。

3. カテゴリ別の閾値調整。「送料」「納期」みたいな単純な質問と、「返品対応」「契約変更」みたいな複雑な質問では、必要な閾値が違うだろう。カテゴリ分類をDifyのワークフロー機能で前段に挟めば、動的な閾値設定もできるはず。

4. 担当者の反応。結局、使ってもらえるかどうかが全て。「あ、この提案使える」と思ってもらえるか。それとも邪魔だと思われて無視されるか。ここはツールの精度だけじゃなく、運用の入れ方の問題でもある。

429件のFAQを、スプレッドシートから引っ張り出して「使われるFAQ」にする。設計はできた。次は動かす。

Chatworkシリーズ


Chatworkシリーズ

0
1
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
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?