Files
playchat/docs/architecture.md
T
colcaroliandClaude Opus 5 c6108a1b29 Route reminders by tool availability, not keyword matching
「7時にモーニングコールをお願い。」のような、REMINDER_KEYWORDS のどれとも
一致しない言い回しがリマインド機能を素通りし、ただの雑談として処理されて
いた(本番の api_logs で実例を確認)。「モーニングコール」「起こして」を
キーワードに追加しても、次の言い回しでまた同じことが起きるだけなので、
キーワード判定そのものをやめる。

discord_bot.on_message は雑談・天気のキーワード分岐はそのまま残し、それ以外の
全メンションで create_reminder / list_reminders / cancel_reminder の3ツールを
常に同席させる(reminders.build_tool_hint() が現在時刻の文脈を添える)。
使うかどうかはLLM自身の判断に委ねるため、キーワード一覧を事前に尽くす必要が
なくなる。本番のBedrock Nova 2 Liteで、実際に失敗していた文言が
create_reminder を正しく呼ぶことを確認済み。

reminders.handle_reminder_request() は resolve_reminder_tools(messages, ctx,
model) に一般化し、discord_bot 側で組み立てたmessages(人格プロンプト+天気
コンテキスト+ツール文脈)をそのまま受け取れるようにした。tool calling
非対応のモデルが設定されていた場合は、1ラウンド目の例外を検知してツール
なしの通常応答にフォールバックする。ツール呼び出しが一般会話の呼び出しに
統合された以上、それだけ別モデルを割り当てる余地がなくなったため
REMINDER_MODEL は廃止する。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 01:17:03 +09:00

22 KiB
Raw 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
│   │   ├── 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 はメンション本文をキーワードで見て分岐する。判定順は 雑談 → 天気 で、 先にマッチしたものが返信を返して終わる。どちらにも当てはまらなければ通常応答へ進む。

分岐 キーワード例 動作
雑談 どうでもいい / 雑談 / 暇 『どうでもいい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 を付与。