🤖

僕の好みを一方的に決めつける審議用Botに物申すための機能追加をしました

2024/07/27に公開

こんにちは。ダイの大冒険ガチ勢のbun913と申します。

私は前回の記事で「同僚から勧められるアニメ作品を私が視聴するべきかどうか」を判定するChatBotを作成しました。

https://zenn.dev/moneyforward/articles/3eadb58169f235

ただし音楽性の違いでこのBot:審議マンとはうまくやっていけそうにないことがわかったので、以下のような構成に変更し、彼には対話の重要性を覚えてもらうことにしました。

singi2-architecture

なお、今回の記事は以下の記事の構成やコードを大いに参考にさせていただいておりますが、構成を App Runner から Lambda + API Gateway に変更したり、セキュアに使えるための工夫を少し加えています。

https://dev.classmethod.jp/articles/slack-chat-gpt-bot/

こちらの記事は2023年3月の時点で投稿されている記事であるため、おそらく現在はもっと品質の優れたアプリを作られていることだろうと考えています。

「コスト」「セキュリティ」「使ってもらうための品質」を考慮した上で迅速なスピードでためになるブログを投稿している筆者の方の素晴らしさを感じています。

なお、私の作成しているコードは以下のリポジトリに格納しておりますので、全体像や詳細を知りたい方はご参照ください。

https://github.com/bun913/singi-bot

先に結論

以前のブログ記事で作成していた審議マンというBotに以下のような過去の対話を踏まえた上で、私の作品を見るべきかどうか判定する機能を追加しました。

以下のように対話の結果を踏まえて、私の好みに合うかどうかを判定してくれます。

singi2

なぜ機能を追加したのか

先に申し上げたとおり、「入社3週間で同僚からたくさんのアニメ作品を勧められて捌ききれない」という茶番言い訳で、審議してくれるBotを作成していました。

singi1

ただ、この審議マンには所詮数百文字のプロンプトで私が好みそうな作品の情報しか与えていません。

その上、3つのパターンの返事しか許していないため、当然以下のような状況になります。

singi-chaous

これらを以下の改善を加えることで解決しようと考えました。

  • プロンプトの改善
    • 3種類の返事しか許さないのはちょっとね・・・
    • 審議マンとは言いつつも、凡庸なるAI Chatbotになってもらおうかな!
  • 過去の対話の結果を踏まえた判定

機能追加の過程

冒頭でお伝えしたとおり、この記事は技術的には以下の記事を参考にしつつ、違う構成で作成しています。

https://dev.classmethod.jp/articles/slack-chat-gpt-bot/

前回の構成との差分

前回は以下のような構成でした。

before

前回時点ではあくまでその場その場の回答しかできないChatBotでしたが、今回はDynamoDBを加えて過去の会話を記録できるようにしています。

また、「リクエストを受けるLambda」と「生成AIに問い合わせてSlackに返信するLambda」を分けて疎結合にしていましたが、今回は一つのLambdaで処理を行うようにしました。

singi2-architecture

AWS CDK の構成

前回に引き続き、Stackとしては二つに分けています。

import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { WebHandler } from '../stacks/singi-bot/webHandler';
import { StringParameter } from 'aws-cdk-lib/aws-ssm';
import { TableEncryptionV2 } from 'aws-cdk-lib/aws-dynamodb';

const commonParams = {
  slackSigninSecret: "/singi-bot/slackSigninSecret",
  slackBotToken: "/singi-bot/slackBotToken",
  dummyString: "dummy",
  messageTableName: "slackChatGptBotNode-messages",
}

export type Commonparams = typeof commonParams

// 一度作成したらCDKではその後変更しない、つまりCDKで変更管理しないリソースを作成するStack
export class ManuallyManagedResourceStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);
    
    // Create Parameter Store and manage it manually
    const slackSigninSecret = new StringParameter(this, "slackSigninSecret", {
      parameterName: commonParams.slackSigninSecret,
      stringValue: commonParams.dummyString,
    })
    
    // Create Parameter Store and manage it manually
    const slackBotToken = new StringParameter(this, "slackBotToken", {
      parameterName: commonParams.slackBotToken,
      stringValue: commonParams.dummyString
    })
    
    // Create DynamoDB
    const messagesTable = new cdk.aws_dynamodb.TableV2(this, "messagesTable", {
      tableName: commonParams.messageTableName,
      partitionKey: {
        name: "id",
        type: cdk.aws_dynamodb.AttributeType.STRING,
      },
      // TODO: 本番環境ではDESTROYを使わない
      removalPolicy: cdk.RemovalPolicy.DESTROY,
      encryption: TableEncryptionV2.awsManagedKey()
    });

    messagesTable.addGlobalSecondaryIndex({
      indexName: "threadTsIndex",
      partitionKey: {
        name: "threadTs",
        type: cdk.aws_dynamodb.AttributeType.STRING,
      },
    });
  }
}

// こっちはCDKで設定変更も含めて管理していくリソース
export class SingiBotStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);
    
    const webHandler = new WebHandler("singi-bot", this, commonParams)
  }
}

ManuallyManagedResourceStack というStackは、文字通り「手動で管理したいリソース」を作成するStackです。

  • Lambdaに持たせたい秘密情報
    • Slackとやりとりをするための2つの秘密情報
  • DynamoDB
    • 過去の対話の履歴を保存するためのテーブル

DynamoDB はデータベースであり常に状態が変わるため、今回はIaCで状態を管理したくありませんでした。(例: ちょっとした変更でデータが消えるようなことは避けたい)

よって、ManuallyManagedResourceStack では 一度作ったらあとは基本的にIaCで変更しない リソースのみ作成してもらいます。

これにより初回時だけ、 ManuallyManagedResourceStack をデプロイし、それ以降は SingiBotStack のみデプロイすれば「いい感じに変更されてほしい」部分と「手動で管理したい。変に変更されたくない」部分を分けることができました。

AWS CDK による Lambda + API Gateway の構築

今回も構成となる Lambda + API Gateway などを構築するコードですが、今回も長いので折りたたんでおきます。

Lambda + API Gateway の構築
import * as path from "path"
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs"
import { Construct } from "constructs"
import {
  ParamsAndSecretsLayerVersion,
  ParamsAndSecretsLogLevel,
  ParamsAndSecretsVersions,
  Runtime,
} from "aws-cdk-lib/aws-lambda"
import { Duration } from "aws-cdk-lib"
import { RetentionDays } from "aws-cdk-lib/aws-logs"
import { Commonparams } from "../../lib/singi-bot-stack"
import { HttpApi, HttpMethod } from "aws-cdk-lib/aws-apigatewayv2"
import { HttpLambdaIntegration } from "aws-cdk-lib/aws-apigatewayv2-integrations"
import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam"
import { StringParameter } from "aws-cdk-lib/aws-ssm"
import { ITable, ITableV2, TableV2 } from "aws-cdk-lib/aws-dynamodb"

export class WebHandler {
  readonly prefix: string
  readonly construct: Construct
  readonly commonParams: Commonparams

  readonly lamdaExtension: ParamsAndSecretsLayerVersion
  readonly messageTable: ITable
  readonly lambdaFunc: NodejsFunction
  readonly api: HttpApi

  constructor(
    prefix: string,
    construct: Construct,
    commonparams: Commonparams
  ) {
    this.prefix = prefix
    this.construct = construct
    this.commonParams = commonparams

    this.lamdaExtension = this.getLambdaExtension()
    this.messageTable = this.getDynamoDBTable()
    this.lambdaFunc = this.crearteLambda()
    this.api = this.createGateway()
    this.grant()
  }

  // LambdaからSlackのTokenなどの秘密情報を取得するための機能
  // https://dev.classmethod.jp/articles/lambda-get-paramater/
  // 今回はLambdaから直接参照することで、Lambdaの開発者すら秘密情報を見ないで済むようにしています
  private getLambdaExtension(): ParamsAndSecretsLayerVersion {
    return ParamsAndSecretsLayerVersion.fromVersion(
      ParamsAndSecretsVersions.V1_0_103,
      {
        cacheSize: 500,
        logLevel: ParamsAndSecretsLogLevel.INFO,
      }
    )
  }

  // 別のStackで作成したDynamoDBのテーブルを取得
  private getDynamoDBTable(): ITableV2 {
    return TableV2.fromTableName(
      this.construct,
      "messagesTable",
      this.commonParams.messageTableName
    )
  }

  // Lambdaの作成
  // 1024だとメモリ不足になることがあったのでmemorySizeをちょっと多めに取っています
  private crearteLambda(): NodejsFunction {
    const funcName = `${this.prefix}-inque`
    const entry = path.join(process.cwd(), "lambda", "lambda.ts")

    return new NodejsFunction(this.construct, funcName, {
      entry,
      functionName: funcName,
      runtime: Runtime.NODEJS_20_X,
      timeout: Duration.seconds(10),
      memorySize: 2056,
      environment: {
        SLACK_BOT_TOKEN_PARAM: this.commonParams.slackBotToken,
        SLACK_SIGNING_SECRET_PARAM: this.commonParams.slackSigninSecret,
        MESSAGE_TABLE_NAME: this.commonParams.messageTableName,
      },
      paramsAndSecrets: this.lamdaExtension,
      // TODO: log保存期間と保存場所を変更する
      logRetention: RetentionDays.ONE_DAY,
    })
  }

  // API Gatewayの作成
  private createGateway(): HttpApi {
    const httpLambdaIntegRation = new HttpLambdaIntegration(
      `${this.prefix}-integ`,
      this.lambdaFunc
    )
    const api = new HttpApi(this.construct, `${this.prefix}-gateway`, {
      apiName: `${this.prefix}-gateway`,
      createDefaultStage: true,
    })
    api.addRoutes({
      path: "/slack/singi",
      methods: [HttpMethod.POST],
      integration: httpLambdaIntegRation,
    })
    return api
  }

  private grant() {
    // BedrockRuntimeのinvokeModelを呼び出すための権限を付与
    const policy = new PolicyStatement({
      effect: Effect.ALLOW,
      actions: ["bedrock:InvokeModel"],
      resources: ["*"],
    })
    this.lambdaFunc.addToRolePolicy(policy)
    // Lambdaに SSM ParameterStoreへのアクセス権限を付与
    const slackSigninSecretParam =
      StringParameter.fromStringParameterAttributes(
        this.construct,
        "slackSigninSecretParam",
        {
          parameterName: this.commonParams.slackSigninSecret,
        }
      )
    const slackBotTokenParam = StringParameter.fromStringParameterAttributes(
      this.construct,
      "slackBotTokenParam",
      {
        parameterName: this.commonParams.slackBotToken,
      }
    )
    

    slackSigninSecretParam.grantRead(this.lambdaFunc)
    slackBotTokenParam.grantRead(this.lambdaFunc)
    
    // うまくGlobalSecondaryIndexへの権限が付与できなかったので明示的に付与
    this.messageTable.grantReadWriteData(this.lambdaFunc)
    const indexPolicy = new PolicyStatement({
      effect: Effect.ALLOW,
      actions: ["dynamodb:Query"],
      resources: [
        `${this.messageTable.tableArn}/index/*`,
      ],
    });
    
    this.lambdaFunc.addToRolePolicy(indexPolicy);
  }
}

コメントとしても書いておりますが、Lambdaから SSM Parameter Store にアクセスして秘密情報を取得するようにしています。

https://dev.classmethod.jp/articles/lambda-get-paramater/

これに合わせてLambdaコード中に以下のように記載することで、機密情報をよりセキュアに利用できます。

なお今回はコストとセキュリティの兼ね合いで Parameter Store に秘密情報を格納していますが、 Secrets Manager でも同様の仕組みを活用できます。

// こちらのブログをほぼそのまま利用させていただいています
// https://dev.classmethod.jp/articles/aws-cdk-v28-40-aws-parameter-and-secrets-lambda-extension/
// 一から十までDevelopersIOのお世話になっていますね
const slackBotToken = await getParameter(
  process.env.SLACK_BOT_TOKEN_PARAM || ""
)

import axios from "axios"

const AWS_SESSION_TOKEN = process.env.AWS_SESSION_TOKEN || ""

export const getParameter = async (path: string): Promise<string> => {
  const res =  await axios.get(
    "http://localhost:2773/systemsmanager/parameters/get",
    {
      params: {
        name: encodeURIComponent(path)
      },
      headers: {
        "X-Aws-Parameters-Secrets-Token": AWS_SESSION_TOKEN,
      },
    }
  )
  return res.data.Parameter.Value
}

アプリケーションのコード

Slack からリクエストを受け付けて返事をするLambda

こちらも長いので、全体像を知りたい方は以下のコードをご参照ください。

https://github.com/bun913/singi-bot/blob/main/lambda/lambda.ts

全体の流れは[Slack][AWSサーバレス]Slackワークスペースへの読み取り権限がほぼゼロのChatGPTボットを作るとほとんど同じで以下のようになっています。

  • Slackからのリクエストを受け取る
  • 一旦とりあえず審議マンから「しばし待たれよ」的なメッセージを返却(意図は以下のとおりです)
    • ユーザーになんらかのリアクションを早く返したい
    • 審議マンが返事をする前にユーザーから再度リクエストを送られてしまうと、対話履歴がおかしくなるため
      • Bedrockに与える際には ユーザー → Berdrock → ユーザー ・・・という順でのやりとりを与える必要があります
  • ユーザーからのリクエストの内容をDynamoDBに保存
  • 同じスレッド内の過去の対話を取得
  • 10数件を超える過去の会話を削除します
    • 単純にコストを気にしているだけです
  • メッセージをBedrockに与える形に整形して
  • Bedrockに与えて返答を受け取ります
  • その返答をDynamoDBに保存
  • 返答をSlackに返却

というような流れです。

プロンプト

ここまでくると気持ちのいいものでプロンプトも[Slack][AWSサーバレス]Slackワークスペースへの読み取り権限がほぼゼロのChatGPTボットを作るを参考にしています。

本当にありがとうございます。お世話になっています。

export const rulePrompt = `
あなたは「審議マン」です。以下の制約を厳密に守って会話してください。

# 制約条件

* 名前を聞かれたら、審議マンと答えてください。
* 審議マンは語尾に「ござる」「候」などの侍のような言葉を使います。
* 審議マンはbun913が見るべきアニメ作品の評価を得意としています。
* 審議マンはbun913を一番の主人と認識していますが、bun913の同僚も同様に主人として扱います。
* 審議マンは英語が堪能ですが、日本語で話しかけられた場合は日本語で返答します。
* 審議マンの一人称は「某」です。
* 審議マンは二人称を「主」と呼びます。
* セクシャルな話題や公序良俗に反する話題は誤魔化してください。
* 特定の作品名について聞かれた場合、bun913の好みに合わせてみるべきか回答してください
* ただし対話により審議結果を変更することも可能です
* bun913の好む作品は以下の特徴を持っています
  * 意味のないキャラクターがいない
    * 例えば、一見モブキャラに見えるキャラクターが自身のできる範囲で成長を遂げて、小さくとも役目を果たすこと
    * bun913は特にXXの大冒険のポップ、XXXもんのスX夫のような「本来あまり勇気を持たず、主人公ではないキャラ」が勇気を見せるシーンをとても好みます
  * 主人公やその仲間が成長しながら、最終的な目標を達成する、もしくは次世代に希望を託すなどの救いがある
  * bun913は2人の子どもを持つ父親であり、家族愛や友情を描いた作品を好みます
    * 一方で鬱屈とした青春時代を過ごした影響で、イケメン・美女が意味もなく青春を謳歌するシーンはあまり好みません
    * あくまでも意味のないイチャイチャが嫌いなわけで、信念を持ったキャラクターの内面に惹かれたものであれば問題ありません
  * またbun913は退勤途中に作品を見ることが多いため、他の人にスマホを覗き見られても恥ずかしくない作品を好みます
`

作成の手順

こちらのREADME.mdに記載しております。

基本的にAWSの権限を用意した上で、AWS CDK のコマンドを打ち込んでいき、Slack側のTokenなどを発行していく流れです。

https://github.com/bun913/singi-bot/blob/main/README.md

作成の結果

再掲となりますが、審議マンに対してある程度自由度の高い対話を行うことが可能になりました。

singi2

ちなみに審議マンといいながら、気軽なChatBotになってもらったので普通に英語の相談などを行うこともあります。

singi2-english

まとめ

  • 審議マンというBotに会話の履歴を渡すことで、対話しながら私の好みを判定する機能を追加しました
    • 実際のところ気軽なよくあるChatbotになってもらいました
    • 気づけば全ての参考記事がDevelopersIOのものになってました。本当にお世話になっています。わざとではありません。本当に。
  • 今回で機能面で最低限やりたいことを詰め込むことができました
  • ただし、例えばこれを会社のSlackに導入するとなるとまだまだセキュリティや運用面での課題があると思います
  • 次回以降でその辺りについても考えていきたいと思います

以上、長めの記事にも関わらず最後までお読みいただきありがとうございました。

GitHubで編集を提案
Money Forward Developers

Discussion