Files
playchat/docs/architecture.md
T
colcaroliandClaude Opus 5 366320c43a Show tool and web search activity in the chat UI
Tool calls were invisible until the answer arrived: the only trace was the
sources list, and only for web searches. Surface them as they happen instead.

Add llm.stream_tool_calls(), an incremental version of resolve_tool_calls()
that yields a start and a completion event per tool call and ends with a
"resolved" event carrying the previous return value. resolve_tool_calls() is
kept as a thin wrapper that drops the progress events.

api/chat.py relays those events as a new SSE "tool" event, and wraps the
search-summary/knowledge-save step in one of the same shape, since it is a
noticeable pause of its own.

On the front end, ToolActivityList.vue renders one row per tool above the
assistant bubble (label, target, spinner while running, short result when
done). The store merges events by id and marks anything still running as
interrupted when the stream ends early. Display only - not persisted, so the
rows disappear when the session is reloaded, same as sources.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 15:11:00 +09:00

217 lines
15 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
│ │ ├── 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・定期通知
│ │ ├── 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, updated_at | ローカル Markdown KB の埋め込みキャッシュ |
| `note_pages` | id, title, content, position, section, created_at, updated_at | ユーザーと AI が共同編集する共有ノートのページ(`section` でタブをグループ化) |
| `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 呼び出しログ |
### コンテキスト管理(`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()` | 全ページ(本文は入れない) |
| 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 より優先される。
#### 要約(長期メモリ化)
`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` を付与する。
---
## フロントエンド
### ディレクトリ構成
```
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`)は未閉じのコードフェンス(` ``` `)を自動補完してから描画
### Vite プロキシ設定
`/chat`、`/memory`、`/logs`、`/health` を `http://localhost:8000` に転送。
`/chat` は SSE バッファリング無効化のため `configure` で `x-accel-buffering: no` を付与。