Files
colcaroliandClaude Opus 5 c6108a1b29 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>
2026-08-24 01:17:03 +09:00

407 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Discord Bot のリマインド機能。
キーワードでは振り分けない。discord_bot.on_message は全メンションで
resolve_reminder_tools() を通し、create_reminder / list_reminders / cancel_reminder の
3ツールを常に同席させる。頼まれたときだけLLM自身がツールを選んで使うため、
「モーニングコール」のようにキーワード一覧に無い言い回しでも拾える
(キーワード方式で「アラーム」等に決め打ちしていたときに取りこぼしがあった)。
保存したリマインドは scheduler が REMINDER_CHECK_INTERVAL_SECONDS ごとに
deliver_due_reminders() を呼ぶことで配信される。配信先は登録されたチャンネルで、
Webhook ではなく Bot クライアントから送るため追加の URL 設定は要らない。
日時の扱い: DB は他のテーブルと同じくUTC。ユーザーとLLMとのやり取りはすべて
このマシンのローカル時刻(JST想定)で行い、この境界で相互に変換する。
"""
import json
import logging
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
import discord
import litellm
from app.core.config import (
DEFAULT_MODEL,
REMINDER_LIST_LIMIT,
REMINDER_MAX_AHEAD_DAYS,
)
from app.core.llm import complete
from app.db.database import SessionLocal
from app.db.models import Reminder
logger = logging.getLogger(__name__)
# 同じリマインドの配信にこの回数失敗したら諦める。チャンネル削除などで永久に
# 失敗し続けるものが、ポーリングのたびにエラーログを出すのを防ぐ。
_MAX_DELIVERY_ATTEMPTS = 5
# 配信に失敗した回数(プロセス内のみ。再起動すればまた試す)
_delivery_failures: dict[int, int] = {}
_AT_FORMATS = ("%Y-%m-%d %H:%M", "%Y-%m-%dT%H:%M", "%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S")
@dataclass
class ReminderContext:
"""リマインドを頼んできた相手。配信先チャンネルと、メンションする相手を決めるのに使う。"""
channel_id: str
user_id: str
user_name: str
def _local_now() -> datetime:
"""このマシンのローカルタイムゾーンでの現在時刻(aware)。"""
return datetime.now().astimezone()
def _to_local(value: datetime) -> datetime:
"""DBから読んだUTCの日時を、表示用にローカル時刻へ直す。"""
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
return value.astimezone(_local_now().tzinfo)
def _parse_at(text: str) -> datetime:
"""LLMが返した "YYYY-MM-DD HH:MM" をローカル時刻の aware な datetime にする。
解釈できない場合は ValueError を投げ、呼び出し側がその旨をツール結果としてLLMに返す。
"""
cleaned = (text or "").strip()
for fmt in _AT_FORMATS:
try:
return datetime.strptime(cleaned, fmt).replace(tzinfo=_local_now().tzinfo)
except ValueError:
continue
raise ValueError(f"日時 '{text}' を解釈できませんでした")
CREATE_REMINDER_TOOL = {
"type": "function",
"function": {
"name": "create_reminder",
"description": "指定した日時にDiscordへ通知するリマインドを登録する。"
"ユーザーが『あとで教えて』『◯時になったら知らせて』などと頼んできたときに使う。"
"一回きりの通知のみで、繰り返しには対応していない。",
"parameters": {
"type": "object",
"properties": {
"at": {
"type": "string",
"description": "通知する日時。必ず 'YYYY-MM-DD HH:MM' 形式のローカル時刻で指定する。"
"『明日の朝9時』『30分後』のような相対表現は、システムメッセージで与えられた"
"現在時刻をもとに自分で計算して絶対日時に直すこと。",
},
"content": {
"type": "string",
"description": "通知するときに読み上げる用件。ユーザーの言葉をそのまま短くまとめる。",
},
},
"required": ["at", "content"],
},
},
}
LIST_REMINDERS_TOOL = {
"type": "function",
"function": {
"name": "list_reminders",
"description": "このユーザーの未通知のリマインド一覧を取得する。"
"『リマインド何が入ってる?』のように予定を尋ねられたときや、"
"取り消し対象のIDを知りたいときに使う。",
"parameters": {"type": "object", "properties": {}},
},
}
CANCEL_REMINDER_TOOL = {
"type": "function",
"function": {
"name": "cancel_reminder",
"description": "登録済みのリマインドを取り消す。IDが分からない場合は先に list_reminders で調べること。",
"parameters": {
"type": "object",
"properties": {
"id": {"type": "integer", "description": "取り消すリマインドのID(list_reminders が返す番号)"},
},
"required": ["id"],
},
},
}
REMINDER_TOOLS = [CREATE_REMINDER_TOOL, LIST_REMINDERS_TOOL, CANCEL_REMINDER_TOOL]
def _create_reminder(db, args: dict, ctx: ReminderContext) -> str:
content = (args.get("content") or "").strip()
if not content:
return "エラー: 通知する用件が空です。ユーザーに何をリマインドすればよいか聞き返してください。"
try:
due_local = _parse_at(args.get("at", ""))
except ValueError as e:
return f"エラー: {e}。'YYYY-MM-DD HH:MM' の形式で指定し直してください。"
now_local = _local_now()
if due_local <= now_local:
return (
f"エラー: 指定された {due_local:%Y-%m-%d %H:%M} は現在時刻 {now_local:%Y-%m-%d %H:%M} より過去です。"
"未来の日時を指定し直すか、ユーザーに確認してください。"
)
if due_local - now_local > timedelta(days=REMINDER_MAX_AHEAD_DAYS):
return (
f"エラー: 指定された {due_local:%Y-%m-%d %H:%M} は{REMINDER_MAX_AHEAD_DAYS}日以上先です。"
"年を取り違えていないか確認してください。"
)
reminder = Reminder(
channel_id=ctx.channel_id,
user_id=ctx.user_id,
user_name=ctx.user_name,
content=content,
due_at=due_local.astimezone(timezone.utc),
)
db.add(reminder)
db.commit()
db.refresh(reminder)
logger.info("Reminder created: id=%s due=%s user=%s", reminder.id, due_local, ctx.user_name)
return (
f"リマインドを登録しました(ID {reminder.id})。"
f"{due_local:%Y-%m-%d %H:%M} に「{content}」を通知します。"
"ユーザーへの返事には、いつ何を通知するのかを必ず含めてください。"
)
def _list_reminders(db, ctx: ReminderContext) -> str:
rows = (
db.query(Reminder)
.filter(
Reminder.user_id == ctx.user_id,
Reminder.notified_at.is_(None),
Reminder.cancelled_at.is_(None),
)
.order_by(Reminder.due_at)
.limit(REMINDER_LIST_LIMIT)
.all()
)
if not rows:
return "このユーザーの未通知のリマインドはありません。"
lines = [f"ID {r.id}: {_to_local(r.due_at):%Y-%m-%d %H:%M} 「{r.content}」" for r in rows]
return "未通知のリマインド:\n" + "\n".join(lines)
def _cancel_reminder(db, args: dict, ctx: ReminderContext) -> str:
reminder_id = args.get("id")
reminder = (
db.query(Reminder)
.filter(
Reminder.id == reminder_id,
Reminder.user_id == ctx.user_id,
Reminder.notified_at.is_(None),
Reminder.cancelled_at.is_(None),
)
.first()
)
if reminder is None:
return (
f"エラー: ID {reminder_id} の未通知のリマインドは見つかりませんでした。"
"list_reminders で確認してください。"
)
reminder.cancelled_at = datetime.now(timezone.utc)
db.commit()
logger.info("Reminder cancelled: id=%s user=%s", reminder_id, ctx.user_name)
return f"ID {reminder_id}「{reminder.content}」のリマインドを取り消しました。"
def execute_reminder_tool(db, name: str, args: dict, ctx: ReminderContext) -> str:
"""リマインド系ツールを実行し、LLMに返す結果テキストを作る。
一覧・取り消しは ctx.user_id で絞るため、他人のリマインドには触れられない。
"""
if name == "create_reminder":
return _create_reminder(db, args, ctx)
if name == "list_reminders":
return _list_reminders(db, ctx)
if name == "cancel_reminder":
return _cancel_reminder(db, args, ctx)
return f"エラー: 未知のツール {name}"
class _ToolFailure(Exception):
"""ツール実行そのものが失敗した(DBエラーなど)。モデルに投げ返さず即座に諦めるための印。"""
def _run_tool(name: str, args: dict, ctx: ReminderContext) -> str:
"""ツール1回分だけDBセッションを開いて実行する。
LLMとの往復のあいだセッションを握り続けると、SQLite のロックを数秒〜数十秒
保持することになり、同時に走る API リクエストと競合して "database is locked"
を起こしうる。開いて閉じるのをツール単位にすることでそれを避ける。
"""
db = SessionLocal()
try:
return execute_reminder_tool(db, name, args, ctx)
except Exception as e:
logger.exception("Reminder tool %s failed (args=%r)", name, args)
db.rollback()
raise _ToolFailure(name) from e
finally:
db.close()
def build_tool_hint() -> str:
"""create_reminder 等を使わせるための時刻文脈。system メッセージとして差し込む。
discord_bot.on_message がキーワード判定なしに全メンションでこれを同席させるので、
ここにはリマインドに関する指示だけを書く(お天気アプリとしての人格や口調は
DISCORD_BOT_SYSTEM_PROMPT 側の責務のまま)。
"""
now = _local_now()
weekday = "月火水木金土日"[now.weekday()]
return (
f"現在の日時は {now:%Y-%m-%d %H:%M}({weekday}曜日)です。\n"
"ユーザーが『あとで知らせて』『◯時になったら教えて』『明日起こして』のように"
"リマインドを頼んできたら、create_reminder / list_reminders / cancel_reminder "
"ツールを使って対応してください。『明日』『30分後』などの相対表現は、この現在時刻を"
"基準に絶対日時へ変換すること。時刻が指定されていない依頼は朝9時と解釈してかまいませんが、"
"その解釈を返事の中で明示してください。\n"
"リマインドと関係ない話題では、これらのツールは使わずいつも通り会話してください。"
)
async def resolve_reminder_tools(
messages: list[dict],
ctx: ReminderContext,
model: str | None = None,
max_rounds: int = 3,
) -> str:
"""会話にリマインドツールを差し込んで解決し、最終的な返信本文を返す。
discord_bot.on_message が全メンションでこれを通す。ツール呼び出しに対応していない
モデルが設定されていた場合は、例外を握りつぶしてツールなしの通常応答にフォールバック
する(リマインドと無関係な会話まで巻き添えで壊れないようにするため)。
"""
used_model = model or DEFAULT_MODEL
working = list(messages)
for round_no in range(1, max_rounds + 1):
try:
# tool_choice は渡さない。Bedrock が非対応で UnsupportedParamsError になり、
# それを握り潰しているのは app.core.llm の litellm.drop_params=True という
# 別モジュールの副作用に過ぎない。tools を渡したときの既定が "auto" なので
# 省いても挙動は変わらず、その隠れた依存だけが消える。
response = await litellm.acompletion(
model=used_model,
messages=working,
tools=REMINDER_TOOLS,
)
except Exception:
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 が空だったり
# 形の違う応答を返すことがある。ここで落ちると原因が分からなくなるので個別に捕まえる。
try:
choice_message = response.choices[0].message
except (AttributeError, IndexError, KeyError, TypeError):
logger.exception(
"Unexpected completion shape [model=%s round=%d]: %r", used_model, round_no, response,
)
return "⚠️ 応答の生成に失敗しました"
tool_calls = getattr(choice_message, "tool_calls", None)
if not tool_calls:
return (choice_message.content or "").strip()
working.append({
"role": "assistant",
"content": choice_message.content or "",
"tool_calls": [
{
"id": tc.id,
"type": "function",
"function": {"name": tc.function.name, "arguments": tc.function.arguments},
}
for tc in tool_calls
],
})
for tc in tool_calls:
try:
args = json.loads(tc.function.arguments or "{}")
except json.JSONDecodeError:
args = {}
try:
result = _run_tool(tc.function.name, args, ctx)
except _ToolFailure:
return "⚠️ リマインドの保存に失敗しました(データベースエラー)。サーバーのログを確認してください。"
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)
return "⚠️ うまく処理しきれませんでした。登録できているかは「リマインド一覧」と聞いて確認してください。"
async def deliver_due_reminders() -> None:
"""期限を過ぎた未通知のリマインドを、登録元のチャンネルへ配信する。
scheduler が REMINDER_CHECK_INTERVAL_SECONDS ごとに呼ぶ。Bot がまだ起動しきって
いない間は何もしないので、バックエンド停止中に期限を迎えたぶんは起動後にまとめて届く。
"""
# discord_bot が scheduler 経由でこのモジュールを読むため、モジュールレベルで
# import すると循環 import になる。
from app.core.discord.discord_bot import client
if not client.is_ready():
return
db = SessionLocal()
try:
due = (
db.query(Reminder)
.filter(
Reminder.notified_at.is_(None),
Reminder.cancelled_at.is_(None),
Reminder.due_at <= datetime.now(timezone.utc),
)
.order_by(Reminder.due_at)
.all()
)
for reminder in due:
try:
channel = client.get_channel(int(reminder.channel_id))
if channel is None:
channel = await client.fetch_channel(int(reminder.channel_id))
# カテゴリやフォーラムそのものには投稿できない(登録時のチャンネルが
# 別種のものに作り替えられた場合に起きうる)ので、送れる相手かを確かめる。
if not isinstance(channel, discord.abc.Messageable):
raise TypeError(f"channel {reminder.channel_id} is not messageable")
await channel.send(f"⏰ <@{reminder.user_id}> リマインドの時間です!\n> {reminder.content}")
except Exception:
failures = _delivery_failures.get(reminder.id, 0) + 1
_delivery_failures[reminder.id] = failures
if failures < _MAX_DELIVERY_ATTEMPTS:
logger.exception(
"Failed to deliver reminder id=%s (attempt %d/%d)",
reminder.id, failures, _MAX_DELIVERY_ATTEMPTS,
)
continue
logger.error("Giving up on reminder id=%s after %d attempts", reminder.id, failures)
_delivery_failures.pop(reminder.id, None)
reminder.notified_at = datetime.now(timezone.utc)
db.commit()
except Exception:
logger.exception("Failed to process due reminders")
finally:
db.close()