Feature/tool based reminder routing #12

Merged
colcaroli merged 3 commits from feature/tool-based-reminder-routing into main 2026-08-23 17:16:36 +00:00
3 changed files with 72 additions and 6 deletions
Showing only changes of commit 70735567f2 - Show all commits
+2 -1
View File
@@ -108,8 +108,9 @@ LLM に渡すコンテキストは `app/core/memory_kb/memory.py` の `get_conte
`main.py` の lifespan が Bot(メンション応答)と APScheduler(定期ジョブ)を起動する。詳細は `docs/architecture.md`。 `main.py` の lifespan が Bot(メンション応答)と APScheduler(定期ジョブ)を起動する。詳細は `docs/architecture.md`。
- **メンション応答** (`discord/discord_bot.py`): 本文のキーワードで **雑談 → 天気** の順に分岐し、どちらにも当てはまらなければ通常応答。リマインドはキーワードで振り分けず、雑談以外の全メンションで `create_reminder` / `list_reminders` / `cancel_reminder` の3ツールを常に同席させ、使うかどうかは LLM 自身に判断させる(「モーニングコール」のようにキーワード一覧に無い言い回しも拾うため)。そのため通常応答に使う `WEATHER_NOTIFY_MODEL`(未設定なら `DEFAULT_MODEL`)は**tool calling 対応が必須**。非対応モデルが設定されていた場合はツールなしの通常応答にフォールバックする - **メンション応答** (`discord/discord_bot.py`): 本文のキーワードで **雑談 → 天気** の順に分岐し、どちらにも当てはまらなければ通常応答。リマインドはキーワードで振り分けず、雑談以外の全メンションで `create_reminder` / `list_reminders` / `cancel_reminder` の3ツールを常に同席させ、使うかどうかは LLM 自身に判断させる(「モーニングコール」のようにキーワード一覧に無い言い回しも拾うため)。そのため通常応答に使う `WEATHER_NOTIFY_MODEL`(未設定なら `DEFAULT_MODEL`)は**tool calling 対応が必須**。非対応モデルが設定されていた場合はツールなしの通常応答にフォールバックする
- **スレッド化** (`discord_bot._ensure_thread()`): 全メンションへの返信をスレッドに集約する。元メッセージからスレッドを新規作成し(既にスレッド内でのメンションなら流用)、以後の返信・タイピング表示・そのメンションから登録したリマインドの⏰通知はすべてそのスレッドに届く。DM や権限不足でスレッドを作れない場合は元のチャンネルへフォールバックする
- **定期ジョブ** (`discord/scheduler.py`): 天気通知(Cron)・どうでもいいTips(Interval)・リマインド配信(Interval)の3つ - **定期ジョブ** (`discord/scheduler.py`): 天気通知(Cron)・どうでもいいTips(Interval)・リマインド配信(Interval)の3つ
- **リマインド** (`discord/reminders.py`): 自然言語登録する**一回きり**の通知(繰り返し非対応)。`resolve_reminder_tools()` が system メッセージに入れた現在時刻を基準に相対表現を絶対日時へ直させる。配信は Webhook ではなく Bot クライアントから登録元チャンネルへ依頼者をメンションして送るので、専用の Webhook URL 設定は不要。DB (`reminders`) は UTC で持ち、ユーザー・LLM とのやり取りはローカル時刻に直す。一覧・取り消しは依頼者の user_id で絞るため他人のリマインドには触れられない - **リマインド** (`discord/reminders.py`): 自然言語登録する**一回きり**の通知(繰り返し非対応)。`resolve_reminder_tools()` が system メッセージに入れた現在時刻を基準に相対表現を絶対日時へ直させる。配信は Webhook ではなく Bot クライアントから `channel_id`(登録時のスレッドまたはチャンネル)へ依頼者をメンションして送るので、専用の Webhook URL 設定は不要。DB (`reminders`) は UTC で持ち、ユーザー・LLM とのやり取りはローカル時刻に直す。一覧・取り消しは依頼者の user_id で絞るため他人のリマインドには触れられない
### SSE ストリーミング ### SSE ストリーミング
+53 -5
View File
@@ -28,6 +28,48 @@ client = discord.Client(intents=intents)
WEATHER_KEYWORDS = ("天気", "天候", "洗濯", "雨", "気温", "降水") WEATHER_KEYWORDS = ("天気", "天候", "洗濯", "雨", "気温", "降水")
USELESS_TALK_KEYWORDS = ("どうでもいい", "雑談", "暇", "つまんない", "退屈") USELESS_TALK_KEYWORDS = ("どうでもいい", "雑談", "暇", "つまんない", "退屈")
# スレッドの自動アーカイブまでの時間(分)。サーバーのブースト状況に関係なく常に選べる値。
_THREAD_AUTO_ARCHIVE_MINUTES = 1440
def _thread_name(content: str) -> str:
name = content.replace("\n", " ").strip() or "会話"
return name[:90] + "…" if len(name) > 90 else name
async def _ensure_thread(message: discord.Message, content: str) -> "discord.abc.MessageableChannel":
"""返信先を決める。
既にスレッド内でのメンションならそのスレッドをそのまま使う。DMにはスレッドが
存在しないのでチャンネルのまま。それ以外は元メッセージからスレッドを新規作成し、
以後の会話をそこに集約する。作成に失敗した場合(権限不足など)は例外を握りつぶし、
元のチャンネルへフォールバックする(Botの応答自体は止めない)。
"""
channel = message.channel
if isinstance(channel, discord.Thread) or message.guild is None:
return channel
try:
return await message.create_thread(
name=_thread_name(content), auto_archive_duration=_THREAD_AUTO_ARCHIVE_MINUTES,
)
except Exception:
logger.warning(
"Failed to create thread for message %s; replying in channel instead", message.id, exc_info=True,
)
return channel
async def _reply(message: discord.Message, reply_channel: "discord.abc.MessageableChannel", text: str) -> None:
"""reply_channel が元のチャンネルのままなら通常どおり message.reply()(引用UI付き)で返す。
スレッドを新規作成した場合、message.reply() は元メッセージのチャンネル(スレッドの親)へ
送ってしまいスレッドには入らないため、reply_channel へ直接 send() する。
"""
if reply_channel is message.channel:
await message.reply(text)
else:
await reply_channel.send(text)
async def _build_weather_context() -> str: async def _build_weather_context() -> str:
"""明日の天気データを取得し、LLMに渡すコンテキスト文字列を作る。失敗時は取得不可の旨を返す。""" """明日の天気データを取得し、LLMに渡すコンテキスト文字列を作る。失敗時は取得不可の旨を返す。"""
@@ -66,11 +108,15 @@ async def on_message(message: discord.Message):
record_message(content) record_message(content)
async with message.channel.typing(): # 以後の返信・タイピング表示・リマインドの通知先はすべてここで決めたチャンネル
# (新規作成したスレッド、または元のチャンネル)に統一する。
reply_channel = await _ensure_thread(message, content)
async with reply_channel.typing():
try: try:
if any(keyword in content for keyword in USELESS_TALK_KEYWORDS): if any(keyword in content for keyword in USELESS_TALK_KEYWORDS):
redirect = await _generate_useless_talk_redirect(content) redirect = await _generate_useless_talk_redirect(content)
await message.reply(redirect) await _reply(message, reply_channel, redirect)
# 振られた話題のタグを生成し、それに近しい話題からTipsのネタを作る # 振られた話題のタグを生成し、それに近しい話題からTipsのネタを作る
seed_tags = await generate_tags(content) seed_tags = await generate_tags(content)
@@ -90,18 +136,20 @@ async def on_message(message: discord.Message):
messages.append({"role": "system", "content": build_tool_hint()}) messages.append({"role": "system", "content": build_tool_hint()})
messages.append({"role": "user", "content": content}) messages.append({"role": "user", "content": content})
# channel_id にスレッドのIDを渡すことで、⏰通知もこの会話に届く。
# スレッドが自動アーカイブされていても、送信時にDiscordが自動で展開する。
ctx = ReminderContext( ctx = ReminderContext(
channel_id=str(message.channel.id), channel_id=str(reply_channel.id),
user_id=str(message.author.id), user_id=str(message.author.id),
user_name=message.author.display_name, user_name=message.author.display_name,
) )
reply = await resolve_reminder_tools(messages, ctx, model=WEATHER_NOTIFY_MODEL) reply = await resolve_reminder_tools(messages, ctx, model=WEATHER_NOTIFY_MODEL)
if reply: if reply:
record_message(reply) record_message(reply)
await message.reply(reply or "(応答が空でした)") await _reply(message, reply_channel, reply or "(応答が空でした)")
except Exception: except Exception:
logger.exception("Failed to generate reply for Discord mention") logger.exception("Failed to generate reply for Discord mention")
await message.reply("⚠️ 応答の生成に失敗しました") await _reply(message, reply_channel, "⚠️ 応答の生成に失敗しました")
async def _generate_useless_talk_redirect(content: str) -> str: async def _generate_useless_talk_redirect(content: str) -> str:
+17
View File
@@ -195,6 +195,23 @@ data: {"type":"error","value":"エラー内容"} # 例外発生時
LLM 自身に判断させる。以前はキーワード一覧(リマインド / アラーム 等)で振り分けていたが、 LLM 自身に判断させる。以前はキーワード一覧(リマインド / アラーム 等)で振り分けていたが、
「モーニングコール」のように一覧に無い言い回しを取りこぼす実例があったため、この方式に変えた。 「モーニングコール」のように一覧に無い言い回しを取りこぼす実例があったため、この方式に変えた。
#### スレッド化(`discord_bot._ensure_thread()` / `_reply()`)
全メンションへの返信を Discord のスレッドに集約する。`on_message` の先頭、キーワード分岐より前に
`reply_channel = await _ensure_thread(message, content)` を呼び、以後の返信・タイピング表示・
`ReminderContext.channel_id` はすべて `reply_channel` を通す。
- 通常のチャンネルでのメンションは、元メッセージから `message.create_thread()` で新規スレッドを作る
(名前は本文を 90 文字に切り詰めたもの、自動アーカイブ 1440 分=1日)
- 既にスレッド内でのメンションはそのスレッドをそのまま使い、新規作成はしない
- DM(`message.guild is None`)や、権限不足など何らかの理由でスレッド作成に失敗した場合は
例外を握りつぶして元のチャンネルへフォールバックする(Bot の応答自体は止めない)
- `_reply()` は `reply_channel` が元のチャンネルのままなら `message.reply()`(引用UI付き)、
新規スレッドを作った場合は `reply_channel.send()` を使う。`message.reply()` は常に元メッセージの
チャンネル(スレッドの親)へ送ってしまいスレッドには入らないため、明示的に使い分けが要る
- そのメンションから登録したリマインドは `channel_id` にスレッドの ID が入るため、⏰通知も同じ
スレッドに届く。スレッドが自動アーカイブされていても、送信すると Discord 側で自動的に展開される
APScheduler に登録されるジョブは 3 つ。 APScheduler に登録されるジョブは 3 つ。
| ジョブ | トリガー | 内容 | | ジョブ | トリガー | 内容 |