diff --git a/CLAUDE.md b/CLAUDE.md index 94a45f8..25d555f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,17 +59,34 @@ Copy-Item .env.example .env frontend (Vue3 + Pinia, :5173) ↕ REST + SSE (Vite proxy) backend (FastAPI, :8000) - ├── app/api/ チャット・メモリ・ノート・ログ のルーター - ├── app/core/ LLM ラッパー・メモリロジック・共有ノート・設定 - └── app/db/ SQLAlchemy モデル・DB 接続 + ├── app/api/ チャット・メモリ・ノート・ログ・設定 のルーター + ├── app/core/ LLM ラッパー・共有ノート・設定 + │ ├── memory_kb/ コンテキスト管理(要約・蓄積知識・ローカルKB・タグ付け) + │ ├── search/ Tavily Web 検索 + │ └── discord/ Discord Bot・定期通知 + └── app/db/ SQLAlchemy モデル・DB 接続 ↕ SQLite (playchat.db) + LiteLLM → 任意の LLM ``` -### メモリ機構 +### コンテキスト管理 -- **短期**: セッション内の全メッセージをコンテキストに含める -- **長期**: メッセージ数が `MEMORY_SUMMARIZE_THRESHOLD`(デフォルト 20)を超えると古い半分を LLM で要約 → `memories` テーブルに保存 → 次回以降のシステムプロンプトに注入 +LLM に渡すコンテキストは `app/core/memory_kb/memory.py` の `get_context_messages()` が毎リクエスト組み立てる。 +以下の順に system メッセージを積み、最後に会話履歴を連結する(詳細は `docs/architecture.md`)。 + +1. **蓄積知識** (`memory_kb/knowledge.py`): 過去の Web 検索で得た `knowledge_entries` の要約全文+出典。最新 `KNOWLEDGE_CONTEXT_LIMIT`(既定 15)件。セッションを横断する +2. **共有ノート** (`core/notes.py`): タイトル一覧+本文先頭 40 文字のプレビューのみ +3. **ローカル KB** (`memory_kb/local_knowledge.py`): `backend/knowledge_base/**.md` のうち、直近ユーザー発言と埋め込みコサイン類似度が高い上位 `LOCAL_KB_TOP_K`(既定 3)件かつ `LOCAL_KB_MIN_SCORE`(既定 0.75)以上を**全文**注入。プロンプト上は Web 検索結果より優先する扱い +4. **長期メモリ**: `memories` の要約を全件連結。上限なし +5. **短期メモリ**: セッション内の `messages` 全件(要約で削除された分を除く) + +**要約**: `maybe_summarize()` がアシスタント応答の保存直後に走り、メッセージ数が `MEMORY_SUMMARIZE_THRESHOLD`(既定 20)以上になると古い半分を LLM で要約して `memories` に保存する。**要約した元メッセージは物理削除される**(復元不可)。 + +**遅延読み込み**: 重い情報は目次だけ注入し、`llm.resolve_tool_calls()` が本番生成の前にツール(`web_search` / `read_note` / `write_note`、最大 2 ラウンド)を解決する。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 回になる。 ### 共有ノート @@ -80,6 +97,7 @@ SQLite (playchat.db) + LiteLLM → 任意の LLM - **ツール実行の流れ**: `llm.resolve_tool_calls()` が本番の応答生成前にツール呼び出しを解決する。`web_search` と共通の仕組みで、ノート系ツールは `db` が渡されたときだけモデルに提示される - **UI への反映**: AI がノートを書き換えた場合、SSE で `notes_updated` を送ってフロント側のノートパネルを再読み込みさせる - **編集の衝突回避**: フロントは未保存の編集があるページ(`dirtyIds`)をサーバーの値で上書きしない +- **セクションによるグルーピング**: `note_pages.section` にセクション名を持たせ、同じセクションのページは `position` 上で常に連続させる(`core/notes.set_section()` が割り当て時に並べ替える)。タブバーは `position` 順に描画し、隣接して同じ `section` が続く区間を1グループとして表示する。`get_notes_context()` はセクションごとに `## セクション名` の見出しを付けてタイトル一覧を出す。セクション名の一括変更は `POST /api/notes/sections/rename`(`core/notes.rename_section()`) ### SSE ストリーミング @@ -109,9 +127,17 @@ SQLite (playchat.db) + LiteLLM → 任意の LLM | 変数 | デフォルト | 説明 | |---|---|---| -| `DEFAULT_MODEL` | `gpt-5.4` | デフォルト LLM モデル | +| `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` は空を返す) | +| `SEARCH_SUMMARY_MODEL` | — | Web 検索結果の要約に使うモデル(未設定なら `DEFAULT_MODEL`) | +| `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 キー | diff --git a/backend/app/api/notes.py b/backend/app/api/notes.py index 998cf00..d5c2fac 100644 --- a/backend/app/api/notes.py +++ b/backend/app/api/notes.py @@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy.orm import Session as DBSession -from app.core.notes import next_position +from app.core.notes import next_position, rename_section, set_section from app.db.database import get_db from app.db.models import NotePage @@ -16,6 +16,7 @@ class NotePageResponse(BaseModel): title: str content: str position: int + section: str | None updated_at: datetime class Config: @@ -25,11 +26,18 @@ class NotePageResponse(BaseModel): class NotePageCreate(BaseModel): title: str = "新しいページ" content: str = "" + section: str | None = None class NotePageUpdate(BaseModel): title: str | None = None content: str | None = None + section: str | None = None + + +class SectionRenameRequest(BaseModel): + old_name: str + new_name: str | None = None @router.get("", response_model=list[NotePageResponse]) @@ -44,9 +52,18 @@ def create_page(req: NotePageCreate, db: DBSession = Depends(get_db)): db.add(page) db.commit() db.refresh(page) + if req.section is not None and req.section.strip(): + page = set_section(db, page.id, req.section) return page +@router.post("/sections/rename") +def rename_section_endpoint(req: SectionRenameRequest, db: DBSession = Depends(get_db)): + """old_name セクションに属する全ページを new_name セクションへ一括で付け替える。new_name が空ならグループ解除。""" + count = rename_section(db, req.old_name, req.new_name) + return {"renamed": count} + + @router.patch("/{page_id}", response_model=NotePageResponse) def update_page(page_id: int, req: NotePageUpdate, db: DBSession = Depends(get_db)): page = db.get(NotePage, page_id) @@ -61,6 +78,11 @@ def update_page(page_id: int, req: NotePageUpdate, db: DBSession = Depends(get_d page.content = req.content db.commit() db.refresh(page) + + # section はページの並び順にも影響するため、変更があれば専用ロジックで反映する + if "section" in req.model_fields_set: + page = set_section(db, page_id, req.section) + return page diff --git a/backend/app/core/notes.py b/backend/app/core/notes.py index 4db7e2c..ee7dd69 100644 --- a/backend/app/core/notes.py +++ b/backend/app/core/notes.py @@ -75,17 +75,65 @@ def next_position(db: DBSession) -> int: return (last.position + 1) if last else 0 +def set_section(db: DBSession, page_id: int, section: str | None) -> NotePage | None: + """ページの所属セクションを変更し、同じセクションのページ群の直後に連続配置し直す。 + + タブバーはposition順に描画され、隣接して同じsectionが続く区間を1つのグループとして + まとめて表示する(app.api.notes / フロントのグルーピング表示を参照)。そのため + セクションを変更したページは、常に同じセクションの最後のページの直後に移動する。 + """ + page = db.get(NotePage, page_id) + if not page: + return None + + normalized = (section or "").strip() or None + ordered = [p for p in db.query(NotePage).order_by(NotePage.position, NotePage.id).all() if p.id != page_id] + + if normalized is None: + insert_at = len(ordered) + else: + matching = [i for i, p in enumerate(ordered) if p.section == normalized] + insert_at = (matching[-1] + 1) if matching else len(ordered) + + ordered.insert(insert_at, page) + page.section = normalized + for i, p in enumerate(ordered): + p.position = i + db.commit() + db.refresh(page) + return page + + +def rename_section(db: DBSession, old_name: str, new_name: str | None) -> int: + """あるセクションに属する全ページのセクション名を一括で変更する。new_name が空ならグループ解除になる。 + + 戻り値: 変更したページ数。 + """ + normalized_new = (new_name or "").strip() or None + pages = db.query(NotePage).filter(NotePage.section == old_name).order_by(NotePage.position, NotePage.id).all() + for page in pages: + page.section = normalized_new + db.commit() + return len(pages) + + def get_notes_context(db: DBSession) -> str | None: - """共有ノートのタイトル一覧をシステムプロンプト用のテキストとして返す。""" + """共有ノートのタイトル一覧をシステムプロンプト用のテキストとして返す。セクションごとに見出しを付ける。""" pages = db.query(NotePage).order_by(NotePage.position, NotePage.id).all() lines = [ "【共有ノート】", - "ユーザーとあなたが共同で編集できるノートです。以下のページがあります。", + "ユーザーとあなたが共同で編集できるノートです。以下のページがあります" + "(セクションはページをグループ化する見出しで、あれば併記します)。", "", ] if pages: + current_section = "__unset__" # どのページのsectionとも一致しない番兵 for page in pages: + if page.section != current_section: + current_section = page.section + if current_section: + lines.append(f"## {current_section}") preview = " ".join(page.content.split())[:40] suffix = f" — {preview}…" if preview else " — (空)" lines.append(f"- {page.title}{suffix}") diff --git a/backend/app/db/database.py b/backend/app/db/database.py index b545a38..73e685d 100644 --- a/backend/app/db/database.py +++ b/backend/app/db/database.py @@ -1,4 +1,4 @@ -from sqlalchemy import create_engine +from sqlalchemy import create_engine, text from sqlalchemy.orm import sessionmaker, DeclarativeBase from app.core.config import DATABASE_URL @@ -18,6 +18,17 @@ def get_db(): db.close() +def _add_missing_columns() -> None: + """マイグレーションフレームワークを使っていないため、既存DBに後から追加されたカラムを + ここで簡易的に補う。create_all() は既存テーブルへのカラム追加は行わないので必要。""" + with engine.connect() as conn: + existing = {row[1] for row in conn.execute(text("PRAGMA table_info(note_pages)"))} + if existing and "section" not in existing: + conn.execute(text("ALTER TABLE note_pages ADD COLUMN section VARCHAR")) + conn.commit() + + def init_db(): from app.db import models # noqa: F401 Base.metadata.create_all(bind=engine) + _add_missing_columns() diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 390fcb6..9693385 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -115,7 +115,8 @@ class NotePage(Base): id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) title: Mapped[str] = mapped_column(String) content: Mapped[str] = mapped_column(Text, default="") - position: Mapped[int] = mapped_column(Integer, default=0) # タブの並び順 + position: Mapped[int] = mapped_column(Integer, default=0) # タブの並び順(セクションでまとめる際、同じセクションのページは連続させる) + section: Mapped[str | None] = mapped_column(String, nullable=True) # タブをグループ化するセクション名。未設定なら未分類 created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc)) updated_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) diff --git a/docs/api.md b/docs/api.md index 0bd7ca2..75b77cd 100644 --- a/docs/api.md +++ b/docs/api.md @@ -93,9 +93,10 @@ SSE でストリーミング応答を返す。 ユーザーと AI が共同編集するノート。ページのタイトル一覧は毎回システムプロンプトに注入され、 本文は AI が `read_note` / `write_note` ツールで読み書きする。 +`section` を設定すると、同じセクション名のページはタブバー上で常に連続してグループ表示される。 ### GET /api/notes -全ページを `position` 昇順で返す。 +全ページを `position` 昇順で返す。同じ `section` のページは常に連続する(隣接=同一グループとして扱われる)。 **レスポンス** ```json @@ -106,6 +107,7 @@ SSE でストリーミング応答を返す。 "content": "- 牛乳 - 卵", "position": 0, + "section": "生活", "updated_at": "2026-08-19T12:00:00" } ] @@ -115,21 +117,43 @@ SSE でストリーミング応答を返す。 ### POST /api/notes ページを新規作成し、`201 Created` で作成したページを返す。 +`section` を指定すると、作成直後にそのセクションの末尾へ配置される(他ページの `position` も再採番される)。 **リクエスト** ```json -{ "title": "新しいページ", "content": "" } +{ "title": "新しいページ", "content": "", "section": null } ``` --- ### PATCH /api/notes/{page_id} -ページのタイトル・本文を更新する。指定したフィールドだけが変更される。 +ページのタイトル・本文・セクションを更新する。指定したフィールドだけが変更される。 タイトルを空文字にしようとした場合は `400`、ページが存在しない場合は `404` を返す。 +`section` はリクエストボディに**キーとして含まれているかどうか**で判定する(`null` を明示的に送るとグループ解除)。 +セクションを変更すると、そのページは新しいセクションの末尾(該当ページが無ければ末尾)へ移動し、 +全ページの `position` が振り直される。 + **リクエスト** ```json -{ "content": "更新後の本文" } +{ "content": "更新後の本文", "section": "生活" } +``` + +--- + +### POST /api/notes/sections/rename +`old_name` セクションに属する全ページを `new_name` セクションへ一括で付け替える。 +`new_name` を空文字/未指定にすると、そのセクションの全ページのグループを解除する。 +ページの並び順(`position`)は変更しない。 + +**リクエスト** +```json +{ "old_name": "生活", "new_name": "生活雑貨" } +``` + +**レスポンス** +```json +{ "renamed": 2 } ``` --- diff --git a/docs/architecture.md b/docs/architecture.md index 8749c32..f49e890 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -21,18 +21,35 @@ ``` backend/ ├── app/ -│ ├── main.py # FastAPI インスタンス、CORS、ルーター登録、DB初期化 +│ ├── main.py # FastAPI インスタンス、CORS、ルーター登録、DB初期化、起動時シード │ ├── api/ -│ │ ├── chat.py # チャット・セッション関連エンドポイント -│ │ ├── memory.py # 長期メモリ参照・削除 -│ │ └── logs.py # 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 ラッパー(ストリーミング・ログ記録) -│ │ └── memory.py # メモリ読み書きロジック(要約・コンテキスト生成) +│ │ ├── 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 モデル定義 +│ ├── database.py # SQLite 接続・セッションファクトリ・テーブル自動作成 +│ └── models.py # SQLAlchemy モデル定義 +├── knowledge_base/ # ローカルナレッジベース(Markdown。web_search/ 配下は自動生成) └── pyproject.toml ``` @@ -42,30 +59,83 @@ backend/ |---|---|---| | `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 | 要約による長期メモリ | -| `note_pages` | id, title, content, position, created_at, updated_at | ユーザーと AI が共同編集する共有ノートのページ | +| `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.py`) +### コンテキスト管理(`core/memory_kb/`) -1. **短期メモリ**: セッション内の全 `messages` をコンテキストとして送信 -2. **長期メモリ**: メッセージ数が `MEMORY_SUMMARIZE_THRESHOLD`(デフォルト 20)を超えると、古い半分を LLM で要約して `memories` に保存しメッセージを削除 -3. **コンテキスト合成**: `get_context_messages()` が `memories` の要約をシステムメッセージとして先頭に付加し、残存メッセージと結合して返す +LLM に渡すコンテキストは `memory_kb/memory.py` の `get_context_messages()` が毎リクエスト組み立てる。 +以下の順に system メッセージを積み、最後に会話履歴を連結する。 + +| # | 層 | 中身 | 実装 | 量の制御 | +|---|---|---|---|---| +| 1 | 蓄積知識 | 過去の Web 検索で得た `knowledge_entries` の**要約全文**+出典 URL | `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. **要約した元メッセージは DB から物理削除**する(復元不可。エクスポートしても生ログは残らない) +4. 保存した要約に `tagging.tag_memory()` でタグを付ける + +#### ツール経由の遅延読み込み + +常時注入すると重い情報は、システムプロンプトに目次だけ置いてツールで取りに行かせる。 +`llm.resolve_tool_calls()` が本番のストリーミング生成の**前に**別途 LLM を呼んでツール呼び出しを解決する(最大 2 ラウンド)。 + +- `read_note` / `write_note` … 共有ノート本文の読み書き(`db` を渡したときだけモデルに提示) +- `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 件のメッセージから再シードされる) + +#### 設計上の注意点 + +- トークン数ベースの制御は無く、すべて件数・文字数ベース。層 1(知識 15 件の要約全文)と層 3(Markdown 全文 ×3)は + 会話が短くてもコンテキストを大きく押し上げる +- ツールを使わない会話でも `resolve_tool_calls()` が必ず 1 回フルコンテキストを投げるため、**LLM 呼び出しは最低 2 回**になる +- 層 4(長期メモリ)に上限が無く、長いセッションでは要約が積み上がる(要約の再要約はしない) +- `get_local_kb_context()` が毎リクエストで `sync_local_kb()`(ディレクトリスキャン)と埋め込み API 呼び出しを 1 回行う ### LLM 呼び出し(`core/llm.py`) -- `stream_chat()` / `complete()` が LiteLLM を薄くラップ -- `_log_context` で呼び出しを囲み、成功・失敗どちらも `api_logs` テーブルに記録 +- `stream_chat()` / `complete()` / `embed()` が LiteLLM を薄くラップ +- `resolve_tool_calls()` が `web_search` / `read_note` / `write_note` を解決し、結果を組み込んだメッセージ列を返す +- `_log_context` で呼び出しを囲み、成功・失敗どちらも `api_logs` テーブルに記録(`embed()` は対象外) - `DEBUG=true` で `litellm._turn_on_debug()` が有効になり詳細ログが出力される ### 共有ノート(`core/notes.py` / `api/notes.py`) ユーザーと AI が共同編集するノート。`note_pages` の 1 行が UI の 1 タブに対応する。 -- `get_notes_context()` が**タイトル一覧のみ**をシステムメッセージとして生成し、`get_context_messages()` が会話コンテキストに差し込む(本文は含めない) +- `get_notes_context()` が**タイトル一覧のみ**をシステムメッセージとして生成し、`get_context_messages()` が会話コンテキストに差し込む(本文は含めない)。セクションがあれば `## セクション名` の見出しでグループ化して提示する - 本文の読み書きはツール経由: `read_note`(本文取得)/ `write_note`(追記・置換・新規作成) - `llm.resolve_tool_calls()` が `web_search` と同じループでこれらを解決する。ノート系ツールは `db` を渡したときのみモデルに提示される -- REST(`/api/notes`)はフロントのノートパネル用。GET 一覧 / POST 作成 / PATCH 更新 / DELETE 削除 +- REST(`/api/notes`)はフロントのノートパネル用。GET 一覧 / POST 作成 / PATCH 更新 / DELETE 削除 / `POST /api/notes/sections/rename`(セクション名の一括変更) +- **セクションによるグルーピング**: `note_pages.section` に名前を持たせ、同じセクションのページは常に `position` 上で連続させる。`notes.set_section()` がページのセクションを変更するたびに全ページを再採番し、同じセクションの末尾に挿入し直す(隣接=同一グループという前提をタブ描画側・システムプロンプト側の両方が使っている)。`notes.rename_section()` は既に連続しているページ群のラベルだけを一括で書き換える +- 既存 DB への追加カラムは Alembic 等の仕組みを使っておらず、`db/database.py` の `_add_missing_columns()` が起動時に `PRAGMA table_info` を見て `ALTER TABLE ... ADD COLUMN` を素朴に実行する(SQLite 専用の簡易マイグレーション) ### SSE ストリーミング(`api/chat.py`) @@ -73,8 +143,9 @@ backend/ ``` data: {"type":"session_id","value":""} # 新規セッション時のみ -data: {"type":"chunk","value":"テキスト断片"} # 0回以上 data: {"type":"notes_updated"} # AIが共有ノートを更新した時のみ +data: {"type":"sources","value":[{...}]} # Web検索した時のみ(出典の title/url) +data: {"type":"chunk","value":"テキスト断片"} # 0回以上 data: {"type":"done"} # 正常完了 data: {"type":"error","value":"エラー内容"} # 例外発生時 ``` diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 670d253..923cec4 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -263,6 +263,7 @@ export interface NotePage { title: string content: string position: number + section: string | null updated_at: string } @@ -271,18 +272,18 @@ export async function fetchNotePages(): Promise { return parseJson(res, 'ノート取得') } -export async function createNotePage(title: string): Promise { +export async function createNotePage(title: string, section?: string | null): Promise { const res = await fetch(`${BASE}/api/notes`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ title }), + body: JSON.stringify({ title, section: section ?? null }), }) return parseJson(res, 'ページ作成') } export async function updateNotePage( pageId: number, - patch: { title?: string; content?: string }, + patch: { title?: string; content?: string; section?: string | null }, ): Promise { const res = await fetch(`${BASE}/api/notes/${pageId}`, { method: 'PATCH', @@ -295,3 +296,13 @@ export async function updateNotePage( export async function deleteNotePage(pageId: number): Promise { await fetch(`${BASE}/api/notes/${pageId}`, { method: 'DELETE' }) } + +/** section = old のページ全体を section = new へ一括で付け替える。new が空/nullならグループ解除。 */ +export async function renameNoteSection(oldName: string, newName: string | null): Promise<{ renamed: number }> { + const res = await fetch(`${BASE}/api/notes/sections/rename`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ old_name: oldName, new_name: newName }), + }) + return parseJson<{ renamed: number }>(res, 'セクション名変更') +} diff --git a/frontend/src/components/NotePanel.vue b/frontend/src/components/NotePanel.vue index c07efc3..efb5642 100644 --- a/frontend/src/components/NotePanel.vue +++ b/frontend/src/components/NotePanel.vue @@ -1,15 +1,36 @@