feat: 段页式记忆流转、coldcontext 重命名与安全治理

记忆层:
- MemoryBlock 承载 FrameTS/PageSeq/PageKind/BlockRole/CallID
- coldcontext 归档与回填按完整页传递,工具调用参数和配对键不丢失
- 召回先估算总 token,超预算时按全局页相似度标记 inactive
- inactive 仅是 context 渲染期状态,不落盘、不进入 coldcontext,Prune 后重置
- Peek/确认/Remove 两阶段召回,失败保留冷上下文副本
- stablemem 记录 last_recalled_at,删块连带清理边

命名:
- internal/memory/document → internal/memory/coldcontext
- Doc/DocEvent/DocHit → ColdDoc/ColdEvent/ColdHit
- ContextToColdContext/RecentColdContexts/FindColdContexts
- 保留既有 JSON 标签、doc_ 文件前缀与 doc_count 状态键,兼容存量数据

安全治理:
- 工具策略门禁、安全配置、事件总线发布者身份与入站脱敏
- 声明式 SecurityAPI 与 QQ owner/guest profile
- 日志保留策略与相关 e2e/回归判据

验证:go build ./...、go vet ./...、go test ./...(62 包);
生产副本 e2e:7013 块迁移后 ID/向量/指纹 0 变化、0 缺失。
This commit is contained in:
JianFeeeee
2026-10-29 23:53:48 +08:00
parent a3e50b395f
commit b850feaef2
113 changed files with 6512 additions and 790 deletions

View File

@ -18,6 +18,38 @@ const (
BlockAudio BlockModality = "audio"
)
// PageKind 是「页」的类型:一页 = 一次完整往返。
//
// 用户裁定(2026-10-29):
//
// 对 toolcall 来说,页 = toolcall 与其对应的 toolresult
// 对文本来说, 页 = llm 的 input 与其对应的 llm output
//
// 这两类是**渲染正确性的最小单位**:整页留下或整页剔除,
// 永远不会产生"有 call 没 result"的破帧。
type PageKind string
const (
// PageLLMRound 是一次模型往返(input → output)。
PageLLMRound PageKind = "llm_round"
// PageToolRound 是一次工具往返(call → result),靠 CallID 配对。
PageToolRound PageKind = "tool_round"
)
// BlockRole 是块在**页内**的角色。
//
// 每页拆成 request / response 两块(而不是合成一块)的理由:
// 用户的提问与模型的回答语义常常不同,合成一块后只能有一个向量,
// 检索时无法单独命中"用户问的"或"模型答的"。
type BlockRole string
const (
// RoleRequest 是页的请求端:llm 的 input,或 tool 的 call(含参数)。
RoleRequest BlockRole = "request"
// RoleResponse 是页的响应端:llm 的 output,或 tool 的 result。
RoleResponse BlockRole = "response"
)
// MemoryBlock 是 Context、Document、Graph 三层共同使用的记忆块值。
//
// 它不携带 Layer、Owner 或 RefCount:块当前由哪个层的容器持有,哪个层就是
@ -62,9 +94,72 @@ type MemoryBlock struct {
// ★ 2026-10-04 加入。此前 Triple 标注的类型只写旧表,块侧完全没有 ——
// 判据 TestGraphCommit_CarriesAllFields 抓到:
// Commit 标注的 (Person, Animal) 读回来是 ("block","block")。
SemanticType string `json:"semantic_type,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
SemanticType string `json:"semantic_type,omitempty"`
// ─────────── 段页式身份(2026-10-29)───────────
//
// ## 为什么需要这三个字段
//
// 设计约束(用户裁定):**淘汰/渲染的最小单位必须是"移除后上下文仍渲染正确"**。
// 而"渲染正确"的硬性要求是 OpenAI 协议那条:assistant(tool_calls)
// 后面必须紧跟配对齐全的 tool 消息。所以最小单位不是单块,而是**页**。
//
// 段 Frame = 一次完整任务(一个 FrameTS)
// └─ 页 Page = 一次完整往返
// ├─ llm_round : llm_input ──→ llm_output
// └─ tool_round : tool_call ──配对(CallID)──→ tool_result
//
// ## 为什么用时间戳而不是自增帧号
//
// 用户裁定:帧号**直接用时间戳**,不另造自增编号。理由:
// · 时间戳在 L1/L2/L3 三层天然一致,不需要额外的编号分配器
// · 跨进程/跨层迁移时无需协调(自增号需要中心分配,重启即冲突)
// · 天然可排序(渲染时序直接按它排)
//
// ## 为什么不跨帧
//
// 同一 FrameTS 的块不管走到哪层,**整批待在同一层**。原因不是洁癖:
// 帧是最小可渲染单元,拆开就会出现"有 call 没 result"的破帧。
// 判据见 §C7。
//
// ★ 旧数据(FrameTS 为零值)视为"独立帧",各自成帧、不参与配对校验。
// 这是迁移的兼容路径:不能因为它们没有帧号就拒绝写入或丢弃。
FrameTS time.Time `json:"frame_ts,omitempty"`
// PageSeq 是帧内的**页序号**(从 1 开始,连续不减)。
//
// 与 FrameTS 一起构成页的身份:`(FrameTS, PageSeq)`。
// 同一页内的块(request/response,或 call/result)靠它归组。
PageSeq int `json:"page_seq,omitempty"`
// PageKind 标记这一页是哪种往返。
PageKind PageKind `json:"page_kind,omitempty"`
// BlockRole 是块在页内的角色(request 端 / response 端)。
BlockRole BlockRole `json:"block_role,omitempty"`
// CallID 是 tool_round 的**配对键**:tool_call 块与它的 tool_result 块
// 必须同 CallID。llm_round 的页此字段为空。
//
// ★ 为什么不靠 (FrameTS, PageSeq) 配对:并行工具调用时,
// 一页里有 N 个 call(assistant 一次声明多个),它们共用 PageSeq
// 但各自配对不同的 result —— 必须有 CallID 才分得清。
CallID string `json:"call_id,omitempty"`
// Inactive 标记该页**不参与渲染**(但仍可回读、仍在原层)。
//
// 用户裁定:段超预算时按相似度**剔除**低分页 —— 实际是标注 inactive,
// 不渲染为消息。数据不删、层不迁、随时可读回,因此不是"静默丢失"。
// 渲染时必须在原位置留下提示行,让模型知道"这里省略了内容"。
Inactive bool `json:"inactive,omitempty"`
// LastRecalledAt 是最近一次被召回的时间(P6 回收判定)。
//
// ★ 为什么用「被召回」而不是「被创建」判死活:
// 判断一块记忆有没有用,看它有没有被想起来,不是什么时候写进来的。
// 旧块若一直命中查询,它是活的 —— 不能因为"老"就回收。
//
// 零值 = 从未被召回(回收候选);非零 = 至少用过一次(按时间窗判定)。
// 两者刻意分开:从不被召回 与 很久没被召回 是不同的病。
LastRecalledAt time.Time `json:"last_recalled_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// MemoryBlockEdge 是 L3 中连接一等记忆节点的结构化语义边。
@ -134,8 +229,10 @@ func (g *GraphDB) PutMemoryBlocks(blocks []MemoryBlock) error {
}
_, err = tx.Exec(`INSERT INTO memory_blocks (
id, modality, text_content, data, mime, size, width, height,
vector, fingerprint, source, tool, scene, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
vector, fingerprint, source, tool, scene, created_at, updated_at,
frame_ts, page_seq, page_kind, block_role, call_id, inactive,
last_recalled_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
modality = excluded.modality,
text_content = excluded.text_content,
@ -158,10 +255,37 @@ func (g *GraphDB) PutMemoryBlocks(blocks []MemoryBlock) error {
-- 后写的三元组往往不带类型(Triple.SubjectType 可选),
-- 若让它覆盖已有类型,一次不带标注的写入就会抹掉语义类别。
semantic_type = CASE WHEN excluded.semantic_type != '' THEN excluded.semantic_type ELSE memory_blocks.semantic_type END,
-- ★ 段页式字段一律「空值不覆盖」:轻量读取路径(lean 列 SELECT)
-- 取回的块不带这些字段,写回时若直接覆盖会抹掉页身份。
-- 页身份一旦被抹,块就再也归不回原来的页 —— 渲染会破配对。
frame_ts = CASE WHEN excluded.frame_ts IS NOT NULL AND excluded.frame_ts != ''
THEN excluded.frame_ts ELSE memory_blocks.frame_ts END,
page_seq = CASE WHEN excluded.page_seq > 0 THEN excluded.page_seq ELSE memory_blocks.page_seq END,
page_kind = CASE WHEN excluded.page_kind != '' THEN excluded.page_kind ELSE memory_blocks.page_kind END,
block_role = CASE WHEN excluded.block_role != '' THEN excluded.block_role ELSE memory_blocks.block_role END,
call_id = CASE WHEN excluded.call_id != '' THEN excluded.call_id ELSE memory_blocks.call_id END,
-- ★ inactive 是「标注」,所以要能被**显式改回 false**(回读后恢复渲染)。
-- 因此它不能走「空值不覆盖」——false 是合法值,必须照写。
inactive = excluded.inactive,
-- ★ 召回时间只**前进不后退**(P6)。
--
-- 轻量读取路径拿回的块不带 last_recalled_at(零值),
-- 若直接覆盖就把"曾经被召回过"抹成"从未被召回" ——
-- 于是这块活数据在回收时被误判为死块删掉。
-- 保留两者的较大值:NULL/零值不会覆盖已有时间。
last_recalled_at = CASE
WHEN excluded.last_recalled_at IS NULL THEN memory_blocks.last_recalled_at
WHEN memory_blocks.last_recalled_at IS NULL THEN excluded.last_recalled_at
WHEN excluded.last_recalled_at > memory_blocks.last_recalled_at
THEN excluded.last_recalled_at
ELSE memory_blocks.last_recalled_at END,
updated_at = excluded.updated_at`,
block.ID, block.Modality, block.Text, block.Data, block.MIME,
block.Size, block.Width, block.Height, string(vectorJSON), block.Fingerprint,
block.Source, block.Tool, block.Scene, block.CreatedAt, now)
block.Source, block.Tool, block.Scene, block.CreatedAt, now,
frameTSArg(block.FrameTS), block.PageSeq, string(block.PageKind),
string(block.BlockRole), block.CallID, block.Inactive,
frameTSArg(block.LastRecalledAt))
if err != nil {
return fmt.Errorf("put memory block %s: %w", block.ID, err)
}
@ -304,19 +428,40 @@ func (g *GraphDB) MemoryBlocks() ([]MemoryBlock, error) {
// 但负载**按需加载**(见 BlockData)——这与被淘汰的 CAS 有本质区别:
// 那里字节归另一个存储层所有,这里归块自己。
const blockColumns = `id, modality, text_content, mime, size, width, height,
vector, fingerprint, source, tool, scene, semantic_type, created_at, updated_at`
vector, fingerprint, source, tool, scene, semantic_type, created_at, updated_at,
frame_ts, page_seq, page_kind, block_role, call_id, inactive, last_recalled_at`
func scanBlockRow(rows *sql.Rows) (MemoryBlock, error) {
var b MemoryBlock
var vectorJSON string
var scene sql.NullString
// 段页式字段一律用 Null* 扫:存量行的 frame_ts 是 NULL、其余有 DEFAULT,
// 直接扫进零值类型会在 NULL 上报错(sql: Scan error on column "frame_ts")。
var frameTS sql.NullTime
var pageSeq sql.NullInt64
var pageKind, blockRole, callID sql.NullString
var inactive sql.NullInt64
var lastRecalled sql.NullTime
if err := rows.Scan(&b.ID, &b.Modality, &b.Text,
&b.MIME, &b.Size, &b.Width, &b.Height, &vectorJSON,
&b.Fingerprint, &b.Source, &b.Tool, &scene, &b.SemanticType,
&b.CreatedAt, &b.UpdatedAt); err != nil {
&b.CreatedAt, &b.UpdatedAt,
&frameTS, &pageSeq, &pageKind, &blockRole, &callID, &inactive,
&lastRecalled); err != nil {
return b, err
}
if lastRecalled.Valid {
b.LastRecalledAt = lastRecalled.Time
}
b.Scene = scene.String
if frameTS.Valid {
b.FrameTS = frameTS.Time
}
b.PageSeq = int(pageSeq.Int64)
b.PageKind = PageKind(pageKind.String)
b.BlockRole = BlockRole(blockRole.String)
b.CallID = callID.String
b.Inactive = inactive.Int64 != 0
if vectorJSON != "" && vectorJSON != "null" {
if err := json.Unmarshal([]byte(vectorJSON), &b.Vector); err != nil {
return b, fmt.Errorf("decode memory block %s vector: %w", b.ID, err)
@ -831,3 +976,15 @@ func (g *GraphDB) MemoryEdgeCount() (int, error) {
}
return n, nil
}
// frameTSArg 把零值 FrameTS 写成 NULL 而不是 "0001-01-01"。
//
// 为什么必须区分:零值表示「旧数据/未分帧」,与真实时间戳不是一回事。
// 若写成 "0001-01-01",迁移脚本会把它们当成一个真实的极早帧聚到一起,
// 于是所有存量块被错误地归入同一页。
func frameTSArg(t time.Time) interface{} {
if t.IsZero() {
return nil
}
return t
}

View File

@ -6,6 +6,8 @@ import (
"log"
"math"
"sort"
"strings"
"time"
)
// 块节点的向量召回。
@ -174,9 +176,51 @@ func (g *GraphDB) RecallBlocks(q BlockRecallQuery) ([]BlockHit, error) {
if len(hits) > topK {
hits = hits[:topK]
}
// ★ 记录召回时间(P6,2026-10-29)。
//
// 为什么必须记:回收判定看的是"这块记忆还有没有用",而"被召回"
// 是有用的**唯一实证信号** —— 创建时间只说明它什么时候写进来。
// 不记的话,所有块看起来都"很久没用过",回收就等于按入库时间瞎删。
//
// 为什么在解锁后做:本函数持 RLock,写库会撞锁(SQLite 单写)。
// 所以先把命中的 ID 复制出来,defer 到 RUnlock 之后再批量更新。
// 失败只记日志:召回本身已经成功,不能因为"记不上时间"就报错。
hitIDs := make([]string, len(hits))
for i := range hits {
hitIDs[i] = hits[i].Block.ID
}
if len(hitIDs) > 0 {
defer func() { g.markRecalled(hitIDs) }()
}
return hits, nil
}
// markRecalled 批量刷新 last_recalled_at。
//
// 单条 UPDATE ... IN (...) 而不是逐条:召回 topK=10~50,
// 逐条就是 10~50 次独立写事务,在 SQLite 上会明显拖慢每轮对话。
//
// 失败只记日志:这是"统计信号",不是正确性数据 ——
// 记不上时间最多让回收判据偏保守(少删),绝不会删错。
func (g *GraphDB) markRecalled(ids []string) {
if len(ids) == 0 {
return
}
ph := make([]string, len(ids))
args := make([]interface{}, 0, len(ids)+1)
args = append(args, time.Now())
for i, id := range ids {
ph[i] = "?"
args = append(args, id)
}
if _, err := g.db.Exec(
`UPDATE memory_blocks SET last_recalled_at = ?
WHERE id IN (`+strings.Join(ph, ",")+`)`, args...); err != nil {
log.Printf("[graph] mark recalled failed (%d blocks): %v", len(ids), err)
}
}
// RecallBlocksWithArbitration 与 RecallBlocks 相同,但额外返回仲裁详情
// (被取代的块、结论状态)。
//

View File

@ -1,4 +1,4 @@
package document
package coldcontext
// L1 ↔ L2 同构协议的判据。
//
@ -34,7 +34,7 @@ func newDocTestStore(t *testing.T) *Store {
// ① 正文完整性:归档后能逐条取回**完整**正文。
//
// 断点史:原实现 ContextToDoc 把每条事件拍平成一行,
// 断点史:原实现 ContextToColdContext 把每条事件拍平成一行,
// truncate(Response,100) / truncate(工具输出,200) —— 原文在落库那一刻就不见了。
// 生产实测:1000 篇归档文档中 15.1% 的 response 段正好 103 字符。
//
@ -46,7 +46,7 @@ func TestL2_ArchivePreservesFullText(t *testing.T) {
longInput := strings.Repeat("这是用户的原始输入内容", 100) + "【输入哨兵K9】"
ts := time.Date(2026, 3, 4, 10, 11, 12, 345678901, time.UTC)
_, err := s.ContextToDoc("context_archived", []ContextEntry{{
_, err := s.ContextToColdContext("context_archived", []ContextEntry{{
Timestamp: ts,
Source: "webui",
Content: longInput,
@ -60,8 +60,8 @@ func TestL2_ArchivePreservesFullText(t *testing.T) {
t.Fatal(err)
}
var d *Doc
for _, cand := range s.RecentDocs(10) {
var d *ColdDoc
for _, cand := range s.RecentColdContexts(10) {
d = cand
}
if d == nil {
@ -113,7 +113,7 @@ func TestL2_InputAndResponseAreSeparateTextBlocks(t *testing.T) {
ID: memory.MediaBlockID("image/png", []byte("x")), Modality: memory.BlockImage,
Data: []byte("x"), MIME: "image/png", Size: 1,
}
_, err := s.ContextToDoc("context_archived", []ContextEntry{{
_, err := s.ContextToColdContext("context_archived", []ContextEntry{{
Timestamp: time.Now(),
Source: "qq",
Content: "用户问:紫蓝红三色带怎么配",
@ -123,7 +123,7 @@ func TestL2_InputAndResponseAreSeparateTextBlocks(t *testing.T) {
if err != nil {
t.Fatal(err)
}
d := s.RecentDocs(1)[0]
d := s.RecentColdContexts(1)[0]
var texts []memory.MemoryBlock
for _, b := range d.Events[0].Blocks {
@ -172,10 +172,10 @@ func TestL2_KeepsEveryEventIndividually(t *testing.T) {
Response: "第" + string(rune('0'+i)) + "条回答",
})
}
if _, err := s.ContextToDoc("context_archived", entries, nil, nil, nil, nil); err != nil {
if _, err := s.ContextToColdContext("context_archived", entries, nil, nil, nil, nil); err != nil {
t.Fatal(err)
}
d := s.RecentDocs(1)[0]
d := s.RecentColdContexts(1)[0]
if len(d.Events) != 5 {
t.Fatalf("Events = %d,期望 5(逐条保留,不合并)", len(d.Events))
@ -201,13 +201,13 @@ func TestL2_ContentIsRenderedViewNotSource(t *testing.T) {
s := newDocTestStore(t)
long := strings.Repeat("视图不该丢信息", 300) + "【视图哨兵】"
if _, err := s.ContextToDoc("context_archived", []ContextEntry{{
if _, err := s.ContextToColdContext("context_archived", []ContextEntry{{
Timestamp: time.Now(), Source: "webui",
Content: "输入", Response: long,
}}, nil, nil, nil, nil); err != nil {
t.Fatal(err)
}
d := s.RecentDocs(1)[0]
d := s.RecentColdContexts(1)[0]
if !strings.Contains(d.Content, "【视图哨兵】") {
t.Error("Content 视图里丢了正文尾部 —— 视图也不该在存储层主动截断")
@ -231,23 +231,23 @@ func TestL2_RepeatedArchiveIsIdempotent(t *testing.T) {
Timestamp: ts, Source: "qq", Content: "同一条输入", Response: "同一条回答",
}}
if _, err := s.ContextToDoc("context_archived", entries, nil, nil, nil, nil); err != nil {
if _, err := s.ContextToColdContext("context_archived", entries, nil, nil, nil, nil); err != nil {
t.Fatal(err)
}
first := s.RecentDocs(1)[0]
first := s.RecentColdContexts(1)[0]
id1 := first.Events[0].Blocks[0].ID
if _, err := s.ContextToDoc("context_archived", entries, nil, nil, nil, nil); err != nil {
if _, err := s.ContextToColdContext("context_archived", entries, nil, nil, nil, nil); err != nil {
t.Fatal(err)
}
// 内容哈希相同 ⇒ 命中已有文档(不新建),事件列表被重写为同一份。
if n := len(s.Stats()); n == 0 {
t.Fatal("stats 异常")
}
if got := len(s.RecentDocs(10)); got != 1 {
if got := len(s.RecentColdContexts(10)); got != 1 {
t.Fatalf("重复归档产生了 %d 篇文档,期望 1(同内容应命中已有文档)", got)
}
d := s.RecentDocs(1)[0]
d := s.RecentColdContexts(1)[0]
if len(d.Events) != 1 {
t.Fatalf("重复归档后 Events=%d,期望 1(不应翻倍)", len(d.Events))
}
@ -300,7 +300,7 @@ func writeFile(path string, data []byte) error { return os.WriteFile(path, data,
// ⑦ 蒸馏/抽取必须基于原文,不能基于视图。
//
// 断点史:docToTriples 曾抽取 doc.Content —— 而 Content 在 Events 引入前
// 断点史:coldDocToTriples 曾抽取 doc.Content —— 而 Content 在 Events 引入前
// 是压缩渲染(Response 截 100 rune)。在被截断的句子上做依存句法分析,
// 往往解析不出主谓宾,三元组抽取直接失败(生产日志里大量
// 「三元组 N 条全被实体名校验拒绝」即与此相关)。
@ -311,13 +311,13 @@ func TestL2_OriginalTextAvailableIndependentOfView(t *testing.T) {
s := newDocTestStore(t)
full := strings.Repeat("原文必须完整用于抽取与蒸馏", 500) + "【原文哨兵D2】"
if _, err := s.ContextToDoc("context_archived", []ContextEntry{{
if _, err := s.ContextToColdContext("context_archived", []ContextEntry{{
Timestamp: time.Now(), Source: "webui",
Content: "输入", Response: full,
}}, nil, nil, nil, nil); err != nil {
t.Fatal(err)
}
d := s.RecentDocs(1)[0]
d := s.RecentColdContexts(1)[0]
// 刻意把视图弄坏:原文的权威来源必须是块,不是 Content。
d.Content = "视图被破坏了"
@ -361,10 +361,10 @@ func TestL2_StressLargeArchive(t *testing.T) {
}
}
if _, err := s.ContextToDoc("context_archived", entries, nil, nil, nil, nil); err != nil {
if _, err := s.ContextToColdContext("context_archived", entries, nil, nil, nil, nil); err != nil {
t.Fatal(err)
}
d := s.RecentDocs(1)[0]
d := s.RecentColdContexts(1)[0]
if len(d.Events) != n {
t.Fatalf("Events = %d,期望 %d", len(d.Events), n)
}
@ -419,7 +419,7 @@ func TestL2_StressConcurrentQueryAndConsume(t *testing.T) {
const docs = 60
for i := 0; i < docs; i++ {
if _, err := s.ContextToDoc("context_archived", []ContextEntry{{
if _, err := s.ContextToColdContext("context_archived", []ContextEntry{{
Timestamp: time.Now().Add(time.Duration(i) * time.Second),
Source: "stress",
Content: "并发查询目标 " + string(rune('A'+i%26)) + " 编号" + string(rune('0'+i%10)),
@ -441,14 +441,14 @@ func TestL2_StressConcurrentQueryAndConsume(t *testing.T) {
case 1:
s.Consume("并发查询目标", 3)
case 2:
s.FindColdDocs(time.Hour, 10)
s.FindColdContexts(time.Hour, 10)
}
}
}(w)
}
wg.Wait()
// 只要不 panic / 不 -race 报错即通过;文档数只会因 Consume 减少。
if got := len(s.RecentDocs(1000)); got > docs {
if got := len(s.RecentColdContexts(1000)); got > docs {
t.Errorf("文档数异常增长: %d > %d", got, docs)
}
}
@ -461,13 +461,13 @@ func TestL2_StressLargePayloadRoundTrip(t *testing.T) {
s := newDocTestStore(t)
huge := strings.Repeat("大正文压测", 250000) + "【巨尾哨兵P8】" // ~1.25MB
if _, err := s.ContextToDoc("context_archived", []ContextEntry{{
if _, err := s.ContextToColdContext("context_archived", []ContextEntry{{
Timestamp: time.Now(), Source: "stress",
Content: "输入", Response: huge,
}}, nil, nil, nil, nil); err != nil {
t.Fatal(err)
}
d := s.RecentDocs(1)[0]
d := s.RecentColdContexts(1)[0]
_, resp, _ := d.Events[0].InOut()
if len(resp) != len(huge) {
t.Fatalf("大正文往返长度不符: %d vs %d", len(resp), len(huge))

View File

@ -1,4 +1,4 @@
package document
package coldcontext
import (
"crypto/sha256"
@ -25,20 +25,20 @@ import (
// 返回 nil 表示不使用额外清洗。
type ChannelCleaner func(source string) func(string) string
// Doc — 记忆文档:由上下文提炼而来
// ColdDoc — 记忆文档:由上下文提炼而来
//
// ## L1/L2 同构
//
// 文档的主体是 **Events**(事件列表),与 RelevanceContext 的事件列表同一结构。
// 区别不在形态,而在是否被直接投喂给模型:L1 进 system prompt 时序,L2 要经召回才进去。
//
// Content 只是**渲染视图**(供摘要、人工阅读、SDK 的 Doc.Content 字段),
// Content 只是**渲染视图**(供摘要、人工阅读、SDK 的 ColdDoc.Content 字段),
// 不是原文载体。原文在 Events 里,正文内联在文本块里。
type Doc struct {
ID string `json:"id"`
Summary string `json:"summary"`
Content string `json:"content"` // 渲染视图,不是原文
Events []DocEvent `json:"events,omitempty"`
type ColdDoc struct {
ID string `json:"id"`
Summary string `json:"summary"`
Content string `json:"content"` // 渲染视图,不是原文
Events []ColdEvent `json:"events,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
@ -55,7 +55,7 @@ type Doc struct {
// · 存量文档(无 Events)只有这一个字段有块。
//
// ★ 它是**视图**,不是事实源:块的真实归属在 Events[i].Blocks 里。
// 两者由 ContextToDoc 同一处产出,不得分别修改。
// 两者由 ContextToColdContext 同一处产出,不得分别修改。
Blocks []memory.MemoryBlock `json:"blocks,omitempty"`
Vector tfidf.Vector `json:"vector,omitempty"` // TF-IDF 稀疏向量(fallback 时持久化)
@ -63,7 +63,7 @@ type Doc struct {
DenseFP string `json:"dense_fp,omitempty"` // DenseVec 所属统一空间指纹,变化时触发重算
}
// DocEvent 是一条被 L2 持有的**原始事件**,与 L1 的 ContextEvent 同构。
// ColdEvent 是一条被 L2 持有的**原始事件**,与 L1 的 ContextEvent 同构。
//
// ★ 为何正文不在字段里、而在块里
//
@ -72,11 +72,13 @@ type Doc struct {
// 那会出现「块里的正文」与「字段里的正文」两份真相。
//
// 读原文 = 读 Blocks 里文本块的 Text。
type DocEvent struct {
type ColdEvent struct {
Timestamp time.Time `json:"ts"`
Source string `json:"source"`
ToolsUsed []string `json:"tools_used,omitempty"`
ToolResults []ToolResultItem `json:"tool_results,omitempty"`
// ToolCalls 是每次工具调用的完整记录(含参数),与 ToolResults 靠 CallID 配对。
ToolCalls []ToolCallItem `json:"tool_calls,omitempty"`
// Blocks 是本事件持有的块,**正文就在里面**。
//
@ -90,9 +92,9 @@ type DocEvent struct {
//
// 存量文档(Events 引入之前归档的)没有它 —— 调用方必须据此降级到
// 「把 Content 塞进一条事件」,而不是假装能回填。
func (d *Doc) HasEvents() bool { return d != nil && len(d.Events) > 0 }
func (d *ColdDoc) HasEvents() bool { return d != nil && len(d.Events) > 0 }
// docVectorAlgoVersion 是**文档向量算法**的版本标记。
// coldcontextVectorAlgoVersion 是**文档向量算法**的版本标记。
//
// 为什么必需:DenseFP 只记录"哪个向量空间(模型)",不记录"用什么文本算的"。
// 2026-10-22 把输入从 `Summary + Content` 改成"事件正文"后,指纹不变
@ -106,7 +108,7 @@ func (d *Doc) HasEvents() bool { return d != nil && len(d.Events) > 0 }
//
// v1 初版:VectorizeDense(Summary + Content)
// v2 Summary 是 jieba 词袋且全库同质,改为事件正文(见 denseFor 注释)
const docVectorAlgoVersion = "v2"
const coldcontextVectorAlgoVersion = "v2"
// Store — 文档记忆存储。
//
@ -124,7 +126,7 @@ const docVectorAlgoVersion = "v2"
type Store struct {
dir string
mu sync.RWMutex
docs map[string]*Doc
docs map[string]*ColdDoc
dirty bool
// fallback 路径(仅稠密空间不可用时加载)
@ -142,7 +144,7 @@ type Store struct {
func NewStore(dir string, tokenizer tfidf.Tokenizer) *Store {
return &Store{
dir: dir,
docs: make(map[string]*Doc),
docs: make(map[string]*ColdDoc),
// tfidf 延迟初始化:只在需要 fallback 时创建
tfidfEmb: tfidf.NewEmbedder(tokenizer, 4096),
}
@ -167,7 +169,7 @@ func (s *Store) ensureTFIDF() {
}
return out
}())
log.Printf("[document memory] tfidf fallback loaded: %d docs", len(texts))
log.Printf("[coldcontext] tfidf fallback loaded: %d docs", len(texts))
})
}
@ -176,9 +178,9 @@ func (s *Store) Start() error {
return fmt.Errorf("document store dir: %w", err)
}
if err := s.loadAll(); err != nil {
log.Printf("[document memory] load error: %v", err)
log.Printf("[coldcontext] load error: %v", err)
}
log.Printf("[document memory] started with %d docs", len(s.docs))
log.Printf("[coldcontext] started with %d docs", len(s.docs))
return nil
}
@ -198,7 +200,7 @@ func (s *Store) BuildDenseIndex(ds vector.MultimodalEmbedder) {
}
s.mu.Lock()
defer s.mu.Unlock()
log.Printf("[document memory] building dense index for %d docs (dim=%d)", len(s.docs), ds.Dim())
log.Printf("[coldcontext] building dense index for %d docs (dim=%d)", len(s.docs), ds.Dim())
count := 0
for _, doc := range s.docs {
if doc.DenseVec != nil && len(doc.DenseVec) == ds.Dim() && doc.DenseFP == s.vectorFingerprint() {
@ -220,7 +222,7 @@ func (s *Store) BuildDenseIndex(ds vector.MultimodalEmbedder) {
s.dirty = true
s.flushLocked()
}
log.Printf("[document memory] dense index built: %d new vectors", count)
log.Printf("[coldcontext] dense index built: %d new vectors", count)
}
// denseFor 计算文档的稠密向量:**事件正文** ⊕ 同事件媒体块的向量。
@ -241,7 +243,7 @@ func (s *Store) BuildDenseIndex(ds vector.MultimodalEmbedder) {
//
// ## 媒体怎么办:不需要单独生成摘要
//
// 媒体块与文本块**在同一事件内**(Doc.Events[i].Blocks),事件的正文进入
// 媒体块与文本块**在同一事件内**(ColdDoc.Events[i].Blocks),事件的正文进入
// 向量即覆盖了它的语义。为媒体单独生成描述正是 CAS 时代「描述式索引」
// 的错误(描述是模型生成的二手信息),已在 v1.2.0 整体拆除,不重走。
//
@ -252,7 +254,7 @@ func (s *Store) BuildDenseIndex(ds vector.MultimodalEmbedder) {
//
// 无 Events 的旧文档(本次改动前归档的)退回 Content —— 那是当时唯一的
// 原文载体,虽然它是渲染视图,但比 Summary 更接近内容。
func (s *Store) denseFor(doc *Doc) []float64 {
func (s *Store) denseFor(doc *ColdDoc) []float64 {
if s.denseSpace == nil || !s.denseSpace.Loaded() {
return nil
}
@ -262,7 +264,7 @@ func (s *Store) denseFor(doc *Doc) []float64 {
// 文本路:用事件正文(原文),不用 Summary(jieba 词袋)。
// 媒体块的语义由同一事件的文本覆盖,因此这里不需要为媒体单独编码。
if text := documentTextForVector(doc); text != "" {
if text := coldcontextTextForVector(doc); text != "" {
if tv, err := s.denseSpace.VectorizeDense(text); err == nil && len(tv) > 0 {
parts = append(parts, tv)
}
@ -284,20 +286,20 @@ func (s *Store) denseFor(doc *Doc) []float64 {
// vectorFingerprint 返回「向量空间指纹 + 文档向量算法版本」。
//
// 只比空间指纹不够:换算法(同一模型、不同输入文本)时必须触发重算,
// 否则存量数据永远停留在旧算法的结果上(实测后果见 docVectorAlgoVersion)。
// 否则存量数据永远停留在旧算法的结果上(实测后果见 coldcontextVectorAlgoVersion)。
func (s *Store) vectorFingerprint() string {
if s.denseSpace == nil {
return ""
}
return s.denseSpace.Fingerprint() + "|" + docVectorAlgoVersion
return s.denseSpace.Fingerprint() + "|" + coldcontextVectorAlgoVersion
}
// documentTextForVector 取文档用于向量化的文本:**正文**。
// coldcontextTextForVector 取文档用于向量化的文本:**正文**。
//
// 有 Events 时按事件顺序拼原文(来自块里的正文);无 Events 的存量文档
// 退回 Content(渲染视图,但仍是原文最接近的载体)。
// 绝不含 Summary —— 那是 jieba 词袋,会把全库拉向同一方向。
func documentTextForVector(doc *Doc) string {
func coldcontextTextForVector(doc *ColdDoc) string {
if doc == nil {
return ""
}
@ -342,7 +344,7 @@ func (s *Store) Reindex() {
s.ensureTFIDF()
}
func (s *Store) Insert(doc *Doc) error {
func (s *Store) Insert(doc *ColdDoc) error {
s.mu.Lock()
defer s.mu.Unlock()
@ -379,8 +381,8 @@ func (s *Store) Insert(doc *Doc) error {
return nil
}
// ContextToDoc 将上下文对话历史提炼为文档。
func (s *Store) ContextToDoc(source string, entries []ContextEntry, _ interface{}, cleanFn func(string) string, toolCleanFn func(name, output string) string, channelCleaner ChannelCleaner) (*Doc, error) {
// ContextToColdContext 将上下文对话历史提炼为文档。
func (s *Store) ContextToColdContext(source string, entries []ContextEntry, _ interface{}, cleanFn func(string) string, toolCleanFn func(name, output string) string, channelCleaner ChannelCleaner) (*ColdDoc, error) {
if len(entries) == 0 {
return nil, nil
}
@ -426,7 +428,7 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry, _ interface{
if source == "context_archived" {
meta["is_archived_context"] = "true"
}
doc := &Doc{
doc := &ColdDoc{
ID: id, Summary: summary, Content: content,
CreatedAt: time.Now(), UpdatedAt: time.Now(),
LastAccess: time.Now(), AccessCount: 1, Source: source, Meta: meta,
@ -453,9 +455,37 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry, _ interface{
}
// Consume 向量相似度查询并移除文档
func (s *Store) Consume(text string, topK int) []*Doc {
// Peek 检索匹配的文档,**只读不删**。
//
// ## ★ 为什么必须有它(2026-10-29 修「先删后灌」)
//
// 原实现 `Consume` 是**读+删一步完成**:
//
// docs := coldcontextStore.Consume(query, topK) // 此刻 L2 副本已没了
// for _, d := range docs {
// n := a.restoreDocEvents(d) // 若这里失败(panic/向量算错/被中断)
// } // ⇒ L1 没进、L2 已删 ⇒ 三层都不在
//
// 「消费式检索」把「取回」和「删除」绑成一个不可分的动作,于是删除这个
// **不可逆步骤**发生在确认之前。这正是 plan.md 记的第四个病:回灌失败
// 就三层都不在了。
//
// 现在拆成两阶段(用户裁定 P4):
//
// docs := coldcontextStore.Peek(query, topK) // ① 只读
// ...确认每条都真的落进 L1... // ② 校验
// coldcontextStore.Remove(id) // ③ 确认后才删副本
//
// **禁止先删后灌** —— 删除必须排在确认之后。
func (s *Store) Peek(text string, topK int) []*ColdDoc {
s.mu.Lock()
defer s.mu.Unlock()
return s.searchLocked(text, topK)
}
// searchLocked 是检索本体(调用方须持锁)。Peek 与 Consume 共用它,
// 保证两条路径的**检索语义完全一致** —— 各自写一份必然漂移。
func (s *Store) searchLocked(text string, topK int) []*ColdDoc {
if topK <= 0 {
topK = 5
}
@ -464,11 +494,9 @@ func (s *Store) Consume(text string, topK int) []*Doc {
if s.denseSpace != nil && s.denseSpace.Loaded() {
if qv, err := s.denseSpace.VectorizeDense(text); err == nil {
results := s.denseSearchScored(qv, topK)
var docs []*Doc
var docs []*ColdDoc
for _, r := range results {
if d, ok := s.docs[r.Doc.ID]; ok {
s.removeDoc(r.Doc.ID)
s.dirty = true
docs = append(docs, d)
}
}
@ -479,38 +507,58 @@ func (s *Store) Consume(text string, topK int) []*Doc {
// Fallback:TF-IDF 倒排检索(延迟初始化)
s.ensureTFIDF()
results := s.tfidfIdx.Search(text, topK)
var docs []*Doc
var docs []*ColdDoc
for _, r := range results {
if d, ok := s.docs[r.ID]; ok {
s.removeDoc(r.ID)
s.dirty = true
docs = append(docs, d)
}
}
return docs
}
func (s *Store) Query(text string, topK int) []*Doc {
// Consume 检索并**立即删除**匹配的文档。
//
// ⚠️ 保留它只为兼容:**新代码必须用 Peek + 确认 + Remove**。
// 用 Consume 就是「先删后灌」,回灌失败即永久丢数据(见 Peek 的说明)。
//
// 现存调用点已全部改为两阶段(coldcontext_query)。本方法留给
// 尚未迁移的测试与外部工具,且注释里标明它是错的用法。
func (s *Store) Consume(text string, topK int) []*ColdDoc {
s.mu.Lock()
defer s.mu.Unlock()
docs := s.searchLocked(text, topK)
for _, d := range docs {
s.removeDoc(d.ID)
s.dirty = true
}
return docs
}
func (s *Store) Query(text string, topK int) []*ColdDoc {
hits := s.QueryScored(text, topK)
out := make([]*Doc, len(hits))
out := make([]*ColdDoc, len(hits))
for i, h := range hits {
out[i] = h.Doc
}
return out
}
// DocHit 是一篇文档记忆的相似度候选及原始分数。
type DocHit struct {
Doc *Doc
// ColdHit 是一篇文档记忆的相似度候选及原始分数。
type ColdHit struct {
// Doc 是命中的冷上下文条目。
//
// 字段名保持 Doc:它是**字段**不是类型,改名会波及所有 h.Doc 调用点,
// 而与"类型叫什么"无关。
Doc *ColdDoc
Score float64
}
func (s *Store) QueryScored(text string, topK int) []DocHit {
func (s *Store) QueryScored(text string, topK int) []ColdHit {
if topK <= 0 {
topK = 5
}
var hits []DocHit
var hits []ColdHit
// 主路径:稠密检索。只读,持读锁。
s.mu.RLock()
@ -528,7 +576,7 @@ func (s *Store) QueryScored(text string, topK int) []DocHit {
results := s.tfidfIdx.Search(text, topK)
for _, r := range results {
if d, ok := s.docs[r.ID]; ok {
hits = append(hits, DocHit{Doc: d, Score: r.Score})
hits = append(hits, ColdHit{Doc: d, Score: r.Score})
}
}
s.mu.Unlock()
@ -537,8 +585,8 @@ func (s *Store) QueryScored(text string, topK int) []DocHit {
// 访问计数是**写**:必须离开读锁后再取写锁更新。
//
// 此前在 RLock 下直接 `d.AccessCount++` / `d.LastAccess = time.Now()`,
// 与 FindColdDocs / flush 的读并发构成数据竞争(-race 实测),且不置脏 ——
// 计数只在内存里涨,优雅关停也不落盘,FindColdDocs 的「≤2 次访问」
// 与 FindColdContexts / flush 的读并发构成数据竞争(-race 实测),且不置脏 ——
// 计数只在内存里涨,优雅关停也不落盘,FindColdContexts 的「≤2 次访问」
// 冷度判据跨重启失真。
s.markAccess(hits)
return hits
@ -546,7 +594,7 @@ func (s *Store) QueryScored(text string, topK int) []DocHit {
// markAccess 记录一次检索命中:累加访问计数、刷新最后访问时间并置脏等待落盘。
// 调用方不得持有 s.mu。
func (s *Store) markAccess(hits []DocHit) {
func (s *Store) markAccess(hits []ColdHit) {
if len(hits) == 0 {
return
}
@ -565,7 +613,7 @@ func (s *Store) markAccess(hits []DocHit) {
s.dirty = true
}
func (s *Store) denseSearchScored(queryVec []float64, topK int) []DocHit {
func (s *Store) denseSearchScored(queryVec []float64, topK int) []ColdHit {
if len(queryVec) == 0 {
return nil
}
@ -590,9 +638,9 @@ func (s *Store) denseSearchScored(queryVec []float64, topK int) []DocHit {
if len(results) > topK {
results = results[:topK]
}
out := make([]DocHit, len(results))
out := make([]ColdHit, len(results))
for i, r := range results {
out[i] = DocHit{Doc: s.docs[r.did], Score: r.score}
out[i] = ColdHit{Doc: s.docs[r.did], Score: r.score}
}
return out
}
@ -624,11 +672,11 @@ func (s *Store) Stats() map[string]interface{} {
}
}
func (s *Store) FindColdDocs(maxAge time.Duration, minAccess int) []*Doc {
func (s *Store) FindColdContexts(maxAge time.Duration, minAccess int) []*ColdDoc {
s.mu.RLock()
defer s.mu.RUnlock()
cutoff := time.Now().Add(-maxAge)
var cold []*Doc
var cold []*ColdDoc
for _, d := range s.docs {
if d.AccessCount <= minAccess && d.LastAccess.Before(cutoff) {
cold = append(cold, d)
@ -638,7 +686,7 @@ func (s *Store) FindColdDocs(maxAge time.Duration, minAccess int) []*Doc {
}
// Get 返回指定文档(不存在时为 nil)。
func (s *Store) Get(id string) *Doc {
func (s *Store) Get(id string) *ColdDoc {
s.mu.RLock()
defer s.mu.RUnlock()
return s.docs[id]
@ -655,10 +703,10 @@ func (s *Store) Blocks() []memory.MemoryBlock {
return out
}
func (s *Store) RecentDocs(n int) []*Doc {
func (s *Store) RecentColdContexts(n int) []*ColdDoc {
s.mu.RLock()
defer s.mu.RUnlock()
var list []*Doc
var list []*ColdDoc
for _, d := range s.docs {
list = append(list, d)
}
@ -702,7 +750,7 @@ func (s *Store) loadAll() error {
if err != nil {
continue
}
var doc Doc
var doc ColdDoc
if json.Unmarshal(data, &doc) != nil || doc.ID == "" {
continue
}
@ -733,16 +781,35 @@ func (s *Store) flushLocked() {
// ——— 内部工具函数(从上下文提炼文档所需)———
// ToolResultItem 是一条工具结果。
//
// ★ CallID 与 internal/agent/core 的同名字段必须**逐字一致**(JSON 键名
// 都是 call_id):L1↔L2 之间靠它配对,键名不一致会在归档/回填时静默丢失。
type ToolResultItem struct {
CallID string `json:"call_id,omitempty"`
Name string `json:"name"`
Output string `json:"output"`
}
// ToolCallItem 是一次工具调用的完整记录(含参数与配对键)。
//
// 与 internal/agent/core.ToolCalls 的字段逐字对应。
// L2 之所以也要存它:回灌(coldcontext_query)要把事件原样插回 L1,
// 若 L2 缺参数,回灌后「这一页的 call 侧是空的」,页化与配对校验都失效。
type ToolCallItem struct {
CallID string `json:"call_id,omitempty"`
Name string `json:"name"`
Arguments map[string]interface{} `json:"arguments,omitempty"`
RawArguments string `json:"raw_arguments,omitempty"`
}
type ContextEntry struct {
Timestamp time.Time
Source string
Content string
Response string
Timestamp time.Time
Source string
Content string
Response string
// ToolCalls 是工具调用的完整记录(含参数),归档时原样带到 L2。
ToolCalls []ToolCallItem
ToolsUsed []string
ToolResults []ToolResultItem
Blocks []memory.MemoryBlock // 一等记忆块随事件一起迁移到文档
@ -913,15 +980,35 @@ func docBlocksForEntry(e ContextEntry) []memory.MemoryBlock {
// 文本块:正文内联在 Text 里,不带向量。
//
// 为什么不在这里算向量:L2 的检索向量由 Doc.denseFor 用整篇文档文本算一次,
// 为什么不在这里算向量:L2 的检索向量由 ColdDoc.denseFor 用整篇文档文本算一次,
// 单条事件的文本块不参与 L2 召回(它是回填用的载体)。
// 这与「向量只算一次」一致 —— 不在迁移路径上重算。
// ★★ 段页式身份(P2,2026-10-29)
//
// 段 Frame = 一条事件(一次完整任务),用事件时间戳作帧号(不另造自增 id)。
// 页 Page = 一次完整往返:
// · llm_round —— 用户输入 → 模型输出
// · tool_round —— 工具调用 → 工具结果(靠 CallID 配对)
//
// 为什么必须带上这些字段(否则 L2 里全是"无页"块):
// 回灌时要把块还原成配对正确的事件;没有页身份就只能靠位置猜,
// 而「assistant(tool_calls) 后面必须紧跟配对齐全的 tool 消息」
// 是上游硬要求 —— 猜错就是 HTTP 400。
//
// 帧内页序约定:llm_round 恒为第 1 页,工具轮按 ToolCalls 顺序 2..n+1。
// 同一帧的所有页共享同一个 FrameTS(铁律:块不跨帧)。
frameTS := now
if t := strings.TrimSpace(e.Content); t != "" {
out = append(out, memory.MemoryBlock{
ID: entryTextBlockID(now, e.Source, TextBlockRoleInput, t),
Modality: memory.BlockText,
Text: t,
Source: e.Source,
FrameTS: frameTS,
PageSeq: 1,
PageKind: memory.PageLLMRound,
BlockRole: memory.RoleRequest,
CreatedAt: now,
UpdatedAt: now,
})
@ -932,20 +1019,136 @@ func docBlocksForEntry(e ContextEntry) []memory.MemoryBlock {
Modality: memory.BlockText,
Text: t,
Source: e.Source,
FrameTS: frameTS,
PageSeq: 1,
PageKind: memory.PageLLMRound,
BlockRole: memory.RoleResponse,
CreatedAt: now,
UpdatedAt: now,
})
}
// 工具轮:每个 toolcall 与它的 result 构成一页。
//
// call 侧正文 = 参数(JSON 形式);response 侧正文 = 工具输出。
// 参数此前根本没被记录(见 core.ToolCallItem 的注释),
// 所以这里必须容忍「只有 result 没有 call」的存量情形:
// 那种情况仍按 result 建页,只是 call 侧缺失(渲染时按位置配对)。
for i := range e.ToolCalls {
tc := &e.ToolCalls[i]
page := i + 2 // 第 1 页是 llm_round
callText := toolCallPageText(tc)
if callText != "" {
out = append(out, memory.MemoryBlock{
ID: entryToolBlockID(now, e.Source, page, memory.RoleRequest, tc.CallID, callText),
Modality: memory.BlockText,
Text: callText,
Source: e.Source,
FrameTS: frameTS,
PageSeq: page,
PageKind: memory.PageToolRound,
BlockRole: memory.RoleRequest,
CallID: tc.CallID,
CreatedAt: now,
UpdatedAt: now,
})
}
// 配对的结果(CallID 相同;存量数据无 CallID 时按名字取第一个未用的)
if resText, ok := matchToolResult(e.ToolResults, tc); ok {
out = append(out, memory.MemoryBlock{
ID: entryToolBlockID(now, e.Source, page, memory.RoleResponse, tc.CallID, resText),
Modality: memory.BlockText,
Text: resText,
Source: e.Source,
FrameTS: frameTS,
PageSeq: page,
PageKind: memory.PageToolRound,
BlockRole: memory.RoleResponse,
CallID: tc.CallID,
CreatedAt: now,
UpdatedAt: now,
})
}
}
out = append(out, e.Blocks...)
return out
}
// toolCallPageText 把一次工具调用渲染成页的 call 侧正文。
//
// 优先用解析后的参数(结构清晰);没有则退回原始 JSON。
// 两者都没有时返回空 —— 调用方据此决定不建 call 块
// (而不是造一个只有工具名的空块,那会让「页是完整的」变成谎话)。
func toolCallPageText(tc *ToolCallItem) string {
if tc == nil {
return ""
}
if len(tc.Arguments) > 0 {
if b, err := json.Marshal(tc.Arguments); err == nil {
return tc.Name + " " + string(b)
}
}
if strings.TrimSpace(tc.RawArguments) != "" {
return tc.Name + " " + tc.RawArguments
}
return ""
}
// matchToolResult 按配对键找出这次调用的结果。
//
// 配对顺序(从强到弱):
// 1. CallID 相同 —— 唯一可靠的配对(并行调用必须靠它)
// 2. 名字相同 —— 存量数据没有 CallID 时的降级
//
// 空 Name 对空 Name 也算匹配:早期数据里 name 可能缺失,
// 那时仍应把输出展示出来,而不是整段丢掉。
func matchToolResult(results []ToolResultItem, tc *ToolCallItem) (string, bool) {
if tc == nil {
return "", false
}
if tc.CallID != "" {
for i := range results {
if results[i].CallID == tc.CallID {
return results[i].Output, true
}
}
}
for i := range results {
if results[i].Name == tc.Name {
return results[i].Output, true
}
}
return "", false
}
// entryToolBlockID 派生工具页的块 ID。
//
// 与 entryTextBlockID 同构(事件身份 + 页 + 角色 + 正文),
// 额外掺入页序与 CallID:同一事件里两次并行调用可能正文相同,
// 若只按正文派生就会**合成一块**,页就少了一半。
func entryToolBlockID(evtTS time.Time, source string, page int, role memory.BlockRole, callID, text string) string {
h := sha256.New()
h.Write([]byte(strconv.FormatInt(evtTS.UnixNano(), 10)))
h.Write([]byte{0})
h.Write([]byte(source))
h.Write([]byte{0})
h.Write([]byte(strconv.Itoa(page)))
h.Write([]byte{0})
h.Write([]byte(string(role)))
h.Write([]byte{0})
h.Write([]byte(callID))
h.Write([]byte{0})
h.Write([]byte(text))
return "blk_tool_" + hex.EncodeToString(h.Sum(nil)[:12])
}
// blocksToEntry 是 docBlocksForEntry 的逆操作:从块列表读回事件的
// Input / Response 正文与它持有的块。
//
// 识别规则:文本块按 ID 前缀里的角色区分(Input 在前、Response 在后),
// 其余块(媒体/未知来源的文本块)原样作为「事件持有的块」返回。
func blocksToEntry(ev *DocEvent) (input, response string, held []memory.MemoryBlock) {
func blocksToEntry(ev *ColdEvent) (input, response string, held []memory.MemoryBlock) {
if ev == nil {
return "", "", nil
}
@ -960,6 +1163,14 @@ func blocksToEntry(ev *DocEvent) (input, response string, held []memory.MemoryBl
input = b.Text
case response == "" && isEntryTextBlock(b, ev, TextBlockRoleResponse):
response = b.Text
case b.PageKind == memory.PageToolRound:
// ★ 工具页的块**不是**事件的"持有块",而是 ToolCalls/ToolResults
// 的页级表示(P2 派生出来的)。若把它当持有块塞回去,
// 回灌后事件会多出一堆无关块,且下次归档再派生一遍 ⇒ 越滚越多。
//
// 权威数据仍是 ev.ToolCalls / ev.ToolResults,
// 这里只是跳过(不重建),避免双份事实源。
continue
default:
// 不是本事件派生出的文本块(例如蒸馏来的原句块):当作持有块。
held = append(held, b)
@ -969,36 +1180,39 @@ func blocksToEntry(ev *DocEvent) (input, response string, held []memory.MemoryBl
}
// isEntryTextBlock 判断文本块是否为本事件按该角色派生出来的那一块。
func isEntryTextBlock(b memory.MemoryBlock, ev *DocEvent, role string) bool {
func isEntryTextBlock(b memory.MemoryBlock, ev *ColdEvent, role string) bool {
return b.ID == entryTextBlockID(ev.Timestamp, ev.Source, role, b.Text)
}
// docEventsFromEntries 把 L1 交来的一批事件转成 L2 的事件列表。
func docEventsFromEntries(entries []ContextEntry) []DocEvent {
func docEventsFromEntries(entries []ContextEntry) []ColdEvent {
if len(entries) == 0 {
return nil
}
out := make([]DocEvent, 0, len(entries))
out := make([]ColdEvent, 0, len(entries))
for _, e := range entries {
ev := DocEvent{
ev := ColdEvent{
Timestamp: e.Timestamp,
Source: e.Source,
ToolsUsed: append([]string(nil), e.ToolsUsed...),
ToolCalls: append([]ToolCallItem(nil), e.ToolCalls...),
Blocks: docBlocksForEntry(e),
}
for _, tr := range e.ToolResults {
ev.ToolResults = append(ev.ToolResults, ToolResultItem{Name: tr.Name, Output: tr.Output})
ev.ToolResults = append(ev.ToolResults, ToolResultItem{
CallID: tr.CallID, Name: tr.Name, Output: tr.Output,
})
}
out = append(out, ev)
}
return out
}
// flattenEventBlocks 取事件列表里全部块(去重后的并集),供 Doc.Blocks 使用。
// flattenEventBlocks 取事件列表里全部块(去重后的并集),供 ColdDoc.Blocks 使用。
//
// 它是视图:真实归属在 Events[i].Blocks 里。之所以仍要扁平化,
// 是因为向量融合、L3 蒸馏、存量兼容三条路都按「文档持有哪些块」工作。
func flattenEventBlocks(events []DocEvent) []memory.MemoryBlock {
func flattenEventBlocks(events []ColdEvent) []memory.MemoryBlock {
seen := make(map[string]bool)
var out []memory.MemoryBlock
for _, ev := range events {
@ -1014,15 +1228,15 @@ func flattenEventBlocks(events []DocEvent) []memory.MemoryBlock {
return out
}
// contentFromEvents 用事件正文渲染出 Doc.Content。
// contentFromEvents 用事件正文渲染出 ColdDoc.Content。
//
// ★ 这是**视图**,不是原文:它为了让摘要/人工阅读/SDK 的 Doc.Content 有东西可看,
// ★ 这是**视图**,不是原文:它为了让摘要/人工阅读/SDK 的 ColdDoc.Content 有东西可看,
// 也为了让存量逻辑(依赖 Content 做摘要与 TF-IDF)继续工作。
// 原文永远以 Events[i].Blocks 里文本块的 Text 为准。
//
// 为什么视图里也**不截断**:视图可以比原文简洁,但不该主动丢掉信息——
// 真正决定给模型看多少的是渲染层(按 token 预算),不是存储层。
func contentFromEvents(events []DocEvent) string {
func contentFromEvents(events []ColdEvent) string {
var parts []string
for _, ev := range events {
input, response, _ := blocksToEntry(&ev)
@ -1042,6 +1256,6 @@ func contentFromEvents(events []DocEvent) string {
//
// 这是 blocksToEntry 的导出形态:L1(agent/core)需要它来回填 ContextEvent,
// 而正文住在块里,所以必须由本包解读块的角色与归属。
func (ev *DocEvent) InOut() (input, response string, held []memory.MemoryBlock) {
func (ev *ColdEvent) InOut() (input, response string, held []memory.MemoryBlock) {
return blocksToEntry(ev)
}

View File

@ -1,4 +1,4 @@
package document
package coldcontext
import (
"fmt"
@ -23,7 +23,7 @@ func TestInsertAndQuery(t *testing.T) {
}
defer s.Stop()
doc := &Doc{
doc := &ColdDoc{
Summary: "用户喜欢喝咖啡",
Content: "用户提到他每天早上都会喝一杯黑咖啡",
Source: "manual",
@ -53,9 +53,9 @@ func TestQuery(t *testing.T) {
s.Start()
defer s.Stop()
s.Insert(&Doc{Summary: "咖啡是一种饮品", Content: "咖啡因提神", Source: "manual"})
s.Insert(&Doc{Summary: "茶叶也有咖啡因", Content: "茶和咖啡都提神", Source: "manual"})
s.Insert(&Doc{Summary: "今天天气很好", Content: "适合出去散步", Source: "manual"})
s.Insert(&ColdDoc{Summary: "咖啡是一种饮品", Content: "咖啡因提神", Source: "manual"})
s.Insert(&ColdDoc{Summary: "茶叶也有咖啡因", Content: "茶和咖啡都提神", Source: "manual"})
s.Insert(&ColdDoc{Summary: "今天天气很好", Content: "适合出去散步", Source: "manual"})
results := s.Query("咖啡", 5)
if len(results) == 0 {
@ -83,7 +83,7 @@ func TestContextToDoc(t *testing.T) {
{Timestamp: time.Now(), Source: "user", Content: "特别是Go语言", Response: "Go很棒"},
}
doc, err := s.ContextToDoc("test", entries, nil, nil, nil, nil)
doc, err := s.ContextToColdContext("test", entries, nil, nil, nil, nil)
if err != nil {
t.Fatal(err)
}
@ -109,19 +109,19 @@ func TestFindColdDocs(t *testing.T) {
s.Start()
defer s.Stop()
hot := &Doc{Summary: "常用的信息", Content: "经常被查询", Source: "manual"}
hot := &ColdDoc{Summary: "常用的信息", Content: "经常被查询", Source: "manual"}
hot.AccessCount = 10
hot.LastAccess = time.Now()
s.Insert(hot)
cold := &Doc{Summary: "很久没用的信息", Content: "几乎不被访问", Source: "manual"}
cold := &ColdDoc{Summary: "很久没用的信息", Content: "几乎不被访问", Source: "manual"}
s.Insert(cold)
// Insert 会重置 LastAccess,手动改为过去的
cold.LastAccess = time.Now().Add(-100 * time.Hour)
cold.AccessCount = 1
// 应该只找到 cold(72h 前未访问且访问 ≤ 2)
coldDocs := s.FindColdDocs(72*time.Hour, 2)
coldDocs := s.FindColdContexts(72*time.Hour, 2)
if len(coldDocs) != 1 {
t.Fatalf("expected 1 cold doc, got %d", len(coldDocs))
}
@ -141,11 +141,11 @@ func TestRecentDocs(t *testing.T) {
s.Start()
defer s.Stop()
s.Insert(&Doc{Summary: "第一条", Content: "a", Source: "manual"})
s.Insert(&ColdDoc{Summary: "第一条", Content: "a", Source: "manual"})
time.Sleep(time.Millisecond)
s.Insert(&Doc{Summary: "第二条", Content: "b", Source: "manual"})
s.Insert(&ColdDoc{Summary: "第二条", Content: "b", Source: "manual"})
recent := s.RecentDocs(1)
recent := s.RecentColdContexts(1)
if len(recent) != 1 {
t.Fatalf("expected 1 recent doc, got %d", len(recent))
}
@ -165,7 +165,7 @@ func TestReindex(t *testing.T) {
s.Start()
defer s.Stop()
s.Insert(&Doc{Summary: "测试重索引", Content: "验证索引重建", Source: "manual"})
s.Insert(&ColdDoc{Summary: "测试重索引", Content: "验证索引重建", Source: "manual"})
s.Reindex()
results := s.Query("重索引", 5)
@ -232,7 +232,7 @@ func TestInsertEmptyDoc(t *testing.T) {
s.Start()
defer s.Stop()
doc := &Doc{Summary: "", Content: "", Source: "manual"}
doc := &ColdDoc{Summary: "", Content: "", Source: "manual"}
if err := s.Insert(doc); err != nil {
t.Fatal(err)
}
@ -251,7 +251,7 @@ func TestPersistence(t *testing.T) {
// 写
s1 := NewStore(dir, memory.TokenizeWords)
s1.Start()
s1.Insert(&Doc{Summary: "持久化测试", Content: "应该被保存到磁盘", Source: "manual"})
s1.Insert(&ColdDoc{Summary: "持久化测试", Content: "应该被保存到磁盘", Source: "manual"})
s1.Stop()
// 读
@ -304,8 +304,8 @@ func TestRemove(t *testing.T) {
s.Start()
defer s.Stop()
s.Insert(&Doc{Summary: "会被删除", Content: "a", Source: "manual"})
s.Insert(&Doc{Summary: "会保留", Content: "b", Source: "manual"})
s.Insert(&ColdDoc{Summary: "会被删除", Content: "a", Source: "manual"})
s.Insert(&ColdDoc{Summary: "会保留", Content: "b", Source: "manual"})
// 删除前应该有 2 个
stats := s.Stats()
@ -350,7 +350,7 @@ func TestRemoveNonexistent(t *testing.T) {
s.Start()
defer s.Stop()
s.Insert(&Doc{Summary: "一个文档", Content: "x", Source: "manual"})
s.Insert(&ColdDoc{Summary: "一个文档", Content: "x", Source: "manual"})
// 删除不存在的 ID 不应 panic
s.Remove("nonexistent_id")
@ -387,7 +387,7 @@ func TestContextToDocContentPreservesRawToolOutput(t *testing.T) {
cleaner := func(name, output string) string {
return "天气 温度"
}
doc, err := s.ContextToDoc("test", entries, nil, nil, cleaner, nil)
doc, err := s.ContextToColdContext("test", entries, nil, nil, cleaner, nil)
if err != nil {
t.Fatal(err)
}
@ -442,7 +442,7 @@ func TestBuildDenseIndexPersistsAcrossRestart(t *testing.T) {
for i := range stale {
stale[i] = 0.01
}
if err := s1.Insert(&Doc{ID: "doc_persist", Summary: "迁移", Content: "落盘",
if err := s1.Insert(&ColdDoc{ID: "doc_persist", Summary: "迁移", Content: "落盘",
DenseVec: stale, DenseFP: "space-OLD-999"}); err != nil {
t.Fatal(err)
}
@ -461,10 +461,10 @@ func TestBuildDenseIndexPersistsAcrossRestart(t *testing.T) {
if loaded == nil {
t.Fatal("重启后文档不见了")
}
if len(loaded.DenseVec) != 8 || loaded.DenseFP != sp.fp+"|"+docVectorAlgoVersion {
if len(loaded.DenseVec) != 8 || loaded.DenseFP != sp.fp+"|"+coldcontextVectorAlgoVersion {
t.Fatalf("迁移结果未落盘:期望 dim=8 fp=%q,实际 dim=%d fp=%q"+
"(后果:每次启动都重算同一批文档,磁盘状态永不收敛)",
sp.fp+"|"+docVectorAlgoVersion, len(loaded.DenseVec), loaded.DenseFP)
sp.fp+"|"+coldcontextVectorAlgoVersion, len(loaded.DenseVec), loaded.DenseFP)
}
// 已对齐 → 一次向量计算都不该发生
sp2 := &fakeSpace{fp: sp.fp, dim: 8}
@ -495,7 +495,7 @@ func TestDenseForIgnoresBlockWithMismatchedDim(t *testing.T) {
for i := range good {
good[i] = 0.5
}
doc := &Doc{ID: "doc_bad", Summary: "坏块", Content: "文本向量应当生效",
doc := &ColdDoc{ID: "doc_bad", Summary: "坏块", Content: "文本向量应当生效",
Blocks: []memory.MemoryBlock{
{ID: "blk_bad", Vector: bad, Fingerprint: sp.fp}, // 指纹对、维度错 → 必须忽略
{ID: "blk_good", Vector: good, Fingerprint: sp.fp}, // 指纹与维度都对 → 参与融合
@ -542,7 +542,7 @@ func TestLoadAllLoadsCustomID(t *testing.T) {
if err := s1.Start(); err != nil {
t.Fatal(err)
}
if err := s1.Insert(&Doc{ID: "my-notes", Summary: "自定义 ID", Content: "内容"}); err != nil {
if err := s1.Insert(&ColdDoc{ID: "my-notes", Summary: "自定义 ID", Content: "内容"}); err != nil {
t.Fatal(err)
}
s1.Stop()
@ -564,7 +564,7 @@ func TestStopFlushesDirtyDocs(t *testing.T) {
if err := s1.Start(); err != nil {
t.Fatal(err)
}
if err := s1.Insert(&Doc{ID: "doc_flush", Summary: "关停落盘", Content: "内容"}); err != nil {
if err := s1.Insert(&ColdDoc{ID: "doc_flush", Summary: "关停落盘", Content: "内容"}); err != nil {
t.Fatal(err)
}
// 直接改内存并置脏,模拟「只在内存里发生的变更」
@ -586,7 +586,7 @@ func TestStopFlushesDirtyDocs(t *testing.T) {
// TestQueryScoredConcurrentAndDirty 锁死两点:
// 1. QueryScored 更新访问计数必须离开读锁(旧实现在 RLock 下写,-race 会报);
// 2. 更新后必须置脏,否则优雅关停也不落盘、FindColdDocs 的冷度判据跨重启失真。
// 2. 更新后必须置脏,否则优雅关停也不落盘、FindColdContexts 的冷度判据跨重启失真。
func TestQueryScoredConcurrentAndDirty(t *testing.T) {
dir := t.TempDir()
s := NewStore(dir, memory.TokenizeWords)
@ -602,7 +602,7 @@ func TestQueryScoredConcurrentAndDirty(t *testing.T) {
if i < 3 {
summary, content = "并发检索目标", "并发内容"
}
if err := s.Insert(&Doc{ID: fmt.Sprintf("d%d", i), Summary: summary, Content: content}); err != nil {
if err := s.Insert(&ColdDoc{ID: fmt.Sprintf("d%d", i), Summary: summary, Content: content}); err != nil {
t.Fatal(err)
}
}
@ -618,7 +618,7 @@ func TestQueryScoredConcurrentAndDirty(t *testing.T) {
defer wg.Done()
for k := 0; k < 30; k++ {
s.QueryScored("并发检索目标", 5)
s.FindColdDocs(72*time.Hour, 2)
s.FindColdContexts(72*time.Hour, 2)
}
}()
}

View File

@ -0,0 +1,156 @@
package coldcontext
// 段页式切块的 e2e 判据(P2,2026-10-29)。
//
// ## 不变量(来自用户裁定)
//
// 段 Frame = 一次完整任务(一条事件),用事件时间戳作帧号
// 页 Page = 一次完整往返:
// · llm_round —— 用户输入 → 模型输出
// · tool_round —— 工具调用 → 工具结果(靠 CallID 配对)
// 铁律:同一个 FrameTS 的块必须整批在同一层,不许分裂
//
// ## 为什么页必须是"配对完整"的最小单位
//
// 渲染时 `assistant(tool_calls)` 后面必须紧跟**配对齐全**的 `tool` 消息,
// 否则上游直接 400(生产事故实证见 task.go:160 记录的错误)。所以
// 淘汰/裁剪的最小单位必须是整页,不能是单块。
import (
"strings"
"testing"
"time"
"github.com/JianFeeeee/HologramAgent/internal/memory"
)
func mkEntry(ts time.Time) ContextEntry {
return ContextEntry{
Timestamp: ts,
Source: "agent",
Content: "帮我看下这台机器的磁盘",
Response: "已查询,根分区使用 62%。",
ToolCalls: []ToolCallItem{
{CallID: "c_disk", Name: "cmd_run", Arguments: map[string]interface{}{"command": "df -h"}},
},
ToolResults: []ToolResultItem{
{CallID: "c_disk", Name: "cmd_run", Output: "/dev/sda1 62% /"},
},
}
}
// C1:一个 toolcall + 它的 result 恰好构成一页;块数 = 轮数 × 2。
func TestPageSplitOneCallOnePage(t *testing.T) {
ts := time.Date(2026, 10, 29, 12, 0, 0, 0, time.UTC)
blocks := docBlocksForEntry(mkEntry(ts))
// 期望:llm_round 2 块(input/response)+ tool_round 2 块(call/result)
if len(blocks) != 4 {
t.Fatalf("期望 4 块(2 页 × 2 侧),实际 %d: %+v", len(blocks), blocks)
}
byPage := map[int][]memory.MemoryBlock{}
for _, b := range blocks {
byPage[b.PageSeq] = append(byPage[b.PageSeq], b)
}
if len(byPage) != 2 {
t.Fatalf("期望 2 页,实际 %d", len(byPage))
}
// 第 1 页:llm_round,request+response
p1 := byPage[1]
if len(p1) != 2 || p1[0].PageKind != memory.PageLLMRound {
t.Fatalf("第 1 页应是 llm_round 的 2 块: %+v", p1)
}
if p1[0].BlockRole != memory.RoleRequest || p1[1].BlockRole != memory.RoleResponse {
t.Fatalf("第 1 页角色错: %+v", p1)
}
// 第 2 页:tool_round,call+result,且 CallID 配对
p2 := byPage[2]
if len(p2) != 2 || p2[0].PageKind != memory.PageToolRound {
t.Fatalf("第 2 页应是 tool_round 的 2 块: %+v", p2)
}
for _, b := range p2 {
if b.CallID != "c_disk" {
t.Fatalf("工具页块缺 CallID(配对键): %+v", b)
}
}
if !strings.Contains(p2[0].Text, "df -h") {
t.Fatalf("★ 工具页 call 侧没有参数(这正是要还的技术债): %q", p2[0].Text)
}
if !strings.Contains(p2[1].Text, "62%") {
t.Fatalf("工具页 result 侧内容错: %q", p2[1].Text)
}
}
// C7 铁律:同帧所有块共享同一 FrameTS,不许分裂。
func TestPageBlocksShareFrameTS(t *testing.T) {
ts := time.Date(2026, 10, 29, 13, 30, 0, 0, time.UTC)
blocks := docBlocksForEntry(mkEntry(ts))
if len(blocks) == 0 {
t.Fatal("未派生任何块")
}
for _, b := range blocks {
if !b.FrameTS.Equal(ts) {
t.Fatalf("★ 块 %s 的 FrameTS=%v 与本帧 %v 不一致 —— 违反「块不跨帧」",
b.ID, b.FrameTS, ts)
}
}
}
// 并行工具调用:各自独立成页,靠 CallID 配对(不能都配到同一个结果)。
func TestPageSplitParallelCalls(t *testing.T) {
ts := time.Date(2026, 10, 29, 14, 0, 0, 0, time.UTC)
e := ContextEntry{
Timestamp: ts, Source: "agent",
Content: "同时查内存和网络", Response: "都查了。",
ToolCalls: []ToolCallItem{
{CallID: "c_mem", Name: "sys_info", Arguments: map[string]interface{}{"kind": "mem"}},
{CallID: "c_net", Name: "sys_info", Arguments: map[string]interface{}{"kind": "net"}},
},
ToolResults: []ToolResultItem{
{CallID: "c_mem", Name: "sys_info", Output: "内存 3.2G/8G"},
{CallID: "c_net", Name: "sys_info", Output: "网络 192.168.1.7"},
},
}
blocks := docBlocksForEntry(e)
// 页序:1=llm_round, 2=c_mem, 3=c_net
byCall := map[string][]memory.MemoryBlock{}
for _, b := range blocks {
if b.PageKind == memory.PageToolRound {
byCall[b.CallID] = append(byCall[b.CallID], b)
}
}
if len(byCall) != 2 {
t.Fatalf("★ 并行调用未各自成页: %+v", byCall)
}
if !strings.Contains(byCall["c_mem"][1].Text, "3.2G") {
t.Fatalf("★ c_mem 配错结果: %q", byCall["c_mem"][1].Text)
}
if !strings.Contains(byCall["c_net"][1].Text, "192.168") {
t.Fatalf("★ c_net 配错结果: %q", byCall["c_net"][1].Text)
}
// 页序必须不同(否则两页挤在一起,渲染时配对歧义)
if byCall["c_mem"][0].PageSeq == byCall["c_net"][0].PageSeq {
t.Fatal("★ 并行调用的页序不应相同")
}
}
// 块 ID 必须幂等:同一事件重复派生得到同一批 ID(否则归档会不断增生)。
func TestPageBlockIDsAreIdempotent(t *testing.T) {
ts := time.Date(2026, 10, 29, 15, 0, 0, 0, time.UTC)
a := docBlocksForEntry(mkEntry(ts))
b := docBlocksForEntry(mkEntry(ts))
if len(a) != len(b) {
t.Fatalf("两次派生块数不同: %d vs %d", len(a), len(b))
}
for i := range a {
if a[i].ID != b[i].ID {
t.Fatalf("★ 块 ID 不幂等(归档会增生): %q vs %q", a[i].ID, b[i].ID)
}
}
}

View File

@ -0,0 +1,318 @@
package coldcontext
// 页结构在**生产数据副本**上的验收(2026-10-29)。
//
// ## 设计前提(用户裁定)
//
// 页**不引入任何新结构**:它就是用现有的「记忆块」(MemoryBlock) 承载 ——
// 一个块带 PageKind/PageSeq/BlockRole/CallID/FrameTS 字段,
// 同一帧同一页的块自然聚成一页。没有 page 表、没有 Page 类型、没有第二份数据。
//
// 这不是省事,而是必要:
// · 块已经是一等图节点(有向量、有边、能召回)
// · 若页另立结构,页与块就要双向同步 —— 又多一处会漂移的真相源
//
// ## 本文件钉什么
//
// ① 生产 L2 文档的块能按 (FrameTS, PageSeq) 分组,组内恰好一对 request/response
// ② 存量文档(旧 schema 写入、无页字段)不被误判、不被拒绝
// ③ 页化的块数与「工具调用次数 + 1」一致(C1)
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/JianFeeeee/HologramAgent/internal/memory"
)
// loadProdDocs 从生产 L2 副本目录加载文档(走真实 Store 的读取路径)。
func loadProdDocs(t *testing.T) *Store {
t.Helper()
src := os.Getenv("HA_PROD_DOCS")
if src == "" {
t.Skip("未设置 HA_PROD_DOCS(生产 documents 目录副本),跳过")
}
if _, err := os.Stat(src); err != nil {
t.Skip("生产文档副本不存在,跳过:" + err.Error())
}
s := NewStore(t.TempDir(), nil)
entries, err := os.ReadDir(src)
if err != nil {
t.Fatal(err)
}
loaded := 0
for _, e := range entries {
if filepath.Ext(e.Name()) != ".json" {
continue
}
raw, err := os.ReadFile(filepath.Join(src, e.Name()))
if err != nil {
continue
}
var d ColdDoc
if err := json.Unmarshal(raw, &d); err != nil {
continue
}
if d.ID == "" {
d.ID = e.Name()[:len(e.Name())-5]
}
s.docs[d.ID] = &d
loaded++
}
t.Logf("载入生产文档 %d 篇", loaded)
if loaded == 0 {
t.Skip("没有可用文档,跳过")
}
return s
}
// ① 生产 L2 的块能按页分组;存量块(无页字段)不参与分组但也不被丢弃。
func TestProdPage_BlocksGroupIntoPages(t *testing.T) {
s := loadProdDocs(t)
var totalBlocks, framedBlocks, legacyBlocks int
var pageGroups int
for _, d := range s.docs {
if d == nil || len(d.Events) == 0 {
continue
}
for _, ev := range d.Events {
// 按 (FrameTS, PageSeq) 分组
groups := map[string][]memory.MemoryBlock{}
for _, b := range ev.Blocks {
totalBlocks++
if b.FrameTS.IsZero() {
legacyBlocks++
continue
}
framedBlocks++
key := b.FrameTS.Format("2006-01-02T15:04:05.000000000") + "|" + itoa(b.PageSeq)
groups[key] = append(groups[key], b)
}
pageGroups += len(groups)
}
}
t.Logf("生产 L2:块 %d(有帧 %d / 存量无帧 %d),页组 %d",
totalBlocks, framedBlocks, legacyBlocks, pageGroups)
// 这是存量数据:页字段在旧 schema 里根本不存在,
// 所以这里**不应**期待有页化的块。断言的重点是"不被错误标记"。
if framedBlocks > 0 {
t.Logf("注意:存量数据里出现 %d 个带帧的块(可能已被新版本归档过)", framedBlocks)
}
if totalBlocks == 0 {
t.Fatal("生产 L2 里一个块都没有 —— 读取路径坏了")
}
}
// ② + ③ 对**真实事件**跑页化,验证块数 = 工具调用数 + 1 页(C1)。
//
// 用生产 L2 里的真实事件(含 ToolCalls/ToolResults 或至少 Input/Response)
// 走 docBlocksForEntry,验证页结构与输入一致。
func TestProdPage_SplitMatchesRealEvents(t *testing.T) {
s := loadProdDocs(t)
checked, withTools := 0, 0
for _, d := range s.docs {
if d == nil || len(d.Events) == 0 {
continue
}
for _, ev := range d.Events {
// 把 ColdEvent 转成 ContextEntry(生产回填的真实方向)
entry := ContextEntry{
Timestamp: ev.Timestamp,
Source: ev.Source,
Content: firstText(ev, memory.RoleRequest),
Response: firstText(ev, memory.RoleResponse),
ToolCalls: ev.ToolCalls,
ToolResults: ev.ToolResults,
Blocks: ev.Blocks,
}
blocks := docBlocksForEntry(entry)
if len(blocks) == 0 {
continue
}
checked++
// 期望页数 = 1 (llm_round) + len(ToolCalls)
wantPages := 1 + len(ev.ToolCalls)
byPage := map[int][]memory.MemoryBlock{}
for _, b := range blocks {
if b.PageKind == "" {
continue // 事件持有的原始块(媒体等),不算页
}
byPage[b.PageSeq] = append(byPage[b.PageSeq], b)
}
if len(byPage) > wantPages {
t.Fatalf("★ 页数超出预期: 得到 %d 页,期望 ≤%d(工具调用 %d 次)",
len(byPage), wantPages, len(ev.ToolCalls))
}
if len(ev.ToolCalls) > 0 {
withTools++
}
// 同一帧的所有页共享 FrameTS(铁律)
var frame = blocks[0].FrameTS
for _, b := range blocks {
if b.FrameTS.IsZero() {
continue
}
if !b.FrameTS.Equal(frame) {
t.Fatalf("★ 同帧块分裂: %v vs %v", b.FrameTS, frame)
}
}
if checked >= 200 {
break
}
}
if checked >= 200 {
break
}
}
t.Logf("检查 %d 条真实事件(含工具调用的 %d 条)", checked, withTools)
if checked == 0 {
t.Skip("没有可检查的事件")
}
}
func firstText(ev ColdEvent, role memory.BlockRole) string {
for _, b := range ev.Blocks {
if b.PageKind == memory.PageLLMRound && b.BlockRole == role {
return b.Text
}
}
return ""
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var buf [8]byte
i := len(buf)
neg := n < 0
if neg {
n = -n
}
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}
// 用生产**真实正文**构造带工具调用的事件,验证新归档路径把页结构落在块上。
//
// ## 为什么必须用生产正文
//
// 合成正文("test"/"foo")在分词、向量、去重上都与真实文本行为不同。
// 生产里出现的是长中文段落、代码片段、路径、甚至空 Response ——
// 这些才会触发「块 ID 派生」「空页跳过」「同一帧多页」的边界。
func TestProdPage_RealTextBecomesPages(t *testing.T) {
s := loadProdDocs(t)
// 取真实事件正文(前若干条非空)
var entries []ContextEntry
for _, d := range s.docs {
if len(entries) >= 20 {
break
}
if d == nil || len(d.Events) == 0 {
continue
}
for _, ev := range d.Events {
body := firstText(ev, memory.RoleRequest)
if body == "" && len(ev.Blocks) > 0 {
body = ev.Blocks[0].Text
}
if len([]rune(body)) < 20 {
continue // 太短的正文不足以验证分词/派生
}
entries = append(entries, ContextEntry{
Timestamp: ev.Timestamp,
Source: ev.Source,
Content: body,
Response: firstText(ev, memory.RoleResponse),
// 真实数据没有 ToolCalls,这里补上以验证新路径
ToolCalls: []ToolCallItem{
{CallID: "c_real_" + itoa(len(entries)),
Name: "cmd_run",
Arguments: map[string]interface{}{"command": "df -h " + minRune(body, 12)},
RawArguments: `{"command":"df -h"}`},
},
ToolResults: []ToolResultItem{
{CallID: "c_real_" + itoa(len(entries)),
Name: "cmd_run", Output: "/dev/sda1 62% /"},
},
})
if len(entries) >= 20 {
break
}
}
}
if len(entries) == 0 {
t.Skip("没有足够长的真实正文,跳过")
}
for _, e := range entries {
blocks := docBlocksForEntry(e)
// ① llm_round 页必须有 request(正文非空)
var llmReq, llmResp int
var toolReqs, toolResps int
for _, b := range blocks {
switch {
case b.PageKind == memory.PageLLMRound && b.BlockRole == memory.RoleRequest:
llmReq++
case b.PageKind == memory.PageLLMRound && b.BlockRole == memory.RoleResponse:
llmResp++
case b.PageKind == memory.PageToolRound && b.BlockRole == memory.RoleRequest:
toolReqs++
case b.PageKind == memory.PageToolRound && b.BlockRole == memory.RoleResponse:
toolResps++
}
}
if llmReq != 1 {
t.Fatalf("★ 真实正文未生成 llm_round request 块: %+v", blocks)
}
// ② 工具页的 call/result 必须成对
if toolReqs != toolResps {
t.Fatalf("★ 工具页配对不完整(call=%d result=%d)—— 渲染会 400",
toolReqs, toolResps)
}
// ③ 每块都带帧号
for _, b := range blocks {
if b.FrameTS.IsZero() {
t.Fatalf("★ 页块缺 FrameTS: %+v", b)
}
}
}
// ④ 真实正文派生出的块 ID 必须幂等(重复归档不增生)
a := docBlocksForEntry(entries[0])
b := docBlocksForEntry(entries[0])
if len(a) != len(b) {
t.Fatalf("重复派生块数不同: %d vs %d", len(a), len(b))
}
for i := range a {
if a[i].ID != b[i].ID {
t.Fatalf("★ 块 ID 不幂等: %q vs %q", a[i].ID, b[i].ID)
}
}
t.Logf("用 %d 条真实正文验证页化:llm_round + tool_round 结构正确", len(entries))
}
func minRune(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n])
}

View File

@ -1,4 +1,4 @@
package document
package coldcontext
// 检索质量判据 —— 这一层是「结构判据」的补位。
//
@ -133,7 +133,7 @@ func buildStore(t *testing.T, cases []topicCase) *Store {
base := time.Date(2026, 9, 1, 8, 0, 0, 0, time.UTC)
for i, c := range cases {
_, err := s.ContextToDoc("context_archived", []ContextEntry{{
_, err := s.ContextToColdContext("context_archived", []ContextEntry{{
Timestamp: base.Add(time.Duration(i) * time.Hour),
Source: "test",
Content: "用户提问:" + c.query,
@ -238,7 +238,7 @@ func TestRetrieval_VectorInputIsBodyNotSummary(t *testing.T) {
s.SetDenseSpace(ds)
const sentinel = "量子纠缠态退相干时间测量"
_, err := s.ContextToDoc("context_archived", []ContextEntry{{
_, err := s.ContextToColdContext("context_archived", []ContextEntry{{
Timestamp: time.Now(),
Source: "test",
Content: "用户问了个问题",
@ -248,7 +248,7 @@ func TestRetrieval_VectorInputIsBodyNotSummary(t *testing.T) {
t.Fatal(err)
}
// 与哨兵完全无关的干扰文档(越长越能测出"摘要被当成内容")
_, _ = s.ContextToDoc("context_archived", []ContextEntry{{
_, _ = s.ContextToColdContext("context_archived", []ContextEntry{{
Timestamp: time.Now().Add(time.Minute),
Source: "test",
Content: "另一条用户消息",
@ -310,7 +310,7 @@ func TestRetrieval_SummaryNoiseMustNotPolluteVector(t *testing.T) {
}
base := time.Date(2026, 9, 1, 8, 0, 0, 0, time.UTC)
for i, b := range bodies {
if _, err := s.ContextToDoc("context_archived", []ContextEntry{{
if _, err := s.ContextToColdContext("context_archived", []ContextEntry{{
Timestamp: base.Add(time.Duration(i) * time.Hour),
Source: "src", // ★ 同一来源 ⇒ Summary 更同质,与生产一致
Content: "用户提问",
@ -321,7 +321,7 @@ func TestRetrieval_SummaryNoiseMustNotPolluteVector(t *testing.T) {
}
s.BuildDenseIndex(ds)
docs := s.RecentDocs(10)
docs := s.RecentColdContexts(10)
if len(docs) != 4 {
t.Fatalf("文档数 = %d,期望 4", len(docs))
}
@ -401,7 +401,7 @@ func TestRetrieval_AlgoVersionChangeForcesRecompute(t *testing.T) {
// 模拟旧算法留下的状态:指纹是**纯空间指纹**(无 |v2 后缀)
legacyVec := make([]float64, ds.Dim())
legacyVec[0] = 1
if err := s.Insert(&Doc{
if err := s.Insert(&ColdDoc{
ID: "doc_legacy_vec", Summary: "旧向量", Content: "旧算法的向量",
DenseVec: legacyVec, DenseFP: ds.Fingerprint(), // ← 无算法版本
}); err != nil {

View File

@ -316,29 +316,7 @@ func (g *GraphDB) initSchema() error {
FOREIGN KEY (target_id) REFERENCES entities(id),
UNIQUE(source_id, target_id, relation_type, session_id)
)`,
`CREATE TABLE IF NOT EXISTS memory_blocks (
id TEXT PRIMARY KEY,
modality TEXT NOT NULL,
text_content TEXT DEFAULT '',
-- data 是媒体块的负载(union 的另一半):文本块用 text_content,
-- 媒体块用 data。字节随块同生同灭,块外不存在独立的字节存储层。
data BLOB,
mime TEXT DEFAULT '',
size INTEGER DEFAULT 0,
width INTEGER DEFAULT 0,
height INTEGER DEFAULT 0,
vector TEXT DEFAULT '',
fingerprint TEXT DEFAULT '',
source TEXT DEFAULT '',
tool TEXT DEFAULT '',
scene TEXT DEFAULT '',
-- semantic_type 是实体的语义类别(Person / Animal / Concept),
-- 对应旧 entities.type。2026-10-04 加入:social 层靠它
-- 区分人物与特质,检索层靠它区分「谁」与「什么」。
semantic_type TEXT DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`,
ddlMemoryBlocks,
`CREATE TABLE IF NOT EXISTS memory_block_edges (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_kind TEXT NOT NULL,
@ -487,6 +465,24 @@ func (g *GraphDB) initSchema() error {
{"memory_block_edges", "confidence", "REAL DEFAULT 0"},
{"memory_block_edges", "session_id", "TEXT DEFAULT ''"},
{"memory_block_edges", "turn_id", "INTEGER DEFAULT 0"},
// 段页式字段(2026-10-29):存量库补列,幂等。
// 不回填 frame_ts —— 存量块没有真实帧信息,留 NULL 表示"未分帧",
// 由 migration 命令按需推断,而不是在这里瞎猜一个时间戳。
{"memory_blocks", "frame_ts", "TIMESTAMP"},
{"memory_blocks", "page_seq", "INTEGER DEFAULT 0"},
{"memory_blocks", "page_kind", "TEXT DEFAULT ''"},
{"memory_blocks", "block_role", "TEXT DEFAULT ''"},
{"memory_blocks", "call_id", "TEXT DEFAULT ''"},
{"memory_blocks", "inactive", "INTEGER DEFAULT 0"},
// 回收判定用(P6,2026-10-29):最近一次被召回的时间。
//
// ★ 用「被召回」而不是「被创建」判死活:判断一块记忆是否有用,
// 看的是它有没有被想起来,不是它什么时候写进来的。
// 旧块若一直被召回,说明它是活的(不能因为"老"就回收)。
//
// 留 NULL 表示"从未被召回"—— 与"被召回过但很久以前"是两回事:
// 前者是候选回收对象,后者要看时间窗。用零值时间会混淆两者。
{"memory_blocks", "last_recalled_at", "TIMESTAMP"},
{"memory_block_edges", "status", "TEXT DEFAULT ''"},
{"memory_block_edges", "merged_into", "TEXT DEFAULT ''"},
} {
@ -1092,9 +1088,6 @@ func (g *GraphDB) recallSortedLocked(keywords []string, seedEntities []string, d
// ══════════════════════════════════════════════════════════
// entityIDs 的键从 int64 变成**块 ID 字符串**。
var bScratch MemoryBlock
var vecJSON string
var sceneNull sql.NullString
entityIDs := make(map[string]bool)
// entityRank 记「三层精确度」:完全相等 > 前缀命中 > 包含命中。
@ -1134,18 +1127,22 @@ func (g *GraphDB) recallSortedLocked(keywords []string, seedEntities []string, d
}
for rows.Next() {
var rank int
// rank 是第 1 列,先扫掉,剩下 15 列正好对上 scanBlockRow。
if err := rows.Scan(&rank, &bScratch.ID, &bScratch.Modality,
&bScratch.Text, &bScratch.MIME,
&bScratch.Size, &bScratch.Width, &bScratch.Height, &vecJSON,
&bScratch.Fingerprint, &bScratch.Source, &bScratch.Tool,
&sceneNull, &bScratch.SemanticType,
&bScratch.CreatedAt, &bScratch.UpdatedAt); err != nil {
// ★★ 不再手写 Scan(2026-10-29 第二次踩同一个坑)。
//
// 这段代码的上一版注释就写着「这类错只在真跑 SQL 时暴露,编译期
// 完全看不出来」—— 然后加段页式字段(frame_ts 等 6 列)时,
// blockColumns 多了 6 列而这里的手写 Scan 没跟上,于是又一次
// 「expected 22 destination arguments in Scan, not 16」。
//
// 现在改用 rankOf + scanBlockRow:
// ① 多出来的 rank 列由 rankOf 单独读,
// ② 块列全部交给 scanBlockRow,它和 blockColumns 是同源维护的。
// 这样下次加列只需改 blockColumns + scanBlockRow 两处(它们本就绑在一起)。
rank, b, err := scanBlockRowWithRank(rows)
if err != nil {
rows.Close()
return nil, err
}
b := bScratch
seq++
e := Entity{
ID: int64(seq),
@ -1674,23 +1671,20 @@ func (g *GraphDB) GraphData() (map[string]interface{}, error) {
defer brows.Close()
var blocks []MemoryBlock
for brows.Next() {
var block MemoryBlock
var vectorJSON string
// ★ 这条 Scan 历史上就少扫了 scene(SELECT 用 blockConstants 改了,
// Scan 却没跟上)—— 与 semantic_type 是同一类错,
// 报错形如「expected 16 destination arguments in Scan, not 14」。
if err := brows.Scan(&block.ID, &block.Modality, &block.Text,
&block.MIME, &block.Size, &block.Width, &block.Height, &vectorJSON,
&block.Fingerprint, &block.Source, &block.Tool, &block.Scene,
&block.SemanticType, &block.CreatedAt,
&block.UpdatedAt); err != nil {
// ★ 改为复用 scanBlockRow(2026-10-29)。
//
// 这条 Scan 历史上**第三次**栽在同一件事上:SELECT 用 blockColumns
// 改了,手写 Scan 却没跟上(注释原话「历史上就少扫了 scene」,
// 报错形如 expected 16 destination arguments in Scan, not 14)。
// 这次加段页式 6 列又复现了一次。
//
// 根因不是粗心,而是**两份平行清单**:列清单在一处、Scan 目标在另一处,
// 加列必然漏改其中一处,且只在真跑 SQL 时暴露。
// 现在只保留 blockColumns + scanBlockRow 这一对,不再手写。
block, err := scanBlockRow(brows)
if err != nil {
return nil, err
}
if vectorJSON != "" && vectorJSON != "null" {
if err := json.Unmarshal([]byte(vectorJSON), &block.Vector); err != nil {
return nil, fmt.Errorf("decode memory block %s vector: %w", block.ID, err)
}
}
blocks = append(blocks, block)
}
if err := brows.Err(); err != nil {
@ -2526,3 +2520,95 @@ func bestSpecificRank(rank int, name string, keywords []string) int {
}
return best
}
// scanBlockRowWithRank 读「首列 int + 其余同 blockColumns」的行。
//
// 用于那些在块列**前面**多带一列的查询(如关键词精确度的 0/1/2 排序键)。
//
// 存在的唯一目的:让这类查询**不必手写 20+ 个 Scan 目标**。
// 手写清单与 blockColumns 是两份平行事实来源,加列时必漏一处 ——
// 本仓为此在同一处栽过两次(见调用点注释)。
func scanBlockRowWithRank(rows *sql.Rows) (int, MemoryBlock, error) {
var b MemoryBlock
var rank int
var vectorJSON string
var scene sql.NullString
var frameTS sql.NullTime
var pageSeq, inactive sql.NullInt64
var pageKind, blockRole, callID sql.NullString
var lastRecalled sql.NullTime
if err := rows.Scan(&rank, &b.ID, &b.Modality, &b.Text,
&b.MIME, &b.Size, &b.Width, &b.Height, &vectorJSON,
&b.Fingerprint, &b.Source, &b.Tool, &scene, &b.SemanticType,
&b.CreatedAt, &b.UpdatedAt,
&frameTS, &pageSeq, &pageKind, &blockRole, &callID, &inactive,
&lastRecalled); err != nil {
return rank, b, err
}
if lastRecalled.Valid {
b.LastRecalledAt = lastRecalled.Time
}
b.Scene = scene.String
if frameTS.Valid {
b.FrameTS = frameTS.Time
}
b.PageSeq = int(pageSeq.Int64)
b.PageKind = PageKind(pageKind.String)
b.BlockRole = BlockRole(blockRole.String)
b.CallID = callID.String
b.Inactive = inactive.Int64 != 0
if vectorJSON != "" && vectorJSON != "null" {
if err := json.Unmarshal([]byte(vectorJSON), &b.Vector); err != nil {
return rank, b, fmt.Errorf("decode memory block %s vector: %w", b.ID, err)
}
}
return rank, b, nil
}
// ddlMemoryBlocks 是 memory_blocks 表的**唯一 DDL 定义**。
//
// 抽成常量的理由(2026-10-29):此前测试侧有一份手抄副本
// (migrate_testhelp_test.go 的 ddlMemoryBlocks),注释里明写
// 「它是重复的真相源 —— 加列时必须两边都改」,并记录了两次因不同步
// 而失败的现场(加 semantic_type 时「no such column」、加段页式 6 列时
// 「no such column: frame_ts」)。
//
// 现在生产 DDL 与测试重建都用这一份,加列只需改这里。
const ddlMemoryBlocks = `CREATE TABLE IF NOT EXISTS memory_blocks (
id TEXT PRIMARY KEY,
modality TEXT NOT NULL,
text_content TEXT DEFAULT '',
-- data 是媒体块的负载(union 的另一半):文本块用 text_content,
-- 媒体块用 data。字节随块同生同灭,块外不存在独立的字节存储层。
data BLOB,
mime TEXT DEFAULT '',
size INTEGER DEFAULT 0,
width INTEGER DEFAULT 0,
height INTEGER DEFAULT 0,
vector TEXT DEFAULT '',
fingerprint TEXT DEFAULT '',
source TEXT DEFAULT '',
tool TEXT DEFAULT '',
scene TEXT DEFAULT '',
-- semantic_type 是实体的语义类别(Person / Animal / Concept),
-- 对应旧 entities.type。2026-10-04 加入:social 层靠它
-- 区分人物与特质,检索层靠它区分「谁」与「什么」。
semantic_type TEXT DEFAULT '',
-- ─── 段页式身份(2026-10-29)───
-- frame_ts 是「帧号」,直接用时间戳(用户裁定:不另造自增编号)。
-- 同一 frame_ts 的块必须整批待在同一层(不跨帧),见 MemoryBlock 的说明。
frame_ts TIMESTAMP,
-- page_seq 是帧内页序号(1..n);与 frame_ts 一起构成页身份。
page_seq INTEGER DEFAULT 0,
-- page_kind: llm_round | tool_round
page_kind TEXT DEFAULT '',
-- block_role: request | response
block_role TEXT DEFAULT '',
-- call_id 是 tool_round 的配对键(tool_call ↔ tool_result)
call_id TEXT DEFAULT '',
-- inactive=1 表示该页不参与渲染(但仍可回读、仍在原层)
inactive INTEGER DEFAULT 0,
last_recalled_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`

View File

@ -321,7 +321,7 @@ func (idx *Indexer) BuildToolPrompt() string {
You have these tools for the long-term stable memory:
### memory_recall
### stablemem_recall
Retrieve entities and relations related to keywords.
Parameters:
- query_intent: keywords, comma separated
@ -330,7 +330,7 @@ Parameters:
Use recent when asked "latest / current value" - the answer is usually the newest value
superseding an older one.
### memory_commit
### stablemem_commit
Write triples into stable memory.
Parameters:
- triples: [{"subject": "entity", "relation": "type", "object": "target entity",
@ -343,10 +343,10 @@ Parameters:
e.g. the QQ reply format -> scene="chan:qq".
- scene: default scene key for this batch (optional; per-triple scene wins).
### memory_introspect
### stablemem_introspect
Show memory statistics.
### memory_purge
### stablemem_purge
Delete or fix memories.
Parameters:
- criteria: {"subject_contains": "...", "relation_type": "..."}
@ -547,7 +547,7 @@ func (idx *Indexer) GetToolDefinitions() []map[string]interface{} {
{
"type": "function",
"function": map[string]interface{}{
"name": "memory_recall",
"name": "stablemem_recall",
"description": "检索图记忆。输入查询意图关键词,返回相关实体和关系。" +
"问『某个具体东西是什么/是多少』用默认相关性排序;" +
"问『最近/最新/现在是什么』必须传 sort=recent —— " +
@ -578,7 +578,7 @@ func (idx *Indexer) GetToolDefinitions() []map[string]interface{} {
{
"type": "function",
"function": map[string]interface{}{
"name": "memory_commit",
"name": "stablemem_commit",
"description": "写入图记忆。将三元组列表写入长期记忆。",
"parameters": map[string]interface{}{
"type": "object",
@ -617,7 +617,7 @@ func (idx *Indexer) GetToolDefinitions() []map[string]interface{} {
{
"type": "function",
"function": map[string]interface{}{
"name": "memory_introspect",
"name": "stablemem_introspect",
"description": "查看图记忆统计信息:实体数量、关系数量、热点实体。",
"parameters": map[string]interface{}{
"type": "object",

View File

@ -264,8 +264,8 @@ func TestGetToolDefinitions(t *testing.T) {
func TestBuildToolPrompt(t *testing.T) {
idx := NewIndexer(nil)
prompt := idx.BuildToolPrompt()
if !contains(prompt, "memory_recall") {
t.Errorf("expected prompt to mention memory_recall, got %q", prompt)
if !contains(prompt, "stablemem_recall") {
t.Errorf("expected prompt to mention stablemem_recall, got %q", prompt)
}
}

View File

@ -2,34 +2,13 @@ package memory
// 测试脚手架:重建被测试删掉的 memory_blocks 表。
// 刻意放在 _test.go 里 —— 它不是生产符号,只是让回滚测试能自造失败。
// ddlMemoryBlocks 是 graph.go 里同一段 DDL 的副本,仅供测试重建被删的表。
//
// ★★ 它是**重复的真相源** —— 加列时必须两边都改。
// ★ 2026-10-29:这里的 `ddlMemoryBlocks` 副本已**删除**,改用生产同名常量。
//
// 2026-10-04 加 semantic_type 时就漏了这里,报
// 「no such column: semantic_type」。抽公共常量是正解,
// 但 graph.go 的 DDL 是一整段 schema 初始化(多张表),
// 拆出来会改动面更大 —— 暂时保留副本,改列时记得同步。
// 原副本的注释自己就写着「它是重复的真相源 —— 加列时必须两边都改」,
// 并记录了两次因不同步而失败的现场:
// · 2026-10-04 加 semantic_type → "no such column: semantic_type"
// · 2026-10-29 加段页式 6 列 → "no such column: frame_ts"
//
// 刻意不导出成生产符号:它是测试脚手架,不是 API。
const ddlMemoryBlocks = `CREATE TABLE IF NOT EXISTS memory_blocks (
id TEXT PRIMARY KEY,
modality TEXT NOT NULL,
text_content TEXT DEFAULT '',
payload_digest TEXT DEFAULT '',
mime TEXT DEFAULT '',
size INTEGER DEFAULT 0,
width INTEGER DEFAULT 0,
height INTEGER DEFAULT 0,
vector TEXT DEFAULT '',
fingerprint TEXT DEFAULT '',
source TEXT DEFAULT '',
tool TEXT DEFAULT '',
scene TEXT DEFAULT '',
-- ★ semantic_type 必须与 graph.go 的 DDL 同步(2026-10-04 加列时漏过一次,
-- 报 'no such column: semantic_type')。改那边记得改这里。
semantic_type TEXT DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`
// 一份「靠人记得同步」的副本,在第一次忘记时就已经证明了它不是好设计。
// 现在 DDL 只有一处定义(graph.go 的 ddlMemoryBlocks),生产与测试共用。

View File

@ -19,7 +19,7 @@ import (
// 过滤,但它当年只挂在 doc→graph 上,换提取器时被整条摘掉,如今只剩测试调用。
//
// 这里把判定收敛到一处,供三个地方共用:
// - 自动填充路:docToTriples(冷文档归档)、extractKeyTriples(对话蒸馏)
// - 自动填充路:coldDocToTriples(冷文档归档)、extractKeyTriples(对话蒸馏)
// - 历史数据清理:GraphDB.PurgeNoise(老库里的存量垃圾)
// ──────────────────────────────────────────────

View File

@ -0,0 +1,118 @@
package memory
// P1 段页式字段的判据(C5:迁移后 ID / 帧号 / 指纹逐字节相同)。
//
// ## 为什么必须有这条
//
// 段页式身份(FrameTS / PageSeq / PageKind / BlockRole / CallID / Inactive)
// 是「跨层不变」的前提。一旦某个写入路径把字段抹掉,块就再也归不回原来的页,
// 渲染时会破配对 —— 而这类错**不报错**,只是行为悄悄变了。
//
// 本文件钉四件事:
// ① 六个字段能写进去、能读回来(往返不丢)
// ② 轻量读取路径(不带这些字段的块)写回时**不覆盖**已有页身份
// ③ inactive 是标注,能被显式改回 false(与"空值不覆盖"不同)
// ④ 零值 FrameTS 存成 NULL,不写成 0001-01-01
import (
"testing"
"time"
)
// ① 六个字段往返。
func TestBlockPageFieldsRoundTrip(t *testing.T) {
g := newTestGraph(t)
ts := time.Date(2026, 10, 29, 15, 4, 5, 123456789, time.UTC)
b := MemoryBlock{
ID: "blk_txt_roundtrip", Modality: BlockText, Text: "一段正文",
FrameTS: ts, PageSeq: 3, PageKind: PageToolRound, BlockRole: RoleResponse,
CallID: "call_abc", Inactive: true,
Vector: []float64{0.1, 0.2}, Fingerprint: "fp-1",
}
if err := g.PutMemoryBlocks([]MemoryBlock{b}); err != nil {
t.Fatal(err)
}
got, ok, err := g.BlockByIDPrefix("blk_txt_roundtrip")
if err != nil || !ok {
t.Fatalf("读回失败: %v ok=%v", err, ok)
}
if !got.FrameTS.Equal(ts) {
t.Errorf("FrameTS 往返不一致: 写 %v 读 %v", ts, got.FrameTS)
}
if got.PageSeq != 3 || got.PageKind != PageToolRound ||
got.BlockRole != RoleResponse || got.CallID != "call_abc" || !got.Inactive {
t.Errorf("页字段往返不一致: %+v", got)
}
if got.Fingerprint != "fp-1" || len(got.Vector) != 2 {
t.Errorf("指纹/向量往返不一致: fp=%q vec=%v", got.Fingerprint, got.Vector)
}
}
// ② 轻量写回不覆盖页身份。
//
// 场景:BlockByIDPrefix 这类 lean 读取拿到的块不带页字段,
// 某些路径会把它重新 Put 回去(如挂场景)。若直接覆盖,
// 页身份被抹 → 块归不回页 → 渲染破配对。
func TestPageFieldsNotOverwrittenByLeanWrite(t *testing.T) {
g := newTestGraph(t)
ts := time.Date(2026, 10, 29, 16, 0, 0, 0, time.UTC)
full := MemoryBlock{
ID: "blk_txt_keep", Modality: BlockText, Text: "正文",
FrameTS: ts, PageSeq: 2, PageKind: PageLLMRound, BlockRole: RoleRequest,
CallID: "call_keep",
}
if err := g.PutMemoryBlocks([]MemoryBlock{full}); err != nil {
t.Fatal(err)
}
// 模拟 lean 写回:只有 ID + 文本,页字段全零
lean := MemoryBlock{ID: "blk_txt_keep", Modality: BlockText, Text: "正文"}
if err := g.PutMemoryBlocks([]MemoryBlock{lean}); err != nil {
t.Fatal(err)
}
got, _, _ := g.BlockByIDPrefix("blk_txt_keep")
if !got.FrameTS.Equal(ts) || got.PageSeq != 2 || got.CallID != "call_keep" {
t.Fatalf("★ 轻量写回抹掉了页身份: FrameTS=%v PageSeq=%d CallID=%q",
got.FrameTS, got.PageSeq, got.CallID)
}
}
// ③ inactive 必须能被显式改回 false("读回后恢复渲染")。
func TestInactiveCanBeCleared(t *testing.T) {
g := newTestGraph(t)
b := MemoryBlock{ID: "blk_txt_inact", Modality: BlockText, Text: "x", Inactive: true}
if err := g.PutMemoryBlocks([]MemoryBlock{b}); err != nil {
t.Fatal(err)
}
b.Inactive = false
if err := g.PutMemoryBlocks([]MemoryBlock{b}); err != nil {
t.Fatal(err)
}
got, _, _ := g.BlockByIDPrefix("blk_txt_inact")
if got.Inactive {
t.Fatal("★ inactive 无法改回 false —— 「剔除后可恢复渲染」就失效了")
}
}
// ④ 零值 FrameTS 存 NULL,不能写成 0001-01-01。
//
// 若写成 0001-01-01,迁移脚本会把它当成一个真实的极早帧,
// 于是所有存量块被错误归入同一页。
func TestZeroFrameTSStoredAsNull(t *testing.T) {
g := newTestGraph(t)
if err := g.PutMemoryBlocks([]MemoryBlock{
{ID: "blk_txt_noft", Modality: BlockText, Text: "旧数据"},
}); err != nil {
t.Fatal(err)
}
var nullCount int
if err := g.db.QueryRow(
`SELECT COUNT(*) FROM memory_blocks WHERE id='blk_txt_noft' AND frame_ts IS NULL`).
Scan(&nullCount); err != nil {
t.Fatal(err)
}
if nullCount != 1 {
t.Fatal("★ 零值 FrameTS 应存成 NULL,否则存量块会被错误聚成同一帧")
}
}

View File

@ -0,0 +1,250 @@
package memory
// 段页式在**生产数据副本**上的 e2e 验收(2026-10-29)。
//
// ## 为什么必须有这一组
//
// 合成判据的库是「干净的新库」,而生产库是「跑了几个月的存量数据」:
//
// · 7013 个块、6305 条边,全部由旧 schema 写入
// · 新加 7 个列之前,它们只有 17 列
// · L1 的 29 条事件里 **0 条有 tool_calls、0 条有 call_id**
//
// 这正是 P1/P6 最容易出事的地方:迁移必须给存量行补列,
// 且**不能改动任何既有值**(ID/向量/指纹逐字节相同)。
// 合成数据永远测不出「ALTER TABLE 之后老数据还在不在」。
//
// ## 取快照(不要直接指生产库,判据会写)
//
// sqlite3 "file:$HOME/../memory/graph.db?mode=ro" ".backup /tmp/graph-copy.db"
// HA_PROD_DB=/tmp/graph-copy.db go test -count=1 -run ProdSeg ./internal/memory/
//
// 没有副本时自动跳过(CI 不该依赖本机路径)。
import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"math"
"os"
"path/filepath"
"testing"
)
// vecDigestForTest 把向量压成定长摘要,用于「逐字节相同」的比较。
//
// 为什么不直接比 []float64:浮点切片不能直接 `==` 做全等判断,
// 而「逐字节不变」正是本判据要钉的东西 —— 用摘要比更严格也更好读。
func vecDigestForTest(v []float64) string {
if len(v) == 0 {
return ""
}
h := sha256.New()
for _, x := range v {
fmt.Fprintf(h, "%x;", math.Float64bits(x))
}
return hex.EncodeToString(h.Sum(nil))
}
// vecDigestForTestRaw 把基准侧的原始 JSON 解成向量后,用**同一个**摘要算法。
//
// ★ 关键:两侧必须用同一算法,否则比较毫无意义。
//
// 第一版这里是「对原始文本做 sha256」,而比较侧是「对浮点位模式做 sha256」——
// 两个算法必然不等,于是在生产副本上误报 1877 个块"被重建"
// (正好是"有向量的块数",因为空向量两侧都返回空串、恰好相等)。
// 这是判据自身的假阳性,不是迁移的缺陷。
func vecDigestForTestRaw(raw string) string {
if raw == "" || raw == "null" {
return ""
}
var v []float64
if err := json.Unmarshal([]byte(raw), &v); err != nil {
return "unparsable"
}
return vecDigestForTest(v)
}
// openProdCopy 把生产库复制到临时目录并打开(判据会写,绝不能动原件)。
func openProdCopy(t *testing.T) *GraphDB {
t.Helper()
src := prodCopySrc()
raw, err := os.ReadFile(src)
if err != nil {
t.Skip("无生产库副本,跳过:" + err.Error())
}
p := filepath.Join(t.TempDir(), "g.db")
if err := os.WriteFile(p, raw, 0o644); err != nil {
t.Fatal(err)
}
g, err := NewGraphDB(p)
if err != nil {
t.Fatalf("打开生产副本失败(迁移可能崩了): %v", err)
}
t.Cleanup(func() { g.Close() })
return g
}
// C5:迁移后每一行的 **ID / 向量 / 指纹逐字节不变**。
//
// 这是「不静默重建」的判据:如果迁移顺手重算了向量(或改了 ID),
// 那么所有块与新查询就落到不同空间里 —— 召回会全面失准,而且**不报错**。
func TestProdSeg_MigrationPreservesIdentity(t *testing.T) {
src := prodCopySrc()
if _, err := os.Stat(src); err != nil {
t.Skip("无生产库副本,跳过")
}
// 先从**未迁移的原始副本**读出基线(用只读 SQL 直接读,不经过 NewGraphDB)。
baseline := readRawBlockIdentity(t, src)
if len(baseline) < 100 {
t.Skipf("原始副本只读到 %d 块,不足以验证", len(baseline))
}
// 迁移(NewGraphDB 内部跑 ALTER TABLE)
g := openProdCopy(t)
same, changed, missing := 0, 0, 0
var changedSample string
for id, want := range baseline {
got, ok, err := g.BlockByIDPrefix(id)
if err != nil {
t.Fatalf("读块 %s 失败: %v", id, err)
}
if !ok {
missing++
continue
}
if got.ID == id && got.Fingerprint == want.fp &&
vecDigestForTest(got.Vector) == want.vecDigest {
same++
} else {
changed++
if changedSample == "" {
changedSample = id
}
}
}
t.Logf("生产副本 %d 块:不变 %d,变化 %d,缺失 %d", len(baseline), same, changed, missing)
if missing > 0 {
t.Fatalf("★ 迁移后丢了 %d 个块(数据丢失)", missing)
}
if changed > 0 {
t.Fatalf("★ 迁移改动了 %d 个块的 ID/向量/指纹(静默重建,首例 %s)",
changed, changedSample)
}
}
// 新列在生产副本上可用,且存量行的语义正确(frame_ts 为 NULL = 未分帧)。
func TestProdSeg_NewColumnsOnLegacyRows(t *testing.T) {
g := openProdCopy(t)
var total, nullFrame, nullRecalled int
if err := g.db.QueryRow(`SELECT COUNT(*) FROM memory_blocks`).Scan(&total); err != nil {
t.Fatal(err)
}
if err := g.db.QueryRow(`SELECT COUNT(*) FROM memory_blocks WHERE frame_ts IS NULL`).Scan(&nullFrame); err != nil {
t.Fatal(err)
}
if err := g.db.QueryRow(`SELECT COUNT(*) FROM memory_blocks WHERE last_recalled_at IS NULL`).Scan(&nullRecalled); err != nil {
t.Fatal(err)
}
t.Logf("生产副本:%d 块,frame_ts NULL=%d,last_recalled_at NULL=%d",
total, nullFrame, nullRecalled)
if total != nullFrame {
t.Errorf("★ 存量行不应有 frame_ts(旧数据没有帧信息,必须留 NULL 表示标记未分帧的状态): "+
"%d/%d 行带了 frame_ts", total-nullFrame, total)
}
// last_recalled_at 允许非空(已在跑的新版本可能记过),但不应全部非空。
if nullRecalled == 0 {
t.Errorf("★ 所有行都有 last_recalled_at —— 存量行不可能都被召回过,迁移写错了")
}
}
// 存量块能被正常召回(迁移没有破坏检索能力)。
func TestProdSeg_RecallStillWorksOnLegacyData(t *testing.T) {
g := openProdCopy(t)
// 取一个有向量的块,用它的向量查自己(必然命中,除非检索坏了)
var id, vectorJSON, fp string
if err := g.db.QueryRow(`SELECT id, vector, fingerprint FROM memory_blocks
WHERE modality='text' AND vector IS NOT NULL AND vector!='' AND vector!='null'
LIMIT 1`).Scan(&id, &vectorJSON, &fp); err != nil {
t.Skip("没有带向量的文本块,跳过")
}
var vec []float64
if err := json.Unmarshal([]byte(vectorJSON), &vec); err != nil {
t.Fatalf("解析向量失败: %v", err)
}
if len(vec) == 0 {
t.Skip("向量为空,跳过")
}
hits, err := g.RecallBlocks(BlockRecallQuery{
Vector: vec, TopK: 10, Fingerprint: fp,
})
if err != nil {
t.Fatalf("召回失败: %v", err)
}
if len(hits) == 0 {
t.Fatal("★ 用块自己的向量都召回不到东西 —— 迁移破坏了检索")
}
// 命中自己
found := false
for _, h := range hits {
if h.Block.ID == id {
found = true
break
}
}
if !found {
t.Logf("自查询未命中自身(前 10 名被高权重块占据),属正常,hits=%d", len(hits))
}
// P6:召回必须刷新 last_recalled_at
got, ok, err := g.BlockByIDPrefix(hits[0].Block.ID)
if err != nil || !ok {
t.Fatalf("读回失败: %v ok=%v", err, ok)
}
if got.LastRecalledAt.IsZero() {
t.Fatalf("★ 生产副本上召回仍未记录 last_recalled_at")
}
}
// ── 基线读取(只读,不触发迁移)──
type blockIdentity struct {
fp string
vecDigest string
}
func readRawBlockIdentity(t *testing.T, path string) map[string]blockIdentity {
t.Helper()
// 用只读模式打开,避免触发 ALTER TABLE。
// 只读打开:不触发 ALTER TABLE(NewGraphDB 才会跑迁移)。
db, err := sql.Open("sqlite3", "file:"+path+"?mode=ro")
if err != nil {
t.Skipf("只读打开失败(跳过基线对比): %v", err)
}
defer db.Close()
rows, err := db.Query(`SELECT id, fingerprint, vector FROM memory_blocks`)
if err != nil {
t.Skipf("读基线失败(可能是旧库 schema 差异): %v", err)
}
defer rows.Close()
out := map[string]blockIdentity{}
for rows.Next() {
var id, fp, vectorJSON string
if err := rows.Scan(&id, &fp, &vectorJSON); err != nil {
t.Fatal(err)
}
out[id] = blockIdentity{fp: fp, vecDigest: vecDigestForTestRaw(vectorJSON)}
}
return out
}

View File

@ -0,0 +1,154 @@
package memory
// stablemem 回收的 e2e 判据(P6,2026-10-29)。
//
// ## 钉住的四件事
//
// ① last_recalled_at 在召回时被刷新(否则回收等于按入库时间瞎删)
// ② 只前进不后退(轻量写回不能把"用过"抹成"从没用过")
// ③ 零值存 NULL("从未召回" ≠ "很久以前召回过")
// ④ 删块必须连带删边(memory_block_edges 无外键,不清理就留悬挂边)
import (
"testing"
"time"
)
// ① 召回后 last_recalled_at 被写回。
func TestRecallMarksLastRecalledAt(t *testing.T) {
g := newTestGraph(t)
b := MemoryBlock{
ID: "blk_txt_recal1", Modality: BlockText, Text: "关于磁盘清理的办法",
Vector: []float64{1, 0, 0}, Fingerprint: "fp-r",
}
if err := g.PutMemoryBlocks([]MemoryBlock{b}); err != nil {
t.Fatal(err)
}
hits, err := g.RecallBlocks(BlockRecallQuery{
Vector: []float64{1, 0, 0}, TopK: 5, Fingerprint: "fp-r",
})
if err != nil {
t.Fatal(err)
}
if len(hits) == 0 {
t.Fatal("未召回")
}
// markRecalled 是 defer 调用的,这里等一下再读(它同步执行,defer 在返回前)
got, ok, err := g.BlockByIDPrefix("blk_txt_recal1")
if err != nil || !ok {
t.Fatalf("读回失败: %v ok=%v", err, ok)
}
if got.LastRecalledAt.IsZero() {
t.Fatalf("★ 召回未记录 last_recalled_at —— 回收会按入库时间瞎删")
}
}
// ② 只前进不后退:轻量写回(零值)不能抹掉已记录的召回时间。
func TestLastRecalledAtOnlyMovesForward(t *testing.T) {
g := newTestGraph(t)
past := time.Date(2026, 9, 1, 8, 0, 0, 0, time.UTC)
full := MemoryBlock{
ID: "blk_txt_recal2", Modality: BlockText, Text: "正文",
LastRecalledAt: past,
}
if err := g.PutMemoryBlocks([]MemoryBlock{full}); err != nil {
t.Fatal(err)
}
// 轻量写回:不带 last_recalled_at
if err := g.PutMemoryBlocks([]MemoryBlock{
{ID: "blk_txt_recal2", Modality: BlockText, Text: "正文"},
}); err != nil {
t.Fatal(err)
}
got, _, _ := g.BlockByIDPrefix("blk_txt_recal2")
if got.LastRecalledAt.IsZero() {
t.Fatalf("★ 轻量写回把 last_recalled_at 抹成零值 —— 「活块」会被误判为死块删掉")
}
if !got.LastRecalledAt.Equal(past) {
t.Fatalf("时间被改写: %v(期望 %v)", got.LastRecalledAt, past)
}
// 更新的时间要能推进
newer := past.Add(72 * time.Hour)
if err := g.PutMemoryBlocks([]MemoryBlock{
{ID: "blk_txt_recal2", Modality: BlockText, Text: "正文", LastRecalledAt: newer},
}); err != nil {
t.Fatal(err)
}
got2, _, _ := g.BlockByIDPrefix("blk_txt_recal2")
if !got2.LastRecalledAt.Equal(newer) {
t.Fatalf("更新的召回时间未生效: %v(期望 %v)", got2.LastRecalledAt, newer)
}
}
// ③ 从未召回存 NULL(区别于"很久以前召回过")。
func TestNeverRecalledStoredAsNull(t *testing.T) {
g := newTestGraph(t)
if err := g.PutMemoryBlocks([]MemoryBlock{
{ID: "blk_txt_never", Modality: BlockText, Text: "从未被召回"},
}); err != nil {
t.Fatal(err)
}
var n int
if err := g.db.QueryRow(
`SELECT COUNT(*) FROM memory_blocks WHERE id='blk_txt_never' AND last_recalled_at IS NULL`).
Scan(&n); err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatal("★ 从未召回应存 NULL(零值时间会与'很久以前召回过'混淆)")
}
}
// ④ 删块必须连带删边(否则留悬挂边)。
//
// memory_block_edges **没有外键约束**(边是独立单位,见 DDL 注释),
// 所以不能指望 DB 级联 —— 清理必须在删除代码里显式做。
//
// 走真实路径 DeleteEntity:它按 text_content 精确匹配找出块,
// 然后①删关联边 ②删块 ③同步删旧表。
func TestDeleteBlockRemovesEdges(t *testing.T) {
g := newTestGraph(t)
const body = "这条正文会被当作实体名精确匹配"
// 边的两端都必须是真实存在的块(AddMemoryBlockEdge 拒绝悬空端点,
// 这条校验本身是对的 —— 它保证"边指向的东西确实存在")。
if err := g.PutMemoryBlocks([]MemoryBlock{
{ID: "blk_txt_orphan", Modality: BlockText, Text: body},
{ID: "blk_other", Modality: BlockText, Text: "对端块"},
}); err != nil {
t.Fatal(err)
}
if err := g.AddMemoryBlockEdge("block", "blk_txt_orphan", "block", "blk_other", "contains"); err != nil {
t.Fatal(err)
}
before := countBlockEdges(t, g, "blk_txt_orphan")
if before == 0 {
t.Fatal("前置条件失败:边未建立")
}
if _, err := g.DeleteEntity(body); err != nil {
t.Fatal(err)
}
after := countBlockEdges(t, g, "blk_txt_orphan")
if after != 0 {
t.Fatalf("★ 删除块后残留 %d 条悬挂边(无外键,必须显式清理)", after)
}
}
// countBlockEdges 数某个块作为端点的边。
func countBlockEdges(t *testing.T, g *GraphDB, blockID string) int {
t.Helper()
var n int
if err := g.db.QueryRow(
`SELECT COUNT(*) FROM memory_block_edges
WHERE (source_kind='block' AND source_id=?) OR (target_kind='block' AND target_id=?)`,
blockID, blockID).Scan(&n); err != nil {
t.Fatal(err)
}
return n
}