Files
playchat/docs/architecture.md
T
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

279 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# アーキテクチャ概要
## 全体構成
```
フロントエンド (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` を付与。