Author SHA1 Message Date
colcaroli 24268cb453 Merge branch 'main' into feature/tool-based-reminder-routing 2026-08-23 17:16:27 +00:00
colcaroliandClaude Opus 5 858dbf4004 Catch typing() failures so Discord replies never go silent
reply_channel.typing() の async with を try の外に置いていたため、
その __aenter__(=タイピング表示リクエストの送信)が失敗すると on_message
自体が落ち、on_message 内の except にも届かず、ユーザーには何の応答も
返らなかった。スレッド作成には成功しても "Send Messages in Threads"
権限が無いだけでこの状態になりうる("Create Public Threads" とは別の
権限のため、片方だけ付与した構成は普通に起こる)。

try を async with の外側に広げ、typing() の失敗も含めて
"⚠️ 応答の生成に失敗しました" を返せるようにした。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 02:14:52 +09:00
colcaroliandClaude Opus 5 70735567f2 Thread Discord bot replies for a tidier channel
長い一般会話やリマインド確認のやり取りがチャンネルに直接積み上がって
埋もれるのを避けるため、全メンションへの返信をスレッドに集約する。

discord_bot._ensure_thread() を on_message の先頭、キーワード分岐より前に
挟み、以後の返信・タイピング表示・ReminderContext.channel_id をすべて
そこで決めた reply_channel 経由にする。

- 通常のチャンネルでのメンションは元メッセージからスレッドを新規作成する
  (名前は本文を90文字に切り詰め、自動アーカイブは1440分=1日。ブースト
  状況に関係なく常に選べる値を使う)
- 既にスレッド内でのメンションはそのスレッドを再利用し、二重に作らない
- DMや権限不足でスレッドを作れない場合は例外を握りつぶし、元のチャンネル
  へフォールバックする(Botの応答自体は止めない)
- message.reply() は常に元メッセージのチャンネル(スレッドの親)へ送って
  しまいスレッドには入らないため、_reply() でreply_channelがスレッドかどうか
  を見て送り先を明示的に使い分ける
- そのメンションから登録したリマインドの⏰通知も、登録元のスレッドに
  届くようにした(channel_idにスレッドのIDを渡すだけで済む。スレッドが
  自動アーカイブされていても送信時にDiscordが自動展開する)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 02:08:45 +09:00
colcaroli 1cd18c4bc5 Merge pull request 'Route reminders by tool availability, not keyword matching' (#11) from feature/tool-based-reminder-routing into main
Reviewed-on: #11
2026-08-23 16:35:09 +00:00
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
colcaroliandClaude Opus 5 c1aba42cf8 Schedule reminders by mentioning the Discord bot
メンション本文にリマインド系のキーワードが含まれていたとき、LLM に
create_reminder / list_reminders / cancel_reminder の3ツールを渡し、
自然言語から日時と用件を取り出して reminders テーブルに保存する。
system メッセージに現在時刻を入れてあるので『明日』『30分後』のような
相対表現はモデル側が絶対日時に直す。扱うのは一回きりの通知のみ。

配信は APScheduler の discord_reminders ジョブ(既定30秒間隔)が担い、
Webhook ではなく Bot クライアントから登録元チャンネルへ依頼者を
メンションして送るため、専用の Webhook URL 設定は要らない。

- 日時は他テーブルと同じく UTC で保存し、ユーザー・LLM とのやり取りは
  ローカル時刻で行って reminders.py の境界で変換する
- 過去・REMINDER_MAX_AHEAD_DAYS より先・解釈不能な日時はツール結果として
  エラー文をモデルに返し、聞き直させる
- 一覧・取り消しは依頼者の user_id で絞るため他人のリマインドには触れない
- 配信に5回失敗したら notified_at を立てて諦める(チャンネル削除などで
  ポーリングのたびにエラーログを出し続けるのを防ぐ)。カウンタは
  プロセス内のみなので、再起動すればまた試す

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 23:29:55 +09:00
colcaroli 3b30dab650 Merge pull request 'Set the OpenAI-compatible endpoint from the settings panel' (#8) from feature/configurable-openai-base-url into main
Reviewed-on: #8
2026-08-23 14:14:16 +00:00
colcaroliandClaude Opus 5 1b88d23f21 Set the OpenAI-compatible endpoint from the settings panel
Pointing OPENAI_BASE_URL at a local llama-server or Ollama sends every gpt-*
call there instead of to OpenAI, and litellm reports that as a bare
"Connection Error" — which reads like a network problem even though the host
can reach api.openai.com fine. Deploying a .env that still carries the local
endpoint reproduces it on the server, with nothing in the message naming the
culprit.

Add OPENAI_BASE_URL and OPENAI_API_BASE to the settings allowlist so the
endpoint can be corrected without editing .env and recreating the container.
Both are listed because litellm reads OPENAI_API_BASE while the OpenAI SDK
reads OPENAI_BASE_URL, so fixing one can leave the other in effect. Neither is
a secret, so the panel shows the current value.

No cache invalidation is needed alongside this: litellm keys its client cache
on the endpoint, so a changed value takes effect on the next call.

Document the trap where it will be read — a commented template in .env.example
and a note in the Lightsail deploy guide about carrying a local .env over.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 23:12:34 +09:00
colcaroli 904458e7df Merge pull request 'Re-embed the knowledge base when the embedding model changes' (#7) from feature/embedding-model-management into main
Reviewed-on: #7
2026-08-23 14:05:26 +00:00
colcaroliandClaude Opus 5 97568ba90e Re-embed the knowledge base when the embedding model changes
Vectors from two different embedding models are not comparable — Titan v2 has
1024 dimensions where nomic-embed-text has 768 — and _cosine_similarity zips
them, so a mismatch produced quietly meaningless scores rather than an error.
Nothing forced a re-embed either: sync_local_kb only looked at the content
hash, so switching models left every cached vector in place.

local_knowledge_docs now records the model each vector came from, and a row is
re-embedded when its content hash OR its model differs. Saving a new
LOCAL_KB_EMBEDDING_MODEL from the settings panel starts a background pass over
every file; the knowledge base panel also has a button for it. While that pass
runs, the per-request sync steps aside so no file is embedded twice.

The panel now shows what the embedding model is doing: model name, vector
size, how many files are embedded with the current model, how many are still
on an older one, a progress bar while a pass runs, and the last error. A
"接続テスト" button embeds one short string so a broken or unreachable model
can be told apart from one that is merely slow to warm up.

Also read TAVILY_API_KEY from os.environ at call time. It was imported as a
config constant, which is evaluated before load_into_env() runs, so a key
saved from the settings panel was stored but never used — not even after a
restart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 22:56:12 +09:00
colcaroli 52dfc0c349 Merge pull request 'Set the search summary model from the settings panel' (#6) from feature/configurable-summary-model into main
Reviewed-on: #6
2026-08-23 13:11:18 +00:00
colcaroliandClaude Opus 5 cd38cbf19d Set the search summary model from the settings panel
Summarizing web search results falls back to DEFAULT_MODEL, so a local Ollama
model ends up doing it and the call can fail — which surfaces only as
"保存できる要約なし" in the chat. Make SEARCH_SUMMARY_MODEL settable at runtime:
add it to the settings allowlist and read it from os.environ on each call
(knowledge._summary_model), the way LOCAL_KB_EMBEDDING_MODEL already works.
Importing it as a config constant would keep the old value until a restart.

Show the current value of the settings that are not secrets. A model name
rendered as ••••qwen3 gives no way to tell what is in effect, so the store now
marks those keys (NON_SECRET_KEYS) and returns them unmasked; the API field is
renamed masked -> display_value and carries a secret flag that the panel uses
to pick the input type, replacing the list it kept on its own side.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 22:10:36 +09:00
colcaroli 2ced2e3cd5 Merge pull request 'Browse the knowledge base from a single side panel menu' (#5) from feature/knowledge-base-panel into main
Reviewed-on: #5
2026-08-23 12:41:29 +00:00
23 changed files with 1250 additions and 65 deletions
+26 -4
View File
@@ -77,7 +77,7 @@ LLM に渡すコンテキストは `app/core/memory_kb/memory.py` の `get_conte
1. **蓄積知識** (`memory_kb/knowledge.py`): 過去の Web 検索で得た `knowledge_entries` の**見出し一覧のみ**(`- 「クエリ」: 一行要約`)。本文と出典は含めず、必要なら `read_knowledge` ツールで読み出す。最新 `KNOWLEDGE_CONTEXT_LIMIT`(既定 15)件。セッションを横断する 1. **蓄積知識** (`memory_kb/knowledge.py`): 過去の Web 検索で得た `knowledge_entries` の**見出し一覧のみ**(`- 「クエリ」: 一行要約`)。本文と出典は含めず、必要なら `read_knowledge` ツールで読み出す。最新 `KNOWLEDGE_CONTEXT_LIMIT`(既定 15)件。セッションを横断する
2. **共有ノート** (`core/notes.py`): タイトル一覧+本文先頭 40 文字のプレビューのみ 2. **共有ノート** (`core/notes.py`): タイトル一覧+本文先頭 40 文字のプレビューのみ
2.5. **参照指定ノート** (`core/notes.py`): ユーザーがそのセッションに紐付けた(GUI のタブで 📌 を押した)ページの**本文全文**。`session_note_pages` テーブルで管理し、1 ページ `SESSION_NOTES_CONTENT_LIMIT`(既定 8000)文字で打ち切る。層 2 の一覧側には印が付き、同じ本文を `read_note` で読み直させない 2.5. **参照指定ノート** (`core/notes.py`): ユーザーがそのセッションに紐付けた(GUI のタブで 📌 を押した)ページの**本文全文**。`session_note_pages` テーブルで管理し、1 ページ `SESSION_NOTES_CONTENT_LIMIT`(既定 8000)文字で打ち切る。層 2 の一覧側には印が付き、同じ本文を `read_note` で読み直させない
3. **ローカル KB** (`memory_kb/local_knowledge.py`): `backend/knowledge_base/**.md` のうち、直近ユーザー発言と埋め込みコサイン類似度が高い上位 `LOCAL_KB_TOP_K`(既定 3)件かつ `LOCAL_KB_MIN_SCORE`(既定 0.75)以上を**全文**注入。プロンプト上は Web 検索結果より優先する扱い。中身は GUI ヘッダーのパネルメニュー →「ナレッジベース」で開くサイドパネル(`api/knowledge_base.py` + `KnowledgeBasePanel.vue`、`GET /api/kb/files` と `GET /api/kb/file?path=`)から閲覧できる(読み取り専用) 3. **ローカル KB** (`memory_kb/local_knowledge.py`): `backend/knowledge_base/**.md` のうち、直近ユーザー発言と埋め込みコサイン類似度が高い上位 `LOCAL_KB_TOP_K`(既定 3)件かつ `LOCAL_KB_MIN_SCORE`(既定 0.75)以上を**全文**注入。プロンプト上は Web 検索結果より優先する扱い。中身は GUI ヘッダーのパネルメニュー →「ナレッジベース」で開くサイドパネル(`api/knowledge_base.py` + `KnowledgeBasePanel.vue`、`GET /api/kb/files` と `GET /api/kb/file?path=`)から閲覧できる(読み取り専用)。同パネルで埋め込みの状態確認・接続テスト・再埋め込みもできる(`GET /api/kb/embedding`、`POST /api/kb/embedding/test`、`POST /api/kb/embedding/reindex`)。埋め込みは `local_knowledge_docs.embedding_model` に使用モデルを持ち、モデルを変えた行は次の同期で埋め直される
4. **長期メモリ**: `memories` の要約を全件連結。上限なし 4. **長期メモリ**: `memories` の要約を全件連結。上限なし
5. **短期メモリ**: セッション内の `messages` 全件(要約で削除された分を除く) 5. **短期メモリ**: セッション内の `messages` 全件(要約で削除された分を除く)
@@ -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 を併記)に任せる - **ダウンロード**: `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()`) - **セクションによるグルーピング**: `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 ストリーミング ### SSE ストリーミング
`POST /chat/stream` が SSE を返す。イベント種別: `session_id`(新規セッション ID)/ `chunk`(テキスト断片)/ `tool`(ツール実行の開始・完了)/ `sources`(Web 検索の出典)/ `notes_updated`(AI が共有ノートを更新)/ `done`(完了)/ `error`(エラー)。 `POST /chat/stream` が SSE を返す。イベント種別: `session_id`(新規セッション ID)/ `chunk`(テキスト断片)/ `tool`(ツール実行の開始・完了)/ `sources`(Web 検索の出典)/ `notes_updated`(AI が共有ノートを更新)/ `done`(完了)/ `error`(エラー)。
@@ -136,16 +145,18 @@ LLM に渡すコンテキストは `app/core/memory_kb/memory.py` の `get_conte
| `DATABASE_URL` | `sqlite:///./playchat.db` | DB 接続先 | | `DATABASE_URL` | `sqlite:///./playchat.db` | DB 接続先 |
| `MEMORY_SUMMARIZE_THRESHOLD` | `20` | 長期メモリ化するメッセージ数の閾値 | | `MEMORY_SUMMARIZE_THRESHOLD` | `20` | 長期メモリ化するメッセージ数の閾値 |
| `KNOWLEDGE_CONTEXT_LIMIT` | `15` | 蓄積知識をシステムプロンプトに注入する最大件数 | | `KNOWLEDGE_CONTEXT_LIMIT` | `15` | 蓄積知識をシステムプロンプトに注入する最大件数 |
| `TAVILY_API_KEY` | — | Tavily Web 検索の API キー(未設定なら `web_search` は空を返す) | | `TAVILY_API_KEY` | — | Tavily Web 検索の API キー(未設定なら `web_search` は空を返す。GUI 設定画面から実行時変更可) |
| `SEARCH_SUMMARY_MODEL` | — | Web 検索結果の要約に使うモデル(未設定なら `DEFAULT_MODEL`) | | `SEARCH_SUMMARY_MODEL` | — | Web 検索結果の要約に使うモデル(未設定なら `DEFAULT_MODEL`。GUI 設定画面から実行時変更可) |
| `TAGGING_MODEL` | — | メッセージの自動タグ付けに使うモデル(未設定なら `DEFAULT_MODEL`) | | `TAGGING_MODEL` | — | メッセージの自動タグ付けに使うモデル(未設定なら `DEFAULT_MODEL`) |
| `LOCAL_KB_DIR` | `knowledge_base` | ローカル Markdown ナレッジベースのディレクトリ | | `LOCAL_KB_DIR` | `knowledge_base` | ローカル Markdown ナレッジベースのディレクトリ |
| `LOCAL_KB_EMBEDDING_MODEL` | `text-embedding-3-small` | ローカル KB の埋め込みモデル(GUI 設定画面から実行時変更可) | | `LOCAL_KB_EMBEDDING_MODEL` | `text-embedding-3-small` | ローカル KB の埋め込みモデル(GUI 設定画面から実行時変更可。変更すると全件が自動で埋め直される) |
| `LOCAL_KB_TOP_K` | `3` | ローカル KB から注入する最大ファイル数 | | `LOCAL_KB_TOP_K` | `3` | ローカル KB から注入する最大ファイル数 |
| `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 設定画面から実行時変更可 |
| `ANTHROPIC_API_KEY` | — | Anthropic API キー | | `ANTHROPIC_API_KEY` | — | Anthropic API キー |
| `OLLAMA_API_BASE` | `http://localhost:11434` | Ollama エンドポイント | | `OLLAMA_API_BASE` | `http://localhost:11434` | Ollama エンドポイント |
| `NOTES_TOOL_CONTENT_LIMIT` | `4000` | `read_note` が 1 回に返す本文の最大文字数 | | `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_PAGES` | `100` | ノート取り込みで一度に作れるページ数の上限 |
| `NOTES_IMPORT_MAX_BYTES` | `2000000` | 取り込む 1 ファイル(ZIP 内は 1 エントリ)あたりの最大バイト数 | | `NOTES_IMPORT_MAX_BYTES` | `2000000` | 取り込む 1 ファイル(ZIP 内は 1 エントリ)あたりの最大バイト数 |
| `KNOWLEDGE_TOOL_CONTENT_LIMIT` | `4000` | `read_knowledge` が 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` | — | サイト全体を保護する共有パスワード(未設定なら無認証) | | `SITE_PASSWORD` | — | サイト全体を保護する共有パスワード(未設定なら無認証) |
| `SESSION_SECRET_KEY` | 起動毎に自動生成 | ログインセッション Cookie の署名鍵 | | `SESSION_SECRET_KEY` | 起動毎に自動生成 | ログインセッション Cookie の署名鍵 |
## AWS Lightsail環境
以下のコマンドで、lightsail上の環境に入ることができる。
```bash
ssh lightsail-playchat
```
+17
View File
@@ -2,6 +2,12 @@
DEFAULT_MODEL=gpt-5.4 DEFAULT_MODEL=gpt-5.4
# OpenAI # OpenAI
OPENAI_API_KEY=sk-... 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
ANTHROPIC_API_KEY=sk-ant-... ANTHROPIC_API_KEY=sk-ant-...
# Ollama (ローカル) — APIキー不要、モデル名は ollama/llama3 形式で指定 # Ollama (ローカル) — APIキー不要、モデル名は ollama/llama3 形式で指定
@@ -67,7 +73,18 @@ USELESS_TIPS_MODEL=
# 未設定ならDISCORD_WEBHOOK_URL(天気通知と同じ)を使う # 未設定ならDISCORD_WEBHOOK_URL(天気通知と同じ)を使う
DISCORD_WEBHOOK_URL_TIPS= 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 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
+52 -1
View File
@@ -9,6 +9,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session as DBSession from sqlalchemy.orm import Session as DBSession
from app.core.memory_kb import local_knowledge
from app.core.memory_kb.local_knowledge import list_kb_files, read_kb_file from app.core.memory_kb.local_knowledge import list_kb_files, read_kb_file
from app.db.database import get_db from app.db.database import get_db
from app.db.models import LocalKnowledgeDoc from app.db.models import LocalKnowledgeDoc
@@ -41,10 +42,14 @@ class KbFileContent(BaseModel):
@router.get("/files", response_model=list[KbFileInfo]) @router.get("/files", response_model=list[KbFileInfo])
def list_files(db: DBSession = Depends(get_db)): def list_files(db: DBSession = Depends(get_db)):
"""ナレッジベース配下の Markdown を一覧で返す(本文は含めず、先頭のプレビューのみ)。""" """ナレッジベース配下の Markdown を一覧で返す(本文は含めず、先頭のプレビューのみ)。"""
# 「埋め込み済み」は現在のモデルで埋め込まれている行だけを指す(別モデルの行は埋め直し待ち)
current_model = local_knowledge.current_embedding_model()
indexed_paths = { indexed_paths = {
# DB 側は OS 依存の区切り文字で保存されているため、比較用に posix 形式へ寄せる # DB 側は OS 依存の区切り文字で保存されているため、比較用に posix 形式へ寄せる
path.replace("\\", "/") path.replace("\\", "/")
for (path,) in db.query(LocalKnowledgeDoc.path).all() for (path,) in db.query(LocalKnowledgeDoc.path)
.filter(LocalKnowledgeDoc.embedding_model == current_model)
.all()
} }
return [ return [
KbFileInfo(**info, indexed=info["path"] in indexed_paths) KbFileInfo(**info, indexed=info["path"] in indexed_paths)
@@ -59,3 +64,49 @@ def get_file(path: str = Query(..., description="ナレッジベースディレ
if file is None: if file is None:
raise HTTPException(status_code=404, detail="Knowledge base file not found") raise HTTPException(status_code=404, detail="Knowledge base file not found")
return KbFileContent(**file) return KbFileContent(**file)
class EmbeddingStatus(BaseModel):
"""埋め込みモデルの現状。GUI の状態表示・進捗バーに使う。"""
model: str
# 現在のモデルで埋め込まれたベクトルの次元数(1件も無ければ None)
dimensions: int | None
file_count: int
indexed: int
# 別のモデルで埋め込まれていて埋め直しが必要な件数
stale: int
running: bool
total: int
done: int
failed: int
started_at: datetime | None
finished_at: datetime | None
last_error: str | None
class EmbeddingProbe(BaseModel):
"""短いテキストを実際に埋め込んでみた結果。"""
ok: bool
model: str
dimensions: int | None
latency_ms: float
error: str | None
@router.get("/embedding", response_model=EmbeddingStatus)
def get_embedding_status(db: DBSession = Depends(get_db)):
"""現在の埋め込みモデルと、埋め込み済み件数・実行中の進捗を返す。"""
return local_knowledge.embedding_status(db)
@router.post("/embedding/test", response_model=EmbeddingProbe)
async def test_embedding():
"""埋め込みモデルに実際に1回リクエストして、応答するかどうかを確かめる。"""
return await local_knowledge.probe_embedding()
@router.post("/embedding/reindex", response_model=EmbeddingStatus)
async def reindex_embeddings(db: DBSession = Depends(get_db)):
"""全ファイルの再埋め込みをバックグラウンドで開始する。実行中なら何もしない(進捗をそのまま返す)。"""
local_knowledge.start_reembed_all()
return local_knowledge.embedding_status(db)
+26 -3
View File
@@ -1,9 +1,12 @@
import os
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session as DBSession from sqlalchemy.orm import Session as DBSession
from app.core import settings_store from app.core import settings_store
from app.core.auth import require_settings_password from app.core.auth import require_settings_password
from app.core.memory_kb import local_knowledge
from app.db.database import get_db from app.db.database import get_db
router = APIRouter(prefix="/api/settings", tags=["settings"], dependencies=[Depends(require_settings_password)]) router = APIRouter(prefix="/api/settings", tags=["settings"], dependencies=[Depends(require_settings_password)])
@@ -12,7 +15,10 @@ router = APIRouter(prefix="/api/settings", tags=["settings"], dependencies=[Depe
class SettingEntry(BaseModel): class SettingEntry(BaseModel):
key: str key: str
is_set: bool is_set: bool
masked: str | None # 秘匿キーはマスク済みの値、それ以外は現在値そのもの。未設定なら None
display_value: str | None
# 入力欄をパスワード扱いにするか(APIキー等は True)
secret: bool
overridden: bool overridden: bool
@@ -26,7 +32,8 @@ def list_settings(db: DBSession = Depends(get_db)):
@router.put("", response_model=list[SettingEntry]) @router.put("", response_model=list[SettingEntry])
def save_settings(req: SaveSettingsRequest, db: DBSession = Depends(get_db)): async def save_settings(req: SaveSettingsRequest, db: DBSession = Depends(get_db)):
before_embedding_model = os.environ.get("LOCAL_KB_EMBEDDING_MODEL")
try: try:
for key, value in req.values.items(): for key, value in req.values.items():
if not value: if not value:
@@ -34,13 +41,29 @@ def save_settings(req: SaveSettingsRequest, db: DBSession = Depends(get_db)):
settings_store.save(db, key, value) settings_store.save(db, key, value)
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e raise HTTPException(status_code=400, detail=str(e)) from e
_reembed_if_model_changed(before_embedding_model)
return settings_store.list_masked(db) return settings_store.list_masked(db)
@router.delete("/{key}", response_model=list[SettingEntry]) @router.delete("/{key}", response_model=list[SettingEntry])
def clear_setting(key: str, db: DBSession = Depends(get_db)): async def clear_setting(key: str, db: DBSession = Depends(get_db)):
before_embedding_model = os.environ.get("LOCAL_KB_EMBEDDING_MODEL")
try: try:
settings_store.clear(db, key) settings_store.clear(db, key)
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e raise HTTPException(status_code=400, detail=str(e)) from e
_reembed_if_model_changed(before_embedding_model)
return settings_store.list_masked(db) return settings_store.list_masked(db)
def _reembed_if_model_changed(before: str | None) -> None:
"""埋め込みモデルが変わったら、ローカルKBの再埋め込みをバックグラウンドで走らせる。
モデルが違うと次元数も意味空間も変わり、古いベクトルは比較しても無意味なので、
設定を変えた時点で全件やり直す(完了を待たずに応答は返す)。
"""
if os.environ.get("LOCAL_KB_EMBEDDING_MODEL") == before:
return
local_knowledge.start_reembed_all()
+13
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")
@@ -25,10 +28,12 @@ SESSION_MAX_AGE_DAYS: int = int(os.getenv("SESSION_MAX_AGE_DAYS", "30"))
TAGGING_MODEL: str | None = os.getenv("TAGGING_MODEL") TAGGING_MODEL: str | None = os.getenv("TAGGING_MODEL")
# Tavily Web検索 # Tavily Web検索
# GUI設定画面から実行時に変更できるため、実際の参照は search/websearch._api_key() が os.environ から行う。
TAVILY_API_KEY: str | None = os.getenv("TAVILY_API_KEY") TAVILY_API_KEY: str | None = os.getenv("TAVILY_API_KEY")
# 蓄積した知識をシステムプロンプトに注入する際の最大件数 # 蓄積した知識をシステムプロンプトに注入する際の最大件数
KNOWLEDGE_CONTEXT_LIMIT: int = int(os.getenv("KNOWLEDGE_CONTEXT_LIMIT", "15")) KNOWLEDGE_CONTEXT_LIMIT: int = int(os.getenv("KNOWLEDGE_CONTEXT_LIMIT", "15"))
# Web検索結果を構造的な要約にまとめる際に使うモデル(未設定ならDEFAULT_MODELを使用) # Web検索結果を構造的な要約にまとめる際に使うモデル(未設定ならDEFAULT_MODELを使用)
# GUI設定画面から実行時に変更できるため、実際の参照は memory_kb/knowledge._summary_model() が os.environ から行う。
SEARCH_SUMMARY_MODEL: str | None = os.getenv("SEARCH_SUMMARY_MODEL") SEARCH_SUMMARY_MODEL: str | None = os.getenv("SEARCH_SUMMARY_MODEL")
# ローカルMarkdownナレッジベース(話題に応じて関連ファイルをシステムプロンプトに注入) # ローカルMarkdownナレッジベース(話題に応じて関連ファイルをシステムプロンプトに注入)
@@ -86,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") USELESS_TIPS_MODEL: str | None = os.getenv("USELESS_TIPS_MODEL")
# 未設定なら天気通知と同じ DISCORD_WEBHOOK_URL を使う # 未設定なら天気通知と同じ DISCORD_WEBHOOK_URL を使う
DISCORD_WEBHOOK_URL_TIPS: str | None = os.getenv("DISCORD_WEBHOOK_URL_TIPS") or os.getenv("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"))
+66 -5
View File
@@ -11,6 +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 ReminderContext, build_tool_hint, resolve_reminder_tools
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
@@ -27,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に渡すコンテキスト文字列を作る。失敗時は取得不可の旨を返す。"""
@@ -65,11 +108,18 @@ async def on_message(message: discord.Message):
record_message(content) record_message(content)
async with message.channel.typing(): # 以後の返信・タイピング表示・リマインドの通知先はすべてここで決めたチャンネル
# (新規作成したスレッド、または元のチャンネル)に統一する。
reply_channel = await _ensure_thread(message, content)
try: 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)
@@ -83,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:
+406
View File
@@ -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
View File
@@ -10,6 +10,7 @@ from apscheduler.triggers.interval import IntervalTrigger
from app.core.config import ( from app.core.config import (
DISCORD_WEBHOOK_URL, DISCORD_WEBHOOK_URL,
DISCORD_WEBHOOK_URL_TIPS, DISCORD_WEBHOOK_URL_TIPS,
REMINDER_CHECK_INTERVAL_SECONDS,
USELESS_TIPS_ENABLED, USELESS_TIPS_ENABLED,
USELESS_TIPS_INTERVAL_HOURS, USELESS_TIPS_INTERVAL_HOURS,
USELESS_TIPS_MODEL, USELESS_TIPS_MODEL,
@@ -21,6 +22,7 @@ from app.core.config import (
) )
from app.core.llm import complete from app.core.llm import complete
from app.core.discord.notify import send_discord_message 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 import fetch_tomorrow_weather, judge_laundry
from app.core.weather.weather_cache import set_latest from app.core.weather.weather_cache import set_latest
from app.core.memory_kb.word_memory import pick_random_word 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) 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() scheduler.start()
+10 -2
View File
@@ -1,5 +1,6 @@
import asyncio import asyncio
import logging import logging
import os
import re import re
import httpx import httpx
@@ -10,7 +11,6 @@ from app.core import llm
from app.core.config import ( from app.core.config import (
KNOWLEDGE_CONTEXT_LIMIT, KNOWLEDGE_CONTEXT_LIMIT,
KNOWLEDGE_TOOL_CONTENT_LIMIT, KNOWLEDGE_TOOL_CONTENT_LIMIT,
SEARCH_SUMMARY_MODEL,
) )
from app.core.memory_kb import local_knowledge from app.core.memory_kb import local_knowledge
from app.db.models import KnowledgeEntry, KnowledgeSource from app.db.models import KnowledgeEntry, KnowledgeSource
@@ -37,6 +37,14 @@ _SUMMARY_PROMPT = (
_HEADLINE_MARKERS = ("要約:", "要約:") _HEADLINE_MARKERS = ("要約:", "要約:")
def _summary_model() -> str | None:
"""GUIの設定画面から実行時に変更できるよう、起動時定数ではなく都度 os.environ を見に行く。
未設定(空文字含む)なら None を返し、llm.complete() 側で DEFAULT_MODEL にフォールバックさせる。
"""
return os.environ.get("SEARCH_SUMMARY_MODEL") or None
def _split_headline(text: str) -> tuple[str, str]: def _split_headline(text: str) -> tuple[str, str]:
"""LLMの出力を (一行要約, 本文) に分ける。1行目が想定の形式でなければ見出しは空で返す。""" """LLMの出力を (一行要約, 本文) に分ける。1行目が想定の形式でなければ見出しは空で返す。"""
lines = text.strip().splitlines() lines = text.strip().splitlines()
@@ -87,7 +95,7 @@ async def summarize_results(query: str, results: list[dict], model: str | None =
try: try:
text = await llm.complete( text = await llm.complete(
[{"role": "user", "content": prompt}], [{"role": "user", "content": prompt}],
model=model or SEARCH_SUMMARY_MODEL, model=model or _summary_model(),
) )
except Exception: except Exception:
logger.exception("Failed to summarize search results for query=%r", query) logger.exception("Failed to summarize search results for query=%r", query)
+165 -10
View File
@@ -3,11 +3,13 @@
- sync_local_kb(): ディレクトリをスキャンし、新規/変更されたファイルだけ埋め込みを計算してDBに保存する - sync_local_kb(): ディレクトリをスキャンし、新規/変更されたファイルだけ埋め込みを計算してDBに保存する
- get_local_kb_context(): 直近のユーザー発言に埋め込み類似度の高いファイルを探し、システムプロンプト用テキストにする - get_local_kb_context(): 直近のユーザー発言に埋め込み類似度の高いファイルを探し、システムプロンプト用テキストにする
""" """
import asyncio
import hashlib import hashlib
import json import json
import logging import logging
import math import math
import os import os
import time
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@@ -19,6 +21,46 @@ from app.db.models import KnowledgeEntry, LocalKnowledgeDoc
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# 埋め込みの進行状況。GUIに出すためだけのプロセス内の状態で、永続化はしない
# (uvicornを複数ワーカーで動かすとワーカーごとに別の値になる点に注意)。
_progress: dict = {
"running": False,
"total": 0,
"done": 0,
"failed": 0,
"started_at": None,
"finished_at": None,
"last_error": None,
"last_model": None,
}
# create_task の戻り値。GCで途中終了しないように保持しておく
_reembed_task: "asyncio.Task | None" = None
def _start_progress(model: str, total: int) -> None:
_progress.update(
running=True, total=total, done=0, failed=0,
started_at=datetime.now(timezone.utc), finished_at=None,
last_error=None, last_model=model,
)
def _finish_progress() -> None:
_progress.update(running=False, finished_at=datetime.now(timezone.utc))
def _progress_snapshot() -> dict:
return {
"running": _progress["running"],
"total": _progress["total"],
"done": _progress["done"],
"failed": _progress["failed"],
"started_at": _progress["started_at"],
"finished_at": _progress["finished_at"],
"last_error": _progress["last_error"],
}
def _kb_dir() -> Path: def _kb_dir() -> Path:
return Path(LOCAL_KB_DIR) return Path(LOCAL_KB_DIR)
@@ -29,6 +71,11 @@ def _embedding_model() -> str:
return os.environ.get("LOCAL_KB_EMBEDDING_MODEL", "text-embedding-3-small") return os.environ.get("LOCAL_KB_EMBEDDING_MODEL", "text-embedding-3-small")
def current_embedding_model() -> str:
"""いま使われている埋め込みモデル名。"""
return _embedding_model()
def _hash(content: str) -> str: def _hash(content: str) -> str:
return hashlib.sha256(content.encode("utf-8")).hexdigest() return hashlib.sha256(content.encode("utf-8")).hexdigest()
@@ -89,46 +136,75 @@ def backfill_search_results(db: DBSession) -> int:
return count return count
async def sync_local_kb(db: DBSession) -> None: async def sync_local_kb(db: DBSession, force: bool = False) -> None:
"""ナレッジベースディレクトリをスキャンし、新規/変更ファイルの埋め込みを更新する。 """ナレッジベースディレクトリをスキャンし、埋め込みが古いファイルだけ計算し直す。
削除されたファイルに対応するレコードも削除する。
埋め込みを取り直すのは、ファイル内容が変わったか、埋め込みモデルが変わったとき
(次元数の違うベクトルを比べても意味のあるスコアにならないため)。force=True なら全件やり直す。
削除されたファイルに対応するレコードも消す。
進捗は _progress に載せてGUIから見えるようにする。
""" """
kb_dir = _kb_dir() kb_dir = _kb_dir()
if not kb_dir.is_dir(): if not kb_dir.is_dir():
return return
# 再埋め込みが走っている最中は、チャット毎の同期は譲って何もしない
# (同じファイルを二重に埋め込むのを避けるため。ここまでawaitを挟まないので取りこぼしはない)。
# force=True で呼ばれるのは start_reembed_all() が実行中フラグを立てた本人だけなので素通しする。
if _progress["running"] and not force:
return
model = _embedding_model()
md_files = sorted(kb_dir.rglob("*.md")) md_files = sorted(kb_dir.rglob("*.md"))
seen_paths: set[str] = set() seen_paths: set[str] = set()
# 先に対象を洗い出す。1件も無ければ進捗を触らない(毎リクエストの同期で履歴が消えないように)
pending: list[tuple[Path, str, str, LocalKnowledgeDoc | None]] = []
for file_path in md_files: for file_path in md_files:
rel_path = str(file_path.relative_to(kb_dir)) rel_path = str(file_path.relative_to(kb_dir))
seen_paths.add(rel_path) seen_paths.add(rel_path)
content = file_path.read_text(encoding="utf-8") content = file_path.read_text(encoding="utf-8", errors="replace")
content_hash = _hash(content) content_hash = _hash(content)
existing = db.query(LocalKnowledgeDoc).filter(LocalKnowledgeDoc.path == rel_path).first() existing = db.query(LocalKnowledgeDoc).filter(LocalKnowledgeDoc.path == rel_path).first()
if existing and existing.content_hash == content_hash: up_to_date = (
existing is not None
and existing.content_hash == content_hash
and existing.embedding_model == model
)
if up_to_date and not force:
continue continue
pending.append((file_path, rel_path, content, existing))
if pending:
_start_progress(model, len(pending))
try: try:
[embedding] = await llm.embed([content], model=_embedding_model()) for file_path, rel_path, content, existing in pending:
except Exception: try:
[embedding] = await llm.embed([content], model=model)
except Exception as e:
logger.exception("Failed to embed local knowledge file: %s", rel_path) logger.exception("Failed to embed local knowledge file: %s", rel_path)
_progress["failed"] += 1
_progress["last_error"] = f"{rel_path}: {e}"[:500]
continue continue
title = _title_from_content(content, fallback=file_path.stem) title = _title_from_content(content, fallback=file_path.stem)
if existing: if existing:
existing.content_hash = content_hash existing.content_hash = _hash(content)
existing.title = title existing.title = title
existing.embedding_json = json.dumps(embedding) existing.embedding_json = json.dumps(embedding)
existing.embedding_model = model
else: else:
db.add(LocalKnowledgeDoc( db.add(LocalKnowledgeDoc(
path=rel_path, path=rel_path,
title=title, title=title,
content_hash=content_hash, content_hash=_hash(content),
embedding_json=json.dumps(embedding), embedding_json=json.dumps(embedding),
embedding_model=model,
)) ))
db.commit() db.commit()
_progress["done"] += 1
finally:
_finish_progress()
stale = db.query(LocalKnowledgeDoc).filter(LocalKnowledgeDoc.path.notin_(seen_paths)).all() stale = db.query(LocalKnowledgeDoc).filter(LocalKnowledgeDoc.path.notin_(seen_paths)).all()
for doc in stale: for doc in stale:
@@ -137,6 +213,85 @@ async def sync_local_kb(db: DBSession) -> None:
db.commit() db.commit()
async def reembed_all() -> None:
"""全ファイルを現在のモデルで埋め込み直す。バックグラウンドタスクから呼ぶ想定で、DBセッションは自前で持つ。"""
from app.db.database import SessionLocal
db = SessionLocal()
try:
await sync_local_kb(db, force=True)
except Exception as e:
logger.exception("Re-embedding of the local knowledge base failed")
_progress["last_error"] = str(e)[:500]
finally:
# 対象0件で sync 側が進捗を触らなかった場合でも、実行中フラグは必ず下ろす
_finish_progress()
db.close()
def start_reembed_all() -> bool:
"""再埋め込みをバックグラウンドで開始する。すでに走っていれば False を返して何もしない。"""
global _reembed_task
if _progress["running"]:
return False
# GUIは開始リクエストの応答をそのまま状態表示に使うので、タスクが動き出すのを待たず
# ここで「実行中」にしておく(総数は実際にスキャンした時点で入る)。
_start_progress(_embedding_model(), 0)
# create_task の戻り値を保持しないとGCで途中終了することがあるためモジュールに持たせる
_reembed_task = asyncio.create_task(reembed_all())
return True
def embedding_status(db: DBSession) -> dict:
"""GUIの「埋め込み」表示用。現在のモデルと、そのモデルで埋め込み済みの件数を返す。"""
model = _embedding_model()
kb_dir = _kb_dir()
file_count = len(list(kb_dir.rglob("*.md"))) if kb_dir.is_dir() else 0
rows = db.query(LocalKnowledgeDoc.embedding_model, LocalKnowledgeDoc.embedding_json).all()
indexed = 0
dimensions = None
for row_model, embedding_json in rows:
if row_model != model:
continue
indexed += 1
if dimensions is None:
dimensions = len(json.loads(embedding_json))
return {
"model": model,
"dimensions": dimensions,
"file_count": file_count,
"indexed": indexed,
# 別のモデルで埋め込まれた(=埋め直しが要る)行。モデル不明の古い行も含む
"stale": len(rows) - indexed,
**_progress_snapshot(),
}
async def probe_embedding() -> dict:
"""埋め込みモデルが実際に応答するかを短いテキストで確認する。GUIの「接続テスト」用。"""
model = _embedding_model()
start = time.perf_counter()
try:
[vector] = await llm.embed(["これは埋め込みモデルの疎通確認です。"], model=model)
except Exception as e:
return {
"ok": False,
"model": model,
"dimensions": None,
"latency_ms": round((time.perf_counter() - start) * 1000, 1),
"error": f"{type(e).__name__}: {e}"[:500],
}
return {
"ok": True,
"model": model,
"dimensions": len(vector),
"latency_ms": round((time.perf_counter() - start) * 1000, 1),
"error": None,
}
async def get_local_kb_context(db: DBSession, query_text: str) -> str | None: async def get_local_kb_context(db: DBSession, query_text: str) -> str | None:
"""直近のユーザー発言に関連するローカルMarkdown知識をシステムプロンプト用テキストにして返す。""" """直近のユーザー発言に関連するローカルMarkdown知識をシステムプロンプト用テキストにして返す。"""
if not query_text.strip(): if not query_text.strip():
+14 -4
View File
@@ -1,13 +1,22 @@
import logging import logging
import os
import httpx import httpx
from app.core.config import TAVILY_API_KEY
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
TAVILY_SEARCH_URL = "https://api.tavily.com/search" TAVILY_SEARCH_URL = "https://api.tavily.com/search"
def _api_key() -> str | None:
"""GUIの設定画面から実行時に変更できるよう、起動時定数ではなく都度 os.environ を見に行く。
app.core.config の定数は import 時に評価されるため、DBに保存した値を
settings_store.load_into_env() が os.environ に入れても反映されない。
"""
return os.environ.get("TAVILY_API_KEY") or None
WEB_SEARCH_TOOL = { WEB_SEARCH_TOOL = {
"type": "function", "type": "function",
"function": { "function": {
@@ -27,7 +36,8 @@ WEB_SEARCH_TOOL = {
async def search_web(query: str, max_results: int = 5) -> list[dict]: async def search_web(query: str, max_results: int = 5) -> list[dict]:
"""Tavily APIでWeb検索する。結果は [{title, url, content}, ...]。失敗時は空リスト。""" """Tavily APIでWeb検索する。結果は [{title, url, content}, ...]。失敗時は空リスト。"""
if not TAVILY_API_KEY: api_key = _api_key()
if not api_key:
logger.warning("TAVILY_API_KEY is not set; skipping web search") logger.warning("TAVILY_API_KEY is not set; skipping web search")
return [] return []
@@ -36,7 +46,7 @@ async def search_web(query: str, max_results: int = 5) -> list[dict]:
response = await client.post( response = await client.post(
TAVILY_SEARCH_URL, TAVILY_SEARCH_URL,
json={ json={
"api_key": TAVILY_API_KEY, "api_key": api_key,
"query": query, "query": query,
"max_results": max_results, "max_results": max_results,
}, },
+21 -1
View File
@@ -7,6 +7,11 @@ from app.db.models import AppSetting
# GUIから編集可能な環境変数の許可リスト。ここに無いキーは save()/clear() で拒否する。 # GUIから編集可能な環境変数の許可リスト。ここに無いキーは save()/clear() で拒否する。
CONFIGURABLE_KEYS = [ CONFIGURABLE_KEYS = [
"OPENAI_API_KEY", "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", "ANTHROPIC_API_KEY",
"AWS_ACCESS_KEY_ID", "AWS_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY", "AWS_SECRET_ACCESS_KEY",
@@ -16,8 +21,20 @@ CONFIGURABLE_KEYS = [
"OLLAMA_API_BASE", "OLLAMA_API_BASE",
"TAVILY_API_KEY", "TAVILY_API_KEY",
"LOCAL_KB_EMBEDDING_MODEL", "LOCAL_KB_EMBEDDING_MODEL",
"SEARCH_SUMMARY_MODEL",
] ]
# 秘匿情報ではないキー。値をマスクせずそのまま返し、GUIでも通常のテキスト入力として扱う。
# モデル名やURLは「今どれが効いているか」が見えないと設定しづらいため。
NON_SECRET_KEYS = {
"OPENAI_BASE_URL",
"OPENAI_API_BASE",
"AWS_REGION_NAME",
"OLLAMA_API_BASE",
"LOCAL_KB_EMBEDDING_MODEL",
"SEARCH_SUMMARY_MODEL",
}
# .env由来の起動時の値のスナップショット。clear() で戻す先として使う。 # .env由来の起動時の値のスナップショット。clear() で戻す先として使う。
_env_defaults: dict[str, str | None] = {} _env_defaults: dict[str, str | None] = {}
_snapshot_taken = False _snapshot_taken = False
@@ -47,15 +64,18 @@ def load_into_env(db: DBSession) -> None:
def list_masked(db: DBSession) -> list[dict]: def list_masked(db: DBSession) -> list[dict]:
"""GUI設定画面に出す一覧。秘匿キーの値はマスクし、そうでないキーは現在値をそのまま見せる。"""
_ensure_snapshot() _ensure_snapshot()
saved = {row.key: row.value for row in db.query(AppSetting).all()} saved = {row.key: row.value for row in db.query(AppSetting).all()}
result = [] result = []
for key in CONFIGURABLE_KEYS: for key in CONFIGURABLE_KEYS:
current = os.environ.get(key) current = os.environ.get(key)
secret = key not in NON_SECRET_KEYS
result.append({ result.append({
"key": key, "key": key,
"is_set": bool(current), "is_set": bool(current),
"masked": _mask(current) if current else None, "display_value": (_mask(current) if secret else current) if current else None,
"secret": secret,
"overridden": key in saved, "overridden": key in saved,
}) })
return result return result
+1
View File
@@ -24,6 +24,7 @@ _ADDED_COLUMNS = [
("knowledge_entries", "headline", "VARCHAR"), ("knowledge_entries", "headline", "VARCHAR"),
("messages", "archived_at", "DATETIME"), ("messages", "archived_at", "DATETIME"),
("messages", "memory_id", "INTEGER"), ("messages", "memory_id", "INTEGER"),
("local_knowledge_docs", "embedding_model", "VARCHAR"),
] ]
+26 -1
View File
@@ -104,7 +104,7 @@ class LocalKnowledgeDoc(Base):
"""backend/knowledge_base/ 配下のMarkdownファイルとその埋め込みベクトル。 """backend/knowledge_base/ 配下のMarkdownファイルとその埋め込みベクトル。
話題に応じて関連ファイルをシステムプロンプトに注入するために使う。 話題に応じて関連ファイルをシステムプロンプトに注入するために使う。
ファイル内容のハッシュが変わった時だけ埋め込みを再計算する。 ファイル内容のハッシュか埋め込みモデルが変わった時だけ埋め込みを再計算する。
""" """
__tablename__ = "local_knowledge_docs" __tablename__ = "local_knowledge_docs"
@@ -113,6 +113,10 @@ class LocalKnowledgeDoc(Base):
title: Mapped[str] = mapped_column(String) title: Mapped[str] = mapped_column(String)
content_hash: Mapped[str] = mapped_column(String) content_hash: Mapped[str] = mapped_column(String)
embedding_json: Mapped[str] = mapped_column(Text) embedding_json: Mapped[str] = mapped_column(Text)
# この埋め込みを計算したモデル名。モデルを切り替えた行を見分けて埋め直すために持つ
# (次元数の違うベクトル同士を比較しても意味のないスコアしか出ないため)。
# このカラムより前に作られた行は NULL で、モデル不明として埋め直し対象になる。
embedding_model: Mapped[str | None] = mapped_column(String, nullable=True)
updated_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) updated_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
@@ -186,3 +190,24 @@ class ApiLog(Base):
error: Mapped[str | None] = mapped_column(Text, nullable=True) error: Mapped[str | None] = mapped_column(Text, nullable=True)
latency_ms: Mapped[float | None] = mapped_column(Float, 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)) 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
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"),
+40
View File
@@ -306,6 +306,46 @@ attachment; filename="______.md"; filename*=UTF-8''%E8%B2%B7%E3%81%84%E7%89%A9%E
--- ---
### GET /api/kb/embedding
埋め込みモデルの現状と、再埋め込みの進捗を返す。
```json
{
"model": "ollama/nomic-embed-text",
"dimensions": 768,
"file_count": 36,
"indexed": 36,
"stale": 0,
"running": false,
"total": 36,
"done": 36,
"failed": 0,
"started_at": "2026-08-23T13:50:23.196274Z",
"finished_at": "2026-08-23T13:50:29.951099Z",
"last_error": null
}
```
`indexed` は**現在のモデルで**埋め込まれている件数、`stale` は別モデル(またはモデル不明)のまま残っている件数。
`running` 以下は実行中の進捗で、プロセス内のメモリにしか無い(再起動で消える。ワーカーを複数立てるとワーカーごとに別の値になる)。
---
### POST /api/kb/embedding/test
短いテキストを実際に 1 回埋め込んでみて、モデルが応答するかを確かめる。失敗しても 200 で返し、`error` に理由が入る。
```json
{ "ok": true, "model": "ollama/nomic-embed-text", "dimensions": 768, "latency_ms": 412.3, "error": null }
```
---
### POST /api/kb/embedding/reindex
全ファイルの再埋め込みをバックグラウンドで開始し、開始直後の状態(`running: true`)を返す。
すでに実行中なら何も起こさず現在の進捗を返す。
---
## ログ ## ログ
### GET /logs ### GET /logs
+72 -1
View File
@@ -45,6 +45,10 @@ backend/
│ │ ├── search/ │ │ ├── search/
│ │ │ └── websearch.py # Tavily による web_search ツール │ │ │ └── websearch.py # Tavily による web_search ツール
│ │ ├── discord/ # Discord Bot・定期通知 │ │ ├── discord/ # Discord Bot・定期通知
│ │ │ ├── discord_bot.py # メンションへの返信(天気 / 雑談 / リマインドで分岐)
│ │ │ ├── scheduler.py # APScheduler ジョブ(天気通知・Tips・リマインド配信)
│ │ │ ├── reminders.py # リマインドの登録ツールと配信ジョブ
│ │ │ └── notify.py # Webhook 送信
│ │ ├── settings_store.py # GUI 設定を os.environ へ反映 │ │ ├── settings_store.py # GUI 設定を os.environ へ反映
│ │ └── auth.py / site_auth.py # 設定画面パスワード・サイトログイン │ │ └── auth.py / site_auth.py # 設定画面パスワード・サイトログイン
│ └── db/ │ └── db/
@@ -65,12 +69,13 @@ backend/
| `memory_tags` | id, memory_id, tag, created_at | 要約への自動タグ | | `memory_tags` | id, memory_id, tag, created_at | 要約への自動タグ |
| `knowledge_entries` | id, query, summary, created_at | Web 検索で得た知識(セッション横断) | | `knowledge_entries` | id, query, summary, created_at | Web 検索で得た知識(セッション横断) |
| `knowledge_sources` | id, entry_id, title, url | 上記知識の出典 URL | | `knowledge_sources` | id, entry_id, title, url | 上記知識の出典 URL |
| `local_knowledge_docs` | id, path, title, content_hash, embedding_json, updated_at | ローカル Markdown KB の埋め込みキャッシュ | | `local_knowledge_docs` | id, path, title, content_hash, embedding_json, embedding_model, updated_at | ローカル Markdown KB の埋め込みキャッシュ(`embedding_model` が現在の設定と違う行は埋め直す) |
| `note_pages` | id, title, content, position, section, created_at, updated_at | ユーザーと AI が共同編集する共有ノートのページ(`section` でタブをグループ化) | | `note_pages` | id, title, content, position, section, created_at, updated_at | ユーザーと AI が共同編集する共有ノートのページ(`section` でタブをグループ化) |
| `session_note_pages` | id, session_id, page_id, created_at | 「このセッションではこのページの本文をプロンプトに載せる」指定(session_id × page_id は一意) | | `session_note_pages` | id, session_id, page_id, created_at | 「このセッションではこのページの本文をプロンプトに載せる」指定(session_id × page_id は一意) |
| `app_settings` | key, value, updated_at | GUI 設定画面から編集した接続情報 | | `app_settings` | key, value, updated_at | GUI 設定画面から編集した接続情報 |
| `model_presets` | id, label, value, created_at | モデル選択プルダウンの選択肢 | | `model_presets` | id, label, value, created_at | モデル選択プルダウンの選択肢 |
| `api_logs` | id, session_id, model, request_json, response_text, error, latency_ms, created_at | LLM API 呼び出しログ | | `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/`) ### コンテキスト管理(`core/memory_kb/`)
@@ -172,6 +177,72 @@ data: {"type":"error","value":"エラー内容"} # 例外発生時
バッファリング防止のため `Cache-Control: no-cache` と `X-Accel-Buffering: no` を付与する。 バッファリング防止のため `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 になった直後にまとめて配信される
--- ---
## フロントエンド ## フロントエンド
+5
View File
@@ -113,6 +113,11 @@ nano backend/.env # APIキーやモデル名など本番用の値を設定
`backend/.env` はコンテナに `env_file` として渡され、Git 管理対象外(`.gitignore` 済み)なので直接編集してよい。 `backend/.env` はコンテナに `env_file` として渡され、Git 管理対象外(`.gitignore` 済み)なので直接編集してよい。
**ローカルの `.env` をそのまま持ち込むときの注意:** `OPENAI_BASE_URL` / `OPENAI_API_BASE` を
ローカルの llama-server や Ollama に向けていると、サーバー上では存在しない宛先に接続しに行くため、
`gpt-*` のモデルを選んでも litellm が「Connection Error」になる。サーバーの `.env` からは消しておくこと
(設定画面の「OpenAI 互換エンドポイント」からも上書きできる)。
### GUIからのAPIキー管理 ### GUIからのAPIキー管理
初回起動後は、`OPENAI_API_KEY` / `ANTHROPIC_API_KEY` / AWS認証情報などをSSHで`.env`を編集し直さなくても、 初回起動後は、`OPENAI_API_KEY` / `ANTHROPIC_API_KEY` / AWS認証情報などをSSHで`.env`を編集し直さなくても、
+23 -6
View File
@@ -28,7 +28,7 @@ Discord 連携は [discord-bot.md](discord-bot.md)、ノートの操作方法は
| 4 | 自動要約による長期記憶 | 一定件数を超えたら古い会話を要約して圧縮 | `memory_kb/memory.py` | | 4 | 自動要約による長期記憶 | 一定件数を超えたら古い会話を要約して圧縮 | `memory_kb/memory.py` |
| 5 | Web 検索と知識の蓄積 | Tavily で検索 →要約 → DB とローカル KB に保存 | `core/search/` / `memory_kb/knowledge.py` | | 5 | Web 検索と知識の蓄積 | Tavily で検索 →要約 → DB とローカル KB に保存 | `core/search/` / `memory_kb/knowledge.py` |
| 6 | ローカル Markdown ナレッジベース | 話題に近いファイルを埋め込み類似度で選んで全文注入 | `memory_kb/local_knowledge.py` | | 6 | ローカル Markdown ナレッジベース | 話題に近いファイルを埋め込み類似度で選んで全文注入 | `memory_kb/local_knowledge.py` |
| 6.5 | ナレッジベース閲覧パネル | KB の Markdown をサイドパネルで一覧・全文表示 | `api/knowledge_base.py` / `KnowledgeBasePanel.vue` | | 6.5 | ナレッジベース閲覧パネル | KB の Markdown を一覧・全文表示。埋め込みの状態確認・接続テスト・再埋め込み | `api/knowledge_base.py` / `KnowledgeBasePanel.vue` |
| 7 | 共有ノート | タブ/セクション付きノートを人と AI の両方が編集 | `core/notes.py` / `NotePanel.vue` | | 7 | 共有ノート | タブ/セクション付きノートを人と AI の両方が編集 | `core/notes.py` / `NotePanel.vue` |
| 8 | 自動タグ付け | 発言・要約にタグを 3 つ付与(話題選びに使う) | `memory_kb/tagging.py` | | 8 | 自動タグ付け | 発言・要約にタグを 3 つ付与(話題選びに使う) | `memory_kb/tagging.py` |
| 9 | ワードプール | 直近の名詞をインメモリに保持しタグクラウド表示 | `memory_kb/word_memory.py` / `WordPool.vue` | | 9 | ワードプール | 直近の名詞をインメモリに保持しタグクラウド表示 | `memory_kb/word_memory.py` / `WordPool.vue` |
@@ -94,7 +94,7 @@ Discord 連携は [discord-bot.md](discord-bot.md)、ノートの操作方法は
## 5. Web 検索と知識の蓄積 ## 5. Web 検索と知識の蓄積
- LLM が必要と判断したときだけ `web_search` ツールが走る(Tavily。`TAVILY_API_KEY` 未設定なら空振り)。 - LLM が必要と判断したときだけ `web_search` ツールが走る(Tavily。`TAVILY_API_KEY` 未設定なら空振り)。
- 結果は `SEARCH_SUMMARY_MODEL` で構造化要約にされ、**2 か所**に保存される。 - 結果は `SEARCH_SUMMARY_MODEL`(未設定なら `DEFAULT_MODEL`。設定画面から変更可)で構造化要約にされ、**2 か所**に保存される。
- `knowledge_entries` / `knowledge_sources` テーブル → 次回以降、層 1 として全セッションに効く - `knowledge_entries` / `knowledge_sources` テーブル → 次回以降、層 1 として全セッションに効く
- `knowledge_base/web_search/*.md` → 層 3 の類似度検索の対象になる - `knowledge_base/web_search/*.md` → 層 3 の類似度検索の対象になる
- `POST /chat/knowledge/regenerate` で、既存の知識エントリを出典 URL から取り直して要約し直せる。 - `POST /chat/knowledge/regenerate` で、既存の知識エントリを出典 URL から取り直して要約し直せる。
@@ -103,9 +103,15 @@ Discord 連携は [discord-bot.md](discord-bot.md)、ノートの操作方法は
- `backend/knowledge_base/**.md` を置いておくだけで、話題が近いときに自動で全文が注入される。 - `backend/knowledge_base/**.md` を置いておくだけで、話題が近いときに自動で全文が注入される。
- 直近のユーザー発言との埋め込みコサイン類似度で選ぶ。埋め込みは `local_knowledge_docs` に - 直近のユーザー発言との埋め込みコサイン類似度で選ぶ。埋め込みは `local_knowledge_docs` に
内容ハッシュ付きでキャッシュされ、ファイルが変わったときだけ取り直す。 内容ハッシュと**使用した埋め込みモデル名**付きでキャッシュされ、
ファイルが変わったときと、埋め込みモデルが変わったときだけ取り直す。
- 設定画面で `LOCAL_KB_EMBEDDING_MODEL` を変更すると、保存した時点でバックグラウンドの再埋め込みが走る
(モデルが違えば次元も意味空間も変わり、古いベクトルは比較しても無意味なため)。
パネルの「埋め直し」ボタンからも手動で開始できる。
- プロンプト上は「Web 検索結果より優先。これで答えられるなら検索するな」と明示している。 - プロンプト上は「Web 検索結果より優先。これで答えられるなら検索するな」と明示している。
- ヘッダーのパネルメニュー →「ナレッジベース」で開くサイドパネル(`KnowledgeBasePanel.vue`)から中身を閲覧できる。 - ヘッダーのパネルメニュー →「ナレッジベース」で開くサイドパネル(`KnowledgeBasePanel.vue`)から中身を閲覧できる。
パネル上部には埋め込みの状態(モデル名・次元数・埋め込み済み件数・進捗バー)が出る。
「接続テスト」は短いテキストを 1 回埋め込んでみて、モデルが応答するかその場で確かめる。
フォルダ単位のグループ表示・折りたたみ、タイトル/パス/本文先頭での絞り込み、Markdown 描画に対応。 フォルダ単位のグループ表示・折りたたみ、タイトル/パス/本文先頭での絞り込み、Markdown 描画に対応。
各ファイルの緑の点は埋め込み済み(=自動注入の対象)であることを示す。閲覧専用で、編集はできない。 各ファイルの緑の点は埋め込み済み(=自動注入の対象)であることを示す。閲覧専用で、編集はできない。
@@ -158,10 +164,21 @@ Discord 連携は [discord-bot.md](discord-bot.md)、ノートの操作方法は
- モデルのプルダウンは `model_presets` テーブル由来で、初回起動時に既定の一覧がシードされる。 - モデルのプルダウンは `model_presets` テーブル由来で、初回起動時に既定の一覧がシードされる。
設定画面からラベル/モデル文字列を追加・編集・削除できる(重複は 400 で弾く)。 設定画面からラベル/モデル文字列を追加・編集・削除できる(重複は 400 で弾く)。
- 設定画面では API キー類を**再起動なしで**変更できる。 - 設定画面では API キー類を**再起動なしで**変更できる。
- 変更できるのは許可リストにあるキーだけ(`OPENAI_API_KEY` / `ANTHROPIC_API_KEY` / AWS 系 / - 変更できるのは許可リストにあるキーだけ(`OPENAI_API_KEY` / `OPENAI_BASE_URL` / `OPENAI_API_BASE` /
`OLLAMA_API_BASE` / `TAVILY_API_KEY` / `LOCAL_KB_EMBEDDING_MODEL`) `ANTHROPIC_API_KEY` / AWS 系 / `OLLAMA_API_BASE` / `TAVILY_API_KEY` /
- 値は末尾 4 文字だけを見せるマスク表示。空欄で保存すると「変更なし」扱い `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`)は
現在値をそのまま表示し、入力欄も伏せ字にしない(今どれが効いているか分からないと設定しづらいため)
- 空欄で保存すると「変更なし」扱い
- 保存すると `app_settings` テーブルに入り `os.environ` に反映される。削除すると `.env` 由来の起動時の値に戻る - 保存すると `app_settings` テーブルに入り `os.environ` に反映される。削除すると `.env` 由来の起動時の値に戻る
- 保存した値は `backend_data` ボリュームに残るため、コンテナを作り直しても消えない。
起動時に `load_into_env()` が `os.environ` へ流し込むので、`.env` の同名の値より優先される
- **アプリ側は `config.py` の定数ではなく `os.environ` を都度読むこと**。定数は import 時に評価され、
`load_into_env()` より前に確定してしまうため、GUI で保存した値が反映されない
(`websearch._api_key()` / `knowledge._summary_model()` / `local_knowledge._embedding_model()` がその形)
## 15-16. ログビューアとシステムモニタ ## 15-16. ログビューアとシステムモニタ
+47 -1
View File
@@ -176,7 +176,10 @@ export async function logout(): Promise<void> {
export interface SettingEntry { export interface SettingEntry {
key: string key: string
is_set: boolean is_set: boolean
masked: string | null /** 秘匿キーはマスク済みの値、それ以外は現在値そのもの。未設定なら null。 */
display_value: string | null
/** APIキーなど、入力欄をパスワード扱いにするキーか。 */
secret: boolean
overridden: boolean overridden: boolean
} }
@@ -428,3 +431,46 @@ export async function fetchKbFile(path: string): Promise<KbFileContent> {
const res = await fetch(`${BASE}/api/kb/file?path=${encodeURIComponent(path)}`) const res = await fetch(`${BASE}/api/kb/file?path=${encodeURIComponent(path)}`)
return parseJson<KbFileContent>(res, 'ナレッジベースファイル取得') return parseJson<KbFileContent>(res, 'ナレッジベースファイル取得')
} }
/** ローカルナレッジベースの埋め込み状況。GUI の状態表示・進捗バーに使う。 */
export interface KbEmbeddingStatus {
model: string
/** 現在のモデルで埋め込まれたベクトルの次元数(1件も無ければ null)。 */
dimensions: number | null
file_count: number
indexed: number
/** 別のモデルで埋め込まれていて埋め直しが必要な件数。 */
stale: number
running: boolean
total: number
done: number
failed: number
started_at: string | null
finished_at: string | null
last_error: string | null
}
/** 埋め込みモデルに実際に1回リクエストしてみた結果。 */
export interface KbEmbeddingProbe {
ok: boolean
model: string
dimensions: number | null
latency_ms: number
error: string | null
}
export async function fetchKbEmbeddingStatus(): Promise<KbEmbeddingStatus> {
const res = await fetch(`${BASE}/api/kb/embedding`)
return parseJson<KbEmbeddingStatus>(res, '埋め込み状態取得')
}
export async function testKbEmbedding(): Promise<KbEmbeddingProbe> {
const res = await fetch(`${BASE}/api/kb/embedding/test`, { method: 'POST' })
return parseJson<KbEmbeddingProbe>(res, '埋め込みテスト')
}
/** 全ファイルの再埋め込みをバックグラウンドで開始する。戻り値は開始直後の状態。 */
export async function reindexKbEmbeddings(): Promise<KbEmbeddingStatus> {
const res = await fetch(`${BASE}/api/kb/embedding/reindex`, { method: 'POST' })
return parseJson<KbEmbeddingStatus>(res, '再埋め込み開始')
}
+85 -2
View File
@@ -1,11 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted } from 'vue' import { computed, onMounted, onUnmounted } from 'vue'
import MarkdownContent from './MarkdownContent.vue' import MarkdownContent from './MarkdownContent.vue'
import { useKnowledgeBaseStore } from '../stores/knowledgeBase' import { useKnowledgeBaseStore } from '../stores/knowledgeBase'
const store = useKnowledgeBaseStore() const store = useKnowledgeBaseStore()
onMounted(() => store.load()) onMounted(() => {
store.load()
store.loadEmbedding()
})
// パネルを閉じている間もポーリングが回り続けないように止める
onUnmounted(() => store.stopPolling())
const progressPercent = computed(() => {
const e = store.embedding
if (!e || e.total === 0) return 0
return Math.round(((e.done + e.failed) / e.total) * 100)
})
function formatSize(bytes: number): string { function formatSize(bytes: number): string {
return bytes < 1024 ? `${bytes} B` : `${Math.round(bytes / 1024)} KB` return bytes < 1024 ? `${bytes} B` : `${Math.round(bytes / 1024)} KB`
@@ -41,6 +52,54 @@ function formatDate(iso: string): string {
<!-- 一覧 --> <!-- 一覧 -->
<template v-else> <template v-else>
<div v-if="store.embedding" class="embedding">
<div class="embedding-head">
<span class="embedding-model" :title="`埋め込みモデル: ${store.embedding.model}`">
🧠 {{ store.embedding.model }}
</span>
<button class="mini-btn" :disabled="store.testing" title="このモデルに実際に1回リクエストして応答を確かめる"
@click="store.testEmbedding()">
{{ store.testing ? 'テスト中…' : '接続テスト' }}
</button>
<button class="mini-btn" :disabled="store.embedding.running" title="全ファイルを現在のモデルで埋め込み直す"
@click="store.reindex()">
埋め直し
</button>
</div>
<div class="embedding-stats">
<span :class="{ warn: store.embedding.indexed < store.embedding.file_count }">
埋め込み済み {{ store.embedding.indexed }} / {{ store.embedding.file_count }}
</span>
<span v-if="store.embedding.stale > 0" class="warn">
・別モデルのまま {{ store.embedding.stale }}
</span>
<span v-if="store.embedding.dimensions">・{{ store.embedding.dimensions }} 次元</span>
</div>
<template v-if="store.embedding.running">
<div class="progress"><div class="progress-bar" :style="{ width: `${progressPercent}%` }" /></div>
<div class="embedding-note">
<template v-if="store.embedding.total === 0">準備中…</template>
<template v-else>
埋め込み中 {{ store.embedding.done + store.embedding.failed }} / {{ store.embedding.total }}
<span v-if="store.embedding.failed > 0" class="ng">(失敗 {{ store.embedding.failed }})</span>
</template>
</div>
</template>
<div v-if="store.probe" class="probe" :class="store.probe.ok ? 'ok' : 'ng'">
<template v-if="store.probe.ok">
✅ 応答あり — {{ store.probe.dimensions }} 次元 / {{ Math.round(store.probe.latency_ms) }} ms
</template>
<template v-else>❌ {{ store.probe.error }}</template>
</div>
<div v-if="store.embedding.last_error" class="probe ng">
直近の埋め込みエラー: {{ store.embedding.last_error }}
</div>
</div>
<div class="search"> <div class="search">
<input v-model="store.filter" type="search" placeholder="タイトル・パス・本文の先頭で絞り込み"> <input v-model="store.filter" type="search" placeholder="タイトル・パス・本文の先頭で絞り込み">
<span class="count">{{ store.filteredFiles.length }} / {{ store.files.length }}</span> <span class="count">{{ store.filteredFiles.length }} / {{ store.files.length }}</span>
@@ -109,6 +168,30 @@ function formatDate(iso: string): string {
.back-btn:hover, .reload-btn:hover:not(:disabled) { color: #fff; } .back-btn:hover, .reload-btn:hover:not(:disabled) { color: #fff; }
.error { padding: 8px 12px; color: #f87171; font-size: 12px; } .error { padding: 8px 12px; color: #f87171; font-size: 12px; }
.embedding {
padding: 8px 12px; border-bottom: 1px solid #1e1e1e; flex-shrink: 0;
display: flex; flex-direction: column; gap: 6px;
}
.embedding-head { display: flex; align-items: center; gap: 6px; }
.embedding-model {
flex: 1; color: #cbd5e1; font-size: 12px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.mini-btn {
background: #2a2a2a; color: #aaa; border: 1px solid #444;
border-radius: 5px; padding: 2px 8px; font-size: 11px; cursor: pointer; white-space: nowrap;
}
.mini-btn:hover:not(:disabled) { color: #fff; }
.mini-btn:disabled { opacity: 0.5; cursor: default; }
.embedding-stats { color: #6b7280; font-size: 11px; }
.embedding-stats .warn { color: #fbbf24; }
.embedding-note { color: #6b7280; font-size: 11px; }
.progress { height: 4px; background: #262626; border-radius: 2px; overflow: hidden; }
.progress-bar { height: 100%; background: #2563eb; transition: width 0.3s; }
.probe { font-size: 11px; line-height: 1.5; word-break: break-word; }
.probe.ok { color: #4ade80; }
.probe.ng, .ng { color: #f87171; }
.search { .search {
display: flex; align-items: center; gap: 8px; display: flex; align-items: center; gap: 8px;
padding: 8px 12px; border-bottom: 1px solid #1e1e1e; flex-shrink: 0; padding: 8px 12px; border-bottom: 1px solid #1e1e1e; flex-shrink: 0;
+5 -5
View File
@@ -37,6 +37,8 @@ async function addModelPreset() {
const KEY_LABELS: Record<string, string> = { const KEY_LABELS: Record<string, string> = {
OPENAI_API_KEY: 'OpenAI APIキー', OPENAI_API_KEY: 'OpenAI APIキー',
OPENAI_BASE_URL: 'OpenAI 互換エンドポイント (OPENAI_BASE_URL)',
OPENAI_API_BASE: 'OpenAI 互換エンドポイント (OPENAI_API_BASE)',
ANTHROPIC_API_KEY: 'Anthropic APIキー', ANTHROPIC_API_KEY: 'Anthropic APIキー',
AWS_ACCESS_KEY_ID: 'AWS Access Key ID', AWS_ACCESS_KEY_ID: 'AWS Access Key ID',
AWS_SECRET_ACCESS_KEY: 'AWS Secret Access Key', AWS_SECRET_ACCESS_KEY: 'AWS Secret Access Key',
@@ -46,11 +48,9 @@ const KEY_LABELS: Record<string, string> = {
OLLAMA_API_BASE: 'Ollama API Base URL', OLLAMA_API_BASE: 'Ollama API Base URL',
TAVILY_API_KEY: 'Tavily APIキー', TAVILY_API_KEY: 'Tavily APIキー',
LOCAL_KB_EMBEDDING_MODEL: '埋め込みモデル(ローカルナレッジベース検索用)', LOCAL_KB_EMBEDDING_MODEL: '埋め込みモデル(ローカルナレッジベース検索用)',
SEARCH_SUMMARY_MODEL: '要約モデル(Web検索結果のまとめ用)',
} }
// 秘匿情報ではないため、マスクせず通常のテキスト入力として表示するキー
const PLAIN_TEXT_KEYS = new Set(['LOCAL_KB_EMBEDDING_MODEL', 'OLLAMA_API_BASE', 'AWS_REGION_NAME'])
async function submitPassword() { async function submitPassword() {
const ok = await store.unlock(passwordInput.value) const ok = await store.unlock(passwordInput.value)
if (ok) passwordInput.value = '' if (ok) passwordInput.value = ''
@@ -106,8 +106,8 @@ onMounted(() => {
<div class="row"> <div class="row">
<input <input
v-model="drafts[entry.key]" v-model="drafts[entry.key]"
:type="PLAIN_TEXT_KEYS.has(entry.key) ? 'text' : 'password'" :type="entry.secret ? 'password' : 'text'"
:placeholder="entry.is_set ? `設定済み ${entry.masked}` : '未設定'" :placeholder="entry.is_set ? `現在: ${entry.display_value}` : '未設定'"
autocomplete="off" autocomplete="off"
/> />
<button <button
+92 -2
View File
@@ -1,6 +1,16 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { fetchKbFile, fetchKbFiles, type KbFileContent, type KbFileInfo } from '../api/client' import {
fetchKbEmbeddingStatus,
fetchKbFile,
fetchKbFiles,
reindexKbEmbeddings,
testKbEmbedding,
type KbEmbeddingProbe,
type KbEmbeddingStatus,
type KbFileContent,
type KbFileInfo,
} from '../api/client'
export interface KbGroup { export interface KbGroup {
/** ナレッジベース直下は空文字。 */ /** ナレッジベース直下は空文字。 */
@@ -18,6 +28,15 @@ export const useKnowledgeBaseStore = defineStore('knowledgeBase', () => {
const filter = ref('') const filter = ref('')
/** 折りたたまれているフォルダ名。web_search のようにファイル数が多いフォルダを畳むために使う。 */ /** 折りたたまれているフォルダ名。web_search のようにファイル数が多いフォルダを畳むために使う。 */
const collapsed = ref<Set<string>>(new Set()) const collapsed = ref<Set<string>>(new Set())
/** 埋め込みモデルの状態と進捗。 */
const embedding = ref<KbEmbeddingStatus | null>(null)
/** 「接続テスト」の結果。 */
const probe = ref<KbEmbeddingProbe | null>(null)
const testing = ref(false)
/** 埋め込み実行中だけ状態をポーリングするタイマー。 */
let pollTimer: number | null = null
const POLL_INTERVAL_MS = 1500
const filteredFiles = computed(() => { const filteredFiles = computed(() => {
const q = filter.value.trim().toLowerCase() const q = filter.value.trim().toLowerCase()
@@ -66,6 +85,57 @@ export const useKnowledgeBaseStore = defineStore('knowledgeBase', () => {
openFile.value = null openFile.value = null
} }
/** 埋め込みの状態を取り直す。実行中ならポーリングを始め、終わっていれば止める。 */
async function loadEmbedding(): Promise<void> {
try {
embedding.value = await fetchKbEmbeddingStatus()
} catch (e) {
error.value = e instanceof Error ? e.message : '埋め込み状態の取得に失敗しました'
stopPolling()
return
}
if (embedding.value.running && pollTimer === null) {
pollTimer = window.setInterval(() => void loadEmbedding(), POLL_INTERVAL_MS)
} else if (!embedding.value.running && pollTimer !== null) {
stopPolling()
// 埋め込みが終わったので、一覧の「埋め込み済み」表示も更新する
await load()
}
}
function stopPolling(): void {
if (pollTimer !== null) {
clearInterval(pollTimer)
pollTimer = null
}
}
/** 埋め込みモデルに実際にリクエストして、応答するか確かめる。 */
async function testEmbedding(): Promise<void> {
testing.value = true
probe.value = null
try {
probe.value = await testKbEmbedding()
} catch (e) {
error.value = e instanceof Error ? e.message : '埋め込みテストに失敗しました'
} finally {
testing.value = false
}
}
/** 全ファイルの再埋め込みをバックグラウンドで開始する。 */
async function reindex(): Promise<void> {
error.value = null
try {
embedding.value = await reindexKbEmbeddings()
if (embedding.value.running && pollTimer === null) {
pollTimer = window.setInterval(() => void loadEmbedding(), POLL_INTERVAL_MS)
}
} catch (e) {
error.value = e instanceof Error ? e.message : '再埋め込みの開始に失敗しました'
}
}
function toggleGroup(directory: string): void { function toggleGroup(directory: string): void {
const next = new Set(collapsed.value) const next = new Set(collapsed.value)
if (next.has(directory)) next.delete(directory) if (next.has(directory)) next.delete(directory)
@@ -73,5 +143,25 @@ export const useKnowledgeBaseStore = defineStore('knowledgeBase', () => {
collapsed.value = next collapsed.value = next
} }
return { files, openFile, loading, error, filter, collapsed, filteredFiles, groups, load, open, close, toggleGroup } return {
files,
openFile,
loading,
error,
filter,
collapsed,
embedding,
probe,
testing,
filteredFiles,
groups,
load,
open,
close,
toggleGroup,
loadEmbedding,
stopPolling,
testEmbedding,
reindex,
}
}) })