mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-06 01:07:35 +00:00
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:
@ -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
|
||||
}
|
||||
|
||||
@ -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 相同,但额外返回仲裁详情
|
||||
// (被取代的块、结论状态)。
|
||||
//
|
||||
|
||||
@ -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))
|
||||
@ -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)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}()
|
||||
}
|
||||
156
internal/memory/coldcontext/page_split_e2e_test.go
Normal file
156
internal/memory/coldcontext/page_split_e2e_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
318
internal/memory/coldcontext/prod_page_e2e_test.go
Normal file
318
internal/memory/coldcontext/prod_page_e2e_test.go
Normal 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])
|
||||
}
|
||||
@ -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 {
|
||||
@ -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
|
||||
)`
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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),生产与测试共用。
|
||||
|
||||
@ -19,7 +19,7 @@ import (
|
||||
// 过滤,但它当年只挂在 doc→graph 上,换提取器时被整条摘掉,如今只剩测试调用。
|
||||
//
|
||||
// 这里把判定收敛到一处,供三个地方共用:
|
||||
// - 自动填充路:docToTriples(冷文档归档)、extractKeyTriples(对话蒸馏)
|
||||
// - 自动填充路:coldDocToTriples(冷文档归档)、extractKeyTriples(对话蒸馏)
|
||||
// - 历史数据清理:GraphDB.PurgeNoise(老库里的存量垃圾)
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
|
||||
118
internal/memory/page_fields_test.go
Normal file
118
internal/memory/page_fields_test.go
Normal 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,否则存量块会被错误聚成同一帧")
|
||||
}
|
||||
}
|
||||
250
internal/memory/prod_segpaging_e2e_test.go
Normal file
250
internal/memory/prod_segpaging_e2e_test.go
Normal 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
|
||||
}
|
||||
154
internal/memory/reclaim_e2e_test.go
Normal file
154
internal/memory/reclaim_e2e_test.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user