Archive summarized messages instead of deleting them

要約で長期メモリに移したメッセージをDBから物理削除していたため、
一度要約されると人間も内容を確認できなくなっていた。削除をやめ、
messages に archived_at(要約に取り込まれた時刻)と memory_id
(どの要約に取り込まれたか)を持たせる。

モデルからは従来どおり見えない:
- get_context_messages() がアーカイブ済みを除外する
- maybe_summarize() の閾値判定も現役メッセージだけを数える
  (ここを除外し忘れると件数が減らず再要約が繰り返される)
- backfill_tags() もアーカイブ済みは対象外にする

人間からは確認できる:
- GET /chat/sessions/{id}/messages がアーカイブ済みも archived_at
  付きで返す。export/import もアーカイブ状態を保持する
- チャット画面では半透明で表示し、最後のアーカイブ済みメッセージの
  直後に「ここから先がモデルに渡る」区切り線を出す

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 14:33:18 +09:00
co-authored by Claude Opus 5
parent be3af44891
commit 2ee7238afb
12 changed files with 101 additions and 19 deletions
+26 -4
View File
@@ -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)
+7 -1
View File
@@ -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: