8 Commits
Author SHA1 Message Date
colcaroli 01dd1ac55b Merge pull request 'Feature/tool based reminder routing' (#12) from feature/tool-based-reminder-routing into main
Reviewed-on: #12
2026-08-23 17:16:36 +00:00
colcaroli 24268cb453 Merge branch 'main' into feature/tool-based-reminder-routing 2026-08-23 17:16:27 +00:00
colcaroliandClaude Opus 5 858dbf4004 Catch typing() failures so Discord replies never go silent
reply_channel.typing() の async with を try の外に置いていたため、
その __aenter__(=タイピング表示リクエストの送信)が失敗すると on_message
自体が落ち、on_message 内の except にも届かず、ユーザーには何の応答も
返らなかった。スレッド作成には成功しても "Send Messages in Threads"
権限が無いだけでこの状態になりうる("Create Public Threads" とは別の
権限のため、片方だけ付与した構成は普通に起こる)。

try を async with の外側に広げ、typing() の失敗も含めて
"⚠️ 応答の生成に失敗しました" を返せるようにした。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 02:14:52 +09:00
colcaroliandClaude Opus 5 70735567f2 Thread Discord bot replies for a tidier channel
長い一般会話やリマインド確認のやり取りがチャンネルに直接積み上がって
埋もれるのを避けるため、全メンションへの返信をスレッドに集約する。

discord_bot._ensure_thread() を on_message の先頭、キーワード分岐より前に
挟み、以後の返信・タイピング表示・ReminderContext.channel_id をすべて
そこで決めた reply_channel 経由にする。

- 通常のチャンネルでのメンションは元メッセージからスレッドを新規作成する
  (名前は本文を90文字に切り詰め、自動アーカイブは1440分=1日。ブースト
  状況に関係なく常に選べる値を使う)
- 既にスレッド内でのメンションはそのスレッドを再利用し、二重に作らない
- DMや権限不足でスレッドを作れない場合は例外を握りつぶし、元のチャンネル
  へフォールバックする(Botの応答自体は止めない)
- message.reply() は常に元メッセージのチャンネル(スレッドの親)へ送って
  しまいスレッドには入らないため、_reply() でreply_channelがスレッドかどうか
  を見て送り先を明示的に使い分ける
- そのメンションから登録したリマインドの⏰通知も、登録元のスレッドに
  届くようにした(channel_idにスレッドのIDを渡すだけで済む。スレッドが
  自動アーカイブされていても送信時にDiscordが自動展開する)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 02:08:45 +09:00
colcaroli 1cd18c4bc5 Merge pull request 'Route reminders by tool availability, not keyword matching' (#11) from feature/tool-based-reminder-routing into main
Reviewed-on: #11
2026-08-23 16:35:09 +00:00
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
colcaroli 0ee9fac3f7 Merge pull request 'Make Discord reminder failures diagnosable' (#10) from fix/reminder-error-visibility into main
Reviewed-on: #10
2026-08-23 15:11:52 +00:00
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
7 changed files with 242 additions and 118 deletions
+12 -3
View File
@@ -107,9 +107,10 @@ 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_bot._ensure_thread()`): 全メンションへの返信をスレッドに集約する。元メッセージからスレッドを新規作成し(既にスレッド内でのメンションなら流用)、以後の返信・タイピング表示・そのメンションから登録したリマインドの⏰通知はすべてそのスレッドに届く。DM や権限不足でスレッドを作れない場合は元のチャンネルへフォールバックする
- **定期ジョブ** (`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 クライアントから `channel_id`(登録時のスレッドまたはチャンネル)へ依頼者をメンションして送るので、専用の Webhook URL 設定は不要。DB (`reminders`) は UTC で持ち、ユーザー・LLM とのやり取りはローカル時刻に直す。一覧・取り消しは依頼者の user_id で絞るため他人のリマインドには触れられない
### SSE ストリーミング
@@ -153,6 +154,7 @@ LLM に渡すコンテキストは `app/core/memory_kb/memory.py` の `get_conte
| `LOCAL_KB_MIN_SCORE` | `0.75` | ローカル KB を注入するコサイン類似度の下限 |
| `CORS_ORIGINS` | `http://localhost:5173` | 許可する CORS オリジン |
| `DEBUG` | `false` | LiteLLM デバッグログの有効化 |
| `LOG_LEVEL` | `INFO` | アプリ側ロガーの出力レベル。`main.py` の `basicConfig` に渡す |
| `OPENAI_API_KEY` | — | OpenAI API キー |
| `OPENAI_API_BASE` / `OPENAI_BASE_URL` | — | OpenAI 互換エンドポイントの接続先(ローカルの llama-server / Ollama 用)。設定したままだと `gpt-*` を選んでも本家 OpenAI に繋がらない。GUI 設定画面から実行時変更可 |
| `ANTHROPIC_API_KEY` | — | Anthropic API キー |
@@ -165,6 +167,13 @@ 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 の署名鍵 |
## AWS Lightsail環境
以下のコマンドで、lightsail上の環境に入ることができる。
```bash
ssh lightsail-playchat
```
+4 -3
View File
@@ -74,16 +74,17 @@ 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
MEMORY_SUMMARIZE_THRESHOLD=20
CORS_ORIGINS=http://localhost:5173
# アプリ側ログの出力レベル(DEBUG / INFO / WARNING / ERROR)
LOG_LEVEL=INFO
+3 -3
View File
@@ -9,6 +9,9 @@ DEFAULT_MODEL: str = os.getenv("DEFAULT_MODEL", "gpt-4o")
MEMORY_SUMMARIZE_THRESHOLD: int = int(os.getenv("MEMORY_SUMMARIZE_THRESHOLD", "20"))
CORS_ORIGINS: list[str] = os.getenv("CORS_ORIGINS", "http://localhost:5173").split(",")
DEBUG: bool = os.getenv("DEBUG", "false").lower() in ("1", "true", "yes")
# アプリ側ロガーの出力レベル。未設定だと logging.lastResort(WARNING以上・書式なし)に
# 落ちて logger.info が全部消えるため、main.py で明示的に設定する。
LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO").upper()
# GUI設定画面(/api/settings)を保護する簡易パスワード。未設定の場合、設定画面は常に403で拒否する。
SETTINGS_PASSWORD: str | None = os.getenv("SETTINGS_PASSWORD")
@@ -96,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")
+68 -21
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
@@ -32,6 +28,48 @@ client = discord.Client(intents=intents)
WEATHER_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:
"""明日の天気データを取得し、LLMに渡すコンテキスト文字列を作る。失敗時は取得不可の旨を返す。"""
@@ -70,20 +108,18 @@ async def on_message(message: discord.Message):
record_message(content)
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
# 以後の返信・タイピング表示・リマインドの通知先はすべてここで決めたチャンネル
# (新規作成したスレッド、または元のチャンネル)に統一する。
reply_channel = await _ensure_thread(message, content)
try:
# typing() の開始(=タイピング表示のリクエスト送信)自体が権限不足で失敗しうる
# (スレッド作成には成功しても "Send Messages in Threads" が無いケースなど)ため、
# try の外に置くと on_message ごと落ちて何の返信もされない。try の内側に入れる。
async with reply_channel.typing():
if any(keyword in content for keyword in USELESS_TALK_KEYWORDS):
redirect = await _generate_useless_talk_redirect(content)
await message.reply(redirect)
await _reply(message, reply_channel, redirect)
# 振られた話題のタグを生成し、それに近しい話題からTipsのネタを作る
seed_tags = await generate_tags(content)
@@ -97,15 +133,26 @@ 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)
# channel_id にスレッドのIDを渡すことで、⏰通知もこの会話に届く。
# スレッドが自動アーカイブされていても、送信時にDiscordが自動で展開する。
ctx = ReminderContext(
channel_id=str(reply_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 "(応答が空でした)")
except Exception:
logger.exception("Failed to generate reply for Discord mention")
await message.reply("⚠️ 応答の生成に失敗しました")
await _reply(message, reply_channel, reply or "(応答が空でした)")
except Exception:
logger.exception("Failed to generate reply for Discord mention")
await _reply(message, reply_channel, "⚠️ 応答の生成に失敗しました")
async def _generate_useless_talk_redirect(content: str) -> str:
+111 -80
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()
@@ -245,79 +229,126 @@ def execute_reminder_tool(db, name: str, args: dict, ctx: ReminderContext) -> st
return f"エラー: 未知のツール {name}"
def _system_prompt() -> str:
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 (
"あなたは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()
db = SessionLocal()
try:
for _ in range(max_rounds):
try:
response = await litellm.acompletion(
model=used_model,
messages=messages,
tools=REMINDER_TOOLS,
tool_choice="auto",
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,
)
except Exception:
logger.exception("Reminder LLM call failed [model=%s]", used_model)
return "⚠️ リマインドの登録に失敗しました。時間を変えてもう一度試してみてください。"
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
tool_calls = getattr(choice_message, "tool_calls", None)
if not tool_calls:
reply = (choice_message.content or "").strip()
return reply or "⚠️ リマインドの内容をうまく読み取れませんでした。日時と用件を教えてください。"
except (AttributeError, IndexError, KeyError, TypeError):
logger.exception(
"Unexpected completion shape [model=%s round=%d]: %r", used_model, round_no, response,
)
return "⚠️ 応答の生成に失敗しました"
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
],
})
tool_calls = getattr(choice_message, "tool_calls", None)
if not tool_calls:
return (choice_message.content or "").strip()
for tc in tool_calls:
try:
args = json.loads(tc.function.arguments or "{}")
except json.JSONDecodeError:
args = {}
result = execute_reminder_tool(db, tc.function.name, args, ctx)
messages.append({"role": "tool", "tool_call_id": tc.id, "content": result})
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
],
})
# max_rounds 使い切ってもツールを呼び続けている=堂々巡り。登録自体は済んでいる可能性がある。
logger.warning("Reminder tool loop exceeded %d rounds", max_rounds)
return "⚠️ うまく処理しきれませんでした。登録できているかは「リマインド一覧」と聞いて確認してください。"
finally:
db.close()
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:
+12 -1
View File
@@ -1,4 +1,5 @@
import asyncio
import logging
from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI
@@ -9,7 +10,7 @@ from app.api import chat, knowledge_base, logs, memory, notes, system
from app.api import models as models_api
from app.api import settings as settings_api
from app.core import settings_store
from app.core.config import CORS_ORIGINS
from app.core.config import CORS_ORIGINS, LOG_LEVEL
from app.core.site_auth import require_site_session
from app.core.discord.discord_bot import start_discord_bot
from app.core.discord.scheduler import start_scheduler
@@ -17,6 +18,16 @@ from app.core.memory_kb.word_memory import seed_from_history
from app.db.database import SessionLocal, init_db
from app.db.models import Message, ModelPreset
# uvicorn は自分のロガーしか設定しないため、アプリ側の logging.getLogger(__name__) は
# 何もしないと logging.lastResort 送りになる。それだと WARNING 未満が消えるうえ、
# 出力にレベルもタイムスタンプもロガー名も付かず、ログから障害を追えない。
logging.basicConfig(
level=LOG_LEVEL,
format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
)
# LiteLLM は自前のハンドラを持つので、root にも流すと同じ行が二重に出る。
logging.getLogger("LiteLLM").propagate = False
# ModelSelector.vue に元々ハードコードされていたプリセット。初回起動時のみDBへシードする。
_DEFAULT_MODEL_PRESETS = [
("GPT-5.5", "gpt-5.5"),
+32 -7
View File
@@ -181,14 +181,36 @@ 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 自身に判断させる。以前はキーワード一覧(リマインド / アラーム 等)で振り分けていたが、
「モーニングコール」のように一覧に無い言い回しを取りこぼす実例があったため、この方式に変えた。
#### スレッド化(`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 つ。
@@ -202,11 +224,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 クライアントから、登録されたチャンネルへ依頼者をメンションして投稿する。