diff --git a/CLAUDE.md b/CLAUDE.md index 4b735e9..1317f5d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,7 +80,7 @@ LLM に渡すコンテキストは `app/core/memory_kb/memory.py` の `get_conte 4. **長期メモリ**: `memories` の要約を全件連結。上限なし 5. **短期メモリ**: セッション内の `messages` 全件(要約で削除された分を除く) -**要約**: `maybe_summarize()` がアシスタント応答の保存直後に走り、メッセージ数が `MEMORY_SUMMARIZE_THRESHOLD`(既定 20)以上になると古い半分を LLM で要約して `memories` に保存する。**要約した元メッセージは物理削除される**(復元不可)。 +**要約**: `maybe_summarize()` がアシスタント応答の保存直後に走り、メッセージ数が `MEMORY_SUMMARIZE_THRESHOLD`(既定 20)以上になると古い半分を LLM で要約して `memories` に保存する。**要約した元メッセージは `archived_at` を立ててアーカイブされる**(物理削除はしない)。アーカイブ済みメッセージは `get_context_messages()` と要約の閾値判定の両方から除外されるため LLM には渡らないが、DB には残り、履歴表示・エクスポートからは参照できる。`memory_id` にどの要約に取り込まれたかを保持する。 **遅延読み込み**: 重い情報は目次だけ注入し、`llm.resolve_tool_calls()` が本番生成の前にツール(`web_search` / `read_note` / `write_note` / `read_knowledge`、最大 2 ラウンド)を解決する。Web 検索の結果は構造化要約にされ `knowledge_entries` と `knowledge_base/web_search/*.md` の両方に保存されるため、次回以降は層 1・層 3 として自動的に効いてくる。 diff --git a/backend/app/api/chat.py b/backend/app/api/chat.py index 3462adc..0c63980 100644 --- a/backend/app/api/chat.py +++ b/backend/app/api/chat.py @@ -42,6 +42,8 @@ class MessageResponse(BaseModel): role: str content: str created_at: datetime + # 要約に取り込まれ、LLMには渡らなくなったメッセージ。人間の履歴確認用に残してある。 + archived_at: datetime | None = None tags: list[str] = [] class Config: @@ -52,6 +54,7 @@ class ImportMessage(BaseModel): role: str content: str created_at: datetime | None = None + archived_at: datetime | None = None tags: list[str] = [] @@ -83,6 +86,7 @@ def get_messages(session_id: str, db: DBSession = Depends(get_db)): role=m.role, content=m.content, created_at=m.created_at, + archived_at=m.archived_at, tags=[t.tag for t in m.tags], ) for m in session.messages @@ -110,6 +114,7 @@ def export_session(session_id: str, db: DBSession = Depends(get_db)): "role": m.role, "content": m.content, "created_at": m.created_at.isoformat(), + "archived_at": m.archived_at.isoformat() if m.archived_at else None, "tags": [t.tag for t in m.tags], } for m in session.messages @@ -143,7 +148,7 @@ def import_session(req: ImportRequest, db: DBSession = Depends(get_db)): db.flush() for m in req.messages: - msg = Message(session_id=session.id, role=m.role, content=m.content) + msg = Message(session_id=session.id, role=m.role, content=m.content, archived_at=m.archived_at) if m.created_at: msg.created_at = m.created_at db.add(msg) diff --git a/backend/app/core/memory_kb/memory.py b/backend/app/core/memory_kb/memory.py index fca300a..25c8b25 100644 --- a/backend/app/core/memory_kb/memory.py +++ b/backend/app/core/memory_kb/memory.py @@ -1,3 +1,5 @@ +from datetime import datetime, timezone + from sqlalchemy.orm import Session as DBSession from app.db.models import Memory, Message, Session from app.core.config import MEMORY_SUMMARIZE_THRESHOLD @@ -11,7 +13,13 @@ from app.core import notes async def get_context_messages(db: DBSession, session_id: str) -> list[dict]: """蓄積知識・長期メモリ(要約)・短期メモリ(最近のメッセージ)を結合してコンテキストを返す。""" memories = db.query(Memory).filter(Memory.session_id == session_id).order_by(Memory.created_at).all() - messages = db.query(Message).filter(Message.session_id == session_id).order_by(Message.created_at).all() + # アーカイブ済み(要約に取り込まれた)メッセージは、その内容が長期メモリ側に入っているため除外する。 + messages = ( + db.query(Message) + .filter(Message.session_id == session_id, Message.archived_at.is_(None)) + .order_by(Message.created_at) + .all() + ) context: list[dict] = [] @@ -42,8 +50,18 @@ async def get_context_messages(db: DBSession, session_id: str) -> list[dict]: async def maybe_summarize(db: DBSession, session: Session) -> None: - """メッセージ数が閾値を超えたら古いメッセージを要約してMemoryに保存し、元メッセージを削除する。""" - messages = db.query(Message).filter(Message.session_id == session.id).order_by(Message.created_at).all() + """メッセージ数が閾値を超えたら古いメッセージを要約してMemoryに保存し、元メッセージをアーカイブする。 + + アーカイブしたメッセージはLLMに渡すコンテキストからは外れるが、人間が後から履歴を + 確認できるようDBには残す(物理削除はしない)。閾値の判定もアーカイブ済みを除いた + 件数で行うため、一度要約した分が再び対象になることはない。 + """ + messages = ( + db.query(Message) + .filter(Message.session_id == session.id, Message.archived_at.is_(None)) + .order_by(Message.created_at) + .all() + ) if len(messages) < MEMORY_SUMMARIZE_THRESHOLD: return @@ -60,8 +78,12 @@ async def maybe_summarize(db: DBSession, session: Session) -> None: memory = Memory(session_id=session.id, summary=summary_text) db.add(memory) + db.flush() # どの要約に取り込まれたかをメッセージ側に持たせるためIDを確定させる + + archived_at = datetime.now(timezone.utc) for msg in to_summarize: - db.delete(msg) + msg.archived_at = archived_at + msg.memory_id = memory.id db.commit() db.refresh(memory) diff --git a/backend/app/core/memory_kb/tagging.py b/backend/app/core/memory_kb/tagging.py index 022b207..1277821 100644 --- a/backend/app/core/memory_kb/tagging.py +++ b/backend/app/core/memory_kb/tagging.py @@ -185,10 +185,16 @@ async def backfill_tags(db: DBSession, model: str | None = None, limit: int | No 単体スクリプト・APIエンドポイントの両方から呼べるようにここに切り出している。 戻り値: タグ付けしたメッセージ数 """ + # アーカイブ済み(要約に取り込まれた)メッセージは対象外。話題選びには要約側のタグを使うため、 + # 過去ログ全体にLLMを走らせる意味が薄い。 query = ( db.query(Message) .outerjoin(MessageTag, MessageTag.message_id == Message.id) - .filter(Message.role.in_(["user", "assistant"]), MessageTag.id.is_(None)) + .filter( + Message.role.in_(["user", "assistant"]), + MessageTag.id.is_(None), + Message.archived_at.is_(None), + ) .order_by(Message.created_at.asc()) ) if limit: diff --git a/backend/app/db/database.py b/backend/app/db/database.py index 12bc486..cb4b127 100644 --- a/backend/app/db/database.py +++ b/backend/app/db/database.py @@ -22,6 +22,8 @@ def get_db(): _ADDED_COLUMNS = [ ("note_pages", "section", "VARCHAR"), ("knowledge_entries", "headline", "VARCHAR"), + ("messages", "archived_at", "DATETIME"), + ("messages", "memory_id", "INTEGER"), ] diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 24d044e..451b7b1 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -26,6 +26,13 @@ class Message(Base): role: Mapped[str] = mapped_column(String) # "user" | "assistant" | "system" content: Mapped[str] = mapped_column(Text) created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc)) + # 要約されて長期メモリに移ったメッセージ。LLMに渡すコンテキストからは外れるが、 + # 人間が後から履歴を確認できるようDBには残す(app.core.memory_kb.memory.maybe_summarize を参照)。 + archived_at: Mapped[datetime | None] = mapped_column(UtcDateTime, nullable=True) + # どの要約に取り込まれたか。要約を削除してもメッセージ自体は残す(SET NULL)。 + memory_id: Mapped[int | None] = mapped_column( + ForeignKey("memories.id", ondelete="SET NULL"), nullable=True, + ) session: Mapped["Session"] = relationship(back_populates="messages") tags: Mapped[list["MessageTag"]] = relationship(back_populates="message", cascade="all, delete-orphan") diff --git a/docs/api.md b/docs/api.md index 386df5b..ffcb827 100644 --- a/docs/api.md +++ b/docs/api.md @@ -48,13 +48,15 @@ SSE でストリーミング応答を返す。 --- ### GET /chat/sessions/{session_id}/messages -セッションのメッセージ一覧を `created_at` 昇順で返す。 +セッションのメッセージ一覧を `created_at` 昇順で返す。要約に取り込まれた(アーカイブ済みの) +メッセージも含めて返し、`archived_at` で区別する。アーカイブ済みのものは LLM に渡す +コンテキストには入らないが、履歴としては残っている。 **レスポンス** ```json [ - { "id": 1, "role": "user", "content": "...", "created_at": "..." }, - { "id": 2, "role": "assistant", "content": "...", "created_at": "..." } + { "id": 1, "role": "user", "content": "...", "created_at": "...", "archived_at": "2026-08-20T04:00:00+00:00", "tags": [] }, + { "id": 2, "role": "assistant", "content": "...", "created_at": "...", "archived_at": null, "tags": [] } ] ``` diff --git a/docs/architecture.md b/docs/architecture.md index dce5a87..4407178 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -91,7 +91,7 @@ LLM に渡すコンテキストは `memory_kb/memory.py` の `get_context_messag 1. メッセージ数が `MEMORY_SUMMARIZE_THRESHOLD`(既定 20)以上になったら発火 2. 古い側の半分(既定 10 件)を LLM で要約し `memories` に 1 行保存 -3. **要約した元メッセージは DB から物理削除**する(復元不可。エクスポートしても生ログは残らない) +3. **要約した元メッセージは `archived_at` / `memory_id` を立ててアーカイブ**する(物理削除はしない)。LLM に渡すコンテキストからも、次回以降の要約対象からも外れるが、DB には残るため履歴表示・エクスポートからは読める 4. 保存した要約に `tagging.tag_memory()` でタグを付ける #### ツール経由の遅延読み込み diff --git a/docs/features.md b/docs/features.md index 64ad8af..bd7030d 100644 --- a/docs/features.md +++ b/docs/features.md @@ -58,7 +58,7 @@ Discord 連携は [discord-bot.md](discord-bot.md)、ノートの操作方法は - `GET /chat/sessions/{id}/export` がセッションのメタ情報と全メッセージ(タグ込み)を JSON で返す。 - `POST /chat/sessions/import` は同じ形式の JSON から**新しいセッションを作成**する(上書きではない)。 サイドバーの ⭱ ボタンからファイルを選ぶだけで取り込める。 -- 注意: 要約済みの会話は元メッセージが物理削除されているため、エクスポートしても生ログは戻らない。 +- 要約済み(アーカイブ済み)のメッセージも `archived_at` 付きでそのまま書き出され、取り込み時もアーカイブ状態を保つ。 ## 3. コンテキスト管理(5 層) @@ -79,7 +79,7 @@ Discord 連携は [discord-bot.md](discord-bot.md)、ノートの操作方法は - メッセージ数が `MEMORY_SUMMARIZE_THRESHOLD`(既定 20)を超えると、古い側の半分を LLM が要約して `memories` に 1 行として保存する。 -- **要約した元メッセージは DB から物理削除される**(復元不可)。 +- **要約した元メッセージはアーカイブされる**(物理削除はしない)。LLM に渡るコンテキストからは外れるが、チャット画面では薄く表示され、区切り線から先だけがモデルに渡ることが分かるようになっている。 - 保存した要約にもタグが付き、Discord Bot の話題選びの材料になる。 - 溜まった要約は `GET /memory/{session_id}` で確認、`DELETE /memory/{id}` で個別削除できる(API のみ。専用の画面は無い)。 @@ -182,7 +182,7 @@ Discord 連携は [discord-bot.md](discord-bot.md)、ノートの操作方法は - **トークン数ベースの制御が一切ない**。すべて件数・文字数ベースなので、層 3(Markdown 全文 ×3)は 会話が短くてもコンテキストを大きく押し上げる(層 1 は見出しのみなので軽い)。 - **長期メモリに上限がない**。長いセッションでは要約が積み上がり続ける(要約の再要約はしない)。 -- **要約は不可逆**。要約対象になった元メッセージは DB から消える。 +- **要約自体は不可逆**。アーカイブされた元メッセージは残るが、要約をやり直したり元の状態に戻す手段は無い。 - **ワードプールは揮発性**。再起動でリセットされ、直近 200 件から作り直される。 - **DB マイグレーションは簡易実装**。Alembic は依存に入っているが使っておらず、起動時に `PRAGMA table_info` を見て不足カラムを `ALTER TABLE` で足すだけ(SQLite 前提)。 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 3daf3e6..a1bcade 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -51,6 +51,8 @@ export interface MessageRecord { role: 'user' | 'assistant' | 'system' content: string created_at: string + // 要約に取り込まれたメッセージ。モデルには渡らないが履歴としては残っている + archived_at: string | null tags: string[] } @@ -83,6 +85,7 @@ export interface ImportPayload { role: string content: string created_at?: string | null + archived_at?: string | null tags?: string[] }[] } diff --git a/frontend/src/components/ChatWindow.vue b/frontend/src/components/ChatWindow.vue index 93df71f..b71ca52 100644 --- a/frontend/src/components/ChatWindow.vue +++ b/frontend/src/components/ChatWindow.vue @@ -1,5 +1,5 @@