diff --git a/CLAUDE.md b/CLAUDE.md index 78657c1..171dcff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,6 +76,7 @@ LLM に渡すコンテキストは `app/core/memory_kb/memory.py` の `get_conte 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 検索結果より優先する扱い 4. **長期メモリ**: `memories` の要約を全件連結。上限なし 5. **短期メモリ**: セッション内の `messages` 全件(要約で削除された分を除く) @@ -97,6 +98,8 @@ LLM に渡すコンテキストは `app/core/memory_kb/memory.py` の `get_conte - **ツール実行の流れ**: `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()`) @@ -145,6 +148,9 @@ LLM に渡すコンテキストは `app/core/memory_kb/memory.py` の `get_conte | `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 の署名鍵 | diff --git a/backend/app/api/chat.py b/backend/app/api/chat.py index 364b125..7e1994a 100644 --- a/backend/app/api/chat.py +++ b/backend/app/api/chat.py @@ -12,6 +12,7 @@ from app.core.memory_kb import memory as mem from app.core import llm from app.core.memory_kb import knowledge from app.core.memory_kb import local_knowledge +from app.core import notes from app.core.notes import NOTE_TOOLS from app.core.search.websearch import WEB_SEARCH_TOOL from app.core.config import DEFAULT_MODEL @@ -25,6 +26,13 @@ class ChatRequest(BaseModel): session_id: str | None = None message: str model: str | None = None + # このセッションで本文をプロンプトに載せるノートのページID。 + # 新規セッションではまだ session_id が無く PUT できないため、送信時にまとめて反映する。 + note_page_ids: list[int] | None = None + + +class SessionNotesRequest(BaseModel): + page_ids: list[int] class SessionResponse(BaseModel): @@ -161,6 +169,22 @@ def import_session(req: ImportRequest, db: DBSession = Depends(get_db)): return session +@router.get("/sessions/{session_id}/notes") +def get_session_notes(session_id: str, db: DBSession = Depends(get_db)): + """このセッションで本文をプロンプトに載せるよう指定された共有ノートのページIDを返す。""" + if not db.get(Session, session_id): + raise HTTPException(status_code=404, detail="Session not found") + return {"page_ids": notes.get_attached_page_ids(db, session_id)} + + +@router.put("/sessions/{session_id}/notes") +def put_session_notes(session_id: str, req: SessionNotesRequest, db: DBSession = Depends(get_db)): + """このセッションに紐付けるノートのページIDを丸ごと置き換える。存在しないIDは無視する。""" + if not db.get(Session, session_id): + raise HTTPException(status_code=404, detail="Session not found") + return {"page_ids": notes.set_attached_pages(db, session_id, req.page_ids)} + + @router.delete("/sessions/{session_id}", status_code=204) def delete_session(session_id: str, db: DBSession = Depends(get_db)): session = db.get(Session, session_id) @@ -202,6 +226,11 @@ async def stream_chat(req: ChatRequest, db: DBSession = Depends(get_db)): record_message(req.message) asyncio.create_task(tag_message_by_id(user_msg.id)) + # 紐付けるノートは送信のたびに送られてくる。新規セッションだとフロントは事前に + # PUT できないため、コンテキストを組み立てる前にここで反映しておく。 + if req.note_page_ids is not None: + notes.set_attached_pages(db, session.id, req.note_page_ids) + context = await mem.get_context_messages(db, session.id) async def generate(): diff --git a/backend/app/api/notes.py b/backend/app/api/notes.py index 24903d2..b144150 100644 --- a/backend/app/api/notes.py +++ b/backend/app/api/notes.py @@ -3,12 +3,12 @@ import zipfile from datetime import datetime, timezone from urllib.parse import quote -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from fastapi.responses import Response from pydantic import BaseModel from sqlalchemy.orm import Session as DBSession -from app.core.notes import next_position, reorder, rename_section, set_section +from app.core.notes import import_files, next_position, reorder, rename_section, set_section from app.db.database import get_db from app.db.models import NotePage @@ -129,6 +129,24 @@ def create_page(req: NotePageCreate, db: DBSession = Depends(get_db)): return page +@router.post("/import", response_model=list[NotePageResponse], status_code=201) +async def import_pages(files: list[UploadFile] = File(...), db: DBSession = Depends(get_db)): + """アップロードされたファイルをページとして取り込む。 + + .md / .txt はそのまま1ページ、.zip は中のテキストファイルを1つずつページにする + (ZIP内のフォルダ名がセクションになるので、エクスポートしたZIPをそのまま戻せる)。 + 対象外の拡張子や大きすぎるファイルは黙って飛ばすので、1つも取り込めなければ 400。 + """ + payload = [(f.filename or "無題", await f.read()) for f in files] + created = import_files(db, payload) + if not created: + raise HTTPException( + status_code=400, + detail="取り込めるファイルがありませんでした(.md / .txt / .zip のみ)", + ) + return created + + @router.post("/sections/rename") def rename_section_endpoint(req: SectionRenameRequest, db: DBSession = Depends(get_db)): """old_name セクションに属する全ページを new_name セクションへ一括で付け替える。new_name が空ならグループ解除。""" diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 5e9e903..ab44e91 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -41,6 +41,13 @@ LOCAL_KB_MIN_SCORE: float = float(os.getenv("LOCAL_KB_MIN_SCORE", "0.75")) # read_note ツールが1回に返す本文の最大文字数 NOTES_TOOL_CONTENT_LIMIT: int = int(os.getenv("NOTES_TOOL_CONTENT_LIMIT", "4000")) +# アップロードで一度に取り込めるページ数と、1ファイル(ZIP内は1エントリ)あたりの最大バイト数 +NOTES_IMPORT_MAX_PAGES: int = int(os.getenv("NOTES_IMPORT_MAX_PAGES", "100")) +NOTES_IMPORT_MAX_BYTES: int = int(os.getenv("NOTES_IMPORT_MAX_BYTES", "2000000")) + +# セッションに紐付けたページ1枚あたり、システムプロンプトに載せる本文の最大文字数 +SESSION_NOTES_CONTENT_LIMIT: int = int(os.getenv("SESSION_NOTES_CONTENT_LIMIT", "8000")) + # read_knowledge ツールが1回に返す蓄積知識の本文の最大文字数 KNOWLEDGE_TOOL_CONTENT_LIMIT: int = int(os.getenv("KNOWLEDGE_TOOL_CONTENT_LIMIT", "4000")) diff --git a/backend/app/core/memory_kb/memory.py b/backend/app/core/memory_kb/memory.py index 25c8b25..9473cbe 100644 --- a/backend/app/core/memory_kb/memory.py +++ b/backend/app/core/memory_kb/memory.py @@ -28,10 +28,15 @@ async def get_context_messages(db: DBSession, session_id: str) -> list[dict]: context.append({"role": "system", "content": knowledge_context}) # 共有ノートは「タイトル一覧」だけを常時見せ、本文は read_note ツールで読ませる - notes_context = notes.get_notes_context(db) + notes_context = notes.get_notes_context(db, session_id) if notes_context: context.append({"role": "system", "content": notes_context}) + # ただしユーザーがこのセッションに紐付けたページは、本文もそのまま載せる + session_notes_context = notes.get_session_notes_context(db, session_id) + if session_notes_context: + context.append({"role": "system", "content": session_notes_context}) + if messages: latest_user_message = next((m.content for m in reversed(messages) if m.role == "user"), "") local_kb_context = await local_knowledge.get_local_kb_context(db, latest_user_message) diff --git a/backend/app/core/notes.py b/backend/app/core/notes.py index 2604801..b05fb54 100644 --- a/backend/app/core/notes.py +++ b/backend/app/core/notes.py @@ -2,14 +2,25 @@ システムプロンプトにはページの「タイトル一覧」だけを注入し、本文はモデルが read_note ツールで必要なときだけ読み出す。書き込みは write_note ツールで行う。 + +例外として、ユーザーがセッションに紐付けたページ(SessionNotePage)は本文も +毎回そのままシステムプロンプトに載る。ツールの往復を待たずに確実に参照させたい +資料のための仕組みで、指定はセッション単位なので会話ごとに組み合わせを変えられる。 """ +import io import logging import re +import zipfile from sqlalchemy.orm import Session as DBSession -from app.core.config import NOTES_TOOL_CONTENT_LIMIT -from app.db.models import NotePage +from app.core.config import ( + NOTES_IMPORT_MAX_BYTES, + NOTES_IMPORT_MAX_PAGES, + NOTES_TOOL_CONTENT_LIMIT, + SESSION_NOTES_CONTENT_LIMIT, +) +from app.db.models import NotePage, SessionNotePage logger = logging.getLogger(__name__) @@ -138,9 +149,169 @@ def rename_section(db: DBSession, old_name: str, new_name: str | None) -> int: return len(pages) -def get_notes_context(db: DBSession) -> str | None: - """共有ノートのタイトル一覧をシステムプロンプト用のテキストとして返す。セクションごとに見出しを付ける。""" +# 取り込めるテキストファイルの拡張子。これ以外はZIP内でも単体でも無視する +_TEXT_SUFFIXES = (".md", ".markdown", ".txt", ".text") +# ダウンロードしたZIPのファイル名に付く並び順の通し番号(01_タイトル.md) +_EXPORT_PREFIX = re.compile(r"^\d{2,}_") + + +def _decode(raw: bytes) -> str: + """取り込んだファイルを文字列にする。UTF-8優先、日本語Windows由来のCP932も拾う。""" + for encoding in ("utf-8-sig", "utf-8", "cp932"): + try: + return raw.decode(encoding) + except UnicodeDecodeError: + continue + return raw.decode("utf-8", errors="replace") + + +def _title_from_filename(filename: str) -> str: + """ファイル名からページタイトルを作る。拡張子とエクスポート時の通し番号を落とす。""" + stem = filename.rsplit("/", 1)[-1].rsplit("\\", 1)[-1] + for suffix in _TEXT_SUFFIXES: + if stem.lower().endswith(suffix): + stem = stem[: -len(suffix)] + break + return _EXPORT_PREFIX.sub("", stem).strip() or "無題のページ" + + +def _unique_title(db: DBSession, title: str, taken: set[str]) -> str: + """既存ページや同時取り込み分とタイトルがぶつかったら「(2)」を付けてずらす。 + + 上書きせずに増やすのは、エクスポートしたZIPを既存のノートに投げ戻したときに + 元のページを壊さないため。要らない方は後からタブの × で消せる。 + """ + existing = {p.title for p in db.query(NotePage.title).all()} | taken + if title not in existing: + return title + n = 2 + while f"{title} ({n})" in existing: + n += 1 + return f"{title} ({n})" + + +def _create_imported_page(db: DBSession, title: str, content: str, section: str | None) -> NotePage: + page = NotePage(title=title, content=content, position=next_position(db)) + db.add(page) + db.commit() + db.refresh(page) + if section: + # 同じセクションの末尾に並べ直す(連続配置の前提を保つ) + page = set_section(db, page.id, section) or page + return page + + +def import_files(db: DBSession, files: list[tuple[str, bytes]]) -> list[NotePage]: + """アップロードされたファイルをノートのページとして取り込む。 + + files は (ファイル名, 中身) のリスト。.md / .txt はそのまま1ページになり、.zip は + 中のテキストファイルを1つずつページにする(ZIP内のフォルダ名がセクションになる)。 + エクスポートで出したZIPをそのまま投げ戻せば、セクションと並び順ごと復元できる。 + + 取り込めないファイル(拡張子が対象外・大きすぎる)は黙って飛ばす。作成したページを返す。 + """ + created: list[NotePage] = [] + taken: set[str] = set() + + def add(filename: str, raw: bytes, section: str | None) -> None: + if len(created) >= NOTES_IMPORT_MAX_PAGES or len(raw) > NOTES_IMPORT_MAX_BYTES: + logger.info("Skipped note import: %s (%d bytes)", filename, len(raw)) + return + title = _unique_title(db, _title_from_filename(filename), taken) + taken.add(title) + created.append(_create_imported_page(db, title, _decode(raw), section)) + + for filename, raw in files: + if filename.lower().endswith(".zip"): + try: + with zipfile.ZipFile(io.BytesIO(raw)) as archive: + for info in archive.infolist(): + if info.is_dir() or not info.filename.lower().endswith(_TEXT_SUFFIXES): + continue + if info.file_size > NOTES_IMPORT_MAX_BYTES: + logger.info("Skipped zip entry: %s (%d bytes)", info.filename, info.file_size) + continue + # ZIP内のフォルダ名をセクションにする(ネストは最初の階層だけ見る) + parts = info.filename.replace("\\", "/").split("/") + section = parts[0] if len(parts) > 1 and parts[0] not in ("", ".", "..") else None + add(info.filename, archive.read(info), section) + except zipfile.BadZipFile: + logger.warning("Not a valid zip: %s", filename) + elif filename.lower().endswith(_TEXT_SUFFIXES): + add(filename, raw, None) + else: + logger.info("Skipped unsupported file: %s", filename) + + return created + + +def get_attached_page_ids(db: DBSession, session_id: str) -> list[int]: + """セッションに紐付けられた(本文をプロンプトに載せる)ページIDを position 順で返す。""" + rows = ( + db.query(SessionNotePage.page_id) + .join(NotePage, NotePage.id == SessionNotePage.page_id) + .filter(SessionNotePage.session_id == session_id) + .order_by(NotePage.position, NotePage.id) + .all() + ) + return [row[0] for row in rows] + + +def set_attached_pages(db: DBSession, session_id: str, page_ids: list[int]) -> list[int]: + """セッションに紐付けるページを page_ids で置き換える。存在しないIDは黙って捨てる。 + + 戻り値は実際に紐付いたページIDを position 順で並べたもの。 + """ + valid = {p.id for p in db.query(NotePage.id).filter(NotePage.id.in_(page_ids)).all()} if page_ids else set() + + existing = db.query(SessionNotePage).filter(SessionNotePage.session_id == session_id).all() + for link in existing: + if link.page_id not in valid: + db.delete(link) + already = {link.page_id for link in existing if link.page_id in valid} + for page_id in valid - already: + db.add(SessionNotePage(session_id=session_id, page_id=page_id)) + db.commit() + return get_attached_page_ids(db, session_id) + + +def get_session_notes_context(db: DBSession, session_id: str) -> str | None: + """セッションに紐付けられたページの本文を、システムプロンプト用のテキストとして返す。 + + 1ページも紐付いていなければ None。1ページあたり SESSION_NOTES_CONTENT_LIMIT 文字で切る。 + """ + pages = ( + db.query(NotePage) + .join(SessionNotePage, SessionNotePage.page_id == NotePage.id) + .filter(SessionNotePage.session_id == session_id) + .order_by(NotePage.position, NotePage.id) + .all() + ) + if not pages: + return None + + lines = [ + "【この会話で参照するノート】", + "ユーザーがこの会話に紐付けたページです。本文をそのまま載せているので、" + "これらのページに read_note を使う必要はありません。", + "", + ] + for page in pages: + content = page.content.strip() or "(空)" + if len(content) > SESSION_NOTES_CONTENT_LIMIT: + content = content[:SESSION_NOTES_CONTENT_LIMIT] + "\n…(以下省略。全文は read_note で読めます)" + lines += [f"## {page.title}", content, ""] + return "\n".join(lines).rstrip() + + +def get_notes_context(db: DBSession, session_id: str | None = None) -> str | None: + """共有ノートのタイトル一覧をシステムプロンプト用のテキストとして返す。セクションごとに見出しを付ける。 + + session_id を渡すと、そのセッションに紐付いた(本文が別途プロンプトに載る)ページに + 印を付ける。同じ本文を read_note で読み直させないため。 + """ pages = db.query(NotePage).order_by(NotePage.position, NotePage.id).all() + attached = set(get_attached_page_ids(db, session_id)) if session_id else set() lines = [ "【共有ノート】", @@ -155,6 +326,9 @@ def get_notes_context(db: DBSession) -> str | None: current_section = page.section if current_section: lines.append(f"## {current_section}") + if page.id in attached: + lines.append(f"- {page.title} — (本文は「この会話で参照するノート」に全文あり)") + continue preview = " ".join(page.content.split())[:40] suffix = f" — {preview}…" if preview else " — (空)" lines.append(f"- {page.title}{suffix}") diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 451b7b1..f98c64e 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -1,5 +1,5 @@ from datetime import datetime, timezone -from sqlalchemy import String, Text, ForeignKey, Integer, Float +from sqlalchemy import String, Text, ForeignKey, Integer, Float, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, relationship from app.db.database import Base from app.db.types import UtcDateTime @@ -16,6 +16,7 @@ class Session(Base): messages: Mapped[list["Message"]] = relationship(back_populates="session", cascade="all, delete-orphan", order_by="Message.created_at") memories: Mapped[list["Memory"]] = relationship(back_populates="session", cascade="all, delete-orphan") + note_links: Mapped[list["SessionNotePage"]] = relationship(back_populates="session", cascade="all, delete-orphan") class Message(Base): @@ -119,7 +120,8 @@ class NotePage(Base): """ユーザーとAIが共同で編集する共有ノートの1ページ。UIではタブとして表示する。 ページのタイトル一覧は毎回システムプロンプトに注入され、本文はモデルが - read_note ツールで読み出す(app.core.notes を参照)。 + read_note ツールで読み出す(app.core.notes を参照)。ただし SessionNotePage で + セッションに紐付けられたページは、本文もそのままシステムプロンプトに載る。 """ __tablename__ = "note_pages" @@ -131,6 +133,28 @@ class NotePage(Base): created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc)) updated_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) + session_links: Mapped[list["SessionNotePage"]] = relationship(back_populates="page", cascade="all, delete-orphan") + + +class SessionNotePage(Base): + """「このセッションではこのページの本文をプロンプトに載せる」という指定。 + + 通常、共有ノートはタイトル一覧だけがシステムプロンプトに入り、本文はモデルが + read_note ツールで取りに行く。ここに登録されたページはそれを待たず、本文が毎回 + そのままシステムプロンプトに入る(app.core.notes.get_session_notes_context を参照)。 + セッション単位なので、会話ごとに違う組み合わせを選べる。 + """ + __tablename__ = "session_note_pages" + __table_args__ = (UniqueConstraint("session_id", "page_id", name="uq_session_note_page"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + session_id: Mapped[str] = mapped_column(ForeignKey("sessions.id", ondelete="CASCADE")) + page_id: Mapped[int] = mapped_column(ForeignKey("note_pages.id", ondelete="CASCADE")) + created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc)) + + session: Mapped["Session"] = relationship(back_populates="note_links") + page: Mapped["NotePage"] = relationship(back_populates="session_links") + class AppSetting(Base): """GUIの設定画面から編集する接続情報(APIキー等)。app.core.settings_store が読み書きする。""" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 7a77692..062d565 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "discord-py>=2.7.1", "janome>=0.5.0", "trafilatura>=2.1.0", + "python-multipart>=0.0.9", ] [project.optional-dependencies] diff --git a/backend/uv.lock b/backend/uv.lock index 8b49e32..916717b 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -1300,6 +1300,7 @@ dependencies = [ { name = "nvidia-ml-py" }, { name = "psutil" }, { name = "python-dotenv" }, + { name = "python-multipart" }, { name = "sqlalchemy" }, { name = "sse-starlette" }, { name = "trafilatura" }, @@ -1336,6 +1337,7 @@ requires-dist = [ { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, + { name = "python-multipart", specifier = ">=0.0.9" }, { name = "sqlalchemy", specifier = ">=2.0.0" }, { name = "sse-starlette", specifier = ">=2.1.0" }, { name = "trafilatura", specifier = ">=2.1.0" }, @@ -1642,6 +1644,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + [[package]] name = "pytz" version = "2026.2" diff --git a/docs/api.md b/docs/api.md index d7d8112..d4f44b6 100644 --- a/docs/api.md +++ b/docs/api.md @@ -15,10 +15,15 @@ SSE でストリーミング応答を返す。 { "message": "こんにちは", "session_id": "uuid(省略時は新規セッション作成)", - "model": "gpt-5.4(省略時は DEFAULT_MODEL)" + "model": "gpt-5.4(省略時は DEFAULT_MODEL)", + "note_page_ids": [1, 2] } ``` +`note_page_ids` は「このセッションで本文をシステムプロンプトに載せる共有ノートのページ ID」。 +渡すとコンテキスト組み立ての前にサーバー側の指定を丸ごと置き換える(`PUT /chat/sessions/{id}/notes` と同じ内容)。 +新規セッションではフロントがまだ session_id を持てないため、初回はここでしか指定できない。省略すると現在の指定を変更しない。 + **SSE イベント** | type | value | タイミング | |---|---|---| @@ -64,6 +69,28 @@ SSE でストリーミング応答を返す。 --- +### GET /chat/sessions/{session_id}/notes +このセッションで本文をシステムプロンプトに載せるよう指定された共有ノートのページ ID を返す。 +順序はノートのタブ順(`position`)。セッションが無ければ `404`。 + +**レスポンス** +```json +{ "page_ids": [1, 2] } +``` + +--- + +### PUT /chat/sessions/{session_id}/notes +指定を丸ごと置き換える。存在しないページ ID は黙って捨てられ、実際に紐付いた ID が返る。 +空配列を渡すと全解除。セッションが無ければ `404`。 + +**リクエスト** +```json +{ "page_ids": [1, 2] } +``` + +--- + ### DELETE /chat/sessions/{session_id} セッションと紐づくメッセージ・メモリをすべて削除。`204 No Content` を返す。 @@ -187,6 +214,22 @@ SSE でストリーミング応答を返す。 --- +### POST /api/notes/import +`multipart/form-data` の `files` でアップロードされたファイルをページとして取り込み、 +`201 Created` で作成したページの配列を返す。 + +| 拡張子 | 取り込み方 | +|---|---| +| `.md` / `.markdown` / `.txt` / `.text` | 1 ファイル 1 ページ。タイトルはファイル名から拡張子と `01_` 形式の通し番号を落としたもの | +| `.zip` | 中のテキストファイルを 1 つずつページにする。第 1 階層のフォルダ名がセクションになる(`GET /api/notes/export` の ZIP をそのまま戻せる) | + +- 既存ページとタイトルが衝突したら `タイトル (2)` のように連番を足して**別ページとして作る**(上書きはしない) +- 文字コードは UTF-8 (BOM 可) → CP932 の順に試す +- 対象外の拡張子・`NOTES_IMPORT_MAX_BYTES`(既定 2,000,000)超のファイル・`NOTES_IMPORT_MAX_PAGES`(既定 100)を超えた分・壊れた ZIP は**黙って飛ばす** +- 1 ページも取り込めなかった場合のみ `400` + +--- + ### GET /api/notes/export 全ページを ZIP でダウンロードさせる(`Content-Disposition: attachment`)。 1 ページ 1 ファイルの Markdown で、本文はそのまま。エントリ名は `<セクション>/<通し番号>_<タイトル>.md` diff --git a/docs/architecture.md b/docs/architecture.md index c94bcc0..ac79a8a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -66,6 +66,7 @@ backend/ | `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 呼び出しログ | @@ -79,12 +80,19 @@ LLM に渡すコンテキストは `memory_kb/memory.py` の `get_context_messag |---|---|---|---|---| | 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()` がアシスタント応答の保存直後に走る。 diff --git a/docs/features.md b/docs/features.md index d777d22..3ea568c 100644 --- a/docs/features.md +++ b/docs/features.md @@ -71,10 +71,13 @@ Discord 連携は [discord-bot.md](discord-bot.md)、ノートの操作方法は |---|---|---| | 1 蓄積知識 | 過去の Web 検索結果の**見出し一覧のみ**(本文は `read_knowledge` で読む) | 最新 `KNOWLEDGE_CONTEXT_LIMIT`(既定 15)件 | | 2 共有ノート | ページのタイトル一覧+本文先頭 40 文字 | 全ページ(本文は入れない) | +| 2.5 参照指定ノート | このチャットに紐付けたページの**本文全文** | 紐付いたページのみ・1 ページ `SESSION_NOTES_CONTENT_LIMIT`(既定 8000)文字 | | 3 ローカル KB | 選ばれた Markdown の**全文** | 類似度上位 `LOCAL_KB_TOP_K`(既定 3)件かつ `LOCAL_KB_MIN_SCORE`(既定 0.75)以上 | | 4 長期メモリ | 要約を全件連結 | 上限なし | | 5 短期メモリ | セッション内の全メッセージ | 上限なし(要約で間接的に制御) | +層 2.5 だけは例外で、ユーザーが 📌 で選んだページの本文をツールを介さず直接載せる。 + 重い情報(ノート本文・蓄積知識の本文・Web 検索)は目次だけを渡し、本体はツール(`read_note` / `write_note` / `read_knowledge` / `web_search`)で 取りに行かせる遅延読み込み方式。`llm.resolve_tool_calls()` が本番生成の前に最大 2 ラウンドでこれを解決する。 @@ -109,6 +112,12 @@ Discord 連携は [discord-bot.md](discord-bot.md)、ノートの操作方法は - AI 側はタイトル一覧だけを常に見ており、`read_note` で本文を読み、`write_note` で追記/全置換/新規作成する。 AI が書き換えると SSE の `notes_updated` が飛び、開いているノートパネルが自動で最新化される。 - 自分が編集中(未保存)のページは AI の更新で上書きされない。 +- **取り込み**: `.md` / `.txt` / `.zip` を本文エリアにドロップするか ⬆ ボタンで選ぶとページになる。 + ZIP はフォルダ名がセクションになるので、⬇ で出した ZIP をそのまま戻せば構成ごと復元される。 + タイトルが衝突したら `(2)` を足して別ページにするので、既存のページは壊れない。 +- **チャットへの紐付け**: タブの 📌 を押すと、そのページの本文が**今開いているチャット**のシステム + プロンプトに毎回そのまま載る。AI が `read_note` を呼ぶのを待たずに確実に参照させたいときに使う。 + 指定はチャット単位で保存されるので、会話ごとに違う組み合わせを選べる。 - **ダウンロード**: パネル下部の ⬇ ボタンで、開いているページを `.md` として、または全ページを ZIP(1 ページ 1 Markdown、セクションはフォルダ、ファイル名の通し番号でタブ順を保持)として 手元に保存できる。未保存の編集は書き出す前に保存される。 diff --git a/docs/notes-guide.md b/docs/notes-guide.md index d81c728..51146d2 100644 --- a/docs/notes-guide.md +++ b/docs/notes-guide.md @@ -18,12 +18,57 @@ API 仕様は `docs/api.md` の「共有ノート」節を参照。 | タイトルを変更 | タブを**ダブルクリック**すると入力欄になるので書き換えて Enter(または欄外をクリック) | | ページを削除 | タブ内の **×** をクリック(確認ダイアログあり) | | 本文を編集 | ページを開いた状態で下の編集エリアに直接入力 | +| ファイルを取り込む | 本文エリアにファイルをドロップ、またはパネル下部の **⬆ 取り込み** から選択 | +| チャットに紐付ける | タブ内の **📌** をクリック(もう一度押すと解除) | | ページを1つ保存 | パネル下部の **⬇ このページ (.md)** をクリック | | 全ページを保存 | パネル下部の **⬇ すべて (.zip)** をクリック | 本文は入力を止めてから少し待つと自動保存される。保存待ちの間はタブに **●**(未保存マーク)が付く。 ページが1つも無い場合は「+ 最初のページを作成」から始められる。 +## ファイルを取り込む + +手元のファイルをページとして取り込める。本文エリアに**ドラッグ&ドロップ**するか、 +パネル下部の **⬆ 取り込み** からファイルを選ぶ(複数まとめて可)。 + +| ファイル | どうなるか | +|---|---| +| `.md` / `.txt` | 1 ファイルが 1 ページになる。ファイル名がタイトル | +| `.zip` | 中のテキストファイルが 1 つずつページになる。フォルダ名がセクションになる | + +**⬇ すべて (.zip) で出した ZIP をそのまま戻せる**ので、バックアップや別環境への引っ越しに使える。 +ファイル名の `01_` のような通し番号は取り込み時に落とされる。 + +既存のページと同じタイトルだった場合は上書きせず、`設計メモ (2)` のように連番を足して別ページを作る。 +要らない方はタブの × で消せばよい。 + +取り込めない拡張子(PDF や画像など)や大きすぎるファイル(1 ファイル 2MB 超)は飛ばされる。 +1 つも取り込めなかったときだけエラーが表示される。 + +## チャットに紐付けて必ず読ませる + +普段、AIに渡っているのは**ページのタイトル一覧だけ**で、本文はAIが必要だと判断したときに +`read_note` で読みに行く。つまり「読みに行ってくれないと参照されない」。 + +タブの **📌** を押すと、そのページの本文が**今開いているチャット**のプロンプトに毎回そのまま載る。 +AIの判断を待たずに確実に参照させたい資料(仕様書、設定、前提条件など)に使う。 + +📌 が付いたタブは上端に黄色い線が入る。もう一度押すと解除。 + +**チャットごとの設定**であることに注意。 + +| チャット | 📌 を付けたページ | +|---|---| +| 「新機能の相談」 | 設計メモ / APIメモ | +| 「夕飯どうする」 | 買い物リスト | + +チャットを切り替えると 📌 の状態もそのチャットのものに切り替わる。新規チャットでは +📌 を付けてから最初のメッセージを送れば、その1通目からちゃんと反映される。 + +長いページは 1 ページ 8000 文字(`SESSION_NOTES_CONTENT_LIMIT`)で打ち切られ、 +残りが必要ならAIが `read_note` で読み足せる。あれもこれも 📌 すると毎回のリクエストが +その分重くなるので、本当に読ませたいページだけに絞るのがよい。 + ## 手元にダウンロードする パネル下部の ⬇ ボタンでノートをファイルとして保存できる。未保存の編集があっても、 @@ -69,7 +114,7 @@ ZIP の中のファイル名には `01_`, `02_` … と通し番号が付く。 ## AIとの連携 - 全ページの**タイトル一覧**は、会話のたびに自動でAIに渡される(セクションがあれば見出し付きで)。 - ただし本文はここには含まれない。 + ただし本文はここには含まれない(📌 を付けたページを除く)。 - AIは `read_note` ツールでページ本文を読み、`write_note` ツールで追記・置き換え・新規ページ作成ができる。 「ノートに書いておいて」「〇〇について記録して」のように頼むと、AIが該当ページを判断して書き込む。 - AIがノートを書き換えると、開いているノートパネルは自動的に最新の内容へ更新される。 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index a2641f5..4d53f5b 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -75,6 +75,24 @@ export async function fetchMessages(sessionId: string): Promise return parseJson(res, 'メッセージ取得') } +/** このセッションで本文をプロンプトに載せるよう指定されたノートのページID。 */ +export async function fetchSessionNotes(sessionId: string): Promise { + const res = await fetch(`${BASE}/chat/sessions/${sessionId}/notes`) + const body = await parseJson<{ page_ids: number[] }>(res, '参照ノート取得') + return body.page_ids +} + +/** セッションに紐付けるノートを丸ごと置き換える。戻り値は実際に紐付いたページID。 */ +export async function updateSessionNotes(sessionId: string, pageIds: number[]): Promise { + const res = await fetch(`${BASE}/chat/sessions/${sessionId}/notes`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ page_ids: pageIds }), + }) + const body = await parseJson<{ page_ids: number[] }>(res, '参照ノート更新') + return body.page_ids +} + export async function deleteSession(sessionId: string): Promise { await fetch(`${BASE}/chat/sessions/${sessionId}`, { method: 'DELETE' }) } @@ -225,6 +243,8 @@ export function streamChat( message: string, sessionId: string | null, model: string, + /** 本文をプロンプトに載せるノートのページID。新規セッションではここでしか指定できない。 */ + notePageIds: number[], onChunk: (chunk: SSEChunk) => void, onError: (err: Error) => void, ): () => void { @@ -233,7 +253,7 @@ export function streamChat( fetch(`${BASE}/chat/stream`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ message, session_id: sessionId, model }), + body: JSON.stringify({ message, session_id: sessionId, model, note_page_ids: notePageIds }), signal: controller.signal, }) .then(async (res) => { @@ -314,6 +334,17 @@ export async function deleteNotePage(pageId: number): Promise { await fetch(`${BASE}/api/notes/${pageId}`, { method: 'DELETE' }) } +/** + * ファイルをページとして取り込む。.md / .txt は1ファイル1ページ、.zip は中身を展開して複数ページ。 + * 取り込めるファイルが1つも無ければサーバーが 400 を返す。 + */ +export async function importNotePages(files: File[]): Promise { + const form = new FormData() + for (const file of files) form.append('files', file) + const res = await fetch(`${BASE}/api/notes/import`, { method: 'POST', body: form }) + return parseJson(res, 'ノート取り込み') +} + /** * ダウンロードのためだけにアンカーをクリックする。 * ファイル名はサーバーの Content-Disposition に任せたいので download 属性は付けない diff --git a/frontend/src/components/NotePanel.vue b/frontend/src/components/NotePanel.vue index a6cd613..5c65785 100644 --- a/frontend/src/components/NotePanel.vue +++ b/frontend/src/components/NotePanel.vue @@ -1,6 +1,7 @@