Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24268cb453 | ||
|
|
858dbf4004 | ||
|
|
70735567f2 | ||
|
|
1cd18c4bc5 | ||
|
|
c6108a1b29 | ||
|
|
0ee9fac3f7 | ||
|
|
ae997914c2 | ||
|
|
caeeb5e921 | ||
|
|
c1aba42cf8 | ||
|
|
3b30dab650 | ||
|
|
1b88d23f21 | ||
|
|
904458e7df |
@@ -103,6 +103,15 @@ LLM に渡すコンテキストは `app/core/memory_kb/memory.py` の `get_conte
|
||||
- **ダウンロード**: `GET /api/notes/{page_id}/export` が 1 ページを Markdown、`GET /api/notes/export` が全ページを ZIP(1 ページ 1 Markdown、セクションはフォルダ、ファイル名の通し番号で `position` 順を保持)で返す。ファイル名はサーバーが決めるのでフロントは `download` 属性を付けず `Content-Disposition`(日本語タイトルのため RFC 5987 を併記)に任せる
|
||||
- **セクションによるグルーピング**: `note_pages.section` にセクション名を持たせ、同じセクションのページは `position` 上で常に連続させる(`core/notes.set_section()` が割り当て時に並べ替える)。タブバーは `position` 順に描画し、隣接して同じ `section` が続く区間を1グループとして表示する。`get_notes_context()` はセクションごとに `## セクション名` の見出しを付けてタイトル一覧を出す。セクション名の一括変更は `POST /api/notes/sections/rename`(`core/notes.rename_section()`)
|
||||
|
||||
### Discord Bot
|
||||
|
||||
`main.py` の lifespan が Bot(メンション応答)と APScheduler(定期ジョブ)を起動する。詳細は `docs/architecture.md`。
|
||||
|
||||
- **メンション応答** (`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`): 自然言語登録する**一回きり**の通知(繰り返し非対応)。`resolve_reminder_tools()` が system メッセージに入れた現在時刻を基準に相対表現を絶対日時へ直させる。配信は Webhook ではなく Bot クライアントから `channel_id`(登録時のスレッドまたはチャンネル)へ依頼者をメンションして送るので、専用の Webhook URL 設定は不要。DB (`reminders`) は UTC で持ち、ユーザー・LLM とのやり取りはローカル時刻に直す。一覧・取り消しは依頼者の user_id で絞るため他人のリマインドには触れられない
|
||||
|
||||
### SSE ストリーミング
|
||||
|
||||
`POST /chat/stream` が SSE を返す。イベント種別: `session_id`(新規セッション ID)/ `chunk`(テキスト断片)/ `tool`(ツール実行の開始・完了)/ `sources`(Web 検索の出典)/ `notes_updated`(AI が共有ノートを更新)/ `done`(完了)/ `error`(エラー)。
|
||||
@@ -145,7 +154,9 @@ 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 キー |
|
||||
| `OLLAMA_API_BASE` | `http://localhost:11434` | Ollama エンドポイント |
|
||||
| `NOTES_TOOL_CONTENT_LIMIT` | `4000` | `read_note` が 1 回に返す本文の最大文字数 |
|
||||
@@ -153,5 +164,16 @@ LLM に渡すコンテキストは `app/core/memory_kb/memory.py` の `get_conte
|
||||
| `NOTES_IMPORT_MAX_PAGES` | `100` | ノート取り込みで一度に作れるページ数の上限 |
|
||||
| `NOTES_IMPORT_MAX_BYTES` | `2000000` | 取り込む 1 ファイル(ZIP 内は 1 エントリ)あたりの最大バイト数 |
|
||||
| `KNOWLEDGE_TOOL_CONTENT_LIMIT` | `4000` | `read_knowledge` が 1 回に返す本文の最大文字数 |
|
||||
| `REMINDER_CHECK_INTERVAL_SECONDS` | `30` | 期限切れリマインドを探す間隔(秒) |
|
||||
| `REMINDER_MAX_AHEAD_DAYS` | `365` | これより先の日時のリマインドは登録を拒否する |
|
||||
| `REMINDER_LIST_LIMIT` | `20` | `list_reminders` が一度に返す件数 |
|
||||
| `SITE_PASSWORD` | — | サイト全体を保護する共有パスワード(未設定なら無認証) |
|
||||
| `SESSION_SECRET_KEY` | 起動毎に自動生成 | ログインセッション Cookie の署名鍵 |
|
||||
|
||||
## AWS Lightsail環境
|
||||
|
||||
以下のコマンドで、lightsail上の環境に入ることができる。
|
||||
|
||||
```bash
|
||||
ssh lightsail-playchat
|
||||
```
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
DEFAULT_MODEL=gpt-5.4
|
||||
# OpenAI
|
||||
OPENAI_API_KEY=sk-...
|
||||
# OpenAI互換エンドポイントに向ける場合だけ設定する(ローカルの llama-server / Ollama など)。
|
||||
# 設定したままにすると、gpt-* のモデルを選んでも接続先がそちらのままになり、
|
||||
# 本家OpenAIには繋がらない(litellm は OPENAI_API_BASE、OpenAI SDK は OPENAI_BASE_URL を見る)。
|
||||
# サーバーにデプロイする際はこの2行を消し忘れないこと。GUIの設定画面からも変更できる。
|
||||
# OPENAI_API_BASE=http://localhost:11434/v1
|
||||
# OPENAI_BASE_URL=http://localhost:11434/v1
|
||||
# Anthropic
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
# Ollama (ローカル) — APIキー不要、モデル名は ollama/llama3 形式で指定
|
||||
@@ -67,7 +73,18 @@ USELESS_TIPS_MODEL=
|
||||
# 未設定ならDISCORD_WEBHOOK_URL(天気通知と同じ)を使う
|
||||
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
|
||||
|
||||
# アプリ設定
|
||||
DATABASE_URL=sqlite:///./playchat.db
|
||||
MEMORY_SUMMARIZE_THRESHOLD=20
|
||||
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"))
|
||||
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")
|
||||
@@ -88,3 +91,11 @@ USELESS_TIPS_INTERVAL_HOURS: int = int(os.getenv("USELESS_TIPS_INTERVAL_HOURS",
|
||||
USELESS_TIPS_MODEL: str | None = os.getenv("USELESS_TIPS_MODEL")
|
||||
# 未設定なら天気通知と同じ DISCORD_WEBHOOK_URL を使う
|
||||
DISCORD_WEBHOOK_URL_TIPS: str | None = os.getenv("DISCORD_WEBHOOK_URL_TIPS") or os.getenv("DISCORD_WEBHOOK_URL")
|
||||
|
||||
# Discord Bot のリマインド機能(メンションで登録し、時刻になったら同じチャンネルへ通知する)
|
||||
# 期限切れのリマインドを探しにいく間隔。短いほど通知の遅れが減るがDBを引く回数は増える。
|
||||
REMINDER_CHECK_INTERVAL_SECONDS: int = int(os.getenv("REMINDER_CHECK_INTERVAL_SECONDS", "30"))
|
||||
# これより先の日時を指定されたら登録を拒否する(LLMの年の取り違えを弾くため)
|
||||
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"))
|
||||
|
||||
@@ -11,6 +11,7 @@ from app.core.config import (
|
||||
WEATHER_NOTIFY_MODEL,
|
||||
)
|
||||
from app.core.llm import complete
|
||||
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
|
||||
@@ -27,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に渡すコンテキスト文字列を作る。失敗時は取得不可の旨を返す。"""
|
||||
@@ -65,11 +108,18 @@ async def on_message(message: discord.Message):
|
||||
|
||||
record_message(content)
|
||||
|
||||
async with message.channel.typing():
|
||||
try:
|
||||
# 以後の返信・タイピング表示・リマインドの通知先はすべてここで決めたチャンネル
|
||||
# (新規作成したスレッド、または元のチャンネル)に統一する。
|
||||
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)
|
||||
@@ -83,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:
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
"""Discord Bot のリマインド機能。
|
||||
|
||||
キーワードでは振り分けない。discord_bot.on_message は全メンションで
|
||||
resolve_reminder_tools() を通し、create_reminder / list_reminders / cancel_reminder の
|
||||
3ツールを常に同席させる。頼まれたときだけLLM自身がツールを選んで使うため、
|
||||
「モーニングコール」のようにキーワード一覧に無い言い回しでも拾える
|
||||
(キーワード方式で「アラーム」等に決め打ちしていたときに取りこぼしがあった)。
|
||||
|
||||
保存したリマインドは scheduler が REMINDER_CHECK_INTERVAL_SECONDS ごとに
|
||||
deliver_due_reminders() を呼ぶことで配信される。配信先は登録されたチャンネルで、
|
||||
Webhook ではなく Bot クライアントから送るため追加の URL 設定は要らない。
|
||||
|
||||
日時の扱い: DB は他のテーブルと同じくUTC。ユーザーとLLMとのやり取りはすべて
|
||||
このマシンのローカル時刻(JST想定)で行い、この境界で相互に変換する。
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import discord
|
||||
import litellm
|
||||
|
||||
from app.core.config import (
|
||||
DEFAULT_MODEL,
|
||||
REMINDER_LIST_LIMIT,
|
||||
REMINDER_MAX_AHEAD_DAYS,
|
||||
)
|
||||
from app.core.llm import complete
|
||||
from app.db.database import SessionLocal
|
||||
from app.db.models import Reminder
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 同じリマインドの配信にこの回数失敗したら諦める。チャンネル削除などで永久に
|
||||
# 失敗し続けるものが、ポーリングのたびにエラーログを出すのを防ぐ。
|
||||
_MAX_DELIVERY_ATTEMPTS = 5
|
||||
# 配信に失敗した回数(プロセス内のみ。再起動すればまた試す)
|
||||
_delivery_failures: dict[int, int] = {}
|
||||
|
||||
_AT_FORMATS = ("%Y-%m-%d %H:%M", "%Y-%m-%dT%H:%M", "%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReminderContext:
|
||||
"""リマインドを頼んできた相手。配信先チャンネルと、メンションする相手を決めるのに使う。"""
|
||||
|
||||
channel_id: str
|
||||
user_id: str
|
||||
user_name: str
|
||||
|
||||
|
||||
def _local_now() -> datetime:
|
||||
"""このマシンのローカルタイムゾーンでの現在時刻(aware)。"""
|
||||
return datetime.now().astimezone()
|
||||
|
||||
|
||||
def _to_local(value: datetime) -> datetime:
|
||||
"""DBから読んだUTCの日時を、表示用にローカル時刻へ直す。"""
|
||||
if value.tzinfo is None:
|
||||
value = value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(_local_now().tzinfo)
|
||||
|
||||
|
||||
def _parse_at(text: str) -> datetime:
|
||||
"""LLMが返した "YYYY-MM-DD HH:MM" をローカル時刻の aware な datetime にする。
|
||||
|
||||
解釈できない場合は ValueError を投げ、呼び出し側がその旨をツール結果としてLLMに返す。
|
||||
"""
|
||||
cleaned = (text or "").strip()
|
||||
for fmt in _AT_FORMATS:
|
||||
try:
|
||||
return datetime.strptime(cleaned, fmt).replace(tzinfo=_local_now().tzinfo)
|
||||
except ValueError:
|
||||
continue
|
||||
raise ValueError(f"日時 '{text}' を解釈できませんでした")
|
||||
|
||||
|
||||
CREATE_REMINDER_TOOL = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "create_reminder",
|
||||
"description": "指定した日時にDiscordへ通知するリマインドを登録する。"
|
||||
"ユーザーが『あとで教えて』『◯時になったら知らせて』などと頼んできたときに使う。"
|
||||
"一回きりの通知のみで、繰り返しには対応していない。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"at": {
|
||||
"type": "string",
|
||||
"description": "通知する日時。必ず 'YYYY-MM-DD HH:MM' 形式のローカル時刻で指定する。"
|
||||
"『明日の朝9時』『30分後』のような相対表現は、システムメッセージで与えられた"
|
||||
"現在時刻をもとに自分で計算して絶対日時に直すこと。",
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "通知するときに読み上げる用件。ユーザーの言葉をそのまま短くまとめる。",
|
||||
},
|
||||
},
|
||||
"required": ["at", "content"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
LIST_REMINDERS_TOOL = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "list_reminders",
|
||||
"description": "このユーザーの未通知のリマインド一覧を取得する。"
|
||||
"『リマインド何が入ってる?』のように予定を尋ねられたときや、"
|
||||
"取り消し対象のIDを知りたいときに使う。",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
}
|
||||
|
||||
CANCEL_REMINDER_TOOL = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "cancel_reminder",
|
||||
"description": "登録済みのリマインドを取り消す。IDが分からない場合は先に list_reminders で調べること。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "integer", "description": "取り消すリマインドのID(list_reminders が返す番号)"},
|
||||
},
|
||||
"required": ["id"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
REMINDER_TOOLS = [CREATE_REMINDER_TOOL, LIST_REMINDERS_TOOL, CANCEL_REMINDER_TOOL]
|
||||
|
||||
|
||||
def _create_reminder(db, args: dict, ctx: ReminderContext) -> str:
|
||||
content = (args.get("content") or "").strip()
|
||||
if not content:
|
||||
return "エラー: 通知する用件が空です。ユーザーに何をリマインドすればよいか聞き返してください。"
|
||||
|
||||
try:
|
||||
due_local = _parse_at(args.get("at", ""))
|
||||
except ValueError as e:
|
||||
return f"エラー: {e}。'YYYY-MM-DD HH:MM' の形式で指定し直してください。"
|
||||
|
||||
now_local = _local_now()
|
||||
if due_local <= now_local:
|
||||
return (
|
||||
f"エラー: 指定された {due_local:%Y-%m-%d %H:%M} は現在時刻 {now_local:%Y-%m-%d %H:%M} より過去です。"
|
||||
"未来の日時を指定し直すか、ユーザーに確認してください。"
|
||||
)
|
||||
if due_local - now_local > timedelta(days=REMINDER_MAX_AHEAD_DAYS):
|
||||
return (
|
||||
f"エラー: 指定された {due_local:%Y-%m-%d %H:%M} は{REMINDER_MAX_AHEAD_DAYS}日以上先です。"
|
||||
"年を取り違えていないか確認してください。"
|
||||
)
|
||||
|
||||
reminder = Reminder(
|
||||
channel_id=ctx.channel_id,
|
||||
user_id=ctx.user_id,
|
||||
user_name=ctx.user_name,
|
||||
content=content,
|
||||
due_at=due_local.astimezone(timezone.utc),
|
||||
)
|
||||
db.add(reminder)
|
||||
db.commit()
|
||||
db.refresh(reminder)
|
||||
|
||||
logger.info("Reminder created: id=%s due=%s user=%s", reminder.id, due_local, ctx.user_name)
|
||||
return (
|
||||
f"リマインドを登録しました(ID {reminder.id})。"
|
||||
f"{due_local:%Y-%m-%d %H:%M} に「{content}」を通知します。"
|
||||
"ユーザーへの返事には、いつ何を通知するのかを必ず含めてください。"
|
||||
)
|
||||
|
||||
|
||||
def _list_reminders(db, ctx: ReminderContext) -> str:
|
||||
rows = (
|
||||
db.query(Reminder)
|
||||
.filter(
|
||||
Reminder.user_id == ctx.user_id,
|
||||
Reminder.notified_at.is_(None),
|
||||
Reminder.cancelled_at.is_(None),
|
||||
)
|
||||
.order_by(Reminder.due_at)
|
||||
.limit(REMINDER_LIST_LIMIT)
|
||||
.all()
|
||||
)
|
||||
if not rows:
|
||||
return "このユーザーの未通知のリマインドはありません。"
|
||||
|
||||
lines = [f"ID {r.id}: {_to_local(r.due_at):%Y-%m-%d %H:%M} 「{r.content}」" for r in rows]
|
||||
return "未通知のリマインド:\n" + "\n".join(lines)
|
||||
|
||||
|
||||
def _cancel_reminder(db, args: dict, ctx: ReminderContext) -> str:
|
||||
reminder_id = args.get("id")
|
||||
reminder = (
|
||||
db.query(Reminder)
|
||||
.filter(
|
||||
Reminder.id == reminder_id,
|
||||
Reminder.user_id == ctx.user_id,
|
||||
Reminder.notified_at.is_(None),
|
||||
Reminder.cancelled_at.is_(None),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if reminder is None:
|
||||
return (
|
||||
f"エラー: ID {reminder_id} の未通知のリマインドは見つかりませんでした。"
|
||||
"list_reminders で確認してください。"
|
||||
)
|
||||
|
||||
reminder.cancelled_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
logger.info("Reminder cancelled: id=%s user=%s", reminder_id, ctx.user_name)
|
||||
return f"ID {reminder_id}「{reminder.content}」のリマインドを取り消しました。"
|
||||
|
||||
|
||||
def execute_reminder_tool(db, name: str, args: dict, ctx: ReminderContext) -> str:
|
||||
"""リマインド系ツールを実行し、LLMに返す結果テキストを作る。
|
||||
|
||||
一覧・取り消しは ctx.user_id で絞るため、他人のリマインドには触れられない。
|
||||
"""
|
||||
if name == "create_reminder":
|
||||
return _create_reminder(db, args, ctx)
|
||||
if name == "list_reminders":
|
||||
return _list_reminders(db, ctx)
|
||||
if name == "cancel_reminder":
|
||||
return _cancel_reminder(db, args, ctx)
|
||||
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 build_tool_hint() -> str:
|
||||
"""create_reminder 等を使わせるための時刻文脈。system メッセージとして差し込む。
|
||||
|
||||
discord_bot.on_message がキーワード判定なしに全メンションでこれを同席させるので、
|
||||
ここにはリマインドに関する指示だけを書く(お天気アプリとしての人格や口調は
|
||||
DISCORD_BOT_SYSTEM_PROMPT 側の責務のまま)。
|
||||
"""
|
||||
now = _local_now()
|
||||
weekday = "月火水木金土日"[now.weekday()]
|
||||
return (
|
||||
f"現在の日時は {now:%Y-%m-%d %H:%M}({weekday}曜日)です。\n"
|
||||
"ユーザーが『あとで知らせて』『◯時になったら教えて』『明日起こして』のように"
|
||||
"リマインドを頼んできたら、create_reminder / list_reminders / cancel_reminder "
|
||||
"ツールを使って対応してください。『明日』『30分後』などの相対表現は、この現在時刻を"
|
||||
"基準に絶対日時へ変換すること。時刻が指定されていない依頼は朝9時と解釈してかまいませんが、"
|
||||
"その解釈を返事の中で明示してください。\n"
|
||||
"リマインドと関係ない話題では、これらのツールは使わずいつも通り会話してください。"
|
||||
)
|
||||
|
||||
|
||||
async def resolve_reminder_tools(
|
||||
messages: list[dict],
|
||||
ctx: ReminderContext,
|
||||
model: str | None = None,
|
||||
max_rounds: int = 3,
|
||||
) -> str:
|
||||
"""会話にリマインドツールを差し込んで解決し、最終的な返信本文を返す。
|
||||
|
||||
discord_bot.on_message が全メンションでこれを通す。ツール呼び出しに対応していない
|
||||
モデルが設定されていた場合は、例外を握りつぶしてツールなしの通常応答にフォールバック
|
||||
する(リマインドと無関係な会話まで巻き添えで壊れないようにするため)。
|
||||
"""
|
||||
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,
|
||||
)
|
||||
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
|
||||
except (AttributeError, IndexError, KeyError, TypeError):
|
||||
logger.exception(
|
||||
"Unexpected completion shape [model=%s round=%d]: %r", used_model, round_no, response,
|
||||
)
|
||||
return "⚠️ 応答の生成に失敗しました"
|
||||
|
||||
tool_calls = getattr(choice_message, "tool_calls", None)
|
||||
if not tool_calls:
|
||||
return (choice_message.content or "").strip()
|
||||
|
||||
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
|
||||
],
|
||||
})
|
||||
|
||||
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:
|
||||
"""期限を過ぎた未通知のリマインドを、登録元のチャンネルへ配信する。
|
||||
|
||||
scheduler が REMINDER_CHECK_INTERVAL_SECONDS ごとに呼ぶ。Bot がまだ起動しきって
|
||||
いない間は何もしないので、バックエンド停止中に期限を迎えたぶんは起動後にまとめて届く。
|
||||
"""
|
||||
# discord_bot が scheduler 経由でこのモジュールを読むため、モジュールレベルで
|
||||
# import すると循環 import になる。
|
||||
from app.core.discord.discord_bot import client
|
||||
|
||||
if not client.is_ready():
|
||||
return
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
due = (
|
||||
db.query(Reminder)
|
||||
.filter(
|
||||
Reminder.notified_at.is_(None),
|
||||
Reminder.cancelled_at.is_(None),
|
||||
Reminder.due_at <= datetime.now(timezone.utc),
|
||||
)
|
||||
.order_by(Reminder.due_at)
|
||||
.all()
|
||||
)
|
||||
for reminder in due:
|
||||
try:
|
||||
channel = client.get_channel(int(reminder.channel_id))
|
||||
if channel is None:
|
||||
channel = await client.fetch_channel(int(reminder.channel_id))
|
||||
# カテゴリやフォーラムそのものには投稿できない(登録時のチャンネルが
|
||||
# 別種のものに作り替えられた場合に起きうる)ので、送れる相手かを確かめる。
|
||||
if not isinstance(channel, discord.abc.Messageable):
|
||||
raise TypeError(f"channel {reminder.channel_id} is not messageable")
|
||||
await channel.send(f"⏰ <@{reminder.user_id}> リマインドの時間です!\n> {reminder.content}")
|
||||
except Exception:
|
||||
failures = _delivery_failures.get(reminder.id, 0) + 1
|
||||
_delivery_failures[reminder.id] = failures
|
||||
if failures < _MAX_DELIVERY_ATTEMPTS:
|
||||
logger.exception(
|
||||
"Failed to deliver reminder id=%s (attempt %d/%d)",
|
||||
reminder.id, failures, _MAX_DELIVERY_ATTEMPTS,
|
||||
)
|
||||
continue
|
||||
logger.error("Giving up on reminder id=%s after %d attempts", reminder.id, failures)
|
||||
|
||||
_delivery_failures.pop(reminder.id, None)
|
||||
reminder.notified_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
except Exception:
|
||||
logger.exception("Failed to process due reminders")
|
||||
finally:
|
||||
db.close()
|
||||
@@ -10,6 +10,7 @@ from apscheduler.triggers.interval import IntervalTrigger
|
||||
from app.core.config import (
|
||||
DISCORD_WEBHOOK_URL,
|
||||
DISCORD_WEBHOOK_URL_TIPS,
|
||||
REMINDER_CHECK_INTERVAL_SECONDS,
|
||||
USELESS_TIPS_ENABLED,
|
||||
USELESS_TIPS_INTERVAL_HOURS,
|
||||
USELESS_TIPS_MODEL,
|
||||
@@ -21,6 +22,7 @@ from app.core.config import (
|
||||
)
|
||||
from app.core.llm import complete
|
||||
from app.core.discord.notify import send_discord_message
|
||||
from app.core.discord.reminders import deliver_due_reminders
|
||||
from app.core.weather.weather import fetch_tomorrow_weather, judge_laundry
|
||||
from app.core.weather.weather_cache import set_latest
|
||||
from app.core.memory_kb.word_memory import pick_random_word
|
||||
@@ -144,4 +146,12 @@ def start_scheduler() -> None:
|
||||
)
|
||||
logger.info("Useless tips job started: every %d hour(s)", USELESS_TIPS_INTERVAL_HOURS)
|
||||
|
||||
scheduler.add_job(
|
||||
deliver_due_reminders,
|
||||
trigger=IntervalTrigger(seconds=REMINDER_CHECK_INTERVAL_SECONDS),
|
||||
id="discord_reminders",
|
||||
replace_existing=True,
|
||||
)
|
||||
logger.info("Reminder delivery job started: every %d second(s)", REMINDER_CHECK_INTERVAL_SECONDS)
|
||||
|
||||
scheduler.start()
|
||||
|
||||
@@ -7,6 +7,11 @@ from app.db.models import AppSetting
|
||||
# GUIから編集可能な環境変数の許可リスト。ここに無いキーは save()/clear() で拒否する。
|
||||
CONFIGURABLE_KEYS = [
|
||||
"OPENAI_API_KEY",
|
||||
# OpenAI 互換エンドポイント。ローカルの llama-server / Ollama を向けたまま忘れると、
|
||||
# OpenAI のモデルを選んでも接続先がそちらのままになる(litellm は OPENAI_API_BASE、
|
||||
# OpenAI SDK は OPENAI_BASE_URL を見るので両方を出しておく)。
|
||||
"OPENAI_BASE_URL",
|
||||
"OPENAI_API_BASE",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"AWS_ACCESS_KEY_ID",
|
||||
"AWS_SECRET_ACCESS_KEY",
|
||||
@@ -22,6 +27,8 @@ CONFIGURABLE_KEYS = [
|
||||
# 秘匿情報ではないキー。値をマスクせずそのまま返し、GUIでも通常のテキスト入力として扱う。
|
||||
# モデル名やURLは「今どれが効いているか」が見えないと設定しづらいため。
|
||||
NON_SECRET_KEYS = {
|
||||
"OPENAI_BASE_URL",
|
||||
"OPENAI_API_BASE",
|
||||
"AWS_REGION_NAME",
|
||||
"OLLAMA_API_BASE",
|
||||
"LOCAL_KB_EMBEDDING_MODEL",
|
||||
|
||||
@@ -190,3 +190,24 @@ class ApiLog(Base):
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
latency_ms: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class Reminder(Base):
|
||||
"""Discord Bot がメンションで受け付けた一回きりのリマインド。
|
||||
|
||||
登録は app.core.discord.reminders の create_reminder ツール、配信は同モジュールの
|
||||
deliver_due_reminders() を APScheduler が定期的に呼ぶことで行う。
|
||||
日時は他のテーブルと同じくUTCで持ち、ユーザーとのやり取りの際にローカル時刻へ直す。
|
||||
"""
|
||||
__tablename__ = "reminders"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
channel_id: Mapped[str] = mapped_column(String) # 登録された(=配信先の)チャンネル
|
||||
user_id: Mapped[str] = mapped_column(String) # 依頼者。配信時にメンションする
|
||||
user_name: Mapped[str] = mapped_column(String) # 一覧表示とログ用の表示名
|
||||
content: Mapped[str] = mapped_column(Text)
|
||||
due_at: Mapped[datetime] = mapped_column(UtcDateTime)
|
||||
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc))
|
||||
# 配信済み。配信を諦めた場合もここを立てて再試行を止める(deliver_due_reminders を参照)
|
||||
notified_at: Mapped[datetime | None] = mapped_column(UtcDateTime, nullable=True)
|
||||
cancelled_at: Mapped[datetime | None] = mapped_column(UtcDateTime, nullable=True)
|
||||
|
||||
+12
-1
@@ -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"),
|
||||
|
||||
@@ -45,6 +45,10 @@ backend/
|
||||
│ │ ├── search/
|
||||
│ │ │ └── websearch.py # Tavily による web_search ツール
|
||||
│ │ ├── discord/ # Discord Bot・定期通知
|
||||
│ │ │ ├── discord_bot.py # メンションへの返信(天気 / 雑談 / リマインドで分岐)
|
||||
│ │ │ ├── scheduler.py # APScheduler ジョブ(天気通知・Tips・リマインド配信)
|
||||
│ │ │ ├── reminders.py # リマインドの登録ツールと配信ジョブ
|
||||
│ │ │ └── notify.py # Webhook 送信
|
||||
│ │ ├── settings_store.py # GUI 設定を os.environ へ反映
|
||||
│ │ └── auth.py / site_auth.py # 設定画面パスワード・サイトログイン
|
||||
│ └── db/
|
||||
@@ -71,6 +75,7 @@ backend/
|
||||
| `app_settings` | key, value, updated_at | GUI 設定画面から編集した接続情報 |
|
||||
| `model_presets` | id, label, value, created_at | モデル選択プルダウンの選択肢 |
|
||||
| `api_logs` | id, session_id, model, request_json, response_text, error, latency_ms, created_at | LLM API 呼び出しログ |
|
||||
| `reminders` | id, channel_id, user_id, user_name, content, due_at, created_at, notified_at, cancelled_at | Discord Bot が預かった一回きりのリマインド(`due_at` は UTC) |
|
||||
|
||||
### コンテキスト管理(`core/memory_kb/`)
|
||||
|
||||
@@ -172,6 +177,72 @@ data: {"type":"error","value":"エラー内容"} # 例外発生時
|
||||
|
||||
バッファリング防止のため `Cache-Control: no-cache` と `X-Accel-Buffering: no` を付与する。
|
||||
|
||||
### Discord Bot(`core/discord/`)
|
||||
|
||||
`main.py` の lifespan が `start_discord_bot()`(メンション応答)と `start_scheduler()`(定期ジョブ)を起動する。
|
||||
|
||||
`on_message` はメンション本文をキーワードで見て分岐する。判定順は **雑談 → 天気** で、
|
||||
先にマッチしたものが返信を返して終わる。どちらにも当てはまらなければ通常応答へ進む。
|
||||
|
||||
| 分岐 | キーワード例 | 動作 |
|
||||
|---|---|---|
|
||||
| 雑談 | どうでもいい / 雑談 / 暇 | 『どうでもいい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 つ。
|
||||
|
||||
| ジョブ | トリガー | 内容 |
|
||||
|---|---|---|
|
||||
| `weather_laundry_notification` | Cron(`WEATHER_NOTIFY_HOUR:MINUTE`) | 明日の天気と洗濯指数を Webhook で投稿 |
|
||||
| `useless_tips` | Interval(`USELESS_TIPS_INTERVAL_HOURS`) | どうでもいい Tips を Webhook で投稿(3時台のみ抑制) |
|
||||
| `discord_reminders` | Interval(`REMINDER_CHECK_INTERVAL_SECONDS`) | 期限を過ぎたリマインドを配信 |
|
||||
|
||||
#### リマインド(`core/discord/reminders.py`)
|
||||
|
||||
一回きりのリマインドのみを扱う(繰り返しには非対応)。
|
||||
|
||||
- **登録**: `resolve_reminder_tools()` が `discord_bot.on_message` の全メンションで
|
||||
`create_reminder` / `list_reminders` / `cancel_reminder` の 3 ツールを差し込み、自然言語から
|
||||
日時と用件を取り出させる。`build_tool_hint()` が現在時刻と曜日を system メッセージに足すので、
|
||||
『明日』『30分後』といった相対表現はモデル側が絶対日時に直す。使うモデルは
|
||||
discord_bot 側が渡す `WEATHER_NOTIFY_MODEL`(未設定なら `DEFAULT_MODEL`)で、一般会話・天気応答と
|
||||
共通。**tool calling 対応モデルであることが望ましい** — 非対応モデルがツール付き呼び出し自体を
|
||||
拒否した場合は、1 ラウンド目の例外を検知してツールなしの通常応答にフォールバックする
|
||||
(リマインド機能は使えないが、一般会話・天気応答は道連れにならない)
|
||||
- **バリデーション**: 過去・`REMINDER_MAX_AHEAD_DAYS` より先・解釈不能な日時はツール結果として
|
||||
エラー文をモデルに返し、モデルに聞き直させる
|
||||
- **配信先**: Webhook ではなく Bot クライアントから、登録されたチャンネルへ依頼者をメンションして投稿する。
|
||||
そのため専用の Webhook URL 設定は要らない
|
||||
- **タイムゾーン**: DB は他テーブルと同じく UTC。ユーザーおよび LLM とのやり取りはすべて
|
||||
このマシンのローカル時刻で行い、`reminders.py` の境界で変換する
|
||||
- **配信の失敗**: チャンネル削除などで送れない場合はプロセス内カウンタで数え、5 回失敗したら
|
||||
`notified_at` を立てて諦める(毎回エラーログを出し続けるのを防ぐ)。カウンタは DB に持たないので
|
||||
バックエンドを再起動すればまた試す
|
||||
- **バックエンド停止中に期限を迎えた分**: 起動して Bot が ready になった直後にまとめて配信される
|
||||
|
||||
---
|
||||
|
||||
## フロントエンド
|
||||
|
||||
@@ -113,6 +113,11 @@ nano backend/.env # APIキーやモデル名など本番用の値を設定
|
||||
|
||||
`backend/.env` はコンテナに `env_file` として渡され、Git 管理対象外(`.gitignore` 済み)なので直接編集してよい。
|
||||
|
||||
**ローカルの `.env` をそのまま持ち込むときの注意:** `OPENAI_BASE_URL` / `OPENAI_API_BASE` を
|
||||
ローカルの llama-server や Ollama に向けていると、サーバー上では存在しない宛先に接続しに行くため、
|
||||
`gpt-*` のモデルを選んでも litellm が「Connection Error」になる。サーバーの `.env` からは消しておくこと
|
||||
(設定画面の「OpenAI 互換エンドポイント」からも上書きできる)。
|
||||
|
||||
### GUIからのAPIキー管理
|
||||
|
||||
初回起動後は、`OPENAI_API_KEY` / `ANTHROPIC_API_KEY` / AWS認証情報などをSSHで`.env`を編集し直さなくても、
|
||||
|
||||
+6
-2
@@ -164,8 +164,12 @@ Discord 連携は [discord-bot.md](discord-bot.md)、ノートの操作方法は
|
||||
- モデルのプルダウンは `model_presets` テーブル由来で、初回起動時に既定の一覧がシードされる。
|
||||
設定画面からラベル/モデル文字列を追加・編集・削除できる(重複は 400 で弾く)。
|
||||
- 設定画面では API キー類を**再起動なしで**変更できる。
|
||||
- 変更できるのは許可リストにあるキーだけ(`OPENAI_API_KEY` / `ANTHROPIC_API_KEY` / AWS 系 /
|
||||
`OLLAMA_API_BASE` / `TAVILY_API_KEY` / `LOCAL_KB_EMBEDDING_MODEL` / `SEARCH_SUMMARY_MODEL`)
|
||||
- 変更できるのは許可リストにあるキーだけ(`OPENAI_API_KEY` / `OPENAI_BASE_URL` / `OPENAI_API_BASE` /
|
||||
`ANTHROPIC_API_KEY` / AWS 系 / `OLLAMA_API_BASE` / `TAVILY_API_KEY` /
|
||||
`LOCAL_KB_EMBEDDING_MODEL` / `SEARCH_SUMMARY_MODEL`)
|
||||
- `OPENAI_BASE_URL` / `OPENAI_API_BASE` は OpenAI 互換エンドポイント(ローカルの llama-server や Ollama)
|
||||
に向けるためのもの。向けたままだと `gpt-*` のモデルを選んでも本家 OpenAI には繋がらず、
|
||||
litellm からは「Connection Error」に見える
|
||||
- API キー類は末尾 4 文字だけを見せるマスク表示。モデル名や URL(`settings_store.NON_SECRET_KEYS`)は
|
||||
現在値をそのまま表示し、入力欄も伏せ字にしない(今どれが効いているか分からないと設定しづらいため)
|
||||
- 空欄で保存すると「変更なし」扱い
|
||||
|
||||
@@ -37,6 +37,8 @@ async function addModelPreset() {
|
||||
|
||||
const KEY_LABELS: Record<string, string> = {
|
||||
OPENAI_API_KEY: 'OpenAI APIキー',
|
||||
OPENAI_BASE_URL: 'OpenAI 互換エンドポイント (OPENAI_BASE_URL)',
|
||||
OPENAI_API_BASE: 'OpenAI 互換エンドポイント (OPENAI_API_BASE)',
|
||||
ANTHROPIC_API_KEY: 'Anthropic APIキー',
|
||||
AWS_ACCESS_KEY_ID: 'AWS Access Key ID',
|
||||
AWS_SECRET_ACCESS_KEY: 'AWS Secret Access Key',
|
||||
|
||||
Reference in New Issue
Block a user