From c6108a1b294c1ed4f93f0b1360e7dcf8ae4694c5 Mon Sep 17 00:00:00 2001 From: zukki259 Date: Mon, 24 Aug 2026 01:17:03 +0900 Subject: [PATCH] Route reminders by tool availability, not keyword matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「7時にモーニングコールをお願い。」のような、REMINDER_KEYWORDS のどれとも 一致しない言い回しがリマインド機能を素通りし、ただの雑談として処理されて いた(本番の api_logs で実例を確認)。「モーニングコール」「起こして」を キーワードに追加しても、次の言い回しでまた同じことが起きるだけなので、 キーワード判定そのものをやめる。 discord_bot.on_message は雑談・天気のキーワード分岐はそのまま残し、それ以外の 全メンションで create_reminder / list_reminders / cancel_reminder の3ツールを 常に同席させる(reminders.build_tool_hint() が現在時刻の文脈を添える)。 使うかどうかはLLM自身の判断に委ねるため、キーワード一覧を事前に尽くす必要が なくなる。本番のBedrock Nova 2 Liteで、実際に失敗していた文言が create_reminder を正しく呼ぶことを確認済み。 reminders.handle_reminder_request() は resolve_reminder_tools(messages, ctx, model) に一般化し、discord_bot 側で組み立てたmessages(人格プロンプト+天気 コンテキスト+ツール文脈)をそのまま受け取れるようにした。tool calling 非対応のモデルが設定されていた場合は、1ラウンド目の例外を検知してツール なしの通常応答にフォールバックする。ツール呼び出しが一般会話の呼び出しに 統合された以上、それだけ別モデルを割り当てる余地がなくなったため REMINDER_MODEL は廃止する。 Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 5 +- backend/.env.example | 5 +- backend/app/core/config.py | 3 - backend/app/core/discord/discord_bot.py | 26 +++---- backend/app/core/discord/reminders.py | 95 ++++++++++++------------- docs/architecture.md | 22 ++++-- 6 files changed, 75 insertions(+), 81 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 32be099..20ee51c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -107,9 +107,9 @@ LLM に渡すコンテキストは `app/core/memory_kb/memory.py` の `get_conte `main.py` の lifespan が Bot(メンション応答)と APScheduler(定期ジョブ)を起動する。詳細は `docs/architecture.md`。 -- **メンション応答** (`discord/discord_bot.py`): 本文のキーワードで **リマインド → 雑談 → 天気** の順に分岐し、先にマッチしたものが返信する +- **メンション応答** (`discord/discord_bot.py`): 本文のキーワードで **雑談 → 天気** の順に分岐し、どちらにも当てはまらなければ通常応答。リマインドはキーワードで振り分けず、雑談以外の全メンションで `create_reminder` / `list_reminders` / `cancel_reminder` の3ツールを常に同席させ、使うかどうかは LLM 自身に判断させる(「モーニングコール」のようにキーワード一覧に無い言い回しも拾うため)。そのため通常応答に使う `WEATHER_NOTIFY_MODEL`(未設定なら `DEFAULT_MODEL`)は**tool calling 対応が必須**。非対応モデルが設定されていた場合はツールなしの通常応答にフォールバックする - **定期ジョブ** (`discord/scheduler.py`): 天気通知(Cron)・どうでもいいTips(Interval)・リマインド配信(Interval)の3つ -- **リマインド** (`discord/reminders.py`): メンションで自然言語登録する**一回きり**の通知(繰り返し非対応)。LLM に `create_reminder` / `list_reminders` / `cancel_reminder` の3ツールを渡し、system メッセージに入れた現在時刻を基準に相対表現を絶対日時へ直させる。**tool calling 対応モデルが必須**。配信は Webhook ではなく Bot クライアントから登録元チャンネルへ依頼者をメンションして送るので、専用の Webhook URL 設定は不要。DB (`reminders`) は UTC で持ち、ユーザー・LLM とのやり取りはローカル時刻に直す。一覧・取り消しは依頼者の user_id で絞るため他人のリマインドには触れられない +- **リマインド** (`discord/reminders.py`): 自然言語登録する**一回きり**の通知(繰り返し非対応)。`resolve_reminder_tools()` が system メッセージに入れた現在時刻を基準に相対表現を絶対日時へ直させる。配信は Webhook ではなく Bot クライアントから登録元チャンネルへ依頼者をメンションして送るので、専用の Webhook URL 設定は不要。DB (`reminders`) は UTC で持ち、ユーザー・LLM とのやり取りはローカル時刻に直す。一覧・取り消しは依頼者の user_id で絞るため他人のリマインドには触れられない ### SSE ストリーミング @@ -166,7 +166,6 @@ LLM に渡すコンテキストは `app/core/memory_kb/memory.py` の `get_conte | `REMINDER_CHECK_INTERVAL_SECONDS` | `30` | 期限切れリマインドを探す間隔(秒) | | `REMINDER_MAX_AHEAD_DAYS` | `365` | これより先の日時のリマインドは登録を拒否する | | `REMINDER_LIST_LIMIT` | `20` | `list_reminders` が一度に返す件数 | -| `REMINDER_MODEL` | — | リマインドの日時解釈に使うモデル(未設定なら `WEATHER_NOTIFY_MODEL` → `DEFAULT_MODEL`。tool calling 対応が必須) | | `SITE_PASSWORD` | — | サイト全体を保護する共有パスワード(未設定なら無認証) | | `SESSION_SECRET_KEY` | 起動毎に自動生成 | ログインセッション Cookie の署名鍵 | diff --git a/backend/.env.example b/backend/.env.example index bf43cbd..c44464a 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -74,14 +74,13 @@ USELESS_TIPS_MODEL= DISCORD_WEBHOOK_URL_TIPS= # Discord Bot のリマインド機能(メンションで登録し、同じチャンネルへ通知する) +# 全メンションでツールを常に同席させ、使うかどうかはLLMが判断する(キーワード判定なし)ため、 +# WEATHER_NOTIFY_MODEL(未設定ならDEFAULT_MODEL)は tool calling 対応モデルである必要がある # 期限切れのリマインドを探しにいく間隔(秒) REMINDER_CHECK_INTERVAL_SECONDS=30 # これより先の日時は登録を拒否する(日数) REMINDER_MAX_AHEAD_DAYS=365 REMINDER_LIST_LIMIT=20 -# 日時の解釈に使うモデル。tool calling 対応モデルを指定すること -# (未設定なら WEATHER_NOTIFY_MODEL → DEFAULT_MODEL の順にフォールバック) -REMINDER_MODEL= # アプリ設定 DATABASE_URL=sqlite:///./playchat.db diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 424bbbd..676920a 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -99,6 +99,3 @@ REMINDER_CHECK_INTERVAL_SECONDS: int = int(os.getenv("REMINDER_CHECK_INTERVAL_SE REMINDER_MAX_AHEAD_DAYS: int = int(os.getenv("REMINDER_MAX_AHEAD_DAYS", "365")) # list_reminders が一度に返す件数 REMINDER_LIST_LIMIT: int = int(os.getenv("REMINDER_LIST_LIMIT", "20")) -# リマインドの解釈に使うモデル。tool callingに対応したモデルを指定すること -# (未設定なら WEATHER_NOTIFY_MODEL → DEFAULT_MODEL の順にフォールバックする) -REMINDER_MODEL: str | None = os.getenv("REMINDER_MODEL") diff --git a/backend/app/core/discord/discord_bot.py b/backend/app/core/discord/discord_bot.py index fd69a03..92020ca 100644 --- a/backend/app/core/discord/discord_bot.py +++ b/backend/app/core/discord/discord_bot.py @@ -11,11 +11,7 @@ from app.core.config import ( WEATHER_NOTIFY_MODEL, ) from app.core.llm import complete -from app.core.discord.reminders import ( - REMINDER_KEYWORDS, - ReminderContext, - handle_reminder_request, -) +from app.core.discord.reminders import ReminderContext, build_tool_hint, resolve_reminder_tools from app.core.discord.scheduler import send_useless_tip from app.core.memory_kb.tagging import find_related_tag, generate_tags from app.core.weather.weather import fetch_tomorrow_weather, judge_laundry @@ -72,15 +68,6 @@ async def on_message(message: discord.Message): async with message.channel.typing(): try: - if any(keyword in content for keyword in REMINDER_KEYWORDS): - ctx = ReminderContext( - channel_id=str(message.channel.id), - user_id=str(message.author.id), - user_name=message.author.display_name, - ) - await message.reply(await handle_reminder_request(content, ctx)) - return - if any(keyword in content for keyword in USELESS_TALK_KEYWORDS): redirect = await _generate_useless_talk_redirect(content) await message.reply(redirect) @@ -97,9 +84,18 @@ async def on_message(message: discord.Message): weather_context = await _build_weather_context() messages.append({"role": "system", "content": weather_context}) + # リマインドはキーワードでは振り分けない。「モーニングコール」のように + # 決め打ちの言い回しから外れる依頼も拾えるよう、ツールを常に同席させて + # 使うかどうかはLLM自身に判断させる。 + messages.append({"role": "system", "content": build_tool_hint()}) messages.append({"role": "user", "content": content}) - reply = await complete(messages, model=WEATHER_NOTIFY_MODEL) + ctx = ReminderContext( + channel_id=str(message.channel.id), + user_id=str(message.author.id), + user_name=message.author.display_name, + ) + reply = await resolve_reminder_tools(messages, ctx, model=WEATHER_NOTIFY_MODEL) if reply: record_message(reply) await message.reply(reply or "(応答が空でした)") diff --git a/backend/app/core/discord/reminders.py b/backend/app/core/discord/reminders.py index d509c6d..ac60fd8 100644 --- a/backend/app/core/discord/reminders.py +++ b/backend/app/core/discord/reminders.py @@ -1,8 +1,10 @@ """Discord Bot のリマインド機能。 -メンション本文にリマインド系のキーワードが含まれていたとき、discord_bot.on_message が -handle_reminder_request() を呼ぶ。中では LLM にツール(create_reminder / list_reminders / -cancel_reminder)を渡して自然言語から日時と本文を取り出させ、reminders テーブルに保存する。 +キーワードでは振り分けない。discord_bot.on_message は全メンションで +resolve_reminder_tools() を通し、create_reminder / list_reminders / cancel_reminder の +3ツールを常に同席させる。頼まれたときだけLLM自身がツールを選んで使うため、 +「モーニングコール」のようにキーワード一覧に無い言い回しでも拾える +(キーワード方式で「アラーム」等に決め打ちしていたときに取りこぼしがあった)。 保存したリマインドは scheduler が REMINDER_CHECK_INTERVAL_SECONDS ごとに deliver_due_reminders() を呼ぶことで配信される。配信先は登録されたチャンネルで、 @@ -23,27 +25,13 @@ from app.core.config import ( DEFAULT_MODEL, REMINDER_LIST_LIMIT, REMINDER_MAX_AHEAD_DAYS, - REMINDER_MODEL, - WEATHER_NOTIFY_MODEL, ) +from app.core.llm import complete from app.db.database import SessionLocal from app.db.models import Reminder logger = logging.getLogger(__name__) -# メンション本文にこれらが含まれるとリマインド処理に入る(discord_bot.on_message を参照) -REMINDER_KEYWORDS = ( - "リマインド", - "リマインダ", - "remind", - "思い出させ", - "忘れないように", - "忘れずに", - "覚えといて", - "覚えておいて", - "アラーム", -) - # 同じリマインドの配信にこの回数失敗したら諦める。チャンネル削除などで永久に # 失敗し続けるものが、ポーリングのたびにエラーログを出すのを防ぐ。 _MAX_DELIVERY_ATTEMPTS = 5 @@ -62,10 +50,6 @@ class ReminderContext: user_name: str -def _model() -> str: - return REMINDER_MODEL or WEATHER_NOTIFY_MODEL or DEFAULT_MODEL - - def _local_now() -> datetime: """このマシンのローカルタイムゾーンでの現在時刻(aware)。""" return datetime.now().astimezone() @@ -267,34 +251,40 @@ def _run_tool(name: str, args: dict, ctx: ReminderContext) -> str: db.close() -def _system_prompt() -> str: +def build_tool_hint() -> str: + """create_reminder 等を使わせるための時刻文脈。system メッセージとして差し込む。 + + discord_bot.on_message がキーワード判定なしに全メンションでこれを同席させるので、 + ここにはリマインドに関する指示だけを書く(お天気アプリとしての人格や口調は + DISCORD_BOT_SYSTEM_PROMPT 側の責務のまま)。 + """ now = _local_now() weekday = "月火水木金土日"[now.weekday()] return ( - "あなたはDiscordでリマインドを預かるアシスタントです。" - "ユーザーの依頼に応じて create_reminder / list_reminders / cancel_reminder ツールを使ってください。\n" - f"現在の日時は {now:%Y-%m-%d %H:%M}({weekday}曜日)です。" - "『明日』『30分後』『今週末』などの相対表現は、この現在時刻を基準に絶対日時へ変換すること。\n" - "時刻が指定されていない『明日』のような依頼は朝9時と解釈してかまいませんが、" + f"現在の日時は {now:%Y-%m-%d %H:%M}({weekday}曜日)です。\n" + "ユーザーが『あとで知らせて』『◯時になったら教えて』『明日起こして』のように" + "リマインドを頼んできたら、create_reminder / list_reminders / cancel_reminder " + "ツールを使って対応してください。『明日』『30分後』などの相対表現は、この現在時刻を" + "基準に絶対日時へ変換すること。時刻が指定されていない依頼は朝9時と解釈してかまいませんが、" "その解釈を返事の中で明示してください。\n" - "ツールを実行したら、その結果をもとに1〜2文の短い返事を書いてください。" - "登録した場合は「いつ」「何を」通知するかを必ず含め、絵文字を1つ程度使った親しみやすい口調にします。" - "出力は返事の本文のみで、ツール名やIDの内部的な説明は不要です。" + "リマインドと関係ない話題では、これらのツールは使わずいつも通り会話してください。" ) -async def handle_reminder_request(content: str, ctx: ReminderContext, max_rounds: int = 3) -> str: - """リマインド依頼をLLMに解釈させ、ツールを実行し、ユーザーへの返事を返す。 +async def resolve_reminder_tools( + messages: list[dict], + ctx: ReminderContext, + model: str | None = None, + max_rounds: int = 3, +) -> str: + """会話にリマインドツールを差し込んで解決し、最終的な返信本文を返す。 - ツール呼び出しに対応していないモデルや、LLM呼び出し自体が失敗した場合は - その旨の固定文を返す(Botが無言になるのを避ける)。 + discord_bot.on_message が全メンションでこれを通す。ツール呼び出しに対応していない + モデルが設定されていた場合は、例外を握りつぶしてツールなしの通常応答にフォールバック + する(リマインドと無関係な会話まで巻き添えで壊れないようにするため)。 """ - messages: list[dict] = [ - {"role": "system", "content": _system_prompt()}, - {"role": "user", "content": content}, - ] - used_model = _model() - logger.info("Reminder request from %s [model=%s]: %s", ctx.user_name, used_model, content) + used_model = model or DEFAULT_MODEL + working = list(messages) for round_no in range(1, max_rounds + 1): try: @@ -304,12 +294,19 @@ async def handle_reminder_request(content: str, ctx: ReminderContext, max_rounds # 省いても挙動は変わらず、その隠れた依存だけが消える。 response = await litellm.acompletion( model=used_model, - messages=messages, + messages=working, tools=REMINDER_TOOLS, ) except Exception: - logger.exception("Reminder LLM call failed [model=%s round=%d]", used_model, round_no) - return "⚠️ リマインドの登録に失敗しました。時間を変えてもう一度試してみてください。" + if round_no == 1: + # 1ラウンド目からして失敗=モデルがそもそも tools 付き呼び出しを + # 受け付けない可能性が高い。ツールなしの通常応答として救済する。 + logger.info( + "Model %s rejected a tools-attached call; falling back to plain completion", used_model, + ) + return await complete(messages, model=used_model) + logger.exception("Reminder-aware completion failed mid-loop [model=%s round=%d]", used_model, round_no) + return "⚠️ 応答の生成に失敗しました" # tool calling 非対応のエンドポイントは、例外を投げずに choices が空だったり # 形の違う応答を返すことがある。ここで落ちると原因が分からなくなるので個別に捕まえる。 @@ -319,15 +316,13 @@ async def handle_reminder_request(content: str, ctx: ReminderContext, max_rounds logger.exception( "Unexpected completion shape [model=%s round=%d]: %r", used_model, round_no, response, ) - return "⚠️ モデルからの応答を解釈できませんでした。tool calling に対応したモデルか確認してください。" + return "⚠️ 応答の生成に失敗しました" tool_calls = getattr(choice_message, "tool_calls", None) if not tool_calls: - reply = (choice_message.content or "").strip() - logger.info("Reminder request finished without tools [round=%d]", round_no) - return reply or "⚠️ リマインドの内容をうまく読み取れませんでした。日時と用件を教えてください。" + return (choice_message.content or "").strip() - messages.append({ + working.append({ "role": "assistant", "content": choice_message.content or "", "tool_calls": [ @@ -349,7 +344,7 @@ async def handle_reminder_request(content: str, ctx: ReminderContext, max_rounds result = _run_tool(tc.function.name, args, ctx) except _ToolFailure: return "⚠️ リマインドの保存に失敗しました(データベースエラー)。サーバーのログを確認してください。" - messages.append({"role": "tool", "tool_call_id": tc.id, "content": result}) + working.append({"role": "tool", "tool_call_id": tc.id, "content": result}) # max_rounds 使い切ってもツールを呼び続けている=堂々巡り。登録自体は済んでいる可能性がある。 logger.warning("Reminder tool loop exceeded %d rounds [model=%s]", max_rounds, used_model) diff --git a/docs/architecture.md b/docs/architecture.md index 56e41e1..63146c6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -181,14 +181,19 @@ data: {"type":"error","value":"エラー内容"} # 例外発生時 `main.py` の lifespan が `start_discord_bot()`(メンション応答)と `start_scheduler()`(定期ジョブ)を起動する。 -`on_message` はメンション本文をキーワードで見て分岐する。判定順は **リマインド → 雑談 → 天気** で、 -先にマッチしたものが返信を返して終わる。 +`on_message` はメンション本文をキーワードで見て分岐する。判定順は **雑談 → 天気** で、 +先にマッチしたものが返信を返して終わる。どちらにも当てはまらなければ通常応答へ進む。 | 分岐 | キーワード例 | 動作 | |---|---|---| -| リマインド | リマインド / 忘れないように / 覚えといて / アラーム | `reminders.handle_reminder_request()` に渡す | | 雑談 | どうでもいい / 雑談 / 暇 | 『どうでもいいTips』へ誘導しつつ、話題に近いタグで Tips を1本投稿 | | 天気 | 天気 / 洗濯 / 雨 | 気象庁の明日の予報を system メッセージに足してから返信 | +| 通常応答 | (上記に当てはまらない全て) | `reminders.resolve_reminder_tools()` に渡す | + +リマインドはキーワードで振り分けない。雑談以外の全メンション(天気キーワード込み)で +`create_reminder` / `list_reminders` / `cancel_reminder` の 3 ツールを常に同席させ、使うかどうかは +LLM 自身に判断させる。以前はキーワード一覧(リマインド / アラーム 等)で振り分けていたが、 +「モーニングコール」のように一覧に無い言い回しを取りこぼす実例があったため、この方式に変えた。 APScheduler に登録されるジョブは 3 つ。 @@ -202,11 +207,14 @@ APScheduler に登録されるジョブは 3 つ。 一回きりのリマインドのみを扱う(繰り返しには非対応)。 -- **登録**: LLM に `create_reminder` / `list_reminders` / `cancel_reminder` の 3 ツールを渡し、 - 自然言語から日時と用件を取り出させる。system メッセージに現在時刻と曜日を入れてあるので、 +- **登録**: `resolve_reminder_tools()` が `discord_bot.on_message` の全メンションで + `create_reminder` / `list_reminders` / `cancel_reminder` の 3 ツールを差し込み、自然言語から + 日時と用件を取り出させる。`build_tool_hint()` が現在時刻と曜日を system メッセージに足すので、 『明日』『30分後』といった相対表現はモデル側が絶対日時に直す。使うモデルは - `REMINDER_MODEL` →`WEATHER_NOTIFY_MODEL` → `DEFAULT_MODEL` の順にフォールバックするが、 - **tool calling 対応モデルでないと動かない**(非対応時はエラー文を返す) + discord_bot 側が渡す `WEATHER_NOTIFY_MODEL`(未設定なら `DEFAULT_MODEL`)で、一般会話・天気応答と + 共通。**tool calling 対応モデルであることが望ましい** — 非対応モデルがツール付き呼び出し自体を + 拒否した場合は、1 ラウンド目の例外を検知してツールなしの通常応答にフォールバックする + (リマインド機能は使えないが、一般会話・天気応答は道連れにならない) - **バリデーション**: 過去・`REMINDER_MAX_AHEAD_DAYS` より先・解釈不能な日時はツール結果として エラー文をモデルに返し、モデルに聞き直させる - **配信先**: Webhook ではなく Bot クライアントから、登録されたチャンネルへ依頼者をメンションして投稿する。 -- 2.54.0