Author SHA1 Message Date
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
colcaroli caeeb5e921 Merge pull request 'Schedule reminders by mentioning the Discord bot' (#9) from feature/discord-reminders into main
Reviewed-on: #9
2026-08-23 14:32:25 +00:00
7 changed files with 167 additions and 112 deletions
+11 -3
View File
@@ -107,9 +107,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`): 本文のキーワードで **リマインド → 雑談 → 天気** の順に分岐し、先にマッチしたものが返信する - **メンション応答** (`discord/discord_bot.py`): 本文のキーワードで **雑談 → 天気** の順に分岐し、どちらにも当てはまらなければ通常応答。リマインドはキーワードで振り分けず、雑談以外の全メンションで `create_reminder` / `list_reminders` / `cancel_reminder` の3ツールを常に同席させ、使うかどうかは LLM 自身に判断させる(「モーニングコール」のようにキーワード一覧に無い言い回しも拾うため)。そのため通常応答に使う `WEATHER_NOTIFY_MODEL`(未設定なら `DEFAULT_MODEL`)は**tool calling 対応が必須**。非対応モデルが設定されていた場合はツールなしの通常応答にフォールバックする
- **定期ジョブ** (`discord/scheduler.py`): 天気通知(Cron)・どうでもいいTips(Interval)・リマインド配信(Interval)の3つ - **定期ジョブ** (`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 クライアントから登録元チャンネルへ依頼者をメンションして送るので、専用の Webhook URL 設定は不要。DB (`reminders`) は UTC で持ち、ユーザー・LLM とのやり取りはローカル時刻に直す。一覧・取り消しは依頼者の user_id で絞るため他人のリマインドには触れられない
### SSE ストリーミング ### SSE ストリーミング
@@ -153,6 +153,7 @@ LLM に渡すコンテキストは `app/core/memory_kb/memory.py` の `get_conte
| `LOCAL_KB_MIN_SCORE` | `0.75` | ローカル KB を注入するコサイン類似度の下限 | | `LOCAL_KB_MIN_SCORE` | `0.75` | ローカル KB を注入するコサイン類似度の下限 |
| `CORS_ORIGINS` | `http://localhost:5173` | 許可する CORS オリジン | | `CORS_ORIGINS` | `http://localhost:5173` | 許可する CORS オリジン |
| `DEBUG` | `false` | LiteLLM デバッグログの有効化 | | `DEBUG` | `false` | LiteLLM デバッグログの有効化 |
| `LOG_LEVEL` | `INFO` | アプリ側ロガーの出力レベル。`main.py` の `basicConfig` に渡す |
| `OPENAI_API_KEY` | — | OpenAI API キー | | `OPENAI_API_KEY` | — | OpenAI API キー |
| `OPENAI_API_BASE` / `OPENAI_BASE_URL` | — | OpenAI 互換エンドポイントの接続先(ローカルの llama-server / Ollama 用)。設定したままだと `gpt-*` を選んでも本家 OpenAI に繋がらない。GUI 設定画面から実行時変更可 | | `OPENAI_API_BASE` / `OPENAI_BASE_URL` | — | OpenAI 互換エンドポイントの接続先(ローカルの llama-server / Ollama 用)。設定したままだと `gpt-*` を選んでも本家 OpenAI に繋がらない。GUI 設定画面から実行時変更可 |
| `ANTHROPIC_API_KEY` | — | Anthropic API キー | | `ANTHROPIC_API_KEY` | — | Anthropic API キー |
@@ -165,6 +166,13 @@ LLM に渡すコンテキストは `app/core/memory_kb/memory.py` の `get_conte
| `REMINDER_CHECK_INTERVAL_SECONDS` | `30` | 期限切れリマインドを探す間隔(秒) | | `REMINDER_CHECK_INTERVAL_SECONDS` | `30` | 期限切れリマインドを探す間隔(秒) |
| `REMINDER_MAX_AHEAD_DAYS` | `365` | これより先の日時のリマインドは登録を拒否する | | `REMINDER_MAX_AHEAD_DAYS` | `365` | これより先の日時のリマインドは登録を拒否する |
| `REMINDER_LIST_LIMIT` | `20` | `list_reminders` が一度に返す件数 | | `REMINDER_LIST_LIMIT` | `20` | `list_reminders` が一度に返す件数 |
| `REMINDER_MODEL` | — | リマインドの日時解釈に使うモデル(未設定なら `WEATHER_NOTIFY_MODEL` → `DEFAULT_MODEL`。tool calling 対応が必須) |
| `SITE_PASSWORD` | — | サイト全体を保護する共有パスワード(未設定なら無認証) | | `SITE_PASSWORD` | — | サイト全体を保護する共有パスワード(未設定なら無認証) |
| `SESSION_SECRET_KEY` | 起動毎に自動生成 | ログインセッション Cookie の署名鍵 | | `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_WEBHOOK_URL_TIPS=
# Discord Bot のリマインド機能(メンションで登録し、同じチャンネルへ通知する) # Discord Bot のリマインド機能(メンションで登録し、同じチャンネルへ通知する)
# 全メンションでツールを常に同席させ、使うかどうかはLLMが判断する(キーワード判定なし)ため、
# WEATHER_NOTIFY_MODEL(未設定ならDEFAULT_MODEL)は tool calling 対応モデルである必要がある
# 期限切れのリマインドを探しにいく間隔(秒) # 期限切れのリマインドを探しにいく間隔(秒)
REMINDER_CHECK_INTERVAL_SECONDS=30 REMINDER_CHECK_INTERVAL_SECONDS=30
# これより先の日時は登録を拒否する(日数) # これより先の日時は登録を拒否する(日数)
REMINDER_MAX_AHEAD_DAYS=365 REMINDER_MAX_AHEAD_DAYS=365
REMINDER_LIST_LIMIT=20 REMINDER_LIST_LIMIT=20
# 日時の解釈に使うモデル。tool calling 対応モデルを指定すること
# (未設定なら WEATHER_NOTIFY_MODEL → DEFAULT_MODEL の順にフォールバック)
REMINDER_MODEL=
# アプリ設定 # アプリ設定
DATABASE_URL=sqlite:///./playchat.db DATABASE_URL=sqlite:///./playchat.db
MEMORY_SUMMARIZE_THRESHOLD=20 MEMORY_SUMMARIZE_THRESHOLD=20
CORS_ORIGINS=http://localhost:5173 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")) MEMORY_SUMMARIZE_THRESHOLD: int = int(os.getenv("MEMORY_SUMMARIZE_THRESHOLD", "20"))
CORS_ORIGINS: list[str] = os.getenv("CORS_ORIGINS", "http://localhost:5173").split(",") CORS_ORIGINS: list[str] = os.getenv("CORS_ORIGINS", "http://localhost:5173").split(",")
DEBUG: bool = os.getenv("DEBUG", "false").lower() in ("1", "true", "yes") 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で拒否する。 # GUI設定画面(/api/settings)を保護する簡易パスワード。未設定の場合、設定画面は常に403で拒否する。
SETTINGS_PASSWORD: str | None = os.getenv("SETTINGS_PASSWORD") 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")) REMINDER_MAX_AHEAD_DAYS: int = int(os.getenv("REMINDER_MAX_AHEAD_DAYS", "365"))
# list_reminders が一度に返す件数 # list_reminders が一度に返す件数
REMINDER_LIST_LIMIT: int = int(os.getenv("REMINDER_LIST_LIMIT", "20")) 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, WEATHER_NOTIFY_MODEL,
) )
from app.core.llm import complete from app.core.llm import complete
from app.core.discord.reminders import ( from app.core.discord.reminders import ReminderContext, build_tool_hint, resolve_reminder_tools
REMINDER_KEYWORDS,
ReminderContext,
handle_reminder_request,
)
from app.core.discord.scheduler import send_useless_tip from app.core.discord.scheduler import send_useless_tip
from app.core.memory_kb.tagging import find_related_tag, generate_tags from app.core.memory_kb.tagging import find_related_tag, generate_tags
from app.core.weather.weather import fetch_tomorrow_weather, judge_laundry 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(): async with message.channel.typing():
try: 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): 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 message.reply(redirect)
@@ -97,9 +84,18 @@ async def on_message(message: discord.Message):
weather_context = await _build_weather_context() weather_context = await _build_weather_context()
messages.append({"role": "system", "content": weather_context}) messages.append({"role": "system", "content": weather_context})
# リマインドはキーワードでは振り分けない。「モーニングコール」のように
# 決め打ちの言い回しから外れる依頼も拾えるよう、ツールを常に同席させて
# 使うかどうかはLLM自身に判断させる。
messages.append({"role": "system", "content": build_tool_hint()})
messages.append({"role": "user", "content": content}) 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: if reply:
record_message(reply) record_message(reply)
await message.reply(reply or "(応答が空でした)") await message.reply(reply or "(応答が空でした)")
+111 -80
View File
@@ -1,8 +1,10 @@
"""Discord Bot のリマインド機能。 """Discord Bot のリマインド機能。
メンション本文にリマインド系のキーワードが含まれていたとき、discord_bot.on_message が キーワードでは振り分けない。discord_bot.on_message は全メンションで
handle_reminder_request() を呼ぶ。中では LLM にツール(create_reminder / list_reminders / resolve_reminder_tools() を通し、create_reminder / list_reminders / cancel_reminder の
cancel_reminder)を渡して自然言語から日時と本文を取り出させ、reminders テーブルに保存する。 3ツールを常に同席させる。頼まれたときだけLLM自身がツールを選んで使うため、
「モーニングコール」のようにキーワード一覧に無い言い回しでも拾える
(キーワード方式で「アラーム」等に決め打ちしていたときに取りこぼしがあった)。
保存したリマインドは scheduler が REMINDER_CHECK_INTERVAL_SECONDS ごとに 保存したリマインドは scheduler が REMINDER_CHECK_INTERVAL_SECONDS ごとに
deliver_due_reminders() を呼ぶことで配信される。配信先は登録されたチャンネルで、 deliver_due_reminders() を呼ぶことで配信される。配信先は登録されたチャンネルで、
@@ -23,27 +25,13 @@ from app.core.config import (
DEFAULT_MODEL, DEFAULT_MODEL,
REMINDER_LIST_LIMIT, REMINDER_LIST_LIMIT,
REMINDER_MAX_AHEAD_DAYS, REMINDER_MAX_AHEAD_DAYS,
REMINDER_MODEL,
WEATHER_NOTIFY_MODEL,
) )
from app.core.llm import complete
from app.db.database import SessionLocal from app.db.database import SessionLocal
from app.db.models import Reminder from app.db.models import Reminder
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# メンション本文にこれらが含まれるとリマインド処理に入る(discord_bot.on_message を参照)
REMINDER_KEYWORDS = (
"リマインド",
"リマインダ",
"remind",
"思い出させ",
"忘れないように",
"忘れずに",
"覚えといて",
"覚えておいて",
"アラーム",
)
# 同じリマインドの配信にこの回数失敗したら諦める。チャンネル削除などで永久に # 同じリマインドの配信にこの回数失敗したら諦める。チャンネル削除などで永久に
# 失敗し続けるものが、ポーリングのたびにエラーログを出すのを防ぐ。 # 失敗し続けるものが、ポーリングのたびにエラーログを出すのを防ぐ。
_MAX_DELIVERY_ATTEMPTS = 5 _MAX_DELIVERY_ATTEMPTS = 5
@@ -62,10 +50,6 @@ class ReminderContext:
user_name: str user_name: str
def _model() -> str:
return REMINDER_MODEL or WEATHER_NOTIFY_MODEL or DEFAULT_MODEL
def _local_now() -> datetime: def _local_now() -> datetime:
"""このマシンのローカルタイムゾーンでの現在時刻(aware)。""" """このマシンのローカルタイムゾーンでの現在時刻(aware)。"""
return datetime.now().astimezone() return datetime.now().astimezone()
@@ -245,79 +229,126 @@ def execute_reminder_tool(db, name: str, args: dict, ctx: ReminderContext) -> st
return f"エラー: 未知のツール {name}" 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() now = _local_now()
weekday = "月火水木金土日"[now.weekday()] weekday = "月火水木金土日"[now.weekday()]
return ( return (
"あなたはDiscordでリマインドを預かるアシスタントです。" f"現在の日時は {now:%Y-%m-%d %H:%M}({weekday}曜日)です。\n"
"ユーザーの依頼に応じて create_reminder / list_reminders / cancel_reminder ツールを使ってください。\n" "ユーザーが『あとで知らせて』『◯時になったら教えて』『明日起こして』のように"
f"現在の日時は {now:%Y-%m-%d %H:%M}({weekday}曜日)です。" "リマインドを頼んできたら、create_reminder / list_reminders / cancel_reminder "
"『明日』『30分後』『今週末』などの相対表現は、この現在時刻を基準に絶対日時へ変換すること。\n" "ツールを使って対応してください。『明日』『30分後』などの相対表現は、この現在時刻を"
"時刻が指定されていない『明日』のような依頼は朝9時と解釈してかまいませんが、" "基準に絶対日時へ変換すること。時刻が指定されていない依頼は朝9時と解釈してかまいませんが、"
"その解釈を返事の中で明示してください。\n" "その解釈を返事の中で明示してください。\n"
"ツールを実行したら、その結果をもとに1〜2文の短い返事を書いてください。" "リマインドと関係ない話題では、これらのツールは使わずいつも通り会話してください。"
"登録した場合は「いつ」「何を」通知するかを必ず含め、絵文字を1つ程度使った親しみやすい口調にします。"
"出力は返事の本文のみで、ツール名やIDの内部的な説明は不要です。"
) )
async def handle_reminder_request(content: str, ctx: ReminderContext, max_rounds: int = 3) -> str: async def resolve_reminder_tools(
"""リマインド依頼をLLMに解釈させ、ツールを実行し、ユーザーへの返事を返す。 messages: list[dict],
ctx: ReminderContext,
model: str | None = None,
max_rounds: int = 3,
) -> str:
"""会話にリマインドツールを差し込んで解決し、最終的な返信本文を返す。
ツール呼び出しに対応していないモデルや、LLM呼び出し自体が失敗した場合は discord_bot.on_message が全メンションでこれを通す。ツール呼び出しに対応していない
その旨の固定文を返す(Botが無言になるのを避ける)。 モデルが設定されていた場合は、例外を握りつぶしてツールなしの通常応答にフォールバック
する(リマインドと無関係な会話まで巻き添えで壊れないようにするため)。
""" """
messages: list[dict] = [ used_model = model or DEFAULT_MODEL
{"role": "system", "content": _system_prompt()}, working = list(messages)
{"role": "user", "content": content},
] for round_no in range(1, max_rounds + 1):
used_model = _model() try:
db = SessionLocal() # tool_choice は渡さない。Bedrock が非対応で UnsupportedParamsError になり、
try: # それを握り潰しているのは app.core.llm の litellm.drop_params=True という
for _ in range(max_rounds): # 別モジュールの副作用に過ぎない。tools を渡したときの既定が "auto" なので
try: # 省いても挙動は変わらず、その隠れた依存だけが消える。
response = await litellm.acompletion( response = await litellm.acompletion(
model=used_model, model=used_model,
messages=messages, messages=working,
tools=REMINDER_TOOLS, tools=REMINDER_TOOLS,
tool_choice="auto", )
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: return await complete(messages, model=used_model)
logger.exception("Reminder LLM call failed [model=%s]", used_model) logger.exception("Reminder-aware completion failed mid-loop [model=%s round=%d]", used_model, round_no)
return "⚠️ リマインドの登録に失敗しました。時間を変えてもう一度試してみてください。" return "⚠️ 応答の生成に失敗しました"
# tool calling 非対応のエンドポイントは、例外を投げずに choices が空だったり
# 形の違う応答を返すことがある。ここで落ちると原因が分からなくなるので個別に捕まえる。
try:
choice_message = response.choices[0].message choice_message = response.choices[0].message
tool_calls = getattr(choice_message, "tool_calls", None) except (AttributeError, IndexError, KeyError, TypeError):
if not tool_calls: logger.exception(
reply = (choice_message.content or "").strip() "Unexpected completion shape [model=%s round=%d]: %r", used_model, round_no, response,
return reply or "⚠️ リマインドの内容をうまく読み取れませんでした。日時と用件を教えてください。" )
return "⚠️ 応答の生成に失敗しました"
messages.append({ tool_calls = getattr(choice_message, "tool_calls", None)
"role": "assistant", if not tool_calls:
"content": choice_message.content or "", return (choice_message.content or "").strip()
"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: working.append({
try: "role": "assistant",
args = json.loads(tc.function.arguments or "{}") "content": choice_message.content or "",
except json.JSONDecodeError: "tool_calls": [
args = {} {
result = execute_reminder_tool(db, tc.function.name, args, ctx) "id": tc.id,
messages.append({"role": "tool", "tool_call_id": tc.id, "content": result}) "type": "function",
"function": {"name": tc.function.name, "arguments": tc.function.arguments},
}
for tc in tool_calls
],
})
# max_rounds 使い切ってもツールを呼び続けている=堂々巡り。登録自体は済んでいる可能性がある。 for tc in tool_calls:
logger.warning("Reminder tool loop exceeded %d rounds", max_rounds) try:
return "⚠️ うまく処理しきれませんでした。登録できているかは「リマインド一覧」と聞いて確認してください。" args = json.loads(tc.function.arguments or "{}")
finally: except json.JSONDecodeError:
db.close() 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: async def deliver_due_reminders() -> None:
+12 -1
View File
@@ -1,4 +1,5 @@
import asyncio import asyncio
import logging
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI 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 models as models_api
from app.api import settings as settings_api from app.api import settings as settings_api
from app.core import settings_store 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.site_auth import require_site_session
from app.core.discord.discord_bot import start_discord_bot from app.core.discord.discord_bot import start_discord_bot
from app.core.discord.scheduler import start_scheduler 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.database import SessionLocal, init_db
from app.db.models import Message, ModelPreset 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へシードする。 # ModelSelector.vue に元々ハードコードされていたプリセット。初回起動時のみDBへシードする。
_DEFAULT_MODEL_PRESETS = [ _DEFAULT_MODEL_PRESETS = [
("GPT-5.5", "gpt-5.5"), ("GPT-5.5", "gpt-5.5"),
+15 -7
View File
@@ -181,14 +181,19 @@ data: {"type":"error","value":"エラー内容"} # 例外発生時
`main.py` の lifespan が `start_discord_bot()`(メンション応答)と `start_scheduler()`(定期ジョブ)を起動する。 `main.py` の lifespan が `start_discord_bot()`(メンション応答)と `start_scheduler()`(定期ジョブ)を起動する。
`on_message` はメンション本文をキーワードで見て分岐する。判定順は **リマインド → 雑談 → 天気** で、 `on_message` はメンション本文をキーワードで見て分岐する。判定順は **雑談 → 天気** で、
先にマッチしたものが返信を返して終わる。 先にマッチしたものが返信を返して終わる。どちらにも当てはまらなければ通常応答へ進む。
| 分岐 | キーワード例 | 動作 | | 分岐 | キーワード例 | 動作 |
|---|---|---| |---|---|---|
| リマインド | リマインド / 忘れないように / 覚えといて / アラーム | `reminders.handle_reminder_request()` に渡す |
| 雑談 | どうでもいい / 雑談 / 暇 | 『どうでもいいTips』へ誘導しつつ、話題に近いタグで Tips を1本投稿 | | 雑談 | どうでもいい / 雑談 / 暇 | 『どうでもいいTips』へ誘導しつつ、話題に近いタグで Tips を1本投稿 |
| 天気 | 天気 / 洗濯 / 雨 | 気象庁の明日の予報を system メッセージに足してから返信 | | 天気 | 天気 / 洗濯 / 雨 | 気象庁の明日の予報を system メッセージに足してから返信 |
| 通常応答 | (上記に当てはまらない全て) | `reminders.resolve_reminder_tools()` に渡す |
リマインドはキーワードで振り分けない。雑談以外の全メンション(天気キーワード込み)で
`create_reminder` / `list_reminders` / `cancel_reminder` の 3 ツールを常に同席させ、使うかどうかは
LLM 自身に判断させる。以前はキーワード一覧(リマインド / アラーム 等)で振り分けていたが、
「モーニングコール」のように一覧に無い言い回しを取りこぼす実例があったため、この方式に変えた。
APScheduler に登録されるジョブは 3 つ。 APScheduler に登録されるジョブは 3 つ。
@@ -202,11 +207,14 @@ APScheduler に登録されるジョブは 3 つ。
一回きりのリマインドのみを扱う(繰り返しには非対応)。 一回きりのリマインドのみを扱う(繰り返しには非対応)。
- **登録**: LLM に `create_reminder` / `list_reminders` / `cancel_reminder` の 3 ツールを渡し、 - **登録**: `resolve_reminder_tools()` が `discord_bot.on_message` の全メンションで
自然言語から日時と用件を取り出させる。system メッセージに現在時刻と曜日を入れてあるので、 `create_reminder` / `list_reminders` / `cancel_reminder` の 3 ツールを差し込み、自然言語から
日時と用件を取り出させる。`build_tool_hint()` が現在時刻と曜日を system メッセージに足すので、
『明日』『30分後』といった相対表現はモデル側が絶対日時に直す。使うモデルは 『明日』『30分後』といった相対表現はモデル側が絶対日時に直す。使うモデルは
`REMINDER_MODEL` →`WEATHER_NOTIFY_MODEL` → `DEFAULT_MODEL` の順にフォールバックするが、 discord_bot 側が渡す `WEATHER_NOTIFY_MODEL`(未設定なら `DEFAULT_MODEL`)で、一般会話・天気応答と
**tool calling 対応モデルでないと動かない**(非対応時はエラー文を返す) 共通。**tool calling 対応モデルであることが望ましい** — 非対応モデルがツール付き呼び出し自体を
拒否した場合は、1 ラウンド目の例外を検知してツールなしの通常応答にフォールバックする
(リマインド機能は使えないが、一般会話・天気応答は道連れにならない)
- **バリデーション**: 過去・`REMINDER_MAX_AHEAD_DAYS` より先・解釈不能な日時はツール結果として - **バリデーション**: 過去・`REMINDER_MAX_AHEAD_DAYS` より先・解釈不能な日時はツール結果として
エラー文をモデルに返し、モデルに聞き直させる エラー文をモデルに返し、モデルに聞き直させる
- **配信先**: Webhook ではなく Bot クライアントから、登録されたチャンネルへ依頼者をメンションして投稿する。 - **配信先**: Webhook ではなく Bot クライアントから、登録されたチャンネルへ依頼者をメンションして投稿する。