Zenn
👴

雰囲気察してくれるLINE Bot エージェントを作る試み

2025/03/19に公開

AIエージェントとは

人工知能 (AI) エージェントは、環境と対話し、データを収集し、そのデータを使用して自己決定タスクを実行して、事前に決められた目標を達成するためのソフトウェアプログラムです。目標は人間が設定しますが、その目標を達成するために実行する必要がある最適なアクションは AI エージェントが独自に選択します。例えば、顧客の問い合わせを解決したいコンタクトセンターの AI エージェントを考えてみましょう。エージェントは自動的に顧客にさまざまな質問をし、内部文書で情報を調べ、回答でソリューションを提示します。顧客の応答に基づいて、クエリ自体を解決できるのか、それとも人間に渡すのかを判断します。

https://aws.amazon.com/jp/what-is/ai-agents/

目標は人間が設定しますが

自分自身の役割と果たすべき目標を自ら定義できるようになってこそ、一人前(?)のAIエージェントだと思うので、作ります。

どんなエージェントを作るのか

LINEのグループチャットから、いい感じにしてほしいことを察して手伝ってくれるポンコツ執事「Butler」を作ります。

https://github.com/fleagne/line-bot-butler

雑に要件をまとめます。

要件

  • 遊ぶ日程が決まらなそうだったら、遊ぶ日程を決める ★今回はここの動作確認まで
  • 遊び先が決まらなそうだったら、遊ぶ先を探す
  • 遊び先が決まったら、遊ぶ先の予約をするかお伺いを立てて、依頼を受けたら予約する
  • 遊ぶ日程に近づいてきたら、遊ぶ予定のリマインドをする
  • 上記について会話が停滞していたら促す
  • 雑談には加わらない(予定関連の話題のみ反応)

会話状況から自分が何をやるのかを雰囲気察して行動するので、よりエージェントっぽい感じがしますね!しませんか?
ポイントは、人間がAIに対して「予約して」「候補日を決めて」と指示するのではなく、グループチャットで繰り広げられる会話からいまどのような状況なのかを自ら判断し、必要なら会話に加わる、という点です。

(...結局、目標を人間が設定している気がしてきましたが、、、続けます)

アーキテクチャ

ユーザ同士の会話をWebhookで受け取り、会話情報をDBに格納していきます。
その時必要ならAIは会話に加わりますし、その時点で加わる必要がないなら何もしません。
定期的に会話情報をサマってDBに入れ、そこから雰囲気察して会話に加わります。
外部情報が必要なら、ツールを使って情報を取得しに行きます。

ER図

1つの予定につき、複数の会話から構成されるような作りです。Meeting???
グループラインでは同時並行で複数の予定が入ることがあるので、ひとまずこういう作りで。どの会話がどの予定に紐づく会話なのかは雰囲気察してもらう予定ですが、沼りそうなので今回は割愛。
booleanの項目はいまこの予定がどうなっているのかを示すためのものですね。このボットのTodo管理みたいな位置づけです。これを見て雰囲気察して投稿してくれるBotができるはず。きっと。

FK貼ってますが嘘ですね。貼ってないです。
group_idに貼ると1つのグループチャットで1回しか予定を組めなくなるので、マルチスケジュール対応はおいおい...

シーケンス図

Webhook

定期実行

ざっくりとしたイメージです。
このほかに、時間で要約情報作ったりする処理を入れれば、状況見てメッセージ送るか否かを自分で判断してくれるようになるのではないかなぁという淡い期待。
キャンセルするか否か、リマインドメッセージを送るか否か、この辺りも自分で判断してもらっていい感じに自分のタスク管理表を更新していってほしい。

動かしてみる

ちゃんと作りこもうとすると一生終わらない気がするので、とりあえず雰囲気動くところまでで感触を得ることにします。

会話を投げ込む

まずはBotとのグルチャを作って、雑に会話を投げ込みます。

@app.post("/callback")
async def webhook(request: Request, db: Session = Depends(get_db)):
    body = await request.body()
    body_str = body.decode("utf-8")
    signature = request.headers.get("X-Line-Signature", "")

    try:
        events = json.loads(body_str)["events"]

        handler.handle(body_str, signature)

        for event in events:
            event_type = event.get("type")

            if event_type == "message":
                if event["message"]["type"] == "text":
                    process_message_event(event, line_bot_api, db)

        return JSONResponse(content={"status": "OK"})

    except InvalidSignatureError:
        raise HTTPException(status_code=400, detail="Invalid signature")
    except Exception as e:
        print(f"Error: Webhook, {e}")
        raise HTTPException(status_code=500, detail=f"Internal Server Error: {str(e)}")
def process_message_event(
    event: Dict[str, Any], line_bot_api: Any, db: Session
) -> None:
    try:
        ...
            try:
                conversation = Conversation(
                    group_id=group_id,
                    user_id=user_id,
                    user_name=user_name,
                    message=text,
                )
                db.add(conversation)
                db.commit()
                db.refresh(conversation)
            except Exception as e:
                print(f"Error: failed to save conversation to db: {e}")

テストにしてももうちょいあるだろう、というのはありますが、「3月末に遊びたいね」という発言を用意しておきました。

これによりButlerが「雰囲気、日程調整した方がよさげか?」と思ってくれることに期待します。

会話情報を取得し、要約してもらう

いったん、1時間に1回スマホでLINEを見る執事になってもらいます。

# main
def start_scheduler(db: Session = Depends(get_db)):
    scheduler = Scheduler(line_bot_api, db)
    scheduler.start()


def on_start():
    init_db()

    db = next(get_db())
    scheduler_thread = threading.Thread(target=start_scheduler, args=(db,))
    scheduler_thread.daemon = True
    scheduler_thread.start()


# Scheduler
def start(self):
    print("Scheduler started.")
    # 1時間に1回動く。テスト時は直接関数実行
    schedule.every(1).hours.do(self.check_meetings)

    # 一度jobを実行した時点でモジュールが終了してしまうため、while文で無限ループ状態にする必要がある
    while True:
        schedule.run_pending()
        time.sleep(1)
# 会話の取得
try:
    conversations = self.db.scalars(
        select(Conversation).where(Conversation.group_id == GROUP_ID)
    ).all()

    conv_dict = [
        {c.name: getattr(conv, c.name) for c in conv.__table__.columns}
        for conv in conversations
    ]

    print(conv_dict)

except Exception as e:
    print(f"Error: failed to fetch conversations: {e}")

# 要約の作成
try:
    system_prompt = """
    あなたは会話情報に基づき、会話の要約を支援するAIアシスタントです。
    次の情報に基づいて、会話の要約を作成してください。
    要約したメッセージだけを回答してください。
    
    以下のポイントを意識してください:
    1. 遊ぶ日程が決まっているかどうか
    """

    response = chat(
        model=OLLAMA_MODEL,
        messages=[
            {
                "role": "system",
                "content": system_prompt,
            },
            {
                "role": "user",
                "content": "\n".join(
                    [
                        f"{conv['user_name']} > {conv['message']}"
                        for conv in conv_dict
                    ]
                ),
            },
        ],
        stream=False,
    )

    summary = response.message.content
    print(summary)

except Exception as e:
    print(f"Error: failed to create summary: {e}")
3月末に遊ぶことを検討している。

3月末に遊ぶことを検討していることを察してくれました。

OLLAMA_MODELgemma3:27bを指定しています。特にこだわりがあるわけではないです。
ここは思考が重要になるので、cyberagent-DeepSeek-R1-Distill-Qwen-32B-Japaneseでもよいと思っています。ただ、出力に時間がかかるので今回はやめました。アップデート時に試してみたい。

日程調整の介入が必要か判定する

try:
    system_prompt = """
    Based on the summary information, output the following information
    needs_date is bool and should return True if you need my intervention to arrange the dates, False if you do not.

    example:
    3月末に遊ぶことを検討している。具体的な日程は未定。
    needs_date: True

    4月1日に遊ぶことを決定した。
    needs_date: False
    """

    response = chat(
        model="llama3.2:latest",
        messages=[
            {
                "role": "system",
                "content": system_prompt,
            },
            {
                "role": "user",
                "content": summary,
            },
        ],
        format=MeetingModel.model_json_schema(),
        stream=False,
    )

    judge = MeetingModel.model_validate_json(response.message.content)
    print(judge)

except Exception as e:
    print(f"Error: failed to create output: {e}")

try:
    meeting = Meeting(
        group_id=GROUP_ID,
        title=judge.title,
        date=datetime.now(), # 本来は予定の実施日が入る
        location=judge.location,
        status=judge.status,
        last_activity=datetime.now(),
        summary=summary,
        needs_date=judge.needs_date,
        needs_location=judge.needs_location,
        needs_reservation=judge.needs_reservation,
        reminder_sent=judge.reminder_sent,
    )
    self.db.add(meeting)
    self.db.commit()
    self.db.refresh(meeting)

except Exception as e:
    print(f"Error: failed to save summary to db: {e}")
title='' date='' location='' status='' last_activity='' summary='' needs_date=True needs_location=False needs_reservation=False reminder_sent=False

Few-shotを与えることで、どういう状況のときはneeds_dateをTrueとするのかを判断してもらうようにしました。
この辺りはpydanticのFieldのdescriptionをうまく使ったり、そもそも変数名...みたいな突っ込みがありますが、いったん動作を見たいのでよしなに。

ここではllama3.2:latestを使っています。Ollamaのstructured outputは対応しているモデルが限られているので、手元で動作確認しているllamaを今回は使用しました。他でもよいかもしれません。

https://ollama.com/blog/structured-outputs

DBにも保存されています。同じ目的のものについては更新処理を行うべきですが、今回は割愛。そのためテストの残骸が残っています。

日程調整の介入をする

if judge.needs_date:
    try:
        system_prompt = """
        あなたはLINEグループの会話を支援するAIアシスタントです。
        分析結果に基づいて、自然で親しみやすい介入メッセージを作成してください。
        完成したメッセージだけを回答してください。

        以下のポイントを意識してください:
        1. フレンドリーで自然な口調を使用
        2. 分析結果の確信度に応じて表現を調整
        3. 具体的な日時を決めるための提案を行う
        4. 押し付けがましくならないよう配慮
        5. 定型文は避け、状況に応じた柔軟な表現を使用
        """

        response = chat(
            model=OLLAMA_MODEL,
            messages=[
                {
                    "role": "system",
                    "content": system_prompt,
                },
                {"role": "user", "content": summary},
            ],
            stream=False,
        )

        content = response["message"]["content"]
        print(content)
        self.line_bot.push_message(GROUP_ID, content)

    except Exception as e:
        print(f"Error: failed to create a content: {e}")

雰囲気察してLINEに投稿してくれました!
ただ、この記事執筆している時間が3/19なので、過去の日にちを提案していたりとポンコツ具合が透けて見えますね。

おわりに

プロトタイプですが、雰囲気察して介入してくるBotを作ることができました。
巷にあふれるAIエージェントは人間からの指示出しが必要なものが多い印象です。
指示出し後は自律的に行動してくれますが、そもそも指示を出さずともデータがあるので、データを見れば自分で役割や目標をコントロールできるようになるのではないかと思っています。

この辺り深堀りするともろもろ課題が出てくるとは思うのですが、このようなアプローチはあまりなかったアプローチとして新しいサービスのきっかけをくれるのではないかなと思っています。

個人的にいちいち指示出さなくても雰囲気察して動いてくれるBotが欲しいので、引き続きアップデートを重ねていきます。
その中で見えてきた課題感は、別記事にまとめていければ。

一人前の執事になる日を目指して、頑張れ「Butler」!

llama3.2:latestだけで駆け抜けていったButler

Discussion

ログインするとコメントできます