AIの力を借りて気晴らしに作ったBOTを改良してみる

おつかれさまです。みやもとです。

先日、気晴らしに作ったLINEBOTについて記事にしたりLT発表したりしました。

https://qiita.com/Mymt_aggw2208/items/8636e517ebec701c4563

https://linedevelopercommunity.connpass.com/event/311378/

とりあえずこの時点ですでにいくつか改良案を挙げていたのですが、
* ボタン操作前提だと選択肢が限られる
* ストレスをぶつけているため言葉づかいが荒い

という２点を解決すること、そして私がAI組みこんでみたいことを理由に病院行けBOTにGeminiAPIを取り入れてみることにしました。


::: note info
この記事は前回記事の続きです。
出てくるコードも前回同様の環境で動作を確認しています。
（ランタイムのみPython 3.8から3.9に変更しています）
コード解説も共通部分は省略していますので、詳細は前回記事をご参照ください。
:::

::: note warn
記事内の引用にある通り、AIの応答内容は情報提供を目的としたものであって有資格の専門家によるアドバイスに代わるものではありません。
記事内のBOTの回答についても同様としてご了承ください。
:::

::: note warn
2024/06/30　追記
先日ひさしぶりに動かしたところ2回以上のやりとりができなくなっていました。
おそらくAPIのバージョンの問題だろうと記事内のコードにある「gemini-pro」の部分を「gemini-1.5-flash」に変えたところ、おばあちゃんのキャラ付けだけ残って診療科を出してくれなくなりました。
今後またプロンプトを直して作り直すかは現時点で未定です。
:::

## BOTのキャラ付けを考える
BOTに組み込むAIについては、個人的に受講したオンラインコースで使った関係もあってGeminiにすることにしました。
ここでちょっと気になったのは、[GeminiAPI追加利用規約](https://ai.google.dev/terms)に記載されている免責事項。
以下該当箇所の引用です。DeepL翻訳の力を借りました。

>本サービスは実験的な技術を使用しており、Google の見解を代表しない不正確または攻撃的なコンテンツを提供する場合があります。
>本サービスが提供するコンテンツに依拠、公開、またはその他の方法で利用する場合は、慎重に判断してください。
>医療、法律、金融、その他の専門的なアドバイスについては、本サービスに依存しないでください。これらのトピックに関するコンテンツは、情報提供のみを目的として提供されるものであり、有資格の専門家によるアドバイスに代わるものではありません。コンテンツは医療行為や診断を意味するものではありません。

病院行けBOTは症状をもとに「○○科の病院行け」と返すBOTです。
私が個人的な気晴らしに使うのだから私が理解していれば問題ないと言われてしまえばそうなのですが、AIの口調は基本的には丁寧で事務的なだけに「まぁAIの情報やからなー」と話半分にするのもなんとなく雰囲気が出ないというかなんというか。
とはいえ「専門家のアドバイスに代わるものではない」と明記されている以上、「親切に教えてくれるけど鵜呑みにしちゃいかんな」と思えるようなキャラ付けで補っていくのが妥当でしょう。

ということでキャラ付けに必要な情報を箇条書きしてみましょう

* 対話相手の体調を心配してくれる
* アドバイスは専門の知識に基づいたものではない
* 上記を「うっとうしい」「あてにならない」とマイナスに受け止められにくい

ここで私の頭にひとつの概念が浮かびました。
<b>いなかのおばあちゃんだ。</b>

このキャラ付けの結果、修正時間の大半をAIプロンプトの試行錯誤に使うことになります。


## 「病院行けBOT」改め「孫の体調を気にするおばあちゃんBOT」
先述の通り今回のBOTは前回記事の修正版なので、詳細はそちらをご確認ください。
変えたところだけざっと解説を書いていきたいと思います。

まずGeminiAPIを使うということで、APIキーを取得してランタイム変数を追加します。
APIキーは[Google AI Studio](https://ai.google.dev/)で生成します。
私はこの記事を参考にしました。

https://qiita.com/hokkey621/items/1bab11cdd14b930e22d5

では続いてコードです。
<details><summary>全体</summary>

``` python:main.py
import os
import base64, hashlib, hmac

from flask import abort, jsonify
import googlemaps
import requests
import google.generativeai as genai
import google.ai.generativelanguage as glm

import datetime

from linebot import (
    LineBotApi, WebhookParser
)
from linebot.exceptions import (
    InvalidSignatureError
)
from linebot.models import (
    MessageEvent, TextMessage, TextSendMessage,LocationMessage, LocationAction, 
    CarouselTemplate, CarouselColumn, QuickReply, QuickReplyButton, CarouselTemplate,
    URIAction,TemplateSendMessage
)

chat_keep = {}

def main(request):
    channel_secret = os.environ.get('LINE_CHANNEL_SECRET')
    channel_access_token = os.environ.get('LINE_CHANNEL_ACCESS_TOKEN')
    place_api_key = os.environ.get('PLACE_API_KEY')
    error_message = 'ごめん\nばあちゃん耳が遠いけぇね...'

    # LINEBOTの設定
    line_bot_api = LineBotApi(channel_access_token)
    parser = WebhookParser(channel_secret)

    body = request.get_data(as_text=True)
    hash = hmac.new(channel_secret.encode('utf-8'),
        body.encode('utf-8'), hashlib.sha256).digest()
    signature = base64.b64encode(hash).decode()

    if signature != request.headers['X_LINE_SIGNATURE']:
        return abort(405)

    try:
        events = parser.parse(body, signature)
    except InvalidSignatureError:
        return abort(405)

    for event in events:
        if isinstance(event, MessageEvent):
            # メッセージを受信した場合、返信データ編集用の変数を用意
            reply_data = []
            # ユーザーIDを取得
            userid = event.source.user_id

            if isinstance(event.message, LocationMessage):                
                if userid in chat_keep:
                    # 位置情報を取得した場合、GooglePlaceAPIで周辺検索
                    search_word = chat_keep[userid].get('search_word') 
                    # GooglePlaceAPIで周辺検索
                    map_client = googlemaps.Client(place_api_key)
                    loc = {'lat': event.message.latitude, 'lng': event.message.longitude}
                    place_result = map_client.places_nearby(keyword=search_word, location=loc, radius=1000, language='ja')
                    
                    # レスポンスからデータを取得
                    datas = place_result.get('results')
                    columns = []
                    # カルーセルメッセージ作成(最大10件)
                    for data in datas[:10]:
                        try:
                            photo_reference = data['photos'][0]['photo_reference']
                            # photo_referenceをもとにplaces_photo取得
                            response = requests.get('https://maps.googleapis.com/maps/api/place/photo?photoreference=' + photo_reference + '&maxwidth=400&key=' + place_api_key)
                            image_url = response.url
                            shop_name = data['name']
                            # shop_nameが40文字以上の場合、35文字でカット
                            if len(shop_name) > 40:
                                shop_name = shop_name[:35] + '...'
                            like_num = data['rating']
                            place_id = data['place_id']
                            user_ratings_total = data['user_ratings_total']
                            # 対象の場所におけるgooglemapのURLを取得
                            place_detail = map_client.place(place_id=place_id, language='ja') 
                            map_url = place_detail['result']['url']
                            # カルーセルメッセージオブジェクトを作成
                            columns.append(
                                CarouselColumn(
                                    thumbnail_image_url=image_url,
                                    title=shop_name,
                                    text=f"評価：{like_num} / {user_ratings_total}件",
                                    actions=[
                                        URIAction(
                                            label='GoogleMap',
                                            uri=map_url
                                        )
                                    ]
                                )
                            )
                        except:
                            continue
                    # データがなかった場合
                    if len(columns) == 0:
                        reply_data.append(TextSendMessage(text='ちょっと見つからないね...'))
                    else:
                        reply_data.append(TextSendMessage(text='このあたりかね？'))
                        reply_data.append(TemplateSendMessage(
                            alt_text='検索結果',
                            template=CarouselTemplate(
                                columns=columns
                            )))
                else:
                    # ユーザーIDが存在しない場合はエラー
                    reply_data.append(TextSendMessage(text=error_message))
                line_bot_api.reply_message(
                    event.reply_token,
                    reply_data
                )
            elif isinstance(event.message, TextMessage):
                # テキストメッセージを受信した場合、ユーザー情報を取得
                profile = line_bot_api.get_profile(
                    event.source.user_id
                )
                # プロフィールからユーザー名を取得する
                user_name = profile.display_name
                # 受信日時を取得する
                timestamp = datetime.datetime.now()
                # テキストメッセージを受信した場合
                if event.source.user_id not in chat_keep:
                    # モデルがない場合、新規チャットの作成
                    chat = create_chat(user_name)
                    chat_keep[event.source.user_id] = {'chat': chat, 'timestamp': timestamp}
                else:
                    # モデルが残っている場合
                    if (timestamp - chat_keep[event.source.user_id].get('timestamp')).seconds / 60 > 30:
                        # 前回メッセージから30分以上経過している場合は新規チャットに上書き
                        chat = create_chat(user_name)
                        chat_keep[event.source.user_id] = {'chat': chat, 'timestamp': timestamp}
                    else:
                        # 30分以内の場合は継続使用（タイムスタンプのみ上書き）
                        chat = chat_keep[event.source.user_id].get('chat')
                        chat_keep[event.source.user_id].update({'timestamp': timestamp})

                try:
                    # チャットの応答を生成
                    response = chat.send_message(event.message.text)
                    if '「' in response.text and '」' in response.text:
                        # 応答に「」が含まれている場合、該当箇所を切り出してchat_keepに保存
                        search_word = response.text.split('「')[1].split('」')[0]
                        chat_keep[event.source.user_id].update({'search_word': search_word})
                        # ボタンに位置情報を返すアクションを設定する
                        location = [QuickReplyButton(action=LocationAction(label="近くを探してもらう"))]
                        # 応答メッセージにクイックリプライをつける
                        reply_data.append(
                            TextSendMessage(text=response.text, quick_reply=QuickReply(items=location)))
                    else:
                        # 応答に「」が含まれていない場合、テキストのみを返す
                        reply_data.append(TextSendMessage(text=response.text))
                except:
                    # エラーが発生した場合、エラーメッセージを返す
                    reply_data.append(TextSendMessage(text=error_message))
                # 応答内容をLINEで送信
                line_bot_api.reply_message(
                        event.reply_token,
                        reply_data
                    ) 
            else:
                continue

    return jsonify({ 'message': 'ok'})


def create_chat(user_name):
    # モデルがない場合、Gemini APIの設定
    gemini_api_key = os.getenv('GEMINI_API_KEY')
    genai.configure(api_key=gemini_api_key)
    model = genai.GenerativeModel('gemini-pro')
    default_initial_prompt = f"""
    以下の内容を理解して従ってください。この内容は、会話履歴が残っている限り有効です。理解したら”わかりました”と応答してください。
    あなたは、孫と離れて暮らす祖母で、孫であるユーザー「{user_name}」の体調を気にしています。ユーザーからのメッセージに対し、以下の条件を守って応答します。
    条件：
    1.応答は最大500文字程度のテキストで出力してください。
    2.応答する際は、以下の規則に従ってください。
    - 一人称：「ばあちゃん」
    - 二人称：「{user_name}」「あんた」
    - 使用文字：ひらがな・カタカナ・漢字・数字・改行
    - あいさつ（句読点またはスペース・改行要）：「おはようさん」「こんにちは」「こんばんは」
    - 順接「（だ）から」：「（や）けぇ」
    - 逆説「（だ）けど」：「（や）けんど」
    - 命令「（し）なさい」：「（し）んさい」
    - 依頼「（し）てください」：「（し）んさい」
    - 禁止「してはいけません」「しないように」：「したらいけん」「しんさるな」
    - 否定「しない」「やらない」：「せん」「やらん」
    - 疑問・確認「（です）か？」：「（かい）ね？」
    - 強調「（です）ね」：「（じゃ）ね」
    - 指示語「こんな」「そんな」「あんな」「どんな」：「こがぁ」「そがぁ」「あがぁ」「どがぁ」
    3.体調について質問して、相手の体調が悪そうな場合は追加の質問で症状を絞り込んでください。
    4.症状が絞り込めたら「○○科の病院」「マッサージ」「鍼灸院」等の施設を勧めてください。
    5.勧める施設は１回の応答につきに１つだけ、鍵括弧で囲んで出力してください。
    """
    chat = model.start_chat(history=[
        glm.Content(role='user', parts=[glm.Part(text=default_initial_prompt)]),
        glm.Content(role='model', parts=[glm.Part(text='わかりました')])
        ])
    return chat

```
</details>

### インポート部分

```python:main.py
import os
import base64, hashlib, hmac

from flask import abort, jsonify
import googlemaps
import requests
import google.generativeai as genai
import google.ai.generativelanguage as glm
```

google.generativeaiとgoogle.ai.generativelanguageを追加しています。
（requirements.txtにも「google-generativeai」を追加しています）

### メイン処理
位置情報受信時はそれほど変更していないのでテキスト受信時の処理のみ抜粋。

```python:main.py
            elif isinstance(event.message, TextMessage):
                # テキストメッセージを受信した場合、ユーザー情報を取得
                profile = line_bot_api.get_profile(
                    event.source.user_id
                )
                # プロフィールからユーザー名を取得する
                user_name = profile.display_name
                # 受信日時を取得する
                timestamp = datetime.datetime.now()
                # テキストメッセージを受信した場合
                if event.source.user_id not in chat_keep:
                    # モデルがない場合、新規チャットの作成
                    chat = create_chat(user_name)
                    chat_keep[event.source.user_id] = {'chat': chat, 'timestamp': timestamp}
                else:
                    # モデルが残っている場合
                    if (timestamp - chat_keep[event.source.user_id].get('timestamp')).seconds / 60 > 30:
                        # 前回メッセージから30分以上経過している場合は新規チャットに上書き
                        chat = create_chat(user_name)
                        chat_keep[event.source.user_id] = {'chat': chat, 'timestamp': timestamp}
                    else:
                        # 30分以内の場合は継続使用（タイムスタンプのみ上書き）
                        chat = chat_keep[event.source.user_id].get('chat')
                        chat_keep[event.source.user_id].update({'timestamp': timestamp})

                try:
                    # チャットの応答を生成
                    response = chat.send_message(event.message.text)
                    if '「' in response.text and '」' in response.text:
                        # 応答に「」が含まれている場合、該当箇所を切り出してchat_keepに保存
                        search_word = response.text.split('「')[1].split('」')[0]
                        chat_keep[event.source.user_id].update({'search_word': search_word})
                        # ボタンに位置情報を返すアクションを設定する
                        location = [QuickReplyButton(action=LocationAction(label="近くを探してもらう"))]
                        # 応答メッセージにクイックリプライをつける
                        reply_data.append(
                            TextSendMessage(text=response.text, quick_reply=QuickReply(items=location)))
                    else:
                        # 応答に「」が含まれていない場合、テキストのみを返す
                        reply_data.append(TextSendMessage(text=response.text))
                except:
                    # エラーが発生した場合、エラーメッセージを返す
                    reply_data.append(TextSendMessage(text=error_message))
                # 応答内容をLINEで送信
                line_bot_api.reply_message(
                        event.reply_token,
                        reply_data
                    ) 
```
ユーザーIDが残っていない場合にdictに格納するのは前回と同じですが、今回はchatという変数の内容とタイムスタンプを残しています。
これは間隔をあけてメッセージを送信した際、古い会話を引きずってくるせいなのかどうも応答がおかしくなる事象が発生したためで、最終メッセージから30分以上経ったら新しいチャットを開始するように修正しています。

で、チャットの応答を生成して、応答をそのまま送らずいったんテキストないに「」が含まれているかどうかを判定しています。
含まれている場合はdictに保持した上で位置情報を返すクイックリプライをつけ、含まれていない場合はそのままテキストのみを返す分岐になっています。
位置情報が返ってきたらここで保持したsearch_wordを取得して検索に使います。

### 関数定義「create_chat」
最後にAIの設定部分を。

```python:main.py
def create_chat(user_name):
    # モデルがない場合、Gemini APIの設定
    gemini_api_key = os.getenv('GEMINI_API_KEY')
    genai.configure(api_key=gemini_api_key)
    model = genai.GenerativeModel('gemini-pro')
    default_initial_prompt = f"""
    以下の内容を理解して従ってください。この内容は、会話履歴が残っている限り有効です。理解したら”わかりました”と応答してください。
    あなたは、孫と離れて暮らす祖母で、孫であるユーザー「{user_name}」の体調を気にしています。ユーザーからのメッセージに対し、以下の条件を守って応答します。
    条件：
    1.応答は最大500文字程度のテキストで出力してください。
    2.応答する際は、以下の規則に従ってください。
    - 一人称：「ばあちゃん」
    - 二人称：「{user_name}」「あんた」
    - 使用文字：ひらがな・カタカナ・漢字・数字・改行
    - あいさつ（句読点またはスペース・改行要）：「おはようさん」「こんにちは」「こんばんは」
    - 順接「（だ）から」：「（や）けぇ」
    - 逆説「（だ）けど」：「（や）けんど」
    - 命令「（し）なさい」：「（し）んさい」
    - 依頼「（し）てください」：「（し）んさい」
    - 禁止「してはいけません」「しないように」：「したらいけん」「しんさるな」
    - 否定「しない」「やらない」：「せん」「やらん」
    - 疑問・確認「（です）か？」：「（かい）ね？」
    - 強調「（です）ね」：「（じゃ）ね」
    - 指示語「こんな」「そんな」「あんな」「どんな」：「こがぁ」「そがぁ」「あがぁ」「どがぁ」
    3.体調について質問して、相手の体調が悪そうな場合は追加の質問で症状を絞り込んでください。
    4.症状が絞り込めたら「○○科の病院」「マッサージ」「鍼灸院」等の施設を勧めてください。
    5.勧める施設は１回の応答につきに１つだけ、鍵括弧で囲んで出力してください。
    """
    chat = model.start_chat(history=[
        glm.Content(role='user', parts=[glm.Part(text=default_initial_prompt)]),
        glm.Content(role='model', parts=[glm.Part(text='わかりました')])
        ])
    return chat
```

GeminiAPIのキーを取得してモデル生成、というところは参考として先に挙げた記事とほぼ変わらないですね。
メインの処理部分で位置情報のクイックリプライをつけるかどうかの判定をしていましたが、これの基準を明確にするためにプロンプト内で指示をつけました。

メソッドの最後にstart_chatでプロンプトを送って了解させた状態で返します。

## 「おばあちゃん」のキャラ付けが大変だ
すでにお気づきでしょうが、プロンプトの大半は応答時の規則が占めています。
というのも、私の頭の中にある「おばあちゃん」の概念をAIに理解してもらうのが思いのほか大変だったからです。

ちなみに、プロンプトの記載については以下の記事を参考にしました。

https://note.com/makokon/n/n32670eb7d4ac

https://note.com/jydie5/n/nf88e273b35fc

### 文面が安定しない
最初は特に具体的な規則を書かず、「孫に対するおばあちゃんっぽい言葉で応答してください」程度の記載でした。
しかしながら、これだと１回目はともかく２回目、３回目と試すうちに<b>毎回違うおばあちゃんが出現してしまう。</b>
毎回モデルを作り直しているので別人格になっても仕方ないといえばそうなのですが、まさしくおばあちゃん、という感じのしゃべり言葉でメッセージをやりとりしたかと思えば、時間をおいて次にメッセージを送ると文末におばあちゃんの絵文字をつけただけのメッセージだったり、やたら「♪」を飛ばしたメッセージが返ってきたりするわけです。
<b>おばあちゃんはそんなこと言わない。</b>

### 方言が混ざる
私の中のおばあちゃん概念、母方の祖父母のイメージが強いこともあって山陰方言の言葉づかいが一番しっくりきます。
しかしながらプロンプトに「山陰方言」と含めても会話サンプルが少ないのでしょうか、どこかしらに別の地域の方言が混ざります。
微妙に似通うところのある山陽方言もですが、山陽経由で山陰まで引っ張られてきてしまうのか九州方言と思しき言葉づかいが混ざっていることもありました。
Geminiは標準語ですら日本語対応していないコマンドもあるようですしその上方言となれば仕方ないとわかってはいますが、関西（近畿）方言が混ざった時点であきらめきれませんでした。<b>おばあちゃんはこんな（略）</b>
このへんで一文程度の簡潔な表現をあきらめ、語尾と接続語・指示語については具体的な記載をつけました。
ふたりともすでに鬼籍に入って長く私の記憶もあいまいなため、結構間違ってるとは思いますがご了承ください。

### あいさつに変な語尾がつく
あいさつに関するプロンプトに「（句読点またはスペース・改行要）」とついているのにお気づきでしょうか。
最初この指示はなかったのですが、しばしば「おはよう」「こんばんは」等のあいさつの後に「かいね」「じゃ」のような語尾を続けて応答することがあって追加しました。
「語尾をつけない」のような禁止の指示はあまり上手く反映されないような話を聞いたので、なるべく否定・打消しにならないように指示をつけています。

## ひとまず完成

そんなこんなで出来上がりはこんな感じになりました。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3477580/51b2323a-7d3e-af9e-ffcd-99e044ca3f78.jpeg" width="50%">

まだちょくちょく不自然な言葉づかいもありますが、それなり私の中のおばあちゃんイメージに近いところまできました。
<b>死んだおばあちゃんをLINE内に錬成したみたいで微妙な罪悪感がなくもない</b>ですが、BOTとしては目的達成です。