メンション本文にリマインド系のキーワードが含まれていたとき、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>
279 lines
21 KiB
Markdown
279 lines
21 KiB
Markdown
# アーキテクチャ概要
|
||
|
||
## 全体構成
|
||
|
||
```
|
||
フロントエンド (Vue 3 + TypeScript + Vite, :5173)
|
||
↕ REST + SSE (Vite dev proxy 経由)
|
||
バックエンド (FastAPI + Python, :8000)
|
||
↕ SQLAlchemy
|
||
SQLite (playchat.db)
|
||
↕ LiteLLM
|
||
OpenAI / Anthropic / Ollama など任意の LLM
|
||
```
|
||
|
||
---
|
||
|
||
## バックエンド
|
||
|
||
### ディレクトリ構成
|
||
|
||
```
|
||
backend/
|
||
├── app/
|
||
│ ├── main.py # FastAPI インスタンス、CORS、ルーター登録、DB初期化、起動時シード
|
||
│ ├── api/
|
||
│ │ ├── chat.py # チャット・セッション関連エンドポイント(SSE / export / import)
|
||
│ │ ├── memory.py # 長期メモリ参照・削除
|
||
│ │ ├── notes.py # 共有ノート CRUD
|
||
│ │ ├── knowledge_base.py # ローカル KB の Markdown を GUI から閲覧(読み取り専用)
|
||
│ │ ├── models.py # モデルプリセット CRUD
|
||
│ │ ├── settings.py # GUI 設定画面(APIキー等)
|
||
│ │ ├── system.py # タグ backfill・ワードプール参照などの運用系
|
||
│ │ ├── auth.py # サイトログイン
|
||
│ │ └── logs.py # API ログ参照・削除
|
||
│ ├── core/
|
||
│ │ ├── config.py # .env 読み込み・定数定義
|
||
│ │ ├── llm.py # LiteLLM ラッパー(ストリーミング・埋め込み・ツール解決・ログ記録)
|
||
│ │ ├── notes.py # 共有ノート(タイトル一覧の注入 / read_note・write_note ツール)
|
||
│ │ ├── memory_kb/ # コンテキスト管理まわり一式
|
||
│ │ │ ├── memory.py # コンテキスト合成・要約(旧 core/memory.py)
|
||
│ │ │ ├── knowledge.py # Web検索で得た知識の蓄積・要約・再生成
|
||
│ │ │ ├── local_knowledge.py # ローカル Markdown KB の埋め込み・類似検索
|
||
│ │ │ ├── tagging.py # メッセージ・要約への自動タグ付け
|
||
│ │ │ └── word_memory.py # 形態素解析による単語プール(Discord Bot 用)
|
||
│ │ ├── 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/
|
||
│ ├── database.py # SQLite 接続・セッションファクトリ・テーブル自動作成
|
||
│ └── models.py # SQLAlchemy モデル定義
|
||
├── knowledge_base/ # ローカルナレッジベース(Markdown。web_search/ 配下は自動生成)
|
||
└── pyproject.toml
|
||
```
|
||
|
||
### DB テーブル
|
||
|
||
| テーブル | 主なカラム | 用途 |
|
||
|---|---|---|
|
||
| `sessions` | id (UUID), title, model, created_at, updated_at | チャットセッション |
|
||
| `messages` | id, session_id, role, content, created_at | 会話メッセージ(短期メモリ) |
|
||
| `message_tags` | id, message_id, tag, created_at | メッセージへの自動タグ |
|
||
| `memories` | id, session_id, summary, embedding_json, created_at | 要約による長期メモリ |
|
||
| `memory_tags` | id, memory_id, tag, created_at | 要約への自動タグ |
|
||
| `knowledge_entries` | id, query, summary, created_at | Web 検索で得た知識(セッション横断) |
|
||
| `knowledge_sources` | id, entry_id, title, url | 上記知識の出典 URL |
|
||
| `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` でタブをグループ化) |
|
||
| `session_note_pages` | id, session_id, page_id, created_at | 「このセッションではこのページの本文をプロンプトに載せる」指定(session_id × page_id は一意) |
|
||
| `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/`)
|
||
|
||
LLM に渡すコンテキストは `memory_kb/memory.py` の `get_context_messages()` が毎リクエスト組み立てる。
|
||
以下の順に system メッセージを積み、最後に会話履歴を連結する。
|
||
|
||
| # | 層 | 中身 | 実装 | 量の制御 |
|
||
|---|---|---|---|---|
|
||
| 1 | 蓄積知識 | 過去の Web 検索で得た `knowledge_entries` の**見出し一覧のみ**(本文・出典は含めない) | `knowledge.get_knowledge_context()` | 最新 `KNOWLEDGE_CONTEXT_LIMIT`(既定 15)件 |
|
||
| 2 | 共有ノート | ページの**タイトル一覧+本文先頭 40 文字**のみ | `notes.get_notes_context()` | 全ページ(本文は入れない) |
|
||
| 2.5 | 参照指定ノート | ユーザーがこのセッションに紐付けたページの**本文全文** | `notes.get_session_notes_context()` | 紐付いたページのみ。1 ページ `SESSION_NOTES_CONTENT_LIMIT`(既定 8000)文字で打ち切り |
|
||
| 3 | ローカル KB | `knowledge_base/**.md` から選ばれた**ファイル全文** | `local_knowledge.get_local_kb_context()` | 直近ユーザー発言との cos 類似度上位 `LOCAL_KB_TOP_K`(既定 3)件、かつ `LOCAL_KB_MIN_SCORE`(既定 0.75)以上 |
|
||
| 4 | 長期メモリ | `memories` の要約を全件連結 | `memory.get_context_messages()` | 上限なし |
|
||
| 5 | 短期メモリ | セッション内の `messages` 全件(要約済みで削除された分を除く) | 同上 | 上限なし(要約で間接的に制御) |
|
||
|
||
層 3 のプロンプトは「Web 検索結果より優先度が高い」「これで答えられるなら検索するな」と明示しており、層 1 より優先される。
|
||
|
||
層 2.5 は「重い本文はツールで取りに行かせる」という原則の唯一の例外で、ユーザーが明示的に選んだページだけが対象。
|
||
`session_note_pages` に行がある間だけ効き、`GET/PUT /chat/sessions/{id}/notes` で読み書きする。
|
||
新規セッションではフロントがまだ session_id を持てないため、`POST /chat/stream` の `note_page_ids`
|
||
でも同じ指定を送れるようにしてあり、コンテキストを組み立てる前にサーバー側で反映される。
|
||
層 2 の一覧では紐付け済みのページに印が付き、同じ本文を `read_note` で読み直させないようにしている。
|
||
|
||
#### 要約(長期メモリ化)
|
||
|
||
`maybe_summarize()` がアシスタント応答の保存直後に走る。
|
||
|
||
1. メッセージ数が `MEMORY_SUMMARIZE_THRESHOLD`(既定 20)以上になったら発火
|
||
2. 古い側の半分(既定 10 件)を LLM で要約し `memories` に 1 行保存
|
||
3. **要約した元メッセージは `archived_at` / `memory_id` を立ててアーカイブ**する(物理削除はしない)。LLM に渡すコンテキストからも、次回以降の要約対象からも外れるが、DB には残るため履歴表示・エクスポートからは読める
|
||
4. 保存した要約に `tagging.tag_memory()` でタグを付ける
|
||
|
||
#### ツール経由の遅延読み込み
|
||
|
||
常時注入すると重い情報は、システムプロンプトに目次だけ置いてツールで取りに行かせる。
|
||
`llm.resolve_tool_calls()` が本番のストリーミング生成の**前に**別途 LLM を呼んでツール呼び出しを解決する(最大 2 ラウンド)。
|
||
|
||
- `read_note` / `write_note` … 共有ノート本文の読み書き(`db` を渡したときだけモデルに提示)
|
||
- `read_knowledge` … 層 1 の見出し一覧に出ている知識の本文を読む(同上)。層 3 として本文が既に注入されていれば呼ぶ必要はない
|
||
- `web_search` … Tavily 検索。結果は `knowledge.summarize_results()` で構造化要約にされ、
|
||
`knowledge_entries` と `knowledge_base/web_search/*.md` の両方に保存される。
|
||
→ 次回以降の層 1・層 3 に自動的に効いてくる
|
||
|
||
#### 自動タグ付け・ワードプール
|
||
|
||
- `tagging.py` … 各メッセージに `asyncio.create_task()` でバックグラウンドにタグを 3 つ生成する。
|
||
コンテキスト注入には使わず、Discord 雑談 Bot の話題選び(`find_related_tag()`)に使う
|
||
- `word_memory.py` … Janome で名詞を抽出し `deque(maxlen=200)` にプロセス内保持する。
|
||
**DB に永続化されず再起動で消える**(起動時に直近 200 件のメッセージから再シードされる)
|
||
|
||
#### 設計上の注意点
|
||
|
||
- トークン数ベースの制御は無く、すべて件数・文字数ベース。層 3(Markdown 全文 ×3)は会話が短くても
|
||
コンテキストを大きく押し上げる(層 1 は見出しのみなので件数を増やしても軽い)
|
||
- ツールを使わない会話でも `resolve_tool_calls()` が必ず 1 回フルコンテキストを投げるため、**LLM 呼び出しは最低 2 回**になる
|
||
- 層 4(長期メモリ)に上限が無く、長いセッションでは要約が積み上がる(要約の再要約はしない)
|
||
- `get_local_kb_context()` が毎リクエストで `sync_local_kb()`(ディレクトリスキャン)と埋め込み API 呼び出しを 1 回行う
|
||
|
||
### LLM 呼び出し(`core/llm.py`)
|
||
|
||
- `stream_chat()` / `complete()` / `embed()` が LiteLLM を薄くラップ
|
||
- `resolve_tool_calls()` が `web_search` / `read_note` / `write_note` / `read_knowledge` を解決し、結果を組み込んだメッセージ列を返す
|
||
- `_log_context` で呼び出しを囲み、成功・失敗どちらも `api_logs` テーブルに記録(`embed()` は対象外)
|
||
- `DEBUG=true` で `litellm._turn_on_debug()` が有効になり詳細ログが出力される
|
||
|
||
### 共有ノート(`core/notes.py` / `api/notes.py`)
|
||
|
||
ユーザーと AI が共同編集するノート。`note_pages` の 1 行が UI の 1 タブに対応する。
|
||
利用者向けの操作方法は `docs/notes-guide.md` を参照。
|
||
|
||
- `get_notes_context()` が**タイトル一覧のみ**をシステムメッセージとして生成し、`get_context_messages()` が会話コンテキストに差し込む(本文は含めない)。セクションがあれば `## セクション名` の見出しでグループ化して提示する
|
||
- 本文の読み書きはツール経由: `read_note`(本文取得)/ `write_note`(追記・置換・新規作成)
|
||
- `llm.resolve_tool_calls()` が `web_search` と同じループでこれらを解決する。ノート系ツールは `db` を渡したときのみモデルに提示される
|
||
- REST(`/api/notes`)はフロントのノートパネル用。GET 一覧 / POST 作成 / PATCH 更新 / DELETE 削除 / `POST /api/notes/sections/rename`(セクション名の一括変更)/ `POST /api/notes/reorder`(ドラッグ&ドロップの並べ替え)
|
||
- **セクションによるグルーピング**: `note_pages.section` に名前を持たせ、同じセクションのページは常に `position` 上で連続させる。`notes.set_section()` がページのセクションを変更するたびに全ページを再採番し、同じセクションの末尾に挿入し直す(隣接=同一グループという前提をタブ描画側・システムプロンプト側の両方が使っている)。`notes.rename_section()` は既に連続しているページ群のラベルだけを一括で書き換える
|
||
- **ドラッグ&ドロップの並べ替え**: `notes.reorder()` は `set_section()` と違い、位置の計算をサーバー側ではなくフロント(`NotePanel.vue`)に任せる。フロントがドラッグ後の全ページIDの順序を計算し、`{ page_id, section, order }` として一括送信、サーバーは検証(`order` が現在の全ページID集合と一致するか)した上でそのまま `position` に反映する。ドロップ先タブの `section` を渡すことで、並べ替えと同時にセクション間移動も1リクエストで完結する
|
||
- 既存 DB への追加カラムは Alembic 等の仕組みを使っておらず、`db/database.py` の `_add_missing_columns()` が起動時に `PRAGMA table_info` を見て `ALTER TABLE ... ADD COLUMN` を素朴に実行する(SQLite 専用の簡易マイグレーション)
|
||
|
||
### SSE ストリーミング(`api/chat.py`)
|
||
|
||
`POST /chat/stream` は `StreamingResponse` で以下のイベントを順番に送出する:
|
||
|
||
```
|
||
data: {"type":"session_id","value":"<uuid>"} # 新規セッション時のみ
|
||
data: {"type":"tool","value":{...}} # ツール実行ごとに開始・完了の2回
|
||
data: {"type":"notes_updated"} # AIが共有ノートを更新した時のみ
|
||
data: {"type":"sources","value":[{...}]} # Web検索した時のみ(出典の title/url)
|
||
data: {"type":"chunk","value":"テキスト断片"} # 0回以上
|
||
data: {"type":"done"} # 正常完了
|
||
data: {"type":"error","value":"エラー内容"} # 例外発生時
|
||
```
|
||
|
||
`tool` イベントは `llm.stream_tool_calls()`(`resolve_tool_calls()` の逐次版)が yield する進行状況を
|
||
そのまま中継したもので、`{id, name, label, detail, status}` で始まり、同じ `id` の
|
||
`{id, status:"done", result}` で終わる。検索結果の要約・蓄積知識への保存も
|
||
`id: "save_knowledge"` の同形式のイベントとして送る。フロント(`ToolActivityList.vue`)は
|
||
`id` をキーにマージし、アシスタントバブルの上に実行中スピナーつきで一覧表示する。
|
||
表示専用なので DB には保存せず、履歴を読み直すと消える。
|
||
|
||
バッファリング防止のため `Cache-Control: no-cache` と `X-Accel-Buffering: no` を付与する。
|
||
|
||
### Discord Bot(`core/discord/`)
|
||
|
||
`main.py` の lifespan が `start_discord_bot()`(メンション応答)と `start_scheduler()`(定期ジョブ)を起動する。
|
||
|
||
`on_message` はメンション本文をキーワードで見て分岐する。判定順は **リマインド → 雑談 → 天気** で、
|
||
先にマッチしたものが返信を返して終わる。
|
||
|
||
| 分岐 | キーワード例 | 動作 |
|
||
|---|---|---|
|
||
| リマインド | リマインド / 忘れないように / 覚えといて / アラーム | `reminders.handle_reminder_request()` に渡す |
|
||
| 雑談 | どうでもいい / 雑談 / 暇 | 『どうでもいいTips』へ誘導しつつ、話題に近いタグで Tips を1本投稿 |
|
||
| 天気 | 天気 / 洗濯 / 雨 | 気象庁の明日の予報を system メッセージに足してから返信 |
|
||
|
||
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`)
|
||
|
||
一回きりのリマインドのみを扱う(繰り返しには非対応)。
|
||
|
||
- **登録**: LLM に `create_reminder` / `list_reminders` / `cancel_reminder` の 3 ツールを渡し、
|
||
自然言語から日時と用件を取り出させる。system メッセージに現在時刻と曜日を入れてあるので、
|
||
『明日』『30分後』といった相対表現はモデル側が絶対日時に直す。使うモデルは
|
||
`REMINDER_MODEL` →`WEATHER_NOTIFY_MODEL` → `DEFAULT_MODEL` の順にフォールバックするが、
|
||
**tool calling 対応モデルでないと動かない**(非対応時はエラー文を返す)
|
||
- **バリデーション**: 過去・`REMINDER_MAX_AHEAD_DAYS` より先・解釈不能な日時はツール結果として
|
||
エラー文をモデルに返し、モデルに聞き直させる
|
||
- **配信先**: Webhook ではなく Bot クライアントから、登録されたチャンネルへ依頼者をメンションして投稿する。
|
||
そのため専用の Webhook URL 設定は要らない
|
||
- **タイムゾーン**: DB は他テーブルと同じく UTC。ユーザーおよび LLM とのやり取りはすべて
|
||
このマシンのローカル時刻で行い、`reminders.py` の境界で変換する
|
||
- **配信の失敗**: チャンネル削除などで送れない場合はプロセス内カウンタで数え、5 回失敗したら
|
||
`notified_at` を立てて諦める(毎回エラーログを出し続けるのを防ぐ)。カウンタは DB に持たないので
|
||
バックエンドを再起動すればまた試す
|
||
- **バックエンド停止中に期限を迎えた分**: 起動して Bot が ready になった直後にまとめて配信される
|
||
|
||
---
|
||
|
||
## フロントエンド
|
||
|
||
### ディレクトリ構成
|
||
|
||
```
|
||
frontend/src/
|
||
├── api/
|
||
│ └── client.ts # fetch ラッパー・SSE クライアント・型定義
|
||
├── stores/
|
||
│ └── chat.ts # Pinia ストア(全アプリ状態)
|
||
├── components/
|
||
│ ├── SessionList.vue # セッション一覧サイドバー
|
||
│ ├── ChatWindow.vue # メッセージ表示エリア
|
||
│ ├── MessageInput.vue # 入力欄・送信
|
||
│ ├── ModelSelector.vue # LLM モデル切替セレクト
|
||
│ ├── MarkdownContent.vue # marked + DOMPurify によるレンダリング
|
||
│ ├── LogViewer.vue # API ログ閲覧パネル
|
||
│ └── ToastList.vue # エラートースト通知
|
||
└── App.vue # ルートレイアウト
|
||
```
|
||
|
||
### 状態管理(`stores/chat.ts`)
|
||
|
||
| 状態 | 型 | 説明 |
|
||
|---|---|---|
|
||
| `sessions` | `SessionInfo[]` | セッション一覧 |
|
||
| `currentSessionId` | `string \| null` | 選択中セッション |
|
||
| `messages` | `Message[]` | 表示中メッセージ |
|
||
| `isStreaming` | `boolean` | SSE 受信中フラグ |
|
||
| `selectedModel` | `string` | 選択中 LLM モデル |
|
||
| `toasts` | `Toast[]` | エラー通知キュー |
|
||
|
||
### SSE 受信フロー
|
||
|
||
1. `sendMessage()` がユーザーメッセージと空のアシスタントメッセージを `messages` に追加
|
||
2. `streamChat()` が fetch + ReadableStream で SSE を受信
|
||
3. `chunk` イベント受信のたびに `messages.value[msgIdx].content` を更新(Vue reactive proxy 経由)
|
||
4. `done` で `pending` フラグを解除
|
||
5. `error` / ネットワークエラーは `error` フラグを立て赤バブル表示 + トースト通知
|
||
|
||
### Markdown レンダリング(`MarkdownContent.vue`)
|
||
|
||
- アシスタントメッセージのみ `marked` で HTML に変換後 `DOMPurify` でサニタイズ
|
||
- ストリーミング中(`pending=true`)は未閉じのコードフェンス(` ``` `)を自動補完してから描画
|
||
- 数式は `src/utils/math.ts` の marked 拡張が KaTeX で描画する(`mathExtension` / `mathSanitizeConfig`)
|
||
- ブロック: 行頭の `$$ ... $$` と `\[ ... \]`。インライン: `$ ... $`、`\( ... \)`、行中の `$$ ... $$`
|
||
- `$ ... $` は誤爆防止のため「開き `$` の直後が空白でない」「中身に改行を含まない」「閉じ `$` の直後が数字でない」を満たすものだけを数式にする(`$100 から $200` のような金額表記は素通しする)。リテラルのドル記号は `\$` でエスケープできる
|
||
- インラインコード・コードフェンス内の `$` は marked のトークナイザ順により数式にならない
|
||
- 構文エラーは例外にせず KaTeX の赤字表示にフォールバックする(`throwOnError: false`)
|
||
- KaTeX の MathML 出力に含まれる `semantics` / `annotation` は DOMPurify の既定では許可されないため、`mathSanitizeConfig` で明示的に許可している
|
||
- ストリーミング途中で閉じ記号が未到達の数式は、閉じるまでプレーンテキストとして表示される
|
||
|
||
### Vite プロキシ設定
|
||
|
||
`/chat`、`/memory`、`/logs`、`/health` を `http://localhost:8000` に転送。
|
||
`/chat` は SSE バッファリング無効化のため `configure` で `x-accel-buffering: no` を付与。
|