Files
playchat/docs/architecture.md

17 KiB
Raw Permalink Blame History

アーキテクチャ概要

全体構成

フロントエンド (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 でタブをグループ化)
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 呼び出しログ

コンテキスト管理(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 を付与する。


フロントエンド

ディレクトリ構成

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 を付与。