Compare commits
8
Commits
caeeb5e921
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01dd1ac55b | ||
|
|
24268cb453 | ||
|
|
858dbf4004 | ||
|
|
70735567f2 | ||
|
|
1cd18c4bc5 | ||
|
|
c6108a1b29 | ||
|
|
0ee9fac3f7 | ||
|
|
ae997914c2 |
@@ -107,9 +107,10 @@ 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_bot._ensure_thread()`): 全メンションへの返信をスレッドに集約する。元メッセージからスレッドを新規作成し(既にスレッド内でのメンションなら流用)、以後の返信・タイピング表示・そのメンションから登録したリマインドの⏰通知はすべてそのスレッドに届く。DM や権限不足でスレッドを作れない場合は元のチャンネルへフォールバックする
|
||||||
- **定期ジョブ** (`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 クライアントから `channel_id`(登録時のスレッドまたはチャンネル)へ依頼者をメンションして送るので、専用の Webhook URL 設定は不要。DB (`reminders`) は UTC で持ち、ユーザー・LLM とのやり取りはローカル時刻に直す。一覧・取り消しは依頼者の user_id で絞るため他人のリマインドには触れられない
|
||||||
|
|
||||||
### SSE ストリーミング
|
### SSE ストリーミング
|
||||||
|
|
||||||
@@ -153,6 +154,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 +167,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
|
||||||
|
```
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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
|
||||||
@@ -32,6 +28,48 @@ client = discord.Client(intents=intents)
|
|||||||
WEATHER_KEYWORDS = ("天気", "天候", "洗濯", "雨", "気温", "降水")
|
WEATHER_KEYWORDS = ("天気", "天候", "洗濯", "雨", "気温", "降水")
|
||||||
USELESS_TALK_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:
|
async def _build_weather_context() -> str:
|
||||||
"""明日の天気データを取得し、LLMに渡すコンテキスト文字列を作る。失敗時は取得不可の旨を返す。"""
|
"""明日の天気データを取得し、LLMに渡すコンテキスト文字列を作る。失敗時は取得不可の旨を返す。"""
|
||||||
@@ -70,20 +108,18 @@ async def on_message(message: discord.Message):
|
|||||||
|
|
||||||
record_message(content)
|
record_message(content)
|
||||||
|
|
||||||
async with message.channel.typing():
|
# 以後の返信・タイピング表示・リマインドの通知先はすべてここで決めたチャンネル
|
||||||
try:
|
# (新規作成したスレッド、または元のチャンネル)に統一する。
|
||||||
if any(keyword in content for keyword in REMINDER_KEYWORDS):
|
reply_channel = await _ensure_thread(message, content)
|
||||||
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
|
|
||||||
|
|
||||||
|
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):
|
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 _reply(message, reply_channel, redirect)
|
||||||
|
|
||||||
# 振られた話題のタグを生成し、それに近しい話題からTipsのネタを作る
|
# 振られた話題のタグを生成し、それに近しい話題からTipsのネタを作る
|
||||||
seed_tags = await generate_tags(content)
|
seed_tags = await generate_tags(content)
|
||||||
@@ -97,15 +133,26 @@ 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)
|
# 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:
|
if reply:
|
||||||
record_message(reply)
|
record_message(reply)
|
||||||
await message.reply(reply or "(応答が空でした)")
|
await _reply(message, reply_channel, reply or "(応答が空でした)")
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to generate reply for Discord mention")
|
logger.exception("Failed to generate reply for Discord mention")
|
||||||
await message.reply("⚠️ 応答の生成に失敗しました")
|
await _reply(message, reply_channel, "⚠️ 応答の生成に失敗しました")
|
||||||
|
|
||||||
|
|
||||||
async def _generate_useless_talk_redirect(content: str) -> str:
|
async def _generate_useless_talk_redirect(content: str) -> str:
|
||||||
|
|||||||
@@ -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
@@ -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"),
|
||||||
|
|||||||
+32
-7
@@ -181,14 +181,36 @@ 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 自身に判断させる。以前はキーワード一覧(リマインド / アラーム 等)で振り分けていたが、
|
||||||
|
「モーニングコール」のように一覧に無い言い回しを取りこぼす実例があったため、この方式に変えた。
|
||||||
|
|
||||||
|
#### スレッド化(`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 つ。
|
APScheduler に登録されるジョブは 3 つ。
|
||||||
|
|
||||||
@@ -202,11 +224,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 クライアントから、登録されたチャンネルへ依頼者をメンションして投稿する。
|
||||||
|
|||||||
Reference in New Issue
Block a user