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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user