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>
This commit is contained in:
@@ -245,6 +245,28 @@ def execute_reminder_tool(db, name: str, args: dict, ctx: ReminderContext) -> st
|
||||
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()]
|
||||
@@ -272,52 +294,66 @@ async def handle_reminder_request(content: str, ctx: ReminderContext, max_rounds
|
||||
{"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",
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Reminder LLM call failed [model=%s]", used_model)
|
||||
return "⚠️ リマインドの登録に失敗しました。時間を変えてもう一度試してみてください。"
|
||||
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
|
||||
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 "⚠️ モデルからの応答を解釈できませんでした。tool calling に対応したモデルか確認してください。"
|
||||
|
||||
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:
|
||||
reply = (choice_message.content or "").strip()
|
||||
logger.info("Reminder request finished without tools [round=%d]", round_no)
|
||||
return reply or "⚠️ リマインドの内容をうまく読み取れませんでした。日時と用件を教えてください。"
|
||||
|
||||
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})
|
||||
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
|
||||
],
|
||||
})
|
||||
|
||||
# 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 "⚠️ リマインドの保存に失敗しました(データベースエラー)。サーバーのログを確認してください。"
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user