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?

Python × Dify × RAGで学ぶ業務システム開発入門【第10回 FAQチャットボット編】

0
Last updated at Posted at 2026-06-20

はじめに

前回は、Dify Knowledgeを利用したRAG社内文書検索を実装しました。

前回の記事:Python × Dify × RAGで学ぶ業務システム開発入門【第9回 RAG構築編】

第10回では、Dify Chat APIを利用したFAQチャットボットを実装します。

本記事の内容は、以下のリポジトリにある最新ソースコードに合わせています。

aiota-jp/human-resource-ai

第9回の社内文書検索は、質問を送信して回答を1件表示する形式でした。第10回では、質問と回答を画面へ追加しながら、同じ会話の文脈を引き継げるようにします。

利用者がメッセージを入力
        ↓
JavaScriptのFetch APIでJSONを送信
        ↓
FlaskがDify Chat APIを呼び出す
        ↓
DifyがKnowledgeを参照して回答
        ↓
回答とconversation_idをJSONで返す
        ↓
チャット画面へ回答を追加

現行実装はストリーミング応答ではありません。Difyへ response_mode: "blocking" を指定し、回答全体を受信してから画面へ追加します。

本記事で学ぶこと

項目 内容
会話ID conversation_id でDifyの会話文脈を引き継ぐ
JSON API FlaskでJSONを受信し、jsonify() で結果を返す
Fetch API ページ遷移なしで質問を送信する
チャットUI 利用者とAIのメッセージを順番に追加する
生成中表示 「回答を生成中...」を表示する
二重送信防止 回答待ちの間は入力欄と送信ボタンを無効化する
エラー処理 空回答・タイムアウト・接続失敗などを表示する

第10回で実装する機能

機能 現行実装
初期メッセージ FAQチャット画面を開いたときに表示
メッセージ送信 Fetch APIで /chat/send へJSONをPOST
会話履歴表示 現在のページ内へ質問と回答を追加
文脈保持 Difyから返る conversation_id を次回送信
生成中表示 AI側の吹き出しに「回答を生成中...」を表示
二重送信防止 入力欄と送信ボタンを一時的に無効化
自動スクロール 新しいメッセージまでスクロール
エラー表示 AI側の吹き出しにエラー内容を表示

/chat と /chat/send はどちらもログイン必須ですが、ロール制限はありません。admin、staff、user、社員番号でログインした社員が利用できます。

会話IDで文脈を保持する

Dify Chat APIは、初回の応答で conversation_id を返します。2回目以降のリクエストに同じIDを含めると、Difyは同じ会話の続きとして処理します。

1回目
質問:「有給休暇の申請方法は?」
conversation_id:送らない
        ↓
Difyから conversation_id="abc123" が返る

2回目
質問:「何日前までですか?」
conversation_id="abc123" を送る
        ↓
前の質問を踏まえた回答が返る

現行実装では、会話IDをブラウザ側のJavaScript変数に保持します。

let conversationId = "";

そのため、同じページを開いている間は文脈を引き継げますが、次の場合はリセットされます。

  • ブラウザでページを再読み込みした場合
  • 別のページへ移動してから戻った場合
  • タブを閉じた場合

会話を再読み込み後も継続するには、Flaskセッションやデータベースへ conversation_id を保存する追加実装が必要です。

Dify側の準備

第9回で使用した、Knowledgeを参照するDifyアプリを引き続き利用します。新しいKnowledgeを作成する必要はありません。

Dify側で確認する項目

  1. 利用するChatアプリまたはChatflowが公開されている
  2. アプリから対象Knowledgeを参照できる
  3. 社内文書がKnowledgeへ登録されている
  4. アプリのAPIキーを取得している
  5. 根拠がない質問への回答方針を設定している

Difyアプリの作成、Knowledgeへの文書登録、APIキー取得については次の記事で解説しています。

Dify × OpenAI × RAG入門 ― 社内ナレッジを回答するRAGアプリケーション「Human Resource AI」を作ってみる

Difyへの指示例

あなたは社内FAQチャットボットです。

Knowledgeに登録された情報を優先し、質問へ簡潔かつ丁寧に回答してください。
根拠となる情報が確認できない場合は、推測せず、
「該当する情報が見つかりません」と回答してください。
重要な手続きは正式な規程または担当部署への確認を案内してください。

環境変数を設定する

第8回・第9回と同じ設定を利用します。

DIFY_API_KEY=取得したDifyアプリのAPIキー
DIFY_API_URL=https://api.dify.ai/v1

config.py では次のように読み込みます。

import os
from dotenv import load_dotenv

load_dotenv()


class Config:
    DIFY_API_KEY = os.getenv("DIFY_API_KEY", "")
    DIFY_API_URL = os.getenv("DIFY_API_URL", "https://api.dify.ai/v1")

.env は .gitignore へ追加し、APIキーをGitHubへコミットしないでください。

実装1:Dify Chat APIを呼び出す

services/dify_service.py の chat_with_faq() が、Difyへメッセージを送信します。

def chat_with_faq(message: str,
                  conversation_id: str = "") -> dict:
    """FAQチャットボットへ問い合わせ、回答と会話IDを返す。"""
    if not Config.DIFY_API_KEY:
        return {
            "success": False,
            "answer": "",
            "conversation_id": conversation_id,
            "error": (
                "DIFY_API_KEY が設定されていません"
                "(.env を確認してください)"
            ),
        }

    payload = {
        "inputs": {},
        "query": message,
        "response_mode": "blocking",
        "user": "human-resource-app",
    }

    if conversation_id:
        payload["conversation_id"] = conversation_id

    try:
        res = requests.post(
            f"{Config.DIFY_API_URL}/chat-messages",
            headers=_headers(),
            json=payload,
            timeout=30,
        )
        res.raise_for_status()
        data = res.json()
        answer = data.get("answer", "").strip()

        if not answer:
            return {
                "success": False,
                "answer": "",
                "conversation_id": data.get(
                    "conversation_id", conversation_id
                ),
                "error": "AIからの回答が空でした",
            }

        return {
            "success": True,
            "answer": answer,
            "conversation_id": data.get(
                "conversation_id", conversation_id
            ),
            "error": "",
        }

    except requests.exceptions.Timeout:
        return {
            "success": False,
            "answer": "",
            "conversation_id": conversation_id,
            "error": "Dify APIへのリクエストがタイムアウトしました",
        }
    except requests.exceptions.ConnectionError:
        return {
            "success": False,
            "answer": "",
            "conversation_id": conversation_id,
            "error": "Dify APIに接続できません",
        }
    except requests.RequestException as exc:
        return {
            "success": False,
            "answer": "",
            "conversation_id": conversation_id,
            "error": (
                "FAQチャットの呼び出しでエラーが発生しました: "
                f"{exc}"
            ),
        }

リクエスト項目

項目 値 役割
inputs {} Difyアプリへ渡す追加変数。現行実装では未使用
query 利用者のメッセージ Difyへ送る質問
response_mode blocking 回答全体が生成されるまで待つ
user human-resource-app Dify側の固定利用者ID
conversation_id 2回目以降に追加 同じ会話の文脈を引き継ぐ

戻り値

成功時も失敗時も、同じキーを持つ辞書を返します。

{
    "success": True,
    "answer": "AIの回答",
    "conversation_id": "Difyから返された会話ID",
    "error": "",
}

この形式に揃えることで、FlaskルートとJavaScriptが結果を判定しやすくなります。

エラー処理

状態 error の内容
APIキー未設定 .env の確認を案内する
回答が空 AIからの回答が空でした
30秒超過 タイムアウトしたことを表示する
接続失敗 Dify APIへ接続できないことを表示する
その他のHTTPエラー リクエスト例外の内容を含める

評価コメント生成機能とは異なり、FAQチャットではフォールバック回答を作りません。Difyを利用できない場合は success: false を返し、画面にエラーを表示します。

実装2:検索サービスのラッパー

services/search_service.py の send_chat_message() は、空欄を確認してからDify連携を呼び出します。

def send_chat_message(message: str,
                      conversation_id: str = "") -> dict:
    if not message.strip():
        return {
            "success": False,
            "answer": "",
            "conversation_id": conversation_id,
            "error": "メッセージを入力してください",
        }
    return chat_with_faq(message, conversation_id)

ブラウザ側でも空欄を防ぎますが、サービス側でも検証することで、画面を経由しない呼び出しに備えます。

実装3:Flaskのルーティング

チャット画面

GET /chat は、FAQチャットのテンプレートを表示します。

@app.route("/chat")
@login_required
def chat():
    """FAQチャット画面"""
    return render_template("chat.html")

メッセージ送信API

POST /chat/send はJSONを受け取り、結果をJSONで返します。

@app.route("/chat/send", methods=["POST"])
@login_required
def chat_send():
    """FAQチャットのメッセージ送信API"""
    data = request.get_json(silent=True) or {}
    message = data.get("message", "").strip()
    conversation_id = data.get("conversation_id", "")

    if not message:
        return jsonify({
            "success": False,
            "answer": "",
            "conversation_id": conversation_id,
            "error": "メッセージを入力してください",
        })

    result = send_chat_message(message, conversation_id)
    return jsonify(result)

request.get_json(silent=True) or {} とすることで、JSONがない場合や解析できない場合も空の辞書として処理します。

APIの入出力例

リクエスト例:

{
  "message": "有給休暇の申請方法は?",
  "conversation_id": ""
}

成功レスポンス例:

{
  "success": true,
  "answer": "有給休暇は社内システムから申請してください。",
  "conversation_id": "abc123",
  "error": ""
}

現行実装では、入力エラーやDifyエラーの場合もHTTPステータスは明示的に変更せず、JSON内の success で成否を判定します。

実装4:チャット画面

テンプレートは templates/chat.html です。

HTML

{% extends "base.html" %}
{% block title %}FAQチャット - AI人事研修システム{% endblock %}

{% block content %}
<div class="page-header">
  <h1>💬 FAQチャット</h1>
</div>

<div class="chat-box" id="chat-messages">
  <div class="chat-message assistant">
    <div class="bubble">
      こんにちは。社内FAQチャットボットです。<br>
      就業規則や社内マニュアルについてお気軽にご質問ください。
    </div>
  </div>
</div>

<form class="chat-form" id="chat-form">
  <input
    type="text"
    id="chat-input"
    name="message"
    placeholder="例: 有給休暇の申請方法は?"
    autocomplete="off"
    required
  >
  <button class="btn btn-primary" id="send-btn" type="submit">
    送信
  </button>
</form>
{% endblock %}

通常のフォーム送信は使わず、JavaScriptで submit イベントを受け取り、Fetch APIで通信します。そのためページ全体を再読み込みせず、既存の質問と回答を残せます。

実装5:JavaScriptでメッセージを送る

要素と会話IDを取得する

let conversationId = "";

const form = document.getElementById("chat-form");
const input = document.getElementById("chat-input");
const sendButton = document.getElementById("send-btn");
const messages = document.getElementById("chat-messages");

form.addEventListener("submit", sendMessage);

Fetch APIで送信する

async function sendMessage(event) {
  event.preventDefault();

  const message = input.value.trim();
  if (!message) return;

  appendMessage("user", message);
  input.value = "";
  setSending(true);

  const loading = appendMessage(
    "assistant",
    "回答を生成中...",
    true
  );

  try {
    const response = await fetch("{{ url_for('chat_send') }}", {
      method: "POST",
      headers: {"Content-Type": "application/json"},
      body: JSON.stringify({
        message: message,
        conversation_id: conversationId,
      }),
    });

    const data = await response.json();
    loading.remove();

    if (data.success) {
      appendMessage("assistant", data.answer);
      conversationId = data.conversation_id || conversationId;
    } else {
      appendMessage(
        "assistant",
        "エラー: "
          + (data.error || "回答を取得できませんでした。")
      );
    }
  } catch (error) {
    loading.remove();
    appendMessage("assistant", "通信エラーが発生しました。");
  } finally {
    setSending(false);
    input.focus();
  }
}

処理の流れは次のとおりです。

  1. 通常のフォーム送信を止める
  2. 空白を除いたメッセージが空なら終了する
  3. 利用者の質問を画面へ追加する
  4. 入力欄を空にする
  5. 入力欄と送信ボタンを無効化する
  6. 「回答を生成中...」を表示する
  7. FlaskへJSONを送信する
  8. 成功時は回答と会話IDを反映する
  9. 失敗時はエラーをAI側の吹き出しへ表示する
  10. 最後に入力欄とボタンを再び有効化する

メッセージを安全に追加する

function appendMessage(role, content, isLoading = false) {
  const row = document.createElement("div");
  row.className =
    "chat-message " + role + (isLoading ? " loading" : "");

  const bubble = document.createElement("div");
  bubble.className = "bubble";
  bubble.textContent = content;

  row.appendChild(bubble);
  messages.appendChild(row);
  messages.scrollTop = messages.scrollHeight;
  return row;
}

innerHTML ではなく textContent へ質問と回答を設定しています。これにより、メッセージにHTMLタグが含まれていてもHTMLとして実行されず、文字列として表示されます。

送信状態を切り替える

function setSending(sending) {
  input.disabled = sending;
  sendButton.disabled = sending;
  sendButton.textContent = sending ? "送信中..." : "送信";
}

回答待ちの間は入力欄と送信ボタンを無効化し、同じ質問が何度も送信されることを防ぎます。処理終了後は finally で必ず元へ戻します。

実装6:チャットUIのCSS

static/css/style.css では、利用者とAIのメッセージ位置や生成中表示を定義しています。

.chat-box {
  padding: 18px;
  min-height: 320px;
  max-height: 560px;
  overflow-y: auto;
  scroll-behavior: smooth;
}

.chat-message {
  display: flex;
  margin: 10px 0;
}

.chat-message.user {
  justify-content: flex-end;
}

.chat-message.assistant {
  justify-content: flex-start;
}

.bubble {
  max-width: 70%;
  padding: 11px 14px;
  border-radius: 16px;
  background: #eef2f7;
}

.chat-message.user .bubble {
  background: var(--blue);
  color: #fff;
}

.chat-message.loading .bubble {
  color: #607d8b;
  font-style: italic;
}

.chat-form button:disabled,
.chat-form input:disabled {
  opacity: 0.65;
  cursor: not-allowed;
}

画面幅が700px以下では、吹き出しの最大幅を92%にし、入力欄と送信ボタンを1列に配置します。

動作確認

1. アプリを起動する

Windows PowerShellで次を実行します。

python app.py

2. FAQチャットを開く

ログイン後、次のURLを開きます。

http://localhost:5000/chat

3. 最初の質問を送る

例として、次の質問を入力します。

有給休暇の申請方法は?

送信直後に利用者の吹き出しが追加され、AI側には「回答を生成中...」と表示されます。この間、入力欄と送信ボタンは無効になり、ボタン表示は「送信中...」へ変わります。

4. 文脈を必要とする質問を送る

回答が表示されたら、続けて次のように質問します。

何日前までに申請しますか?

同じ conversation_id が送信されるため、Difyは前の質問を踏まえて回答します。

5. エラー動作を確認する

.env の DIFY_API_KEY が空の場合は、次のエラーが表示されます。

エラー: DIFY_API_KEY が設定されていません(.env を確認してください)

Difyから空回答が返った場合、タイムアウトした場合、接続できない場合にも、それぞれ対応するエラーをAI側の吹き出しへ表示します。

実行結果
image.png

確認項目

操作・状態 期待する動作
質問を入力して送信 質問とAI回答がチャット形式で表示される
続けて質問を送信 前の質問・回答を残したまま追加される
文脈を必要とする質問 同じ会話IDにより前の内容を踏まえて回答する
空欄で送信 required とJavaScriptにより送信されない
回答生成中 「回答を生成中...」と「送信中...」を表示する
回答生成中に再送信 入力欄と送信ボタンが無効で送信できない
回答表示後 入力欄と送信ボタンが有効になり、入力欄へフォーカスする
APIキー未設定 設定確認を促すエラーを表示する
通信失敗 「通信エラーが発生しました。」またはAPI側のエラーを表示する
ページ再読み込み 画面上の履歴と会話IDがリセットされる

セキュリティと運用上の注意

APIキーをブラウザへ渡さない

Dify APIキーはFlaskサーバーだけが使用します。ブラウザは /chat/send と通信するため、HTMLやJavaScriptへAPIキーを埋め込む必要はありません。

生成内容をそのまま確定情報にしない

Knowledgeを利用しても誤回答の可能性は残ります。人事・労務・法務などの重要事項は、正式文書と担当部署でも確認するよう案内します。

エラー詳細をそのまま公開しない

現行実装は一部のリクエスト例外をエラー文字列へ含めます。本番環境では詳細をサーバーログへ記録し、利用者には一般化したメッセージを返すほうが安全です。

CSRF対策を検討する

/chat/send はログインが必要なPOST APIですが、本教材ではCSRFトークンを実装していません。本番環境ではCSRF対策やSameSite Cookie設定などを検討します。

現行実装の注意点

項目 現行仕様 改善例
応答方式 blocking ストリーミング表示または非同期ジョブを導入する
会話ID JavaScript変数だけで保持 セッションやDBへ保存する
画面履歴 DOM上だけで保持 会話履歴テーブルを作成する
Dify利用者ID human-resource-app 固定 ログインユーザーごとのIDを送信する
HTTPステータス 主にJSON内の成否で判定 入力・認証・外部API障害ごとに使い分ける
キャンセル なし AbortController で通信を中止できるようにする
Markdown AI回答をプレーンテキスト表示 安全にサニタイズしてMarkdown表示する

おわりに

第10回では、Dify APIとKnowledgeを利用したFAQチャットボットを実装しました。

  • Fetch APIでページ遷移なしにJSONを送信した
  • Difyの conversation_id を次のリクエストへ渡して文脈を保持した
  • 質問と回答を現在のチャット画面へ順番に追加した
  • textContent を使い、メッセージをHTMLとして実行しないようにした
  • 「回答を生成中...」と「送信中...」で処理状態を表示した
  • 入力欄と送信ボタンを無効化して二重送信を防止した
  • 空回答、APIキー未設定、タイムアウト、接続失敗を処理した
  • 会話履歴と会話IDはページ再読み込みでリセットされる仕様を確認した

これで、Python・Flaskで構築した業務システムへ、Difyを利用した評価コメント生成、RAG社内文書検索、FAQチャットを組み込む一連の実装が完成しました。

本連載について

回 タイトル 対応画面 主な内容
第1回 業務システム全体設計編 ― システム概要・技術選定・アーキテクチャ
第2回 要求定義・要件定義編 ― 業務分析・機能要件・非機能要件
第3回 ER図・画面設計編 ― テーブル設計・画面遷移図・ワイヤーフレーム
第4回 Flaskログイン機能編 🔐 ログイン セッション・ハッシュ・ロール認可・社員番号ログイン
第5回 社員管理CRUD編 👤 社員管理 一覧・検索・登録・更新・論理削除・入力制御
第6回 研修管理編 📚 研修管理 研修登録・LEFT JOIN・履歴一括入力・UPSERT
第7回 Excel業務自動化編 📥 取込 / 📤 出力 pandas取込・openpyxl出力
第8回 Dify API連携編 📊 評価管理 API・フォールバック・生成中UI・二重送信防止
第9回 RAG構築編 🔍 文書検索 Dify Chat API・質問送信・回答表示・エラー処理
第10回 FAQチャットボット編 💬 FAQチャット 会話ID・Fetch API・生成中表示・二重送信防止
公開編 Ubuntu VPS・Docker公開編 🌐 サービス公開 Ubuntu VPS・Docker・Nginxを使った本番環境構築とWebアプリケーション公開

参考リンク

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?