Make Discord reminder failures diagnosable #10

Merged
colcaroli merged 1 commits from fix/reminder-error-visibility into main 2026-08-23 15:11:53 +00:00
5 changed files with 103 additions and 42 deletions
Showing only changes of commit ae997914c2 - Show all commits
+9
View File
@@ -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 キー |
@@ -168,3 +169,11 @@ LLM に渡すコンテキストは `app/core/memory_kb/memory.py` の `get_conte
| `REMINDER_MODEL` | — | リマインドの日時解釈に使うモデル(未設定なら `WEATHER_NOTIFY_MODEL` → `DEFAULT_MODEL`。tool calling 対応が必須) | | `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
```
+2
View File
@@ -87,3 +87,5 @@ 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
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")
+45 -9
View File
@@ -245,6 +245,28 @@ def execute_reminder_tool(db, name: str, args: dict, ctx: ReminderContext) -> st
return f"エラー: 未知のツール {name}" 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: def _system_prompt() -> str:
now = _local_now() now = _local_now()
weekday = "月火水木金土日"[now.weekday()] weekday = "月火水木金土日"[now.weekday()]
@@ -272,24 +294,37 @@ async def handle_reminder_request(content: str, ctx: ReminderContext, max_rounds
{"role": "user", "content": content}, {"role": "user", "content": content},
] ]
used_model = _model() used_model = _model()
db = SessionLocal() logger.info("Reminder request from %s [model=%s]: %s", ctx.user_name, used_model, content)
try:
for _ in range(max_rounds): for round_no in range(1, max_rounds + 1):
try: try:
# tool_choice は渡さない。Bedrock が非対応で UnsupportedParamsError になり、
# それを握り潰しているのは app.core.llm の litellm.drop_params=True という
# 別モジュールの副作用に過ぎない。tools を渡したときの既定が "auto" なので
# 省いても挙動は変わらず、その隠れた依存だけが消える。
response = await litellm.acompletion( response = await litellm.acompletion(
model=used_model, model=used_model,
messages=messages, messages=messages,
tools=REMINDER_TOOLS, tools=REMINDER_TOOLS,
tool_choice="auto",
) )
except Exception: except Exception:
logger.exception("Reminder LLM call failed [model=%s]", used_model) logger.exception("Reminder LLM call failed [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
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) tool_calls = getattr(choice_message, "tool_calls", None)
if not tool_calls: if not tool_calls:
reply = (choice_message.content or "").strip() reply = (choice_message.content or "").strip()
logger.info("Reminder request finished without tools [round=%d]", round_no)
return reply or "⚠️ リマインドの内容をうまく読み取れませんでした。日時と用件を教えてください。" return reply or "⚠️ リマインドの内容をうまく読み取れませんでした。日時と用件を教えてください。"
messages.append({ messages.append({
@@ -310,14 +345,15 @@ async def handle_reminder_request(content: str, ctx: ReminderContext, max_rounds
args = json.loads(tc.function.arguments or "{}") args = json.loads(tc.function.arguments or "{}")
except json.JSONDecodeError: except json.JSONDecodeError:
args = {} args = {}
result = execute_reminder_tool(db, tc.function.name, args, ctx) try:
result = _run_tool(tc.function.name, args, ctx)
except _ToolFailure:
return "⚠️ リマインドの保存に失敗しました(データベースエラー)。サーバーのログを確認してください。"
messages.append({"role": "tool", "tool_call_id": tc.id, "content": result}) messages.append({"role": "tool", "tool_call_id": tc.id, "content": result})
# max_rounds 使い切ってもツールを呼び続けている=堂々巡り。登録自体は済んでいる可能性がある。 # max_rounds 使い切ってもツールを呼び続けている=堂々巡り。登録自体は済んでいる可能性がある。
logger.warning("Reminder tool loop exceeded %d rounds", max_rounds) logger.warning("Reminder tool loop exceeded %d rounds [model=%s]", max_rounds, used_model)
return "⚠️ うまく処理しきれませんでした。登録できているかは「リマインド一覧」と聞いて確認してください。" return "⚠️ うまく処理しきれませんでした。登録できているかは「リマインド一覧」と聞いて確認してください。"
finally:
db.close()
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"),