Feature/knowledge index and timezone #2

Merged
colcaroli merged 3 commits from feature/knowledge-index-and-timezone into main 2026-08-20 06:00:21 +00:00
12 changed files with 101 additions and 19 deletions
Showing only changes of commit 2ee7238afb - Show all commits
+1 -1
View File
@@ -80,7 +80,7 @@ LLM に渡すコンテキストは `app/core/memory_kb/memory.py` の `get_conte
4. **長期メモリ**: `memories` の要約を全件連結。上限なし 4. **長期メモリ**: `memories` の要約を全件連結。上限なし
5. **短期メモリ**: セッション内の `messages` 全件(要約で削除された分を除く) 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 として自動的に効いてくる。 **遅延読み込み**: 重い情報は目次だけ注入し、`llm.resolve_tool_calls()` が本番生成の前にツール(`web_search` / `read_note` / `write_note` / `read_knowledge`、最大 2 ラウンド)を解決する。Web 検索の結果は構造化要約にされ `knowledge_entries` と `knowledge_base/web_search/*.md` の両方に保存されるため、次回以降は層 1・層 3 として自動的に効いてくる。
+6 -1
View File
@@ -42,6 +42,8 @@ class MessageResponse(BaseModel):
role: str role: str
content: str content: str
created_at: datetime created_at: datetime
# 要約に取り込まれ、LLMには渡らなくなったメッセージ。人間の履歴確認用に残してある。
archived_at: datetime | None = None
tags: list[str] = [] tags: list[str] = []
class Config: class Config:
@@ -52,6 +54,7 @@ class ImportMessage(BaseModel):
role: str role: str
content: str content: str
created_at: datetime | None = None created_at: datetime | None = None
archived_at: datetime | None = None
tags: list[str] = [] tags: list[str] = []
@@ -83,6 +86,7 @@ def get_messages(session_id: str, db: DBSession = Depends(get_db)):
role=m.role, role=m.role,
content=m.content, content=m.content,
created_at=m.created_at, created_at=m.created_at,
archived_at=m.archived_at,
tags=[t.tag for t in m.tags], tags=[t.tag for t in m.tags],
) )
for m in session.messages for m in session.messages
@@ -110,6 +114,7 @@ def export_session(session_id: str, db: DBSession = Depends(get_db)):
"role": m.role, "role": m.role,
"content": m.content, "content": m.content,
"created_at": m.created_at.isoformat(), "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], "tags": [t.tag for t in m.tags],
} }
for m in session.messages for m in session.messages
@@ -143,7 +148,7 @@ def import_session(req: ImportRequest, db: DBSession = Depends(get_db)):
db.flush() db.flush()
for m in req.messages: 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: if m.created_at:
msg.created_at = m.created_at msg.created_at = m.created_at
db.add(msg) db.add(msg)
+26 -4
View File
@@ -1,3 +1,5 @@
from datetime import datetime, timezone
from sqlalchemy.orm import Session as DBSession from sqlalchemy.orm import Session as DBSession
from app.db.models import Memory, Message, Session from app.db.models import Memory, Message, Session
from app.core.config import MEMORY_SUMMARIZE_THRESHOLD 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]: 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() 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] = [] 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: async def maybe_summarize(db: DBSession, session: Session) -> None:
"""メッセージ数が閾値を超えたら古いメッセージを要約してMemoryに保存し、元メッセージを削除する。""" """メッセージ数が閾値を超えたら古いメッセージを要約してMemoryに保存し、元メッセージをアーカイブする。
messages = db.query(Message).filter(Message.session_id == session.id).order_by(Message.created_at).all()
アーカイブしたメッセージは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: if len(messages) < MEMORY_SUMMARIZE_THRESHOLD:
return return
@@ -60,8 +78,12 @@ async def maybe_summarize(db: DBSession, session: Session) -> None:
memory = Memory(session_id=session.id, summary=summary_text) memory = Memory(session_id=session.id, summary=summary_text)
db.add(memory) db.add(memory)
db.flush() # どの要約に取り込まれたかをメッセージ側に持たせるためIDを確定させる
archived_at = datetime.now(timezone.utc)
for msg in to_summarize: for msg in to_summarize:
db.delete(msg) msg.archived_at = archived_at
msg.memory_id = memory.id
db.commit() db.commit()
db.refresh(memory) 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エンドポイントの両方から呼べるようにここに切り出している。 単体スクリプト・APIエンドポイントの両方から呼べるようにここに切り出している。
戻り値: タグ付けしたメッセージ数 戻り値: タグ付けしたメッセージ数
""" """
# アーカイブ済み(要約に取り込まれた)メッセージは対象外。話題選びには要約側のタグを使うため、
# 過去ログ全体にLLMを走らせる意味が薄い。
query = ( query = (
db.query(Message) db.query(Message)
.outerjoin(MessageTag, MessageTag.message_id == Message.id) .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()) .order_by(Message.created_at.asc())
) )
if limit: if limit:
+2
View File
@@ -22,6 +22,8 @@ def get_db():
_ADDED_COLUMNS = [ _ADDED_COLUMNS = [
("note_pages", "section", "VARCHAR"), ("note_pages", "section", "VARCHAR"),
("knowledge_entries", "headline", "VARCHAR"), ("knowledge_entries", "headline", "VARCHAR"),
("messages", "archived_at", "DATETIME"),
("messages", "memory_id", "INTEGER"),
] ]
+7
View File
@@ -26,6 +26,13 @@ class Message(Base):
role: Mapped[str] = mapped_column(String) # "user" | "assistant" | "system" role: Mapped[str] = mapped_column(String) # "user" | "assistant" | "system"
content: Mapped[str] = mapped_column(Text) content: Mapped[str] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc)) 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") session: Mapped["Session"] = relationship(back_populates="messages")
tags: Mapped[list["MessageTag"]] = relationship(back_populates="message", cascade="all, delete-orphan") tags: Mapped[list["MessageTag"]] = relationship(back_populates="message", cascade="all, delete-orphan")
+5 -3
View File
@@ -48,13 +48,15 @@ SSE でストリーミング応答を返す。
--- ---
### GET /chat/sessions/{session_id}/messages ### GET /chat/sessions/{session_id}/messages
セッションのメッセージ一覧を `created_at` 昇順で返す。 セッションのメッセージ一覧を `created_at` 昇順で返す。要約に取り込まれた(アーカイブ済みの)
メッセージも含めて返し、`archived_at` で区別する。アーカイブ済みのものは LLM に渡す
コンテキストには入らないが、履歴としては残っている。
**レスポンス** **レスポンス**
```json ```json
[ [
{ "id": 1, "role": "user", "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": "..." } { "id": 2, "role": "assistant", "content": "...", "created_at": "...", "archived_at": null, "tags": [] }
] ]
``` ```
+1 -1
View File
@@ -91,7 +91,7 @@ LLM に渡すコンテキストは `memory_kb/memory.py` の `get_context_messag
1. メッセージ数が `MEMORY_SUMMARIZE_THRESHOLD`(既定 20)以上になったら発火 1. メッセージ数が `MEMORY_SUMMARIZE_THRESHOLD`(既定 20)以上になったら発火
2. 古い側の半分(既定 10 件)を LLM で要約し `memories` に 1 行保存 2. 古い側の半分(既定 10 件)を LLM で要約し `memories` に 1 行保存
3. **要約した元メッセージは DB から物理削除**する(復元不可。エクスポートしても生ログは残らない) 3. **要約した元メッセージは `archived_at` / `memory_id` を立ててアーカイブ**する(物理削除はしない)。LLM に渡すコンテキストからも、次回以降の要約対象からも外れるが、DB には残るため履歴表示・エクスポートからは読める
4. 保存した要約に `tagging.tag_memory()` でタグを付ける 4. 保存した要約に `tagging.tag_memory()` でタグを付ける
#### ツール経由の遅延読み込み #### ツール経由の遅延読み込み
+3 -3
View File
@@ -58,7 +58,7 @@ Discord 連携は [discord-bot.md](discord-bot.md)、ノートの操作方法は
- `GET /chat/sessions/{id}/export` がセッションのメタ情報と全メッセージ(タグ込み)を JSON で返す。 - `GET /chat/sessions/{id}/export` がセッションのメタ情報と全メッセージ(タグ込み)を JSON で返す。
- `POST /chat/sessions/import` は同じ形式の JSON から**新しいセッションを作成**する(上書きではない)。 - `POST /chat/sessions/import` は同じ形式の JSON から**新しいセッションを作成**する(上書きではない)。
サイドバーの ⭱ ボタンからファイルを選ぶだけで取り込める。 サイドバーの ⭱ ボタンからファイルを選ぶだけで取り込める。
- 注意: 要約済みの会話は元メッセージが物理削除されているため、エクスポートしても生ログは戻らない。 - 要約済み(アーカイブ済み)のメッセージも `archived_at` 付きでそのまま書き出され、取り込み時もアーカイブ状態を保つ。
## 3. コンテキスト管理(5 層) ## 3. コンテキスト管理(5 層)
@@ -79,7 +79,7 @@ Discord 連携は [discord-bot.md](discord-bot.md)、ノートの操作方法は
- メッセージ数が `MEMORY_SUMMARIZE_THRESHOLD`(既定 20)を超えると、古い側の半分を LLM が要約して - メッセージ数が `MEMORY_SUMMARIZE_THRESHOLD`(既定 20)を超えると、古い側の半分を LLM が要約して
`memories` に 1 行として保存する。 `memories` に 1 行として保存する。
- **要約した元メッセージは DB から物理削除される**(復元不可)。 - **要約した元メッセージはアーカイブされる**(物理削除はしない)。LLM に渡るコンテキストからは外れるが、チャット画面では薄く表示され、区切り線から先だけがモデルに渡ることが分かるようになっている。
- 保存した要約にもタグが付き、Discord Bot の話題選びの材料になる。 - 保存した要約にもタグが付き、Discord Bot の話題選びの材料になる。
- 溜まった要約は `GET /memory/{session_id}` で確認、`DELETE /memory/{id}` で個別削除できる(API のみ。専用の画面は無い)。 - 溜まった要約は `GET /memory/{session_id}` で確認、`DELETE /memory/{id}` で個別削除できる(API のみ。専用の画面は無い)。
@@ -182,7 +182,7 @@ Discord 連携は [discord-bot.md](discord-bot.md)、ノートの操作方法は
- **トークン数ベースの制御が一切ない**。すべて件数・文字数ベースなので、層 3(Markdown 全文 ×3)は - **トークン数ベースの制御が一切ない**。すべて件数・文字数ベースなので、層 3(Markdown 全文 ×3)は
会話が短くてもコンテキストを大きく押し上げる(層 1 は見出しのみなので軽い)。 会話が短くてもコンテキストを大きく押し上げる(層 1 は見出しのみなので軽い)。
- **長期メモリに上限がない**。長いセッションでは要約が積み上がり続ける(要約の再要約はしない)。 - **長期メモリに上限がない**。長いセッションでは要約が積み上がり続ける(要約の再要約はしない)。
- **要約は不可逆**。要約対象になった元メッセージは DB から消える。 - **要約自体は不可逆**。アーカイブされた元メッセージは残るが、要約をやり直したり元の状態に戻す手段は無い。
- **ワードプールは揮発性**。再起動でリセットされ、直近 200 件から作り直される。 - **ワードプールは揮発性**。再起動でリセットされ、直近 200 件から作り直される。
- **DB マイグレーションは簡易実装**。Alembic は依存に入っているが使っておらず、起動時に - **DB マイグレーションは簡易実装**。Alembic は依存に入っているが使っておらず、起動時に
`PRAGMA table_info` を見て不足カラムを `ALTER TABLE` で足すだけ(SQLite 前提)。 `PRAGMA table_info` を見て不足カラムを `ALTER TABLE` で足すだけ(SQLite 前提)。
+3
View File
@@ -51,6 +51,8 @@ export interface MessageRecord {
role: 'user' | 'assistant' | 'system' role: 'user' | 'assistant' | 'system'
content: string content: string
created_at: string created_at: string
// 要約に取り込まれたメッセージ。モデルには渡らないが履歴としては残っている
archived_at: string | null
tags: string[] tags: string[]
} }
@@ -83,6 +85,7 @@ export interface ImportPayload {
role: string role: string
content: string content: string
created_at?: string | null created_at?: string | null
archived_at?: string | null
tags?: string[] tags?: string[]
}[] }[]
} }
+33 -4
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, nextTick } from 'vue' import { computed, ref, watch, nextTick } from 'vue'
import { useChatStore } from '../stores/chat' import { useChatStore } from '../stores/chat'
import MarkdownContent from './MarkdownContent.vue' import MarkdownContent from './MarkdownContent.vue'
import type { Source } from '../api/client' import type { Source } from '../api/client'
@@ -7,6 +7,14 @@ import type { Source } from '../api/client'
const store = useChatStore() const store = useChatStore()
const scrollEl = ref<HTMLElement | null>(null) const scrollEl = ref<HTMLElement | null>(null)
// 要約済みメッセージの最後の位置。ここに「ここから先がモデルに渡る」区切りを描く。
const lastArchivedIndex = computed(() => {
for (let i = store.messages.length - 1; i >= 0; i--) {
if (store.messages[i].archived) return i
}
return -1
})
function uniqueSources(sources: Source[]): Source[] { function uniqueSources(sources: Source[]): Source[] {
const seen = new Set<string>() const seen = new Set<string>()
return sources.filter((s) => { return sources.filter((s) => {
@@ -30,10 +38,10 @@ watch(
<div v-if="store.messages.length === 0" class="empty"> <div v-if="store.messages.length === 0" class="empty">
メッセージを送信してチャットを開始してください メッセージを送信してチャットを開始してください
</div> </div>
<template v-for="(msg, i) in store.messages" :key="i">
<div <div
v-for="(msg, i) in store.messages" :class="['message-wrap', msg.role, { archived: msg.archived }]"
:key="i" :title="msg.archived ? '要約済み: 内容は長期メモリに引き継がれ、この発言自体はモデルに渡りません' : undefined"
:class="['message-wrap', msg.role]"
> >
<div :class="['message', msg.role, { error: msg.error }]"> <div :class="['message', msg.role, { error: msg.error }]">
<div class="bubble"> <div class="bubble">
@@ -65,6 +73,10 @@ watch(
>{{ src.title || src.url }}</a> >{{ src.title || src.url }}</a>
</div> </div>
</div> </div>
<div v-if="i === lastArchivedIndex" class="archive-divider">
<span>ここまでは要約済み — 履歴として残っていますが、モデルには要約だけが渡ります</span>
</div>
</template>
</div> </div>
</template> </template>
@@ -85,6 +97,23 @@ watch(
.message-wrap { display: flex; flex-direction: column; gap: 4px; } .message-wrap { display: flex; flex-direction: column; gap: 4px; }
.message-wrap.user { align-items: flex-end; } .message-wrap.user { align-items: flex-end; }
.message-wrap.assistant { align-items: flex-start; } .message-wrap.assistant { align-items: flex-start; }
/* 要約済み(モデルには渡らない)メッセージ。読めるが現役の発言と区別できる程度に落とす */
.message-wrap.archived { opacity: 0.45; }
.message-wrap.archived:hover { opacity: 0.75; }
.archive-divider {
display: flex;
align-items: center;
gap: 8px;
margin: 4px 0;
color: #777;
font-size: 11px;
}
.archive-divider::before,
.archive-divider::after {
content: '';
flex: 1;
border-top: 1px dashed #3a3a3a;
}
.message { display: flex; width: 100%; } .message { display: flex; width: 100%; }
.message.user { justify-content: flex-end; } .message.user { justify-content: flex-end; }
.message.assistant { justify-content: flex-start; } .message.assistant { justify-content: flex-start; }
+7 -1
View File
@@ -8,6 +8,7 @@ export interface Message {
content: string content: string
pending?: boolean pending?: boolean
error?: boolean error?: boolean
archived?: boolean
tags?: string[] tags?: string[]
sources?: Source[] sources?: Source[]
} }
@@ -55,7 +56,12 @@ export const useChatStore = defineStore('chat', () => {
const records = await fetchMessages(sessionId) const records = await fetchMessages(sessionId)
messages.value = records messages.value = records
.filter((r) => r.role === 'user' || r.role === 'assistant') .filter((r) => r.role === 'user' || r.role === 'assistant')
.map((r) => ({ role: r.role as 'user' | 'assistant', content: r.content, tags: r.tags })) .map((r) => ({
role: r.role as 'user' | 'assistant',
content: r.content,
tags: r.tags,
archived: r.archived_at !== null,
}))
} catch (e) { } catch (e) {
pushToast(`メッセージ取得失敗: ${e instanceof Error ? e.message : e}`) pushToast(`メッセージ取得失敗: ${e instanceof Error ? e.message : e}`)
} }