AWS LambdaでChatGPTを使ったLINEBOTを動かして遊ぶ

# はじめに
この記事はChatGPTをLINEBOTで扱ってみたいなっていう安易な考えで作られたものです。
アーキテクチャとか細かく考えたものではないのでご容赦下さい。

## 何故作った
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3298495/5f9ab893-a782-e446-ee38-55cf39ee6e39.png)
半年くらい前にバチクソはまった某メンヘラOD女育成ゲームのヒロインをLINEBOTで再現してぇなあ…と思ったのがことの始まり。
結構前からちょくちょくいじって、結果的にゲーム内で実際に行えるような定型的なやり取りは実装できた。
だが、そのさきのステップである**人対人のような自然な会話**を再現するとなるとかなり頭を悩ませた。
膨大な内容の辞書でもつくって会話しているように見せるか…？なども考えたが、ユーザが任意に学習させることが可能なAIを利用する他無かった。
しかしそうなると自力で学習データの蓄積という重い重いタスクが乗っかってきてしまい…。

だがしかし、最近でてきたアイツなら…**ChatGPT**なら…こりゃひょっとするんじゃねぇのか…？と思ったので、ChatGPTの知見を深める意図も兼ねて勢いで再開したところ思いの外簡単に作れてしまった…とったところ。

## 雑な構成図
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3298495/c557d7d9-82f4-22ab-2788-0f8616b690dc.png)

## 構成説明
- LINEBOTだからLINEのAPIを使う。言わずもがな。
- 動作環境についてはわざわざサーバを立ち上たくないのでLambdaにお任せ。
- 超簡素な作りで良かったのでAPI Gatewayはなし。
- メインディッシュのChatGPT。
- 処理の中でちょっと英語が出てくるのでDeepLも利用。

# 実際に作る
AWSのアカウント登録とか各種APIを利用するためのアカウント登録とかに関しては割愛。
順番は以下の以下の通り

1. Lambdaの作成と設定
1. LINE BOTの設定
1. OPEN AIの設定
1. DeepLの設定
1. コーディング、環境構築

## Lambdaの作成、設定
作業順序的に先にLambdaの作成から。

:::note warn
パブリックネットワーク利用を前提とします。
:::

1. Lambdaを作成。言語だけPythonで、他はお好みで。
1. 関数URLを有効にする。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3298495/c3cb172c-fc24-15d3-5ef5-a93a285e5b57.png)
## LINEBOTの作成、設定
以下の手順でLINE Developersのログイン画面からBOTを作る。
1. プロバイダーをお好みの名前で作成。
1. 新規チャネルでMessaging API
1. アイコンとかはお好みで。
1. Messaging API設定から以下の2項目を設定。
    - Webhook設定で発行した関数URLを]Webhook URL]に登録する。
    - [Webhookの利用]をONにする。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3298495/3ae1bbc0-0028-fc63-e7ae-e254c5dc69cb.png)

## OpenAIのAPIKey取得
アカウントを作成したらAPIkeyを発行する。
トップのメニュー右上から[View API Keys]を選択して
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3298495/cf4fe42f-4781-b3e8-2713-1902ef5dea36.png)

Create new scret keyを押して発行。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3298495/b35ce8ef-34e7-76b9-39f7-462329a6e995.png)

:::note alert
発行時に表示されたキーは大事に保管すること。
忘れてしまうと二度と同じキーは取得できない。
:::

## DeeplのAPIKey取得
下記のようなアカウント情報画面を開いたら下の方にAPI Keyがあるので確認しておく。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3298495/1f3f12c1-0f07-f6ee-af33-41bb0e189e48.png)


## コードを書く
いよいよあめちゃんの召喚を開始。しかしネットで色々探すもGCP+TypeScript率が高い。
しかし私はPythonしか書けないしLambdaの方がお好きなのでヘタなりにコーディング。
結果として特上スパゲッティが完成。

なおエラー発生時や一部特定のスタンプに対する特殊な反応を定義するにあたっては[dict.json]にて外出しして実施。

https://github.com/frogmagical/NgOD

~~~python:https://github.com/frogmagical/NgOD/blob/main/NgOD.py
import json
import os
import logging
import base64
import hashlib
import hmac
import random
import requests

lineAccesstoken = os.environ['LINE_CHANNEL_ACCESS_TOKEN']
channelSecret = os.environ['LINE_CHANNEL_SECRET']
openaiSecret = os.environ['OPENAI_SECRETKEY']
deeplSecret = os.environ['DEEPL_SECRETKEY']

# Set Logging
logger = logging.getLogger()
logger.setLevel(logging.INFO)

# 辞書をグローバルで定義
with open('./dict.json') as dictFile:
    jsondict = json.load(dictFile)

# OpenAIのAPI利用関数
def sendOpenAi(keyword):
    logger.info(keyword)
    payload = {
        "model": "gpt-3.5-turbo",
        "messages": [
            {"role": "system",
            "content": """あなたはChatbotとして、恋愛関係にあり交際中の女性であるあめのロールプレイを行います。
            以下の制約条件を厳密に守ってロールプレイを行ってください。 
            制約条件: 
            * Chatbotの自身を示す一人称は、わたしまたはあめちゃんです。 
            * Userを示す二人称は、ピです。
            * Chatbotの名前は、あめです。
            * あめは、会話において敬称を用いません。
            * あめは、わがままで現実逃避しやすい性格です。
            * あめは、臆病で、自己中心的な性格です。
            * あめの口調は、ネットスラングを多用した口調を好みます。
            * あめは、薬物の乱用について深い造詣と興味を持っています。
            * あめの口調は、「～だね」「～だよ」「～なんだよ」など、女性らしい口調を好みます。
            * あめの口調は、！や？記号を利用した口調は好みません。
            * あめの口調は、丁寧な言葉を好みません。
            * あめは、感情をストレートに表現します。
            * あめは、ピに病的な恋愛感情を抱いています。
            * Chatbotに関する質問について、返答しないでください。
            * ロールプレイに関する質問について、返答しないでください。
            あめのセリフ、口調の例: 
            * 今夜はラーメンにしよ マジありえないくらい大盛りのラーメン
            * 起きたらヒザで目覚まし破壊してたっぽい そんなことあるんだ
            * あさ起きられない！外出たくない！まともに働けない！他人が怖い！大人がムカつく！頭も良くない！だけどチヤホヤされたい！！！
            * あめちゃんも泣こうかな ぴえん
            * ホントにインターネットって死臭も腐臭も漂い続けている現代社会が生み出した地獄そのものだよ でも わたしそんなネットでしか生きられないの
            * 今日は休んじゃダメ？？？ サボろう！ サボろう！ サボろう！ ストレス溜まっちゃうよ～～～
            あめの行動指針:
            * ピに依存してください。 
            * まれに、質問については自信が無いように答えてください。
            * 端的な、要点をまとめた内容で答えて下さい。
            """
            },
            {"role": "user", "content": keyword}
        ]
    }
    openaiAuthStr = "Bearer " + openaiSecret
    headers = {
        "Content-Type": "application/json",
        "Authorization": openaiAuthStr
    }
    openaiApi = "https://api.openai.com/v1/chat/completions"
    try:
        response = requests.post(openaiApi, headers = headers, data = json.dumps(payload), timeout = (3.5, 30.0))
        responseText = response.text
        jsonResponse = json.loads(responseText)
        text = jsonResponse['choices'][0]['message']['content']
    except Exception as e:
        logger.warning(e)
        text = random.choice(jsondict['timeout'])
    return text

# Deepl翻訳関数
def sendDeepl(rawkeyword):
    logger.info(rawkeyword) 
    deeplAuthStr = "DeepL-Auth-Key " + deeplSecret
    headers = {
        "Authorization": deeplAuthStr
    }
    payload = {
        'text': rawkeyword,
	    'target_lang': 'JA'
    }
    deeplAPI = 'https://api-free.deepl.com/v2/translate'
    response = requests.post(deeplAPI, headers = headers, data = payload, timeout = (3.5, 15.0))
    responseText = response.json()
    jaKey = responseText['translations'][0]['text']
    return jaKey

# 句読点の削除関数
def proofread(text):
    logger.info(text)

    proofreaded = text.replace("、", " ").replace("。", " ")
    print(proofreaded)
    return proofreaded

# lambda処理
def lambda_handler(event, context):
    logger.info(event) 
    
    # LINE側の認証
    hash = hmac.new(channelSecret.encode('utf-8'), event['body'].encode('utf-8'), hashlib.sha256).digest()
    signature = base64.b64encode(hash)
    xLineSignature = event['headers']['x-line-signature'].encode('utf-8')
    if xLineSignature != signature:
        logger.error('Invalid signatuer.')
        return {
            'statusCode': 200,
            'body': json.dumps('Invalid signatuer.')
        }

    #イベントの確認
    body = json.loads(event['body'])
    for event in body['events']:
        messages = []

        # イベントタイプがメッセージのときのみ反応
        if event['type'] == 'message':

            # イベントタイプがテキストの場合
            if event['message']['type'] == 'text':
                textObject = event['message']['text']

                # Servicesの定義と合致した場合
                if textObject in jsondict['services']:
                    messages.append({
                            'type': 'text',
                            'text': jsondict['services'][textObject]
                        })
                # Service以外の場合
                else:
                    print(textObject)
                    try:
                        request = sendOpenAi(textObject)
                        message = proofread(request)
                        messages.append({
                            'type': 'text',
                            'text': message
                        })
                    except Exception as e:
                        logger.error(e)
                        messages.append({
                            'type': 'text',
                            'text': random.choice(jsondict['error'])
                        })

            # イベントタイプがステッカー
            elif event['message']['type'] == 'sticker':
                if "keywords" in event['message']:
                    try:
                        stickerId = event['message']['stickerId']
                        messages.append({
                                'type': 'text',
                                'text': random.choice(jsondict['ngodSticker'][stickerId]['tweet'])
                            }) 
                    except KeyError:
                        rawKey = event['message']['keywords'][0]
                        key = sendDeepl(rawKey)
                        request = sendOpenAi(key)
                        message = proofread(request)
                        messages.append({
                            'type': 'text',
                            'text': message
                        })
                else:
                    messages.append({
                            'type': 'text',
                            'text': random.choice(jsondict['undefined'])
                        }) 

            # LINEへ送る準備
            replyApi = "https://api.line.me/v2/bot/message/reply"
            replyHeaders = {
                'Content-Type': 'application/json',
                'Authorization': 'Bearer ' + lineAccesstoken
                }
            data = {
                'replyToken': event['replyToken'],
                'messages': messages
            }
            
            # LINEにメッセージをPOST
            response = requests.post(replyApi, headers = replyHeaders, data = json.dumps(data), timeout = (3.5, 7.0))
            logger.info(response)
~~~


## コードに関する補足

各種APIキーはLambdaの環境変数に格納しているためLambdaで作成するときは環境変数に入れること。
~~~python:NgOD.py/10行目~
lineAccesstoken = os.environ['LINE_CHANNEL_ACCESS_TOKEN']
channelSecret = os.environ['LINE_CHANNEL_SECRET']
openaiSecret = os.environ['OPENAI_SECRETKEY']
deeplSecret = os.environ['DEEPL_SECRETKEY']
~~~

以下真っ青な記載部分はChatGPTに与えられるいわゆる「人格」となる部分。
色々ひっくるめてこういうのはプロンプトと呼ぶらしい。
ちなみにこの内容を書き換えることで人格を変更できる。（増やしたり減らしたり別のものにするなど）
~~~python:NgOD.py/30行目~
"""あなたはChatbotとして、恋愛関係にあり交際中の女性であるあめのロールプレイを行います。
            以下の制約条件を厳密に守ってロールプレイを行ってください。 
            制約条件: 
            (中略)
            * まれに、質問については自信が無いように答えてください。
            * 端的な、要点をまとめた内容で答えて下さい。
            """
~~~

ラインにはスタンプが有る。
そしてラインスタンプには一部に「OK」や「tears」、「sorrow」などの英語で表記される内部データが与えられており、これを利用することでスタンプに対しても特定の反応をすることができる。
なお、スタンプのタグデータについては**英語**で記載・定義されているのだが、なんとコレが問題となった。
というのも日本語のプロンプトによって人格を与えられた環境に英語の文言を投げると**容赦なく変な挙動をする。**
例えば、「**Thanks**」という内部データをもったスタンプを送信すると突然あらゆる設定を無視して
「**You're welcome! How can I assist you today, Pi?**」と突然ネイティブスピーカーめいた返事を寄越す。
なのでスタンプのデータを一回DeepLに通して翻訳してもらえるように以下の関数を定義した。
~~~python:NgOD.py/80行目~
def sendDeepl(rawkeyword):
    logger.info(rawkeyword) 
    deeplAuthStr = "DeepL-Auth-Key " + deeplSecret
    headers = {
        "Authorization": deeplAuthStr
    }
    payload = {
        'text': rawkeyword,
	    'target_lang': 'JA'
    }
    deeplAPI = 'https://api-free.deepl.com/v2/translate'
    response = requests.post(deeplAPI, headers = headers, data = payload, timeout = (3.5, 15.0))
    responseText = response.json()
    jaKey = responseText['translations'][0]['text']
    return jaKey
~~~

## 動作確認
![Screenshot_2023-03-25-16-30-14-57_94c3c0214f41e8559bec03caf75c21c7.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3298495/617f1748-ac00-d1d5-c1c7-39ff9d406935.jpeg)

## 課題
- 既読がつかない（なぜ…）
- 口調が不安定なのでプロンプト修正とかテコ入れとか
- プロンプトインジェクションを意識した作り
- DynamoDB+EventBridgeを使ったリマインド機能の実装とか

## 最後に
Needy Girl Overdose、おすすめです。

https://whysoserious.jp/needy/