Files
playchat/backend/app/core/discord/reminders.py
T
colcaroliandClaude Opus 5 ae997914c2 Make Discord reminder failures diagnosable
リマインドが「⚠️ 応答の生成に失敗しました」だけを返して原因が追えなかった
ため、失敗経路を個別に捕まえてログにも残るようにする。

ロギング: uvicorn は自分のロガーしか設定しないため、アプリ側の
logging.getLogger(__name__) は logging.lastResort 送りになっていた。
WARNING 未満が全て消え、出力にレベルもタイムスタンプもロガー名も付かず、
logger.info("Reminder created: ...") すら残らない状態だった。
main.py で basicConfig を呼び、LOG_LEVEL(既定 INFO)で制御する。

reminders.py:
- choices[0].message の取り出しを個別に捕捉する。tool calling 非対応の
  エンドポイントは例外を投げずに choices が空の応答を返すことがあり、
  ここで落ちると on_message の汎用ハンドラに流れて原因が消える
- ツール実行を _run_tool() に切り出し、DBエラーを専用の文言で返す。
  併せてDBセッションをツール1回分だけ開くようにした。LLMとの往復のあいだ
  握り続けると SQLite のロックを長時間保持し、同時に走るAPIリクエストと
  競合して "database is locked" を起こしうるため
- tool_choice="auto" を渡すのをやめた。Bedrock はこの引数に非対応で、
  今まで通っていたのは app.core.llm が litellm.drop_params=True を
  立てている副作用に過ぎず、import 順が変われば壊れる。tools を渡した
  ときの既定が "auto" なので省いても挙動は変わらない
- どのモデルで何を処理したかを INFO で残す

CLAUDE.md には Lightsail への接続方法(作業ツリーにあった未コミット分)も
含む。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 00:08:44 +09:00

412 lines
18 KiB
Python
Raw 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 が
handle_reminder_request() を呼ぶ。中では LLM にツール(create_reminder / list_reminders /
cancel_reminder)を渡して自然言語から日時と本文を取り出させ、reminders テーブルに保存する。
保存したリマインドは 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,
REMINDER_MODEL,
WEATHER_NOTIFY_MODEL,
)
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
# 配信に失敗した回数(プロセス内のみ。再起動すればまた試す)
_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 _model() -> str:
return REMINDER_MODEL or WEATHER_NOTIFY_MODEL or DEFAULT_MODEL
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 _system_prompt() -> str:
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時と解釈してかまいませんが、"
"その解釈を返事の中で明示してください。\n"
"ツールを実行したら、その結果をもとに1〜2文の短い返事を書いてください。"
"登録した場合は「いつ」「何を」通知するかを必ず含め、絵文字を1つ程度使った親しみやすい口調にします。"
"出力は返事の本文のみで、ツール名やIDの内部的な説明は不要です。"
)
async def handle_reminder_request(content: str, ctx: ReminderContext, max_rounds: int = 3) -> str:
"""リマインド依頼をLLMに解釈させ、ツールを実行し、ユーザーへの返事を返す。
ツール呼び出しに対応していないモデルや、LLM呼び出し自体が失敗した場合は
その旨の固定文を返す(Botが無言になるのを避ける)。
"""
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)
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=messages,
tools=REMINDER_TOOLS,
)
except Exception:
logger.exception("Reminder LLM call failed [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 calling に対応したモデルか確認してください。"
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 "⚠️ リマインドの内容をうまく読み取れませんでした。日時と用件を教えてください。"
messages.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 "⚠️ リマインドの保存に失敗しました(データベースエラー)。サーバーのログを確認してください。"
messages.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()