Route reminders by tool availability, not keyword matching

「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 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 01:17:03 +09:00
co-authored by Claude Opus 5
parent 0ee9fac3f7
commit c6108a1b29
6 changed files with 75 additions and 81 deletions
-3
View File
@@ -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")
+11 -15
View File
@@ -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 "(応答が空でした)")
+45 -50
View File
@@ -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)