Files
playchat/CLAUDE.md
colcaroliandClaude Opus 5 ae997914c2 Make Discord reminder failures diagnosable
リマインドが「⚠️ 応答の生成に失敗しました」だけを返して原因が追えなかった
ため、失敗経路を個別に捕まえてログにも残るようにする。

ロギング: uvicorn は自分のロガーしか設定しないため、アプリ側の
logging.getLogger(__name__) は logging.lastResort 送りになっていた。
WARNING 未満が全て消え、出力にレベルもタイムスタンプもロガー名も付かず、
logger.info("Reminder created: ...") すら残らない状態だった。
main.py で basicConfig を呼び、LOG_LEVEL(既定 INFO)で制御する。

reminders.py:
- choices[0].message の取り出しを個別に捕捉する。tool calling 非対応の
  エンドポイントは例外を投げずに choices が空の応答を返すことがあり、
  ここで落ちると on_message の汎用ハンドラに流れて原因が消える
- ツール実行を _run_tool() に切り出し、DBエラーを専用の文言で返す。
  併せてDBセッションをツール1回分だけ開くようにした。LLMとの往復のあいだ
  握り続けると SQLite のロックを長時間保持し、同時に走るAPIリクエストと
  競合して "database is locked" を起こしうるため
- tool_choice="auto" を渡すのをやめた。Bedrock はこの引数に非対応で、
  今まで通っていたのは app.core.llm が litellm.drop_params=True を
  立てている副作用に過ぎず、import 順が変われば壊れる。tools を渡した
  ときの既定が "auto" なので省いても挙動は変わらない
- どのモデルで何を処理したかを INFO で残す

CLAUDE.md には Lightsail への接続方法(作業ツリーにあった未コミット分)も
含む。

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

16 KiB
Raw Permalink Blame History

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)。

  1. 蓄積知識 (memory_kb/knowledge.py): 過去の Web 検索で得た knowledge_entries の見出し一覧のみ(- 「クエリ」: 一行要約)。本文と出典は含めず、必要なら read_knowledge ツールで読み出す。最新 KNOWLEDGE_CONTEXT_LIMIT(既定 15)件。セッションを横断する
  2. 共有ノート (core/notes.py): タイトル一覧+本文先頭 40 文字のプレビューのみ 2.5. 参照指定ノート (core/notes.py): ユーザーがそのセッションに紐付けた(GUI のタブで 📌 を押した)ページの本文全文。session_note_pages テーブルで管理し、1 ページ SESSION_NOTES_CONTENT_LIMIT(既定 8000)文字で打ち切る。層 2 の一覧側には印が付き、同じ本文を read_note で読み直させない
  3. ローカル 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 に使用モデルを持ち、モデルを変えた行は次の同期で埋め直される
  4. 長期メモリ: memories の要約を全件連結。上限なし
  5. 短期メモリ: セッション内の 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())

Discord Bot

main.py の lifespan が Bot(メンション応答)と APScheduler(定期ジョブ)を起動する。詳細は docs/architecture.md。

  • メンション応答 (discord/discord_bot.py): 本文のキーワードで リマインド → 雑談 → 天気 の順に分岐し、先にマッチしたものが返信する
  • 定期ジョブ (discord/scheduler.py): 天気通知(Cron)・どうでもいいTips(Interval)・リマインド配信(Interval)の3つ
  • リマインド (discord/reminders.py): メンションで自然言語登録する一回きりの通知(繰り返し非対応)。LLM に create_reminder / list_reminders / cancel_reminder の3ツールを渡し、system メッセージに入れた現在時刻を基準に相対表現を絶対日時へ直させる。tool calling 対応モデルが必須。配信は Webhook ではなく Bot クライアントから登録元チャンネルへ依頼者をメンションして送るので、専用の Webhook URL 設定は不要。DB (reminders) は UTC で持ち、ユーザー・LLM とのやり取りはローカル時刻に直す。一覧・取り消しは依頼者の user_id で絞るため他人のリマインドには触れられない

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 デバッグログの有効化
LOG_LEVEL INFO アプリ側ロガーの出力レベル。main.py の basicConfig に渡す
OPENAI_API_KEY — OpenAI API キー
OPENAI_API_BASE / OPENAI_BASE_URL — OpenAI 互換エンドポイントの接続先(ローカルの llama-server / Ollama 用)。設定したままだと gpt-* を選んでも本家 OpenAI に繋がらない。GUI 設定画面から実行時変更可
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 回に返す本文の最大文字数
REMINDER_CHECK_INTERVAL_SECONDS 30 期限切れリマインドを探す間隔(秒)
REMINDER_MAX_AHEAD_DAYS 365 これより先の日時のリマインドは登録を拒否する
REMINDER_LIST_LIMIT 20 list_reminders が一度に返す件数
REMINDER_MODEL — リマインドの日時解釈に使うモデル(未設定なら WEATHER_NOTIFY_MODEL → DEFAULT_MODEL。tool calling 対応が必須)
SITE_PASSWORD — サイト全体を保護する共有パスワード(未設定なら無認証)
SESSION_SECRET_KEY 起動毎に自動生成 ログインセッション Cookie の署名鍵

AWS Lightsail環境

以下のコマンドで、lightsail上の環境に入ることができる。

ssh lightsail-playchat