AIにSlackへ投稿された問い合わせへ回答対応させることにした

AIにSlackへ投稿された問い合わせへ回答対応させることにした

問い合わせ対応へのコストを減らすためにGeminiを使ったAIChatを用意したものの、案外使われていないようでした。そこで、Bedrockを使ってAIに問い合わせ対応させてみることにしました。
2026.01.30

問い合わせへのリードタイムや調査コストを減らすため、各種仕様書等をGemini for workspaceのGemに搭載しての問い合わせ自動対応も行っていましたが、案外使われないことも多いと理解しました。

そこで、AIに問い合わせ対応まで任せようと思い立ちました。

構成

Gemに搭載したデータはNotion FAQ、Zendesk、Google Docsの3種でした。Notion FAQはSlack Logを元に人力でNotionに書き起こし、GAS経由でSpreadsheetに入力したものです。今後、書き起こしをBedrockにフォーマットを含めて推論させてMarkdown化する想定で、Slack Logとして追加しています。

費用面は問い合わせの頻度にもよりますが、月あたり$3程度を見込んでいます。

AIに要件だけ伝えたところ、当初OpenSearch Serverlessが提案されました。実際の問い合わせ頻度も伝えるとPineconeで再提案され、確認の上で採用しました。

プロセスは以下の通り。

  1. スタッフがSlackの問い合わせフォームから投稿
  2. EventBridge Schedulerが5分毎にLambdaを起動
  3. Lambdaが未着手の問い合わせを検出
  4. Bedrock RAGで回答生成
  5. Slackスレッドに自動返信

実装

YAMLファイル一つに収まっているのは、Claude Codeに小規模出力から始めて次第に拡張していったためです。

AWSTemplateFormatVersion: '2010-09-09'
Description: |
  Slack自動返信システム(ステートレス版)
  - DynamoDB不要、Slackのスレッド状態のみを参照
  - EventBridge Scheduler + Lambda + Bedrock RAG

Parameters:
  Environment:
    Type: String
    Default: dev
    AllowedValues:
      - dev
      - prod
    Description: 環境名(dev/prod)

  SlackBotTokenSecretArn:
    Type: String
    Description: Slack Bot TokenのSecrets Manager ARN(事前に作成が必要)

  SlackChannelId:
    Type: String
    Description: 監視対象のSlackチャンネルID

  SlackUsergroupHandle:
    Type: String
    Default: g-cm-team-aws-serviceg
    Description: サービス開発室のユーザーグループハンドル(@なし)

  BedrockKnowledgeBaseId:
    Type: String
    Description: Bedrock Knowledge Base ID

  BedrockModelArn:
    Type: String
    Default: arn:aws:bedrock:ap-northeast-1::foundation-model/anthropic.claude-3-haiku-20240307-v1:0
    Description: Bedrock基盤モデルのARN(デフォルト:Haiku、高品質が必要な場合はSonnetに変更)

  ScheduleRate:
    Type: String
    Default: rate(5 minutes)
    Description: 実行間隔(例:rate(5 minutes))

  MinAgeMinutes:
    Type: Number
    Default: 5
    MinValue: 1
    MaxValue: 60
    Description: 回答対象とする最小経過時間(分)- 人間対応中を除外

  MaxAgeMinutes:
    Type: Number
    Default: 60
    MinValue: 10
    MaxValue: 1440
    Description: 回答対象とする最大経過時間(分)- 古い質問を除外

Resources:
  # =============================================================================
  # Lambda実行ロール
  # =============================================================================
  LambdaExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub slack-auto-reply-lambda-role-${Environment}
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Policies:
        - PolicyName: BedrockAccess
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - bedrock:RetrieveAndGenerate
                  - bedrock:Retrieve
                  - bedrock:InvokeModel
                Resource: '*'
        - PolicyName: SecretsManagerAccess
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - secretsmanager:GetSecretValue
                Resource: !Ref SlackBotTokenSecretArn

  # =============================================================================
  # Lambda関数
  # =============================================================================
  SlackAutoReplyFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Sub slack-auto-reply-${Environment}
      Description: Slackの未着手問い合わせを検出し、Bedrock RAGで自動返信
      Runtime: python3.12
      Handler: index.lambda_handler
      Role: !GetAtt LambdaExecutionRole.Arn
      Timeout: 300
      MemorySize: 256
      Environment:
        Variables:
          SLACK_BOT_TOKEN_SECRET_ARN: !Ref SlackBotTokenSecretArn
          SLACK_CHANNEL_ID: !Ref SlackChannelId
          SLACK_USERGROUP_HANDLE: !Ref SlackUsergroupHandle
          BEDROCK_KNOWLEDGE_BASE_ID: !Ref BedrockKnowledgeBaseId
          BEDROCK_MODEL_ARN: !Ref BedrockModelArn
          MIN_AGE_MINUTES: !Ref MinAgeMinutes
          MAX_AGE_MINUTES: !Ref MaxAgeMinutes
      Code:
        ZipFile: |
          """
          Slack自動返信Lambda(ステートレス版)

          servicedev-manage-gas/reception-snapshot の判定ロジックを移植
          - 問い合わせフォームBot投稿を検出
          - サービス開発室メンバーの対応状況で未着手を判定
          - Bedrock RAGで回答を生成してスレッドに返信
          """
          import json
          import os
          import time
          import urllib.request
          import urllib.parse
          import boto3
          from datetime import datetime

          # =============================================================================
          # 環境変数
          # =============================================================================
          SLACK_BOT_TOKEN_SECRET_ARN = os.environ['SLACK_BOT_TOKEN_SECRET_ARN']
          SLACK_CHANNEL_ID = os.environ['SLACK_CHANNEL_ID']
          SLACK_USERGROUP_HANDLE = os.environ['SLACK_USERGROUP_HANDLE']
          BEDROCK_KNOWLEDGE_BASE_ID = os.environ['BEDROCK_KNOWLEDGE_BASE_ID']
          BEDROCK_MODEL_ARN = os.environ['BEDROCK_MODEL_ARN']
          MIN_AGE_MINUTES = int(os.environ.get('MIN_AGE_MINUTES', '5'))
          MAX_AGE_MINUTES = int(os.environ.get('MAX_AGE_MINUTES', '60'))

          # 問い合わせフォームの判定用定数
          INQUIRY_FORM_HEADER = "問い合わせフォーム"
          INQUIRY_USERNAME = "問い合わせ"

          # 完了リアクション
          FINISH_REACTIONS = ["sumi", "zumi"]

          # Botのリアクション(対応中判定から除外)
          BOT_REACTIONS = ["miru-nya", "megane-nya", "help-nya"]

          # AWSクライアント
          secrets_client = boto3.client('secretsmanager')
          bedrock_agent_runtime = boto3.client('bedrock-agent-runtime')

          # キャッシュ(Lambda実行中のみ有効)
          _slack_token_cache = None
          _service_dev_member_ids_cache = None

          # =============================================================================
          # Slack API ヘルパー関数
          # =============================================================================
          def get_slack_token():
              """Secrets ManagerからSlack Bot Tokenを取得(キャッシュ付き)"""
              global _slack_token_cache
              if _slack_token_cache:
                  return _slack_token_cache

              response = secrets_client.get_secret_value(SecretId=SLACK_BOT_TOKEN_SECRET_ARN)
              _slack_token_cache = response['SecretString']
              return _slack_token_cache

          def slack_api_call(method, endpoint, token, params=None, body=None):
              """Slack API呼び出し"""
                        url = f"https://slack.com/api/{endpoint}"
              headers = {
                  'Authorization': f'Bearer {token}',
                  'Content-Type': 'application/json; charset=utf-8'
              }

              if method == 'GET' and params:
                  url += '?' + urllib.parse.urlencode(params)
                  data = None
              else:
                  data = json.dumps(body).encode('utf-8') if body else None

              req = urllib.request.Request(url, data=data, headers=headers, method=method)

              with urllib.request.urlopen(req) as response:
                  return json.loads(response.read().decode('utf-8'))

          # =============================================================================
          # サービス開発室メンバーID取得
          # =============================================================================
          def get_usergroup_id_by_handle(token, handle):
              """ユーザーグループハンドルからIDを取得"""
              response = slack_api_call('GET', 'usergroups.list', token, params={'include_users': 'false'})

              if not response.get('ok'):
                  print(f"usergroups.list エラー: {response}")
                  return None

              for group in response.get('usergroups', []):
                  if group.get('handle') == handle:
                      return group.get('id')

              return None

          def get_usergroup_members(token, usergroup_id):
              """ユーザーグループのメンバーID一覧を取得"""
              response = slack_api_call('GET', 'usergroups.users.list', token,
                                        params={'usergroup': usergroup_id})

              if not response.get('ok'):
                  print(f"usergroups.users.list エラー: {response}")
                  return []

              return response.get('users', [])

          def get_service_dev_member_ids(token):
              """サービス開発室メンバーのUser ID一覧を取得(キャッシュ付き)"""
              global _service_dev_member_ids_cache
              if _service_dev_member_ids_cache is not None:
                  return _service_dev_member_ids_cache

              usergroup_id = get_usergroup_id_by_handle(token, SLACK_USERGROUP_HANDLE)
              if not usergroup_id:
                  print(f"ユーザーグループ '{SLACK_USERGROUP_HANDLE}' が見つかりません")
                  _service_dev_member_ids_cache = []
                  return []

              member_ids = get_usergroup_members(token, usergroup_id)
              print(f"サービス開発室メンバー数: {len(member_ids)}")
              _service_dev_member_ids_cache = member_ids
              return member_ids

          # =============================================================================
          # 問い合わせメッセージ判定
          # =============================================================================
          def is_inquiry_message(message):
              """
              問い合わせフォームからのBot投稿かどうかを判定

              判定条件:
              - message.type === "message"
              - message.subtype === "bot_message"
              - message.username === "問い合わせ"
              - blocks内に「問い合わせフォーム」ヘッダーがある
              """
              if not message:
                  return False

              if message.get('type') != 'message':
                  return False

              if message.get('subtype') != 'bot_message':
                  return False

              if message.get('username') != INQUIRY_USERNAME:
                  return False

              # blocksの構造チェック
              blocks = message.get('blocks', [])
              if not blocks:
                  return False

              try:
                  # blocks[0].elements[0].elements[0].text に「問い合わせフォーム」が含まれるか
                  first_block = blocks[0]
                  elements = first_block.get('elements', [])
                  if not elements:
                      return False

                  first_element = elements[0]
                  inner_elements = first_element.get('elements', [])
                  if not inner_elements:
                      return False

                  header_text = inner_elements[0].get('text', '')
                  if INQUIRY_FORM_HEADER not in header_text:
                      return False

              except (IndexError, KeyError, TypeError):
                  return False

              return True

          def extract_title_from_inquiry(message):
              """問い合わせメッセージから件名を抽出(ログ表示用)"""
              text = message.get('text', '')
              # 件名行を探す
              lines = text.split('\n')
              for i, line in enumerate(lines):
                  if '件名' in line and i + 1 < len(lines):
                      return lines[i + 1].strip()[:100]
              return text[:50]

          def get_thread_replies(token, channel_id, thread_ts):
              """スレッドの返信を取得"""
              try:
                  response = slack_api_call('GET', 'conversations.replies', token, params={
                      'channel': channel_id,
                      'ts': thread_ts,
                      'limit': 10
                  })
                  if response.get('ok'):
                      # 最初のメッセージ(親)を除いた返信を返す
                      messages = response.get('messages', [])
                      return messages[1:] if len(messages) > 1 else []
                  return []
              except Exception as e:
                  print(f"スレッド取得エラー: {e}")
                  return []

          def extract_inquiry_for_bedrock(message, token):
              """
              問い合わせから種類・件名・詳細を抽出してBedrock用の質問文を生成
              """
              text = message.get('text', '')
              message_ts = message.get('ts')

              # 親メッセージから種類と件名を抽出
              category = ""
              title = ""

              lines = text.split('\n')
              current_field = None

              for line in lines:
                  line = line.strip()
                  if '*種類*' in line:
                      current_field = 'category'
                      continue
                  if '件名' in line:
                      current_field = 'title'
                      continue
                  if '*問い合わせ元' in line or '詳細は以下' in line:
                      current_field = None
                      continue

                  if current_field == 'category' and line and not line.startswith('*'):
                      category = line.strip()
                      current_field = None
                  elif current_field == 'title' and line and not line.startswith('*'):
                      title = line.strip()
                      current_field = None

              # スレッドから詳細を取得
              detail = ""
              replies = get_thread_replies(token, SLACK_CHANNEL_ID, message_ts)
              if replies:
                  # 最初の返信(通常は詳細)を取得
                  first_reply = replies[0]
                  # Bot自身の返信でなければ詳細として使用
                  if first_reply.get('subtype') != 'bot_message' or '問い合わせフォーム' not in first_reply.get('text', ''):
                      detail = first_reply.get('text', '')

              # Bedrock用の質問文を生成
              question_parts = []
              if category:
                  question_parts.append(f"種類: {category}")
              if title:
                  question_parts.append(f"件名: {title}")
              if detail:
                  question_parts.append(f"詳細: {detail}")

              question = '\n'.join(question_parts) if question_parts else text
              print(f"Bedrock質問文: {question[:200]}...")

              return question

          # =============================================================================
          # ステータス判定
          # =============================================================================
          def determine_status(message, service_dev_member_ids):
              """
              問い合わせのステータスを判定

              判定ロジック(優先順):
              1. sumi または zumi リアクション → 完了
              2. megane-nya リアクション → 処理中(AI回答済み)
              3. サービス開発室メンバーがリアクション → 処理中
              4. サービス開発室メンバーがスレッド投稿 → 処理中
              5. それ以外 → 未着手

              Returns:
                  str: "完了", "処理中", "未着手" のいずれか
              """
              if not message:
                  return "未着手"

              reactions = message.get('reactions', [])
              reply_users = message.get('reply_users', [])

              # 1. sumi または zumi リアクションチェック(最優先)
              for reaction in reactions:
                  if reaction.get('name') in FINISH_REACTIONS:
                      return "完了"

              # 2. megane-nya リアクション(AI回答済み)チェック
              for reaction in reactions:
                  if reaction.get('name') == 'megane-nya':
                      return "処理中"

              # 3. サービス開発室メンバーがリアクションしているかチェック
              #    (Botのリアクションは除外)
              for reaction in reactions:
                  if reaction.get('name') in BOT_REACTIONS:
                      continue
                  for user_id in reaction.get('users', []):
                      if user_id in service_dev_member_ids:
                          return "処理中"

              # 4. サービス開発室メンバーがスレッド投稿しているかチェック
              for user_id in reply_users:
                  if user_id in service_dev_member_ids:
                      return "処理中"

              # 5. それ以外は未着手
              return "未着手"

          # =============================================================================
          # チャンネルメッセージ取得
          # =============================================================================
          def get_channel_messages(token, oldest_ts, latest_ts=None):
              """チャンネルのメッセージ履歴を取得(ページネーション対応)"""
              all_messages = []
              cursor = None
              page_count = 0
              max_pages = 50

              while True:
                  page_count += 1

                  params = {
                      'channel': SLACK_CHANNEL_ID,
                      'oldest': oldest_ts,
                      'limit': 200
                  }
                  if latest_ts:
                      params['latest'] = latest_ts
                  if cursor:
                      params['cursor'] = cursor

                  response = slack_api_call('GET', 'conversations.history', token, params=params)

                  if not response.get('ok'):
                      print(f"conversations.history エラー: {response}")
                      break

                  messages = response.get('messages', [])
                  all_messages.extend(messages)
                  print(f"ページ {page_count}: {len(messages)}件取得(累計: {len(all_messages)}件)")

                  # 次のカーソル
                  metadata = response.get('response_metadata', {})
                  cursor = metadata.get('next_cursor')

                  if not cursor or page_count >= max_pages:
                      break

                  time.sleep(0.1)  # レート制限対策

              return all_messages

          # =============================================================================
          # Bedrock RAG
          # =============================================================================
          # スコア閾値
          SCORE_THRESHOLD_HIGH = 0.75    # 確信を持って回答
          SCORE_THRESHOLD_LOW = 0.60     # 参考情報として回答

          def check_relevance_score(question):
              """
              Retrieve APIでスコアを事前チェック

              Returns:
                  tuple: (max_score, confidence_level)
                  - confidence_level: "high" (>=0.75), "medium" (0.60-0.74), "low" (<0.60)
              """
              try:
                  response = bedrock_agent_runtime.retrieve(
                      knowledgeBaseId=BEDROCK_KNOWLEDGE_BASE_ID,
                      retrievalQuery={'text': question},
                      retrievalConfiguration={
                          'vectorSearchConfiguration': {
                              'numberOfResults': 3
                          }
                      }
                  )

                  results = response.get('retrievalResults', [])
                  if not results:
                      print("検索結果なし")
                      return 0.0, "low"

                  max_score = max(r.get('score', 0) for r in results)
                  print(f"最高スコア: {max_score:.4f}")

                  if max_score >= SCORE_THRESHOLD_HIGH:
                      return max_score, "high"
                  elif max_score >= SCORE_THRESHOLD_LOW:
                      return max_score, "medium"
                  else:
                      return max_score, "low"

              except Exception as e:
                  print(f"Retrieve APIエラー: {e}")
                  return 0.0, "low"

          def generate_answer_with_bedrock(question):
              """
              Bedrock Knowledge Baseを使って回答を生成

              スコア閾値に基づいて回答:
              - 0.75以上: 確信を持って回答
              - 0.60-0.74: 参考情報として回答
              - 0.60未満: 回答しない(Noneを返す)

              Returns:
                  tuple: (answer_text, confidence_level) or (None, "low")
              """
              # まずスコアをチェック
              score, confidence = check_relevance_score(question)

              if confidence == "low":
                  print(f"スコア {score:.4f} が閾値 {SCORE_THRESHOLD_LOW} 未満のため回答をスキップ")
                  return None, "low"

              try:
                  response = bedrock_agent_runtime.retrieve_and_generate(
                      input={'text': question},
                      retrieveAndGenerateConfiguration={
                          'type': 'KNOWLEDGE_BASE',
                          'knowledgeBaseConfiguration': {
                              'knowledgeBaseId': BEDROCK_KNOWLEDGE_BASE_ID,
                              'modelArn': BEDROCK_MODEL_ARN,
                              'generationConfiguration': {
                                  'promptTemplate': {
                                      'textPromptTemplate': '''あなたはAWSの技術サポート担当です。
          以下の検索結果を参考に、質問に対して丁寧かつ正確に回答してください。

          検索結果:
          $search_results$

          質問: $query$

          回答の際の注意点:
          - 検索結果に情報がない場合は、正直に「この情報については確認が必要です」と伝えてください
          - 専門用語は可能な限り平易な言葉で説明してください
          - 回答の最後に、参考にした情報源があれば簡潔に示してください
          - 回答は簡潔にまとめてください(500文字以内目安)'''
                                  }
                              }
                          }
                      }
                  )

                  # 関連情報(citations)が見つかったかチェック
                  citations = response.get('citations', [])
                  has_relevant_info = False

                  for citation in citations:
                      retrieved_refs = citation.get('retrievedReferences', [])
                      if retrieved_refs:
                          has_relevant_info = True
                          break

                  if not has_relevant_info:
                      print("Knowledge Baseから関連情報が見つかりませんでした。リプライをスキップします。")
                      return None, "low"

                  print(f"関連情報が見つかりました({len(citations)}件の引用、信頼度: {confidence})")
                  return response['output']['text'], confidence

              except Exception as e:
                  print(f"Bedrock呼び出しエラー: {e}")
                  return None, "low"

          # =============================================================================
          # Slack返信
          # =============================================================================
          def post_reply(token, thread_ts, text):
              """スレッドに返信を投稿"""
              body = {
                  'channel': SLACK_CHANNEL_ID,
                  'thread_ts': thread_ts,
                  'text': text
              }
              return slack_api_call('POST', 'chat.postMessage', token, body=body)

          def add_reaction(token, message_ts, reaction_name):
              """メッセージにリアクションを追加"""
              body = {
                  'channel': SLACK_CHANNEL_ID,
                  'timestamp': message_ts,
                  'name': reaction_name
              }
              response = slack_api_call('POST', 'reactions.add', token, body=body)
              if not response.get('ok') and response.get('error') != 'already_reacted':
                  print(f"リアクション追加エラー: {response}")
              return response

          def remove_reaction(token, message_ts, reaction_name):
              """メッセージからリアクションを削除"""
              print(f"リアクション削除開始: {reaction_name} from {message_ts}")
              body = {
                  'channel': SLACK_CHANNEL_ID,
                  'timestamp': message_ts,
                  'name': reaction_name
              }
              response = slack_api_call('POST', 'reactions.remove', token, body=body)
              if response.get('ok'):
                  print(f"リアクション削除成功: {reaction_name}")
              elif response.get('error') == 'no_reaction':
                  print(f"リアクション削除スキップ(既になし): {reaction_name}")
              else:
                  print(f"リアクション削除エラー: {response}")
              return response

          # =============================================================================
          # 時間窓チェック
          # =============================================================================
          def is_within_time_window(message_ts):
              """
              メッセージが処理対象の時間窓内かどうかを判定

              - MIN_AGE_MINUTES以上経過(人間対応中を除外)
              - MAX_AGE_MINUTES以内(古い質問を除外)
              """
              current_time = time.time()
              message_time = float(message_ts)
              age_minutes = (current_time - message_time) / 60

              return MIN_AGE_MINUTES <= age_minutes <= MAX_AGE_MINUTES

          # =============================================================================
          # メインハンドラー
          # =============================================================================
          def lambda_handler(event, context):
              """
              メインハンドラー

              1. サービス開発室メンバーIDを取得
              2. Slackチャンネルのメッセージを取得
              3. 問い合わせフォームBot投稿をフィルタリング
              4. 未着手かつ時間窓内のメッセージを特定
              5. Bedrock RAGで回答を生成
              6. スレッドに返信
              """
              print("=== Slack自動返信処理開始 ===")

              # Slack Bot Token取得
              token = get_slack_token()

              # サービス開発室メンバーID取得
              service_dev_member_ids = get_service_dev_member_ids(token)
              if not service_dev_member_ids:
                  print("警告: サービス開発室メンバーIDが取得できませんでした")

              # 時間窓の計算(MAX_AGE分前から現在まで)
              oldest_ts = str(time.time() - (MAX_AGE_MINUTES * 60))

              # チャンネルのメッセージを取得
              messages = get_channel_messages(token, oldest_ts)
              print(f"取得メッセージ数: {len(messages)}")

              processed_count = 0
              skipped_count = 0

              for message in messages:
                  message_ts = message.get('ts')

                  # 問い合わせフォームBot投稿かチェック
                  if not is_inquiry_message(message):
                      continue

                  # 時間窓チェック
                  if not is_within_time_window(message_ts):
                      skipped_count += 1
                      continue

                  # miru-nyaが付いているかチェック(2段階処理の判定)
                  reactions = message.get('reactions', [])
                  reaction_names = [r.get('name') for r in reactions]
                  print(f"リアクション確認: {reaction_names}")
                  has_miru_nya = 'miru-nya' in reaction_names

                  # ステータス判定
                  status = determine_status(message, service_dev_member_ids)

                  # miru-nya付きで処理中/完了の場合はmiru-nyaを削除するだけ
                  if has_miru_nya and status != "未着手":
                      print(f"人間対応開始のためmiru-nya削除: ステータス={status}")
                      remove_reaction(token, message_ts, 'miru-nya')
                      skipped_count += 1
                      continue

                  if status != "未着手":
                      print(f"スキップ: ステータス={status}")
                      skipped_count += 1
                      continue

                  # 未着手の問い合わせを検出
                  title = extract_title_from_inquiry(message)
                  print(f"未着手問い合わせ検出: {title[:50]}...")

                  # help-nyaまたはmegane-nyaが付いている場合は処理済みとしてスキップ
                  has_help_nya = 'help-nya' in reaction_names
                  has_megane_nya = 'megane-nya' in reaction_names
                  if has_help_nya or has_megane_nya:
                      print(f"AI処理済みのためスキップ: help-nya={has_help_nya}, megane-nya={has_megane_nya}")
                      continue

                  if not has_miru_nya:
                      # 1回目: miru-nyaを追加するだけ(次回実行時に回答生成)
                      add_reaction(token, message_ts, 'miru-nya')
                      print(f"確認中マーク追加: {message_ts}")
                      skipped_count += 1
                      continue

                  # 2回目: miru-nyaが付いている → 回答生成して切り替え
                  # スレッドから種類・件名・詳細を抽出
                  question = extract_inquiry_for_bedrock(message, token)
                  print(f"回答生成開始: {message_ts}")

                  # Bedrockで回答生成(スコア閾値チェック付き)
                  answer, confidence = generate_answer_with_bedrock(question)

                  if answer:
                      # 信頼度に応じてヘッダーを変更
                      if confidence == "high":
                          # 0.75以上: 確信を持って回答
                          header = ":megane-nya: *AI自動返信*"
                          footer = "_この回答はAIによる自動生成です。正確性については担当者にご確認ください。_"
                      else:
                          # 0.60-0.74: 参考情報として回答
                          header = ":megane-nya: *AI自動返信(参考情報)*"
                          footer = "_この回答は関連度が中程度のため、参考情報としてご確認ください。詳細は担当者にお問い合わせください。_"

                      reply_text = f"{header}\n\n{answer}\n\n---\n{footer}"

                      # スレッドに返信
                      reply_response = post_reply(token, message_ts, reply_text)

                      if reply_response.get('ok'):
                          print(f"返信成功: {message_ts} (信頼度: {confidence})")
                          # 確認中リアクションを削除し、返信成功リアクションを追加
                          remove_reaction(token, message_ts, 'miru-nya')
                          add_reaction(token, message_ts, 'megane-nya')
                          processed_count += 1
                      else:
                          print(f"返信エラー: {reply_response}")
                  else:
                      # スコアが閾値未満、確認中リアクションを削除し、ヘルプ要請リアクションを追加
                      print(f"スコア閾値未満、スキップ: {message_ts}")
                      remove_reaction(token, message_ts, 'miru-nya')
                      add_reaction(token, message_ts, 'help-nya')
                      skipped_count += 1

              print(f"=== 処理完了: 返信 {processed_count}, スキップ {skipped_count}件 ===")

              return {
                  'statusCode': 200,
                  'body': json.dumps({
                      'processed': processed_count,
                      'skipped': skipped_count
                  })
              }

  # =============================================================================
  # EventBridge Scheduler
  # =============================================================================
  SchedulerExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub slack-auto-reply-scheduler-role-${Environment}
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: scheduler.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: InvokeLambda
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action: lambda:InvokeFunction
                Resource: !GetAtt SlackAutoReplyFunction.Arn

  SlackAutoReplySchedule:
    Type: AWS::Scheduler::Schedule
    Properties:
      Name: !Sub slack-auto-reply-schedule-${Environment}
      Description: Slack未着手問い合わせの定期チェック
      ScheduleExpression: !Ref ScheduleRate
      FlexibleTimeWindow:
        Mode: 'OFF'
      State: ENABLED
      Target:
        Arn: !GetAtt SlackAutoReplyFunction.Arn
        RoleArn: !GetAtt SchedulerExecutionRole.Arn

  # =============================================================================
  # CloudWatch Logs(Lambda用、保持期間設定)
  # =============================================================================
  LambdaLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub /aws/lambda/slack-auto-reply-${Environment}
      RetentionInDays: 14

Outputs:
  LambdaFunctionArn:
    Description: Lambda関数のARN
    Value: !GetAtt SlackAutoReplyFunction.Arn

  LambdaFunctionName:
    Description: Lambda関数名
    Value: !Ref SlackAutoReplyFunction

  ScheduleName:
    Description: EventBridge Scheduler名
    Value: !Ref SlackAutoReplySchedule

セットアップに必要なプロセスは以下の通り。

Step 1: Slack Bot作成

  1. https://api.slack.com/apps でApp作成
  2. Bot Token Scopesを設定:
    • channels:history
    • channels:read
    • chat:write
    • reactions:write
    • reactions:read
    • usergroups:read
  3. Botをチャンネルに招待

Step 2: Pinecone設定

  1. https://www.pinecone.io/ でアカウント作成(無料枠)
  2. Index作成:
    • Dimensions: 1024
    • Metric: cosine

Step 3: S3バケット作成

以下のコマンドで作成します。コンソール上から実施しても構いません。

aws s3 mb s3://your-faq-documents-bucket --region ap-northeast-1

Step 4: Bedrock Knowledge Base作成

以下の手順で実施します。

  1. AWSコンソール → Bedrock → Knowledge bases → Create
  2. Data source: S3バケットを指定
  3. Vector database: Pinecone を選択
  4. Embeddings: Titan Text Embeddings V2
  5. Knowledge Base ID をメモ

Step 5: Slack TokenをSecrets Managerに登録

aws secretsmanager create-secret \
--name slack-auto-reply/slack-bot-token-prod \
--secret-string "xoxb-your-token" \
--region ap-northeast-1

Step 6: CloudFormationデプロイ

SECRET_ARN=$(aws secretsmanager describe-secret \
--secret-id slack-auto-reply/slack-bot-token-prod \
--query 'ARN' --output text)

aws cloudformation deploy \
--template-file infra/slack-auto-reply.yaml \
--stack-name slack-auto-reply-prod \
--parameter-overrides \
Environment=prod \
SlackBotTokenSecretArn=$SECRET_ARN \
SlackChannelId=XXXXXXXX \
BedrockKnowledgeBaseId=YOUR_KB_ID \
--capabilities CAPABILITY_NAMED_IAM \
--region ap-northeast-1

実際の動作

Sandboxでの動作となります。

スクリーンショット 2026-01-29 15.42.36

AIが着手したタイミングで人が調査をして、ほぼ同時にリプライが付いて、結果として問い合わせた人が悩むケースを回避するため、AIが着手したサインを付けます。

既存データソースにマッチしない場合は専用の絵文字を付けて終了、マッチする場合は専用の絵文字を付けた上でレスポンスを入れます。

更に効率のよい問い合わせ判定のため、Slackワークフローの指定フォーム経由のものだけが対象になるよう絞っています。

今後の課題

Gemに搭載したPDFデータソースをそのままBedrockに使うよりは、テキスト化したほうが精度が高くなります。

そこで、GitHub Actionsワークフローへ、PDFではなくMarkdownで出力するJobも追加しました。ただ、全てのワークフローで対応できているわけではないため、本番反映の前にこれから一通り対応させる必要があります。

あとがき

今回のプロセスについて作成は考えていましたが、問い合わせへの返答が完了するまでの時間と、プロセスを仕上げるまでの期間を比べてコストメリットが少なく、見送っていました。生成AIを利用開始したばかりの頃は出力が見合わないこともあって断念していました。

ですが、Claude Codeに対して、ローカル環境でのペルソナ設定やプロンプトが積み上げられていった結果、プロジェクトのコンテキストに沿った実装が素早くできるようになったため、今回試してみました。

vibe-codingの動作で思ったような成果が得られない、と悩む場合は、先ずはペルソナやプロンプトの積み重ねを実施することをおすすめします。

この記事をシェアする

FacebookHatena blogX

関連記事