# アーキテクチャ概要 ## 全体構成 ``` フロントエンド (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":""} # 新規セッション時のみ 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` はメンション本文をキーワードで見て分岐する。判定順は **雑談 → 天気** で、 先にマッチしたものが返信を返して終わる。どちらにも当てはまらなければ通常応答へ進む。 | 分岐 | キーワード例 | 動作 | |---|---|---| | 雑談 | どうでもいい / 雑談 / 暇 | 『どうでもいいTips』へ誘導しつつ、話題に近いタグで Tips を1本投稿 | | 天気 | 天気 / 洗濯 / 雨 | 気象庁の明日の予報を system メッセージに足してから返信 | | 通常応答 | (上記に当てはまらない全て) | `reminders.resolve_reminder_tools()` に渡す | リマインドはキーワードで振り分けない。雑談以外の全メンション(天気キーワード込み)で `create_reminder` / `list_reminders` / `cancel_reminder` の 3 ツールを常に同席させ、使うかどうかは LLM 自身に判断させる。以前はキーワード一覧(リマインド / アラーム 等)で振り分けていたが、 「モーニングコール」のように一覧に無い言い回しを取りこぼす実例があったため、この方式に変えた。 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 になった直後にまとめて配信される --- ## フロントエンド ### ディレクトリ構成 ``` 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` を付与。