Vectors from two different embedding models are not comparable — Titan v2 has 1024 dimensions where nomic-embed-text has 768 — and _cosine_similarity zips them, so a mismatch produced quietly meaningless scores rather than an error. Nothing forced a re-embed either: sync_local_kb only looked at the content hash, so switching models left every cached vector in place. local_knowledge_docs now records the model each vector came from, and a row is re-embedded when its content hash OR its model differs. Saving a new LOCAL_KB_EMBEDDING_MODEL from the settings panel starts a background pass over every file; the knowledge base panel also has a button for it. While that pass runs, the per-request sync steps aside so no file is embedded twice. The panel now shows what the embedding model is doing: model name, vector size, how many files are embedded with the current model, how many are still on an older one, a progress bar while a pass runs, and the last error. A "接続テスト" button embeds one short string so a broken or unreachable model can be told apart from one that is merely slow to warm up. Also read TAVILY_API_KEY from os.environ at call time. It was imported as a config constant, which is evaluated before load_into_env() runs, so a key saved from the settings panel was stored but never used — not even after a restart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
14 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
プロジェクト概要
Python (FastAPI) バックエンド + Vue 3 (TypeScript) フロントエンドのチャットアプリ。
LLM は LiteLLM 経由で OpenAI / Anthropic / Ollama など任意のプロバイダーを統一 API で扱い、会話履歴とメモリは SQLite に永続化する。
コマンド
バックエンド
Set-Location backend
# 依存インストール (SSL プロキシ環境では --native-tls が必要)
uv sync --native-tls
# 開発サーバー起動
uv run uvicorn app.main:app --reload
# テスト
pytest
pytest tests/test_chat.py # 単一ファイル
フロントエンド
Set-Location frontend
# 依存インストール
npm install
# 開発サーバー起動 (port 5173)
npm run dev
# ビルド
npm run build
# 型チェック
npm run type-check
環境設定
Set-Location backend
Copy-Item .env.example .env
# .env を編集して API キーとモデル名を設定
アーキテクチャ
詳細は docs/architecture.md を参照。
frontend (Vue3 + Pinia, :5173)
↕ REST + SSE (Vite proxy)
backend (FastAPI, :8000)
├── app/api/ チャット・メモリ・ノート・ナレッジベース閲覧・ログ・設定 のルーター
├── app/core/ LLM ラッパー・共有ノート・設定
│ ├── memory_kb/ コンテキスト管理(要約・蓄積知識・ローカルKB・タグ付け)
│ ├── search/ Tavily Web 検索
│ └── discord/ Discord Bot・定期通知
└── app/db/ SQLAlchemy モデル・DB 接続
↕
SQLite (playchat.db) + LiteLLM → 任意の LLM
コンテキスト管理
LLM に渡すコンテキストは app/core/memory_kb/memory.py の get_context_messages() が毎リクエスト組み立てる。
以下の順に system メッセージを積み、最後に会話履歴を連結する(詳細は docs/architecture.md)。
- 蓄積知識 (
memory_kb/knowledge.py): 過去の Web 検索で得たknowledge_entriesの見出し一覧のみ(- 「クエリ」: 一行要約)。本文と出典は含めず、必要ならread_knowledgeツールで読み出す。最新KNOWLEDGE_CONTEXT_LIMIT(既定 15)件。セッションを横断する - 共有ノート (
core/notes.py): タイトル一覧+本文先頭 40 文字のプレビューのみ 2.5. 参照指定ノート (core/notes.py): ユーザーがそのセッションに紐付けた(GUI のタブで 📌 を押した)ページの本文全文。session_note_pagesテーブルで管理し、1 ページSESSION_NOTES_CONTENT_LIMIT(既定 8000)文字で打ち切る。層 2 の一覧側には印が付き、同じ本文をread_noteで読み直させない - ローカル KB (
memory_kb/local_knowledge.py):backend/knowledge_base/**.mdのうち、直近ユーザー発言と埋め込みコサイン類似度が高い上位LOCAL_KB_TOP_K(既定 3)件かつLOCAL_KB_MIN_SCORE(既定 0.75)以上を全文注入。プロンプト上は Web 検索結果より優先する扱い。中身は GUI ヘッダーのパネルメニュー →「ナレッジベース」で開くサイドパネル(api/knowledge_base.py+KnowledgeBasePanel.vue、GET /api/kb/filesとGET /api/kb/file?path=)から閲覧できる(読み取り専用)。同パネルで埋め込みの状態確認・接続テスト・再埋め込みもできる(GET /api/kb/embedding、POST /api/kb/embedding/test、POST /api/kb/embedding/reindex)。埋め込みはlocal_knowledge_docs.embedding_modelに使用モデルを持ち、モデルを変えた行は次の同期で埋め直される - 長期メモリ:
memoriesの要約を全件連結。上限なし - 短期メモリ: セッション内の
messages全件(要約で削除された分を除く)
要約: maybe_summarize() がアシスタント応答の保存直後に走り、メッセージ数が MEMORY_SUMMARIZE_THRESHOLD(既定 20)以上になると古い半分を LLM で要約して memories に保存する。要約した元メッセージは archived_at を立ててアーカイブされる(物理削除はしない)。アーカイブ済みメッセージは get_context_messages() と要約の閾値判定の両方から除外されるため LLM には渡らないが、DB には残り、履歴表示・エクスポートからは参照できる。memory_id にどの要約に取り込まれたかを保持する。
遅延読み込み: 重い情報は目次だけ注入し、llm.stream_tool_calls()(結果だけ欲しい場合は同等のラッパー resolve_tool_calls())が本番生成の前にツール(web_search / read_note / write_note / read_knowledge、最大 2 ラウンド)を解決する。前者は実行の開始・完了を逐次 yield し、api/chat.py がそれを SSE の tool イベントとして中継して GUI に進行状況を出す。Web 検索の結果は構造化要約にされ knowledge_entries と knowledge_base/web_search/*.md の両方に保存されるため、次回以降は層 1・層 3 として自動的に効いてくる。
付随機能: memory_kb/tagging.py が各メッセージ・要約にバックグラウンドでタグを 3 つ付ける(コンテキストには注入せず、Discord 雑談 Bot の話題選びに使う)。memory_kb/word_memory.py は Janome で抽出した名詞を deque(maxlen=200) にプロセス内保持する(DB 非永続。起動時に直近 200 件から再シード)。
変更時の注意: トークン数ベースの制御は一切なく、すべて件数・文字数ベース。またツールを使わない会話でも resolve_tool_calls() がフルコンテキストを 1 回投げるため、1 応答あたり LLM 呼び出しは最低 2 回になる。
共有ノート
ユーザーと AI が共同編集するノート。note_pages テーブルに 1 ページ 1 行で保存し、UI ではタブとして切り替える。
- モデルから見える範囲: システムプロンプトに入るのはページのタイトル一覧(と本文の先頭 40 文字程度のプレビュー)だけ。本文全体は含めない
- モデルによる読み書き:
read_note/write_noteツール(app/core/notes.py)で行う。write_noteは存在しないタイトルを指定するとページを新規作成し、modeがappend(既定)なら追記、replaceなら全置換 - ツール実行の流れ:
llm.resolve_tool_calls()が本番の応答生成前にツール呼び出しを解決する。web_searchと共通の仕組みで、ノート系ツールはdbが渡されたときだけモデルに提示される - UI への反映: AI がノートを書き換えた場合、SSE で
notes_updatedを送ってフロント側のノートパネルを再読み込みさせる - 編集の衝突回避: フロントは未保存の編集があるページ(
dirtyIds)をサーバーの値で上書きしない - チャットへの紐付け:
session_note_pages(session_id × page_id)に登録したページは、本文全文がそのセッションのシステムプロンプトに毎回入る。GET/PUT /chat/sessions/{id}/notesで読み書きする。新規セッションではフロントがまだ session_id を持てないため、POST /chat/streamのnote_page_idsでも同じ指定を送れ、コンテキスト組み立て前にサーバー側で反映される - 取り込み:
POST /api/notes/import(multipart)が.md/.txtを 1 ファイル 1 ページ、.zipを中身ごと複数ページに変換する(ZIP 第 1 階層のフォルダ名がセクション)。タイトル衝突時は(2)を足して別ページを作り、対象外・サイズ超過・壊れた ZIP は黙って飛ばす。1 件も取り込めなければ 400。python-multipartが必要 - ダウンロード:
GET /api/notes/{page_id}/exportが 1 ページを Markdown、GET /api/notes/exportが全ページを ZIP(1 ページ 1 Markdown、セクションはフォルダ、ファイル名の通し番号でposition順を保持)で返す。ファイル名はサーバーが決めるのでフロントはdownload属性を付けずContent-Disposition(日本語タイトルのため RFC 5987 を併記)に任せる - セクションによるグルーピング:
note_pages.sectionにセクション名を持たせ、同じセクションのページはposition上で常に連続させる(core/notes.set_section()が割り当て時に並べ替える)。タブバーはposition順に描画し、隣接して同じsectionが続く区間を1グループとして表示する。get_notes_context()はセクションごとに## セクション名の見出しを付けてタイトル一覧を出す。セクション名の一括変更はPOST /api/notes/sections/rename(core/notes.rename_section())
SSE ストリーミング
POST /chat/stream が SSE を返す。イベント種別: session_id(新規セッション ID)/ chunk(テキスト断片)/ tool(ツール実行の開始・完了)/ sources(Web 検索の出典)/ notes_updated(AI が共有ノートを更新)/ done(完了)/ error(エラー)。
フロントは src/api/client.ts の streamChat() で fetch + ReadableStream により受信し、messages.value[msgIdx] を reactive proxy 経由で更新する。
Markdown レンダリング
アシスタントメッセージは marked + DOMPurify でレンダリング。ストリーミング中の未閉じコードフェンスは自動補完してから描画する(MarkdownContent.vue)。
数式は src/utils/math.ts の marked 拡張が KaTeX で描画する(ブロック $$...$$ / \[...\]、インライン $...$ / \(...\))。詳細は docs/architecture.md を参照。
モデル切替
.env の DEFAULT_MODEL またはリクエストの model フィールドで指定。
ローカル Ollama は ollama/llama3 形式、OLLAMA_API_BASE でエンドポイントを設定。
DEBUG=true で LiteLLM の詳細ログが有効になる。
ローカル開発環境メモ
- このマシンでは PostgreSQL がポート 8080 で起動している(本アプリの
DATABASE_URLには使用していないが、ポート競合時は確認すること) - このマシンには TLS 監視プロキシらしき設定があり、環境変数
SSLKEYLOGFILEが名前付きパイプ(nllMonFltProxy)を指している。これが原因でlitellm(OpenSSL を直接ロード)やpyright(内部で Node.js サブプロセスを起動)がOPENSSL_Uplinkエラーでクラッシュすることがある。発生した場合は当該コマンド実行時のみSSLKEYLOGFILEを unset すると回避できるRemove-Item Env:\SSLKEYLOGFILE uv run pyright
環境変数(backend/.env)
| 変数 | デフォルト | 説明 |
|---|---|---|
DEFAULT_MODEL |
gpt-4o |
デフォルト LLM モデル(.env.example では gpt-5.4) |
DATABASE_URL |
sqlite:///./playchat.db |
DB 接続先 |
MEMORY_SUMMARIZE_THRESHOLD |
20 |
長期メモリ化するメッセージ数の閾値 |
KNOWLEDGE_CONTEXT_LIMIT |
15 |
蓄積知識をシステムプロンプトに注入する最大件数 |
TAVILY_API_KEY |
— | Tavily Web 検索の API キー(未設定なら web_search は空を返す。GUI 設定画面から実行時変更可) |
SEARCH_SUMMARY_MODEL |
— | Web 検索結果の要約に使うモデル(未設定なら DEFAULT_MODEL。GUI 設定画面から実行時変更可) |
TAGGING_MODEL |
— | メッセージの自動タグ付けに使うモデル(未設定なら DEFAULT_MODEL) |
LOCAL_KB_DIR |
knowledge_base |
ローカル Markdown ナレッジベースのディレクトリ |
LOCAL_KB_EMBEDDING_MODEL |
text-embedding-3-small |
ローカル KB の埋め込みモデル(GUI 設定画面から実行時変更可。変更すると全件が自動で埋め直される) |
LOCAL_KB_TOP_K |
3 |
ローカル KB から注入する最大ファイル数 |
LOCAL_KB_MIN_SCORE |
0.75 |
ローカル KB を注入するコサイン類似度の下限 |
CORS_ORIGINS |
http://localhost:5173 |
許可する CORS オリジン |
DEBUG |
false |
LiteLLM デバッグログの有効化 |
OPENAI_API_KEY |
— | OpenAI API キー |
ANTHROPIC_API_KEY |
— | Anthropic API キー |
OLLAMA_API_BASE |
http://localhost:11434 |
Ollama エンドポイント |
NOTES_TOOL_CONTENT_LIMIT |
4000 |
read_note が 1 回に返す本文の最大文字数 |
SESSION_NOTES_CONTENT_LIMIT |
8000 |
セッションに紐付けたノート 1 ページあたり、システムプロンプトに載せる本文の最大文字数 |
NOTES_IMPORT_MAX_PAGES |
100 |
ノート取り込みで一度に作れるページ数の上限 |
NOTES_IMPORT_MAX_BYTES |
2000000 |
取り込む 1 ファイル(ZIP 内は 1 エントリ)あたりの最大バイト数 |
KNOWLEDGE_TOOL_CONTENT_LIMIT |
4000 |
read_knowledge が 1 回に返す本文の最大文字数 |
SITE_PASSWORD |
— | サイト全体を保護する共有パスワード(未設定なら無認証) |
SESSION_SECRET_KEY |
起動毎に自動生成 | ログインセッション Cookie の署名鍵 |