mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-05 16:57:32 +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:
523
internal/memory/coldcontext/blockflow_test.go
Normal file
523
internal/memory/coldcontext/blockflow_test.go
Normal file
@ -0,0 +1,523 @@
|
||||
package coldcontext
|
||||
|
||||
// L1 ↔ L2 同构协议的判据。
|
||||
//
|
||||
// 依据(不是我的推断):
|
||||
//
|
||||
// assets/docs/zh/ARCHITECTURE.md「三层记忆」
|
||||
// · L1 与 L2 结构完全同构,唯一区别是「是否被直接投喂给模型」
|
||||
// · 正文(含多媒体正文)内联在块里:文本块的 Text 是正文,媒体块的 Data 是字节
|
||||
// · 任何一层都不压缩(视图不是记忆)
|
||||
//
|
||||
// 每条判据都对应一个真实发生过的断点,且做过变异测试(见各条注释)。
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory"
|
||||
)
|
||||
|
||||
func newDocTestStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
s := NewStore(t.TempDir(), memory.TokenizeWords)
|
||||
if err := s.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(s.Stop)
|
||||
return s
|
||||
}
|
||||
|
||||
// ① 正文完整性:归档后能逐条取回**完整**正文。
|
||||
//
|
||||
// 断点史:原实现 ContextToColdContext 把每条事件拍平成一行,
|
||||
// truncate(Response,100) / truncate(工具输出,200) —— 原文在落库那一刻就不见了。
|
||||
// 生产实测:1000 篇归档文档中 15.1% 的 response 段正好 103 字符。
|
||||
//
|
||||
// 变异:把 docBlocksForEntry 里文本块的 Text 改成截断版 ⇒ 本判据必须红。
|
||||
func TestL2_ArchivePreservesFullText(t *testing.T) {
|
||||
s := newDocTestStore(t)
|
||||
|
||||
longResp := strings.Repeat("这是一段用于验证原文完整性的长正文", 200) + "【哨兵ZQ7】"
|
||||
longInput := strings.Repeat("这是用户的原始输入内容", 100) + "【输入哨兵K9】"
|
||||
ts := time.Date(2026, 3, 4, 10, 11, 12, 345678901, time.UTC)
|
||||
|
||||
_, err := s.ContextToColdContext("context_archived", []ContextEntry{{
|
||||
Timestamp: ts,
|
||||
Source: "webui",
|
||||
Content: longInput,
|
||||
Response: longResp,
|
||||
ToolsUsed: []string{"report_fetch"},
|
||||
ToolResults: []ToolResultItem{
|
||||
{Name: "report_fetch", Output: strings.Repeat("工具输出", 500) + "【工具哨兵M3】"},
|
||||
},
|
||||
}}, nil, nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var d *ColdDoc
|
||||
for _, cand := range s.RecentColdContexts(10) {
|
||||
d = cand
|
||||
}
|
||||
if d == nil {
|
||||
t.Fatal("没有归档出文档")
|
||||
}
|
||||
if !d.HasEvents() {
|
||||
t.Fatal("文档没有 Events —— L2 必须与 L1 同构,事件列表是主体")
|
||||
}
|
||||
|
||||
ev := d.Events[0]
|
||||
in, resp, _ := ev.InOut()
|
||||
if in != longInput {
|
||||
t.Errorf("Input 不完整:len=%d,原长 %d", len(in), len(longInput))
|
||||
}
|
||||
if resp != longResp {
|
||||
t.Errorf("Response 不完整:len=%d,原长 %d\n"+
|
||||
"L2 是 L1 的下沉层,不是摘要层 —— 正文不能在落库时被截断", len(resp), len(longResp))
|
||||
}
|
||||
if !strings.Contains(resp, "【哨兵ZQ7】") {
|
||||
t.Error("Response 丢了结尾哨兵")
|
||||
}
|
||||
if !strings.Contains(in, "【输入哨兵K9】") {
|
||||
t.Error("Input 丢了结尾哨兵")
|
||||
}
|
||||
// 工具输出是事件结构的一部分,必须原样保留。
|
||||
if len(ev.ToolResults) != 1 || !strings.Contains(ev.ToolResults[0].Output, "【工具哨兵M3】") {
|
||||
t.Errorf("工具输出不完整或丢失: %+v", ev.ToolResults)
|
||||
}
|
||||
// ToolsUsed 以前完全不存。
|
||||
if len(ev.ToolsUsed) != 1 || ev.ToolsUsed[0] != "report_fetch" {
|
||||
t.Errorf("ToolsUsed 丢失: %+v", ev.ToolsUsed)
|
||||
}
|
||||
// 原始时刻(纳秒精度)必须保留,不能只剩 HH:MM。
|
||||
if !ev.Timestamp.Equal(ts) {
|
||||
t.Errorf("时间戳不精确:got %s,want %s", ev.Timestamp, ts)
|
||||
}
|
||||
}
|
||||
|
||||
// ② 正文各自成块:Input 一块、Response 一块,都是 BlockText。
|
||||
//
|
||||
// 拍板(2026-10-22):正文各自一块,不合成一个块 —— 两段内容语义常不同,
|
||||
// 分别成块才能各自被检索。
|
||||
//
|
||||
// 变异:把两块合并成一块 ⇒ 本判据必须红。
|
||||
func TestL2_InputAndResponseAreSeparateTextBlocks(t *testing.T) {
|
||||
s := newDocTestStore(t)
|
||||
|
||||
media := memory.MemoryBlock{
|
||||
ID: memory.MediaBlockID("image/png", []byte("x")), Modality: memory.BlockImage,
|
||||
Data: []byte("x"), MIME: "image/png", Size: 1,
|
||||
}
|
||||
_, err := s.ContextToColdContext("context_archived", []ContextEntry{{
|
||||
Timestamp: time.Now(),
|
||||
Source: "qq",
|
||||
Content: "用户问:紫蓝红三色带怎么配",
|
||||
Response: "回答:紫蓝红按 6:3:1 配",
|
||||
Blocks: []memory.MemoryBlock{media},
|
||||
}}, nil, nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := s.RecentColdContexts(1)[0]
|
||||
|
||||
var texts []memory.MemoryBlock
|
||||
for _, b := range d.Events[0].Blocks {
|
||||
if b.Modality == memory.BlockText {
|
||||
texts = append(texts, b)
|
||||
}
|
||||
}
|
||||
if len(texts) != 2 {
|
||||
t.Fatalf("一条事件应产出 2 个文本块(Input/Response 各一),实际 %d", len(texts))
|
||||
}
|
||||
if texts[0].Text != "用户问:紫蓝红三色带怎么配" {
|
||||
t.Errorf("第一个文本块应是 Input 正文,实际 %q", texts[0].Text)
|
||||
}
|
||||
if texts[1].Text != "回答:紫蓝红按 6:3:1 配" {
|
||||
t.Errorf("第二个文本块应是 Response 正文,实际 %q", texts[1].Text)
|
||||
}
|
||||
// 文本块的 CreatedAt 必须是事件时刻(回填靠它定位)。
|
||||
if !texts[0].CreatedAt.Equal(d.Events[0].Timestamp) {
|
||||
t.Error("文本块 CreatedAt 应等于事件时间戳")
|
||||
}
|
||||
// 媒体块仍在,且不被文本块顶掉。
|
||||
var hasMedia bool
|
||||
for _, b := range d.Events[0].Blocks {
|
||||
if b.ID == media.ID {
|
||||
hasMedia = true
|
||||
}
|
||||
}
|
||||
if !hasMedia {
|
||||
t.Error("媒体块在归档后丢失")
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 逐条保留:一次归档 N 条事件,L2 里就是 N 条,不合并、不丢。
|
||||
//
|
||||
// 变异:只保留第一条 ⇒ 红。
|
||||
func TestL2_KeepsEveryEventIndividually(t *testing.T) {
|
||||
s := newDocTestStore(t)
|
||||
|
||||
base := time.Date(2026, 5, 6, 8, 0, 0, 0, time.UTC)
|
||||
var entries []ContextEntry
|
||||
for i := 0; i < 5; i++ {
|
||||
entries = append(entries, ContextEntry{
|
||||
Timestamp: base.Add(time.Duration(i) * 13 * time.Minute),
|
||||
Source: "cli",
|
||||
Content: "第" + string(rune('0'+i)) + "条输入",
|
||||
Response: "第" + string(rune('0'+i)) + "条回答",
|
||||
})
|
||||
}
|
||||
if _, err := s.ContextToColdContext("context_archived", entries, nil, nil, nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := s.RecentColdContexts(1)[0]
|
||||
|
||||
if len(d.Events) != 5 {
|
||||
t.Fatalf("Events = %d,期望 5(逐条保留,不合并)", len(d.Events))
|
||||
}
|
||||
for i, ev := range d.Events {
|
||||
wantTS := base.Add(time.Duration(i) * 13 * time.Minute)
|
||||
if !ev.Timestamp.Equal(wantTS) {
|
||||
t.Errorf("第 %d 条时间戳错位: %s vs %s", i, ev.Timestamp, wantTS)
|
||||
}
|
||||
in, resp, _ := ev.InOut()
|
||||
if in != "第"+string(rune('0'+i))+"条输入" || resp != "第"+string(rune('0'+i))+"条回答" {
|
||||
t.Errorf("第 %d 条正文错位: input=%q response=%q", i, in, resp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ④ Content 是视图而不是事实源:它由事件渲染,且不截断。
|
||||
//
|
||||
// 拍板(2026-10-22):Content 保留为视图。
|
||||
// 判据要点:如果 Content 里还能看到完整正文,说明视图没有偷偷丢信息;
|
||||
// 同时原文的权威来源是块,Content 改坏了也不影响 InOut()。
|
||||
func TestL2_ContentIsRenderedViewNotSource(t *testing.T) {
|
||||
s := newDocTestStore(t)
|
||||
long := strings.Repeat("视图不该丢信息", 300) + "【视图哨兵】"
|
||||
|
||||
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.RecentColdContexts(1)[0]
|
||||
|
||||
if !strings.Contains(d.Content, "【视图哨兵】") {
|
||||
t.Error("Content 视图里丢了正文尾部 —— 视图也不该在存储层主动截断")
|
||||
}
|
||||
// 权威来源是块:把 Content 清空,InOut() 仍要能读回完整正文。
|
||||
d.Content = ""
|
||||
_, resp, _ := d.Events[0].InOut()
|
||||
if !strings.Contains(resp, "【视图哨兵】") {
|
||||
t.Error("正文权威来源应是块,不是 Content")
|
||||
}
|
||||
}
|
||||
|
||||
// ⑤ 幂等:同一批事件重复归档,块 ID 不变、事件不翻倍。
|
||||
//
|
||||
// 为何重要:Prune 可能因重试/重复触发而对同一批事件归档两次;
|
||||
// 块 ID 由内容派生,必须收敛而不是长出两套。
|
||||
func TestL2_RepeatedArchiveIsIdempotent(t *testing.T) {
|
||||
s := newDocTestStore(t)
|
||||
ts := time.Date(2026, 7, 8, 9, 10, 11, 123456789, time.UTC)
|
||||
entries := []ContextEntry{{
|
||||
Timestamp: ts, Source: "qq", Content: "同一条输入", Response: "同一条回答",
|
||||
}}
|
||||
|
||||
if _, err := s.ContextToColdContext("context_archived", entries, nil, nil, nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := s.RecentColdContexts(1)[0]
|
||||
id1 := first.Events[0].Blocks[0].ID
|
||||
|
||||
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.RecentColdContexts(10)); got != 1 {
|
||||
t.Fatalf("重复归档产生了 %d 篇文档,期望 1(同内容应命中已有文档)", got)
|
||||
}
|
||||
d := s.RecentColdContexts(1)[0]
|
||||
if len(d.Events) != 1 {
|
||||
t.Fatalf("重复归档后 Events=%d,期望 1(不应翻倍)", len(d.Events))
|
||||
}
|
||||
if d.Events[0].Blocks[0].ID != id1 {
|
||||
t.Errorf("同一事件重复归档得到不同块 ID:%s → %s(块 ID 必须内容稳定)",
|
||||
id1, d.Events[0].Blocks[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
// ⑥ 存量兼容:没有 Events 的旧文档必须能被读,且不伪造成"有原始事件"。
|
||||
//
|
||||
// 决定(2026-10-22):存量等系统自然冲刷,不迁移。
|
||||
// 但新代码必须能读旧格式 —— 否则升级瞬间老文档全部不可召回。
|
||||
func TestL2_LegacyDocWithoutEventsStillLoads(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s := NewStore(dir, memory.TokenizeWords)
|
||||
|
||||
// 手工写一份旧格式 JSON(无 events 字段)。
|
||||
legacy := map[string]interface{}{
|
||||
"id": "doc_legacy_1",
|
||||
"summary": "旧格式文档",
|
||||
"content": "[10:00] old: 这是旧格式的渲染视图",
|
||||
"source": "context_archived",
|
||||
"created_at": "2026-01-02T03:04:05Z",
|
||||
"updated_at": "2026-01-02T03:04:05Z",
|
||||
"access_count": 1,
|
||||
"last_access": "2026-01-02T03:04:05Z",
|
||||
}
|
||||
raw, _ := json.Marshal(legacy)
|
||||
if err := writeFile(dir+"/doc_legacy_1.json", raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := s.Start(); err != nil {
|
||||
t.Fatalf("读取旧格式失败(不得 panic):%v", err)
|
||||
}
|
||||
got := s.Get("doc_legacy_1")
|
||||
if got == nil {
|
||||
t.Fatal("旧格式文档未被加载")
|
||||
}
|
||||
if got.HasEvents() {
|
||||
t.Error("旧格式文档不该被伪造成有 Events")
|
||||
}
|
||||
if got.Content == "" {
|
||||
t.Error("旧格式文档的 Content 视图丢失")
|
||||
}
|
||||
}
|
||||
|
||||
func writeFile(path string, data []byte) error { return os.WriteFile(path, data, 0o644) }
|
||||
|
||||
// ⑦ 蒸馏/抽取必须基于原文,不能基于视图。
|
||||
//
|
||||
// 断点史:coldDocToTriples 曾抽取 doc.Content —— 而 Content 在 Events 引入前
|
||||
// 是压缩渲染(Response 截 100 rune)。在被截断的句子上做依存句法分析,
|
||||
// 往往解析不出主谓宾,三元组抽取直接失败(生产日志里大量
|
||||
// 「三元组 N 条全被实体名校验拒绝」即与此相关)。
|
||||
//
|
||||
// 本判据守的是 document 包这一侧的能力:
|
||||
// 只要给出原文,就能完整取回 —— 不被视图的形变影响。
|
||||
func TestL2_OriginalTextAvailableIndependentOfView(t *testing.T) {
|
||||
s := newDocTestStore(t)
|
||||
|
||||
full := strings.Repeat("原文必须完整用于抽取与蒸馏", 500) + "【原文哨兵D2】"
|
||||
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.RecentColdContexts(1)[0]
|
||||
|
||||
// 刻意把视图弄坏:原文的权威来源必须是块,不是 Content。
|
||||
d.Content = "视图被破坏了"
|
||||
|
||||
var b strings.Builder
|
||||
for _, ev := range d.Events {
|
||||
_, resp, _ := ev.InOut()
|
||||
b.WriteString(resp)
|
||||
}
|
||||
if got := b.String(); got != full {
|
||||
t.Errorf("事件正文取不回原文:len=%d,原长 %d", len(got), len(full))
|
||||
}
|
||||
if !strings.Contains(b.String(), "【原文哨兵D2】") {
|
||||
t.Error("原文尾部丢失")
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 压力测试:新形态(事件列表 + 正文成块)的规模与并发
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
// 大规模归档:500 条事件 → 1000 个文本块,必须能落盘再完整读回。
|
||||
//
|
||||
// 关注点:新形态把每条事件变成 2 个块,块数翻倍。
|
||||
// 若某处仍按"一篇文档一个块"或"块数=事件数"假设,规模上来就会暴露。
|
||||
func TestL2_StressLargeArchive(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("压力测试")
|
||||
}
|
||||
const n = 500
|
||||
s := newDocTestStore(t)
|
||||
|
||||
base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
entries := make([]ContextEntry, n)
|
||||
for i := 0; i < n; i++ {
|
||||
entries[i] = ContextEntry{
|
||||
Timestamp: base.Add(time.Duration(i) * time.Second),
|
||||
Source: "stress",
|
||||
Content: strings.Repeat("输入", 20) + "|" + string(rune('A'+i%26)),
|
||||
Response: strings.Repeat("回答", 20) + "|" + string(rune('a'+i%26)),
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := s.ContextToColdContext("context_archived", entries, nil, nil, nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := s.RecentColdContexts(1)[0]
|
||||
if len(d.Events) != n {
|
||||
t.Fatalf("Events = %d,期望 %d", len(d.Events), n)
|
||||
}
|
||||
var textBlocks int
|
||||
for _, ev := range d.Events {
|
||||
for _, b := range ev.Blocks {
|
||||
if b.Modality == memory.BlockText {
|
||||
textBlocks++
|
||||
}
|
||||
}
|
||||
}
|
||||
if textBlocks != 2*n {
|
||||
t.Fatalf("文本块 = %d,期望 %d(每条事件 Input/Response 各一块)", textBlocks, 2*n)
|
||||
}
|
||||
|
||||
// 落盘再重新加载:验证序列化能承载这个规模、且逐条完整。
|
||||
s.Stop()
|
||||
s2 := NewStore(s.dir, memory.TokenizeWords)
|
||||
if err := s2.Start(); err != nil {
|
||||
t.Fatalf("重新加载失败: %v", err)
|
||||
}
|
||||
defer s2.Stop()
|
||||
|
||||
d2 := s2.Get(d.ID)
|
||||
if d2 == nil {
|
||||
t.Fatal("重新加载后文档丢失")
|
||||
}
|
||||
if len(d2.Events) != n {
|
||||
t.Fatalf("重新加载后 Events = %d,期望 %d", len(d2.Events), n)
|
||||
}
|
||||
// 抽查首尾,确认正文没被合并或错位。
|
||||
for _, i := range []int{0, n / 2, n - 1} {
|
||||
in, resp, _ := d2.Events[i].InOut()
|
||||
wantIn := strings.Repeat("输入", 20) + "|" + string(rune('A'+i%26))
|
||||
wantResp := strings.Repeat("回答", 20) + "|" + string(rune('a'+i%26))
|
||||
if in != wantIn || resp != wantResp {
|
||||
t.Errorf("第 %d 条正文错位: in=%q resp=%q", i, in, resp)
|
||||
}
|
||||
if !d2.Events[i].Timestamp.Equal(base.Add(time.Duration(i) * time.Second)) {
|
||||
t.Errorf("第 %d 条时间戳错位", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 并发召回压力:Consume 持有写锁,而 QueryScored 走读锁 + markAccess。
|
||||
// 新形态下 Consume 要回填更多数据,不能引入竞态或丢文档。
|
||||
func TestL2_StressConcurrentQueryAndConsume(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("压力测试")
|
||||
}
|
||||
s := newDocTestStore(t)
|
||||
|
||||
const docs = 60
|
||||
for i := 0; i < docs; i++ {
|
||||
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)),
|
||||
Response: "回答 " + string(rune('A'+i%26)),
|
||||
}}, nil, nil, nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for w := 0; w < 8; w++ {
|
||||
wg.Add(1)
|
||||
go func(w int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 40; i++ {
|
||||
switch w % 3 {
|
||||
case 0:
|
||||
s.QueryScored("并发查询目标", 5)
|
||||
case 1:
|
||||
s.Consume("并发查询目标", 3)
|
||||
case 2:
|
||||
s.FindColdContexts(time.Hour, 10)
|
||||
}
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
wg.Wait()
|
||||
// 只要不 panic / 不 -race 报错即通过;文档数只会因 Consume 减少。
|
||||
if got := len(s.RecentColdContexts(1000)); got > docs {
|
||||
t.Errorf("文档数异常增长: %d > %d", got, docs)
|
||||
}
|
||||
}
|
||||
|
||||
// 大正文:单条事件 1MB 正文,必须完整往返(不截断、不 OOM)。
|
||||
func TestL2_StressLargePayloadRoundTrip(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("压力测试")
|
||||
}
|
||||
s := newDocTestStore(t)
|
||||
|
||||
huge := strings.Repeat("大正文压测", 250000) + "【巨尾哨兵P8】" // ~1.25MB
|
||||
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.RecentColdContexts(1)[0]
|
||||
_, resp, _ := d.Events[0].InOut()
|
||||
if len(resp) != len(huge) {
|
||||
t.Fatalf("大正文往返长度不符: %d vs %d", len(resp), len(huge))
|
||||
}
|
||||
if !strings.Contains(resp, "【巨尾哨兵P8】") {
|
||||
t.Error("大正文尾部丢失")
|
||||
}
|
||||
}
|
||||
|
||||
// ⑧ simpleHash 必须 O(n) 且定长。
|
||||
//
|
||||
// 断点史(2026-10-22 压力测试实测):
|
||||
//
|
||||
// h += fmt.Sprintf("%x", c) // 每 rune 一次 Sprintf + 字符串拼接 ⇒ O(n²)
|
||||
//
|
||||
// 1.25MB 正文归档耗时 **270 秒**;且哈希串长度是内容的 2.2 倍
|
||||
// (生产实测 content 52072 字符 → content_hash 114491 字符)。
|
||||
//
|
||||
// 本判据同时钉两点:长度定长(不随内容膨胀)、时间线性(不是 O(n²))。
|
||||
func TestL2_HashIsLinearAndFixedLength(t *testing.T) {
|
||||
small := "小内容"
|
||||
large := strings.Repeat("大内容", 200000)
|
||||
|
||||
hs, hl := simpleHash(small), simpleHash(large)
|
||||
if len(hs) != len(hl) {
|
||||
t.Errorf("哈希长度随内容变化: %d vs %d(必须定长)", len(hs), len(hl))
|
||||
}
|
||||
if len(hl) > 64 {
|
||||
t.Errorf("哈希本身过长: %d 字符(会成为文档体积负担)", len(hl))
|
||||
}
|
||||
|
||||
// 线性性:64 倍输入不应产生平方级增长。留足余量避免机器抖动误判。
|
||||
t0 := time.Now()
|
||||
for i := 0; i < 32; i++ {
|
||||
simpleHash(large)
|
||||
}
|
||||
big := time.Since(t0)
|
||||
|
||||
t1 := time.Now()
|
||||
for i := 0; i < 32; i++ {
|
||||
simpleHash(large[:len(large)/64])
|
||||
}
|
||||
sm := time.Since(t1)
|
||||
|
||||
// O(n²) 下这个比值应接近 64²=4096;线性下约 64。
|
||||
// 取 300 作为阈值:远离线性(64),远低于平方(4096)。
|
||||
if sm > 0 && big > 300*sm {
|
||||
t.Errorf("哈希耗时随输入超线性增长:大 %.1fms / 小 %.1fms = %.0f 倍"+
|
||||
"(输入差 64 倍,线性应约 64 倍,O(n²) 会到 4096 倍)",
|
||||
float64(big.Microseconds())/1000, float64(sm.Microseconds())/1000,
|
||||
float64(big)/float64(sm))
|
||||
}
|
||||
}
|
||||
1261
internal/memory/coldcontext/coldcontext.go
Normal file
1261
internal/memory/coldcontext/coldcontext.go
Normal file
File diff suppressed because it is too large
Load Diff
637
internal/memory/coldcontext/coldcontext_test.go
Normal file
637
internal/memory/coldcontext/coldcontext_test.go
Normal file
@ -0,0 +1,637 @@
|
||||
package coldcontext
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory"
|
||||
)
|
||||
|
||||
func TestInsertAndQuery(t *testing.T) {
|
||||
dir, err := os.MkdirTemp("", "doc_test_*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
s := NewStore(dir, memory.TokenizeWords)
|
||||
if err := s.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Stop()
|
||||
|
||||
doc := &ColdDoc{
|
||||
Summary: "用户喜欢喝咖啡",
|
||||
Content: "用户提到他每天早上都会喝一杯黑咖啡",
|
||||
Source: "manual",
|
||||
}
|
||||
if err := s.Insert(doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if doc.ID == "" {
|
||||
t.Error("doc ID should be auto-generated")
|
||||
}
|
||||
|
||||
stats := s.Stats()
|
||||
if stats["doc_count"].(int) != 1 {
|
||||
t.Errorf("expected 1 doc, got %d", stats["doc_count"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuery(t *testing.T) {
|
||||
dir, err := os.MkdirTemp("", "doc_query_*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
s := NewStore(dir, memory.TokenizeWords)
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
|
||||
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 {
|
||||
t.Fatal("expected results for '咖啡'")
|
||||
}
|
||||
|
||||
if results[0].AccessCount <= 0 {
|
||||
t.Error("access count should be updated on query")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextToDoc(t *testing.T) {
|
||||
dir, err := os.MkdirTemp("", "doc_ctx_*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
s := NewStore(dir, memory.TokenizeWords)
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
|
||||
entries := []ContextEntry{
|
||||
{Timestamp: time.Now(), Source: "user", Content: "我喜欢编程", Response: "很好"},
|
||||
{Timestamp: time.Now(), Source: "user", Content: "特别是Go语言", Response: "Go很棒"},
|
||||
}
|
||||
|
||||
doc, err := s.ContextToColdContext("test", entries, nil, nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if doc == nil {
|
||||
t.Fatal("expected non-nil doc")
|
||||
}
|
||||
if doc.Summary == "" {
|
||||
t.Error("summary should not be empty")
|
||||
}
|
||||
if doc.Content == "" {
|
||||
t.Error("content should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindColdDocs(t *testing.T) {
|
||||
dir, err := os.MkdirTemp("", "doc_cold_*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
s := NewStore(dir, memory.TokenizeWords)
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
|
||||
hot := &ColdDoc{Summary: "常用的信息", Content: "经常被查询", Source: "manual"}
|
||||
hot.AccessCount = 10
|
||||
hot.LastAccess = time.Now()
|
||||
s.Insert(hot)
|
||||
|
||||
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.FindColdContexts(72*time.Hour, 2)
|
||||
if len(coldDocs) != 1 {
|
||||
t.Fatalf("expected 1 cold doc, got %d", len(coldDocs))
|
||||
}
|
||||
if coldDocs[0].Summary != "很久没用的信息" {
|
||||
t.Errorf("expected cold doc, got %s", coldDocs[0].Summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecentDocs(t *testing.T) {
|
||||
dir, err := os.MkdirTemp("", "doc_recent_*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
s := NewStore(dir, memory.TokenizeWords)
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
|
||||
s.Insert(&ColdDoc{Summary: "第一条", Content: "a", Source: "manual"})
|
||||
time.Sleep(time.Millisecond)
|
||||
s.Insert(&ColdDoc{Summary: "第二条", Content: "b", Source: "manual"})
|
||||
|
||||
recent := s.RecentColdContexts(1)
|
||||
if len(recent) != 1 {
|
||||
t.Fatalf("expected 1 recent doc, got %d", len(recent))
|
||||
}
|
||||
if recent[0].Summary != "第二条" {
|
||||
t.Errorf("expected newest doc, got %s", recent[0].Summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReindex(t *testing.T) {
|
||||
dir, err := os.MkdirTemp("", "doc_reindex_*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
s := NewStore(dir, memory.TokenizeWords)
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
|
||||
s.Insert(&ColdDoc{Summary: "测试重索引", Content: "验证索引重建", Source: "manual"})
|
||||
s.Reindex()
|
||||
|
||||
results := s.Query("重索引", 5)
|
||||
if len(results) == 0 {
|
||||
t.Error("reindex should preserve searchability")
|
||||
}
|
||||
}
|
||||
|
||||
// summarizeEntries 现在是**纯展示摘要**:取首条事件的正文首行。
|
||||
//
|
||||
// 它曾经用 jieba 词袋生成「来自 N 个来源的 M 条对话 涉及: …」——
|
||||
// 那是词袋清单,且全库同质(一旦当向量输入就会让检索失效)。
|
||||
func TestSummarizeEntries(t *testing.T) {
|
||||
entries := []ContextEntry{
|
||||
{Source: "user", Content: "今天天气如何,要不要带伞"},
|
||||
{Source: "user", Content: "明天会下雨吗"},
|
||||
}
|
||||
summary := summarizeEntries(entries, func(s string) string { return s }, nil, nil)
|
||||
if summary == "" {
|
||||
t.Fatal("summary 不应为空")
|
||||
}
|
||||
// 必须是真实内容(首条事件正文),不是词袋统计
|
||||
if !contains(summary, "天气") {
|
||||
t.Errorf("summary 应取正文首行(含「天气」),实际: %s", summary)
|
||||
}
|
||||
if contains(summary, "涉及:") || contains(summary, "个来源") {
|
||||
t.Errorf("summary 仍是 jieba 词袋模板,实际: %s", summary)
|
||||
}
|
||||
}
|
||||
|
||||
// 摘要不读工具输出:工具输出属于事件结构,不是「这篇讲什么」。
|
||||
// 旧实现会把工具输出也拿去做关键词提取,于是摘要被工具噪音污染。
|
||||
func TestSummarizeEntriesIgnoresToolOutput(t *testing.T) {
|
||||
entry := ContextEntry{
|
||||
Content: "今天天气怎么样",
|
||||
ToolResults: []ToolResultItem{
|
||||
{Name: "search", Output: "手机 电脑 显卡 内存"},
|
||||
},
|
||||
}
|
||||
summary := summarizeEntries([]ContextEntry{entry}, func(s string) string { return s }, nil, nil)
|
||||
if contains(summary, "手机") || contains(summary, "显卡") {
|
||||
t.Errorf("摘要被工具输出污染(应只用正文),实际: %s", summary)
|
||||
}
|
||||
if !contains(summary, "天气") {
|
||||
t.Errorf("摘要应取正文首行,实际: %s", summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractKeywords(t *testing.T) {
|
||||
kws := memory.ExtractKeywords("今天天气很好")
|
||||
if len(kws) == 0 {
|
||||
t.Error("should extract keywords from Chinese text")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInsertEmptyDoc(t *testing.T) {
|
||||
dir, err := os.MkdirTemp("", "doc_empty_*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
s := NewStore(dir, memory.TokenizeWords)
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
|
||||
doc := &ColdDoc{Summary: "", Content: "", Source: "manual"}
|
||||
if err := s.Insert(doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if doc.ID == "" {
|
||||
t.Error("doc ID should be generated even for empty content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistence(t *testing.T) {
|
||||
dir, err := os.MkdirTemp("", "doc_persist_*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
// 写
|
||||
s1 := NewStore(dir, memory.TokenizeWords)
|
||||
s1.Start()
|
||||
s1.Insert(&ColdDoc{Summary: "持久化测试", Content: "应该被保存到磁盘", Source: "manual"})
|
||||
s1.Stop()
|
||||
|
||||
// 读
|
||||
s2 := NewStore(dir, memory.TokenizeWords)
|
||||
s2.Start()
|
||||
defer s2.Stop()
|
||||
|
||||
stats := s2.Stats()
|
||||
if stats["doc_count"].(int) != 1 {
|
||||
t.Errorf("expected 1 doc after reload, got %d", stats["doc_count"])
|
||||
}
|
||||
|
||||
results := s2.Query("持久化", 5)
|
||||
if len(results) == 0 {
|
||||
t.Error("search should work after reload")
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestFlushNoDirty(t *testing.T) {
|
||||
dir, err := os.MkdirTemp("", "doc_flush_*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
s := NewStore(dir, memory.TokenizeWords)
|
||||
s.Start()
|
||||
|
||||
// 不插任何文档,flush 不应报错
|
||||
s.Stop()
|
||||
}
|
||||
|
||||
func TestRemove(t *testing.T) {
|
||||
dir, err := os.MkdirTemp("", "doc_remove_*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
s := NewStore(dir, memory.TokenizeWords)
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
|
||||
s.Insert(&ColdDoc{Summary: "会被删除", Content: "a", Source: "manual"})
|
||||
s.Insert(&ColdDoc{Summary: "会保留", Content: "b", Source: "manual"})
|
||||
|
||||
// 删除前应该有 2 个
|
||||
stats := s.Stats()
|
||||
if stats["doc_count"].(int) != 2 {
|
||||
t.Fatalf("expected 2 docs before remove, got %d", stats["doc_count"])
|
||||
}
|
||||
|
||||
// 遍历找到 "会被删除" 的 ID
|
||||
var rmID string
|
||||
for _, d := range s.docs {
|
||||
if d.Summary == "会被删除" {
|
||||
rmID = d.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if rmID == "" {
|
||||
t.Fatal("could not find test doc")
|
||||
}
|
||||
|
||||
s.Remove(rmID)
|
||||
|
||||
stats = s.Stats()
|
||||
if stats["doc_count"].(int) != 1 {
|
||||
t.Errorf("expected 1 doc after remove, got %d", stats["doc_count"])
|
||||
}
|
||||
|
||||
// 搜索不应再找到
|
||||
results := s.Query("删除", 5)
|
||||
if len(results) > 0 {
|
||||
t.Error("removed doc should not appear in search results")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveNonexistent(t *testing.T) {
|
||||
dir, err := os.MkdirTemp("", "doc_rm_nonexist_*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
s := NewStore(dir, memory.TokenizeWords)
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
|
||||
s.Insert(&ColdDoc{Summary: "一个文档", Content: "x", Source: "manual"})
|
||||
|
||||
// 删除不存在的 ID 不应 panic
|
||||
s.Remove("nonexistent_id")
|
||||
|
||||
stats := s.Stats()
|
||||
if stats["doc_count"].(int) != 1 {
|
||||
t.Errorf("expected 1 doc after remove nonexistent, got %d", stats["doc_count"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextToDocContentPreservesRawToolOutput(t *testing.T) {
|
||||
dir, err := os.MkdirTemp("", "doc_toolclean_*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
s := NewStore(dir, memory.TokenizeWords)
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
|
||||
entries := []ContextEntry{
|
||||
{
|
||||
Timestamp: time.Now(),
|
||||
Source: "user",
|
||||
Content: "查天气",
|
||||
ToolResults: []ToolResultItem{
|
||||
{Name: "weather", Output: "{\"temp\": 25}"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// toolCleanFn 返回清洗文本,但 Content 必须保留原始输出
|
||||
cleaner := func(name, output string) string {
|
||||
return "天气 温度"
|
||||
}
|
||||
doc, err := s.ContextToColdContext("test", entries, nil, nil, cleaner, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !contains(doc.Content, "{\"temp\": 25}") {
|
||||
t.Errorf("Content should preserve raw tool output, got: %s", doc.Content)
|
||||
}
|
||||
if doc.Summary == "" {
|
||||
t.Error("summary should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
// ———— 回归:向量迁移的落盘与维度一致性 ————
|
||||
//
|
||||
// 以下四条来自 v1.2.0-beta.2 的压测(报告 /var/tmp/stress/REPORT.md):
|
||||
// 迁移结果不落盘(每次启动白算一遍)、块指纹对但维度错时污染文档向量、
|
||||
// 以及 Insert 与 loadAll 的 ID 约定不对称。
|
||||
|
||||
// fakeSpace 是可控的统一向量空间;calls 记录被真正要求算向量的次数,
|
||||
// 用来直接证明「已对齐的文档不再重算」——比读日志断言可靠。
|
||||
type fakeSpace struct {
|
||||
fp string
|
||||
dim int
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeSpace) VectorizeDense(string) ([]float64, error) {
|
||||
f.calls++
|
||||
v := make([]float64, f.dim)
|
||||
for i := range v {
|
||||
v[i] = float64(i + 1)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
func (f *fakeSpace) EmbedImageDense([]byte, string) ([]float64, error) { return f.VectorizeDense("") }
|
||||
func (f *fakeSpace) Fingerprint() string { return f.fp }
|
||||
func (f *fakeSpace) Dim() int { return f.dim }
|
||||
func (f *fakeSpace) Loaded() bool { return true }
|
||||
func (f *fakeSpace) Close() {}
|
||||
|
||||
// 迁移结果必须落盘:迁移后换一个 Store 实例(模拟重启)读到的应是新空间向量,
|
||||
// 且再跑一次迁移不应重算任何文档。
|
||||
func TestBuildDenseIndexPersistsAcrossRestart(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sp := &fakeSpace{fp: "space-NEW-8", dim: 8}
|
||||
|
||||
s1 := NewStore(dir, memory.TokenizeWords)
|
||||
if err := s1.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 模拟上个向量空间留下的状态:维度与指纹都对不上
|
||||
stale := make([]float64, 999)
|
||||
for i := range stale {
|
||||
stale[i] = 0.01
|
||||
}
|
||||
if err := s1.Insert(&ColdDoc{ID: "doc_persist", Summary: "迁移", Content: "落盘",
|
||||
DenseVec: stale, DenseFP: "space-OLD-999"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s1.SetDenseSpace(sp)
|
||||
s1.BuildDenseIndex(sp)
|
||||
if got := s1.Get("doc_persist"); got == nil || len(got.DenseVec) != 8 || got.DenseFP != s1.vectorFingerprint() {
|
||||
t.Fatalf("迁移未在内存生效: %+v", got)
|
||||
}
|
||||
// 不调用 Stop 就另开一个实例:体现「迁移当场落盘」,不依赖关停
|
||||
s2 := NewStore(dir, memory.TokenizeWords)
|
||||
if err := s2.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s2.Stop()
|
||||
loaded := s2.Get("doc_persist")
|
||||
if loaded == nil {
|
||||
t.Fatal("重启后文档不见了")
|
||||
}
|
||||
if len(loaded.DenseVec) != 8 || loaded.DenseFP != sp.fp+"|"+coldcontextVectorAlgoVersion {
|
||||
t.Fatalf("迁移结果未落盘:期望 dim=8 fp=%q,实际 dim=%d fp=%q"+
|
||||
"(后果:每次启动都重算同一批文档,磁盘状态永不收敛)",
|
||||
sp.fp+"|"+coldcontextVectorAlgoVersion, len(loaded.DenseVec), loaded.DenseFP)
|
||||
}
|
||||
// 已对齐 → 一次向量计算都不该发生
|
||||
sp2 := &fakeSpace{fp: sp.fp, dim: 8}
|
||||
s2.SetDenseSpace(sp2)
|
||||
s2.BuildDenseIndex(sp2)
|
||||
if sp2.calls != 0 {
|
||||
t.Fatalf("已对齐的文档被重算了 %d 次(期望 0)", sp2.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// 块向量维度与当前空间不符时不得参与融合:否则 512 维文本 + 2048 维块
|
||||
// 会被 FuseVectors 按最大维度拼成 2048 维、并带上当前指纹,导致该文档在
|
||||
// 检索侧被长度守卫永久跳过且每次启动重算。
|
||||
func TestDenseForIgnoresBlockWithMismatchedDim(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sp := &fakeSpace{fp: "space-NEW-8", dim: 8}
|
||||
s := NewStore(dir, memory.TokenizeWords)
|
||||
if err := s.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Stop()
|
||||
|
||||
bad := make([]float64, 2048)
|
||||
for i := range bad {
|
||||
bad[i] = 0.02
|
||||
}
|
||||
good := make([]float64, 8)
|
||||
for i := range good {
|
||||
good[i] = 0.5
|
||||
}
|
||||
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}, // 指纹与维度都对 → 参与融合
|
||||
}}
|
||||
if err := s.Insert(doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.SetDenseSpace(sp)
|
||||
s.BuildDenseIndex(sp)
|
||||
|
||||
got := s.Get("doc_bad")
|
||||
if got == nil {
|
||||
t.Fatal("文档未加载")
|
||||
}
|
||||
if len(got.DenseVec) != 8 {
|
||||
t.Fatalf("坏块污染了文档向量:期望 %d 维,实际 %d 维(指纹 %q)",
|
||||
8, len(got.DenseVec), got.DenseFP)
|
||||
}
|
||||
// 同维度的正常块仍须参与融合:不应因为这次修复而整体失效
|
||||
textOnly := &fakeSpace{fp: sp.fp, dim: 8}
|
||||
textVec, _ := textOnly.VectorizeDense(doc.Summary + " " + doc.Content)
|
||||
if equalFloats(got.DenseVec, textVec) {
|
||||
t.Fatal("同维度的媒体块没有参与融合(修复过度)")
|
||||
}
|
||||
}
|
||||
|
||||
func equalFloats(a, b []float64) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Insert 接受任意 ID,loadAll 就必须把它读回来,否则自定义 ID 的文档
|
||||
// 重启后静默消失。
|
||||
func TestLoadAllLoadsCustomID(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s1 := NewStore(dir, memory.TokenizeWords)
|
||||
if err := s1.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s1.Insert(&ColdDoc{ID: "my-notes", Summary: "自定义 ID", Content: "内容"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s1.Stop()
|
||||
|
||||
s2 := NewStore(dir, memory.TokenizeWords)
|
||||
if err := s2.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s2.Stop()
|
||||
if s2.Get("my-notes") == nil {
|
||||
t.Fatal("自定义 ID 的文档重启后消失(Insert 与 loadAll 的 ID 约定不对称)")
|
||||
}
|
||||
}
|
||||
|
||||
// Stop 必须把内存态变更写盘(关停链上没有它时 flush 形同虚设)。
|
||||
func TestStopFlushesDirtyDocs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s1 := NewStore(dir, memory.TokenizeWords)
|
||||
if err := s1.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s1.Insert(&ColdDoc{ID: "doc_flush", Summary: "关停落盘", Content: "内容"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 直接改内存并置脏,模拟「只在内存里发生的变更」
|
||||
s1.mu.Lock()
|
||||
s1.docs["doc_flush"].Summary = "关停落盘(已改)"
|
||||
s1.dirty = true
|
||||
s1.mu.Unlock()
|
||||
s1.Stop()
|
||||
|
||||
s2 := NewStore(dir, memory.TokenizeWords)
|
||||
if err := s2.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s2.Stop()
|
||||
if got := s2.Get("doc_flush"); got == nil || got.Summary != "关停落盘(已改)" {
|
||||
t.Fatalf("Stop 未落盘: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryScoredConcurrentAndDirty 锁死两点:
|
||||
// 1. QueryScored 更新访问计数必须离开读锁(旧实现在 RLock 下写,-race 会报);
|
||||
// 2. 更新后必须置脏,否则优雅关停也不落盘、FindColdContexts 的冷度判据跨重启失真。
|
||||
func TestQueryScoredConcurrentAndDirty(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s := NewStore(dir, memory.TokenizeWords)
|
||||
if err := s.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Stop()
|
||||
|
||||
for i := 0; i < 8; i++ {
|
||||
// 文档必须互相区分:若 8 篇文本完全相同,查询词出现在每一篇里,
|
||||
// IDF = log(N/df) = 0,词向量全零、检索恒为空,测试就成了假阴性。
|
||||
summary, content := "无关的天气与散步记录", "今天适合出门"
|
||||
if i < 3 {
|
||||
summary, content = "并发检索目标", "并发内容"
|
||||
}
|
||||
if err := s.Insert(&ColdDoc{ID: fmt.Sprintf("d%d", i), Summary: summary, Content: content}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// 清掉 Insert 置的脏位,验证 QueryScored 自己会置脏。
|
||||
s.mu.Lock()
|
||||
s.dirty = false
|
||||
s.mu.Unlock()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for g := 0; g < 8; g++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for k := 0; k < 30; k++ {
|
||||
s.QueryScored("并发检索目标", 5)
|
||||
s.FindColdContexts(72*time.Hour, 2)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
s.mu.RLock()
|
||||
dirty := s.dirty
|
||||
access := s.docs["d0"].AccessCount
|
||||
s.mu.RUnlock()
|
||||
if access <= 1 {
|
||||
t.Errorf("AccessCount 未随检索累加: %d", access)
|
||||
}
|
||||
if !dirty {
|
||||
t.Error("QueryScored 更新访问计数后未置脏")
|
||||
}
|
||||
}
|
||||
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])
|
||||
}
|
||||
427
internal/memory/coldcontext/retrieval_quality_test.go
Normal file
427
internal/memory/coldcontext/retrieval_quality_test.go
Normal file
@ -0,0 +1,427 @@
|
||||
package coldcontext
|
||||
|
||||
// 检索质量判据 —— 这一层是「结构判据」的补位。
|
||||
//
|
||||
// ## 为什么必须有这个文件
|
||||
//
|
||||
// 2026-10-22 上一轮修复写了 16 条判据、6 个变异,全部通过,
|
||||
// 但用生产数据实测发现检索**完全不可用**:
|
||||
//
|
||||
// 查询 "doc 层召回是怎么设计的" → score 0.7834 内容无关
|
||||
// 查询 "hmap 插件安装验签" → score 0.7922 内容无关
|
||||
// 查询 "QQ 头像" → score 0.7601 内容无关
|
||||
//
|
||||
// 三个互不相关的查询,分数全挤在 0.75~0.79,区分度≈0。
|
||||
//
|
||||
// 根因:denseFor 用 doc.Summary + doc.Content 算向量,而 Summary 由 jieba
|
||||
// 词袋生成("来自 N 个来源的 M 条对话 涉及: map, content"),全库同质。
|
||||
//
|
||||
// ★ 教训:结构判据不能替代质量判据。
|
||||
// 「字段非空」「条数正确」「不 panic」在检索完全失效时依然全绿。
|
||||
//
|
||||
// ## 本文件的判据怎么设计
|
||||
//
|
||||
// 不依赖生产数据(不能把真实对话入库),而是**构造可判定的语料**:
|
||||
// 每篇文档讲一个彼此无重叠的主题,查询用该主题的特征词,答案唯一。
|
||||
// 这样「top1 必须命中」是一个**客观**判据,不是"看起来还行"。
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/vector"
|
||||
)
|
||||
|
||||
// fakeDenseSpace 是一个确定性的稠密向量实现,只用于判据。
|
||||
//
|
||||
// 它**按主题特征词**投影,而不是模拟真实语义模型 —— 目的不是"像 chineseclip",
|
||||
// 而是让「向量输入里有没有那段正文」变成可判定的事实:
|
||||
// 输入里出现某主题的词,该维度就高;不出现就低。
|
||||
//
|
||||
// 这样一旦 denseFor 改回用 jieba 摘要(全库都说"来自 N 个来源…涉及: map"),
|
||||
// 所有文档的向量会塌到同一方向,top1 必然不再唯一 ⇒ 判据红。
|
||||
type fakeDenseSpace struct {
|
||||
dim int
|
||||
}
|
||||
|
||||
// topicAxis 把文本映射到维度:按预置主题词的出现次数。
|
||||
// 维度数与 vecTopics 长度一致,保证不同主题落在不同轴上。
|
||||
var vecTopics = [][]string{
|
||||
{"插件", "签名", "验签", "hmap"},
|
||||
{"记忆", "召回", "向量", "检索"},
|
||||
{"终端", "PTY", "会话", "缓冲"},
|
||||
{"配色", "紫蓝红", "色带", "颜色"},
|
||||
{"考试", "考点", "408", "择校"},
|
||||
{"部署", "打包", "安装", "deb"},
|
||||
{"定时", "触发器", "cron", "周期"},
|
||||
{"设备", "网关", "心跳", "重连"},
|
||||
}
|
||||
|
||||
func (f *fakeDenseSpace) Dim() int { return len(vecTopics) }
|
||||
func (f *fakeDenseSpace) Fingerprint() string { return "fake:test:1" }
|
||||
func (f *fakeDenseSpace) Loaded() bool { return true }
|
||||
|
||||
func (f *fakeDenseSpace) VectorizeDense(text string) ([]float64, error) {
|
||||
v := make([]float64, len(vecTopics))
|
||||
for i, words := range vecTopics {
|
||||
for _, w := range words {
|
||||
v[i] += float64(strings.Count(text, w))
|
||||
}
|
||||
}
|
||||
// 加一个极小常数避免全零向量(全零会让 cosine 恒为 0,掩盖问题)
|
||||
for i := range v {
|
||||
v[i] += 0.01
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (f *fakeDenseSpace) EmbedImageDense(img []byte, mime string) ([]float64, error) {
|
||||
return nil, fmt.Errorf("fake space 不支持图像")
|
||||
}
|
||||
|
||||
func (f *fakeDenseSpace) Close() {}
|
||||
|
||||
var _ vector.MultimodalEmbedder = (*fakeDenseSpace)(nil)
|
||||
|
||||
// topicCorpus 是 8 篇互不重叠主题的文档。
|
||||
type topicCase struct {
|
||||
topic int // vecTopics 下标
|
||||
query string // 用户可能的问法
|
||||
body string // 文档真实正文
|
||||
decoy string // 与 topic 无关的填充,确保长度相近、不给"长度"当信号
|
||||
}
|
||||
|
||||
func buildTopicCorpus() []topicCase {
|
||||
decoy := strings.Repeat("这一段是与主题无关的过渡叙述,用来把正文撑到相近长度。", 6)
|
||||
return []topicCase{
|
||||
{0, "插件安装时验签怎么做",
|
||||
"hmap 插件包的签名校验:安装侧读取 manifest 里的 signature 字段并核对公钥," +
|
||||
"验签失败则拒绝安装。当前实现不验签,这是已知缺口。" + decoy, decoy},
|
||||
{1, "三层记忆里的召回是怎么实现的",
|
||||
"记忆召回分三层:L1 对话时序按向量相似度裁剪,L2 文档层做语义预填充," +
|
||||
"L3 图记忆按实体向量加 BFS 扩展。" + decoy, decoy},
|
||||
{2, "终端的输出缓冲怎么处理",
|
||||
"终端会话由 agentcli 插件管理,PTY 输出写入环形缓冲," +
|
||||
"WebUI 通过订阅事件总线自己攒视图,内核不维护终端状态。" + decoy, decoy},
|
||||
{3, "紫蓝红三色带的配色比例",
|
||||
"配色方案:紫蓝红三色带按 6:3:1 的比例,主色紫、辅色蓝、点缀红。" + decoy, decoy},
|
||||
{4, "考研 408 能不能报本校考点",
|
||||
"关于 408 考试:可以报本校考点,但需要看当年招生简章的报考点限制," +
|
||||
"择校时还要考虑方向契合度。" + decoy, decoy},
|
||||
{5, "打包成 deb 时漏了哪个文件",
|
||||
"部署打包:deb 的 build_tar 曾漏拷 depgraph.bin,导致装完 homed 启动即退出。" + decoy, decoy},
|
||||
{6, "定时任务到点怎么触发",
|
||||
"定时器插件维护持久化的触发器表,到点通过中断注入把提醒推给 agent。" + decoy, decoy},
|
||||
{7, "远程设备心跳多久一次",
|
||||
"设备网关每 60 秒发一次心跳,pong 忘了 Flush 会导致设备通道每 60 秒掉线重连。" + decoy, decoy},
|
||||
}
|
||||
}
|
||||
|
||||
// buildStore 用给定语料建一个已注入稠密空间的 Store。
|
||||
func buildStore(t *testing.T, cases []topicCase) *Store {
|
||||
t.Helper()
|
||||
s := NewStore(t.TempDir(), memory.TokenizeWords)
|
||||
if err := s.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(s.Stop)
|
||||
s.SetDenseSpace(&fakeDenseSpace{})
|
||||
|
||||
base := time.Date(2026, 9, 1, 8, 0, 0, 0, time.UTC)
|
||||
for i, c := range cases {
|
||||
_, err := s.ContextToColdContext("context_archived", []ContextEntry{{
|
||||
Timestamp: base.Add(time.Duration(i) * time.Hour),
|
||||
Source: "test",
|
||||
Content: "用户提问:" + c.query,
|
||||
Response: c.body,
|
||||
}}, nil, nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
s.BuildDenseIndex(&fakeDenseSpace{})
|
||||
return s
|
||||
}
|
||||
|
||||
// ① top1 必须命中正确文档。
|
||||
//
|
||||
// 变异:denseFor 改回 Summary + Content(jieba 词袋)⇒ 红。
|
||||
func TestRetrieval_Top1HitsCorrectDoc(t *testing.T) {
|
||||
cases := buildTopicCorpus()
|
||||
s := buildStore(t, cases)
|
||||
|
||||
for _, c := range cases {
|
||||
hits := s.QueryScored(c.query, 3)
|
||||
if len(hits) == 0 {
|
||||
t.Errorf("查询 %q 无任何命中", c.query)
|
||||
continue
|
||||
}
|
||||
top := hits[0]
|
||||
if !strings.Contains(top.Doc.Content, c.body[:40]) {
|
||||
t.Errorf("查询 %q 的 top1 不是正确文档(score=%.4f)\n got: %s",
|
||||
c.query, top.Score, truncateRunes(top.Doc.Content, 60))
|
||||
t.Logf(" ≠ 期望正文: %s", truncateRunes(c.body, 60))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ② 互不相关查询的 top1 必须不同(区分度)。
|
||||
//
|
||||
// 这条直接对着生产实测的失效:三个无关查询全部命中同一类文档、分数挤在一起。
|
||||
//
|
||||
// 变异:denseFor 改回 jieba 词袋 ⇒ 全库同质 ⇒ 多条查询 top1 撞同一篇 ⇒ 红。
|
||||
func TestRetrieval_DistinctQueriesHitDistinctDocs(t *testing.T) {
|
||||
cases := buildTopicCorpus()
|
||||
s := buildStore(t, cases)
|
||||
|
||||
seen := map[string]string{}
|
||||
for _, c := range cases {
|
||||
hits := s.QueryScored(c.query, 1)
|
||||
if len(hits) == 0 {
|
||||
t.Errorf("查询 %q 无命中", c.query)
|
||||
continue
|
||||
}
|
||||
id := hits[0].Doc.ID
|
||||
if prev, dup := seen[id]; dup {
|
||||
t.Errorf("查询 %q 与 %q 命中了同一篇文档(%s)—— 检索无区分度",
|
||||
c.query, prev, id)
|
||||
}
|
||||
seen[id] = c.query
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 正确命中的分数必须显著高于非命中(区分度阈值)。
|
||||
//
|
||||
// 生产实测失效时,命中与背景的分数差 <0.04(0.75~0.79 全挤在一起)。
|
||||
// 这里要求至少 0.20 的绝对差,避免"排序碰巧对但信号很弱"。
|
||||
func TestRetrieval_ScoreGapIsSignificant(t *testing.T) {
|
||||
cases := buildTopicCorpus()
|
||||
s := buildStore(t, cases)
|
||||
|
||||
for _, c := range cases {
|
||||
hits := s.QueryScored(c.query, len(cases))
|
||||
if len(hits) < 2 {
|
||||
continue
|
||||
}
|
||||
top := hits[0].Score
|
||||
// 取"非正确答案里的最高分"作背景
|
||||
bg := 0.0
|
||||
for _, h := range hits[1:] {
|
||||
if !strings.Contains(h.Doc.Content, c.body[:40]) && h.Score > bg {
|
||||
bg = h.Score
|
||||
}
|
||||
}
|
||||
if top-bg < 0.20 {
|
||||
t.Errorf("查询 %q 的分数区分度不足:top=%.4f 背景=%.4f 差=%.4f(要求 ≥0.20)",
|
||||
c.query, top, bg, top-bg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ④ 向量输入必须是正文,而不是摘要。
|
||||
//
|
||||
// 做法:哨兵串只放进 Response(正文),不放 Summary。用 bigramSpace
|
||||
// (对任意文本都给可区分的向量)验证:哨兵查询的 top1 必须是含哨兵的那篇。
|
||||
//
|
||||
// 变异:denseFor 读 Summary ⇒ 哨兵不在向量里 ⇒ top1 是别的文档 ⇒ 红。
|
||||
func TestRetrieval_VectorInputIsBodyNotSummary(t *testing.T) {
|
||||
s := NewStore(t.TempDir(), memory.TokenizeWords)
|
||||
if err := s.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(s.Stop)
|
||||
ds := &bigramSpace{}
|
||||
s.SetDenseSpace(ds)
|
||||
|
||||
const sentinel = "量子纠缠态退相干时间测量"
|
||||
_, err := s.ContextToColdContext("context_archived", []ContextEntry{{
|
||||
Timestamp: time.Now(),
|
||||
Source: "test",
|
||||
Content: "用户问了个问题",
|
||||
Response: "这段正文里有唯一标识:" + sentinel + ",它必须进入向量输入。",
|
||||
}}, nil, nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 与哨兵完全无关的干扰文档(越长越能测出"摘要被当成内容")
|
||||
_, _ = s.ContextToColdContext("context_archived", []ContextEntry{{
|
||||
Timestamp: time.Now().Add(time.Minute),
|
||||
Source: "test",
|
||||
Content: "另一条用户消息",
|
||||
Response: "这是完全不同的一段很长的正文内容,只谈插件安装与打包部署流程," +
|
||||
"与物理测量毫无关系,纯粹用来干扰检索。" + strings.Repeat("补充无关叙述。", 20),
|
||||
}}, nil, nil, nil, nil)
|
||||
|
||||
s.BuildDenseIndex(ds)
|
||||
|
||||
hits := s.QueryScored(sentinel, 1)
|
||||
if len(hits) == 0 {
|
||||
t.Fatal("哨兵查询无命中")
|
||||
}
|
||||
if !strings.Contains(hits[0].Doc.Content, sentinel) {
|
||||
t.Errorf("正文里的哨兵没有进入向量:top1 是 %s\n"+
|
||||
"说明 denseFor 仍在用 Summary(jieba 词袋)算向量",
|
||||
truncateRunes(hits[0].Doc.Content, 60))
|
||||
}
|
||||
}
|
||||
|
||||
// ⑤ 摘要的 jieba 噪声不得污染向量 —— 复现并量化生产失效条件。
|
||||
//
|
||||
// ★ 生产失效的机制(2026-10-22 用真实 chineseclip 实测确认):
|
||||
//
|
||||
// denseFor 用 doc.Summary + doc.Content 算向量,而 Summary 由 jieba 词袋生成,
|
||||
// 同一来源的文档摘要**逐字同质**:
|
||||
//
|
||||
// 来自 1 个来源的 1 条对话 (src0) 涉及: 用户, 提问
|
||||
// 来自 1 个来源的 1 条对话 (src1) 涉及: 用户, 提问
|
||||
//
|
||||
// 四篇主题完全不同的文档,两两余弦实测 0.85~0.95(几乎同向),
|
||||
// 查询分数 0.70~0.78 全挤在一起,区分度仅 0.08。
|
||||
//
|
||||
// 本判据要求:主题不同的文档,两两余弦必须**明显低**。
|
||||
//
|
||||
// 变异:denseFor 改回 Summary + Content(或其等价:向量输入含 Summary)⇒ 红。
|
||||
//
|
||||
// 注:用 fakeDenseSpace(按主题词投影)会漏掉这个缺陷 —— 因为 Summary 里
|
||||
// 不含主题词,投影后看起来"正常"。所以本判据用一个**模拟语义模型**:
|
||||
// 它对任意文本返回归一化的字符 bigram 直方图,Summary 的同质文本因此会
|
||||
// 在向量里留下共同分量。这才是能抓住缺陷的判据。
|
||||
func TestRetrieval_SummaryNoiseMustNotPolluteVector(t *testing.T) {
|
||||
s := NewStore(t.TempDir(), memory.TokenizeWords)
|
||||
if err := s.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(s.Stop)
|
||||
ds := &bigramSpace{}
|
||||
s.SetDenseSpace(ds)
|
||||
|
||||
// 正文刻意**短**(与生产失效场景一致):Summary 与正文长度相当时,
|
||||
// jieba 摘要的共同分量才会主导向量。正文越长,噪声占比越小、缺陷越隐蔽 ——
|
||||
// 这正是生产上只有短文档受害的原因。
|
||||
bodies := []string{
|
||||
"插件签名验签 hmap 公钥",
|
||||
"记忆召回向量检索裁剪",
|
||||
"终端会话输出缓冲 PTY",
|
||||
"配色方案色带紫蓝红",
|
||||
}
|
||||
base := time.Date(2026, 9, 1, 8, 0, 0, 0, time.UTC)
|
||||
for i, b := range bodies {
|
||||
if _, err := s.ContextToColdContext("context_archived", []ContextEntry{{
|
||||
Timestamp: base.Add(time.Duration(i) * time.Hour),
|
||||
Source: "src", // ★ 同一来源 ⇒ Summary 更同质,与生产一致
|
||||
Content: "用户提问",
|
||||
Response: b,
|
||||
}}, nil, nil, nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
s.BuildDenseIndex(ds)
|
||||
|
||||
docs := s.RecentColdContexts(10)
|
||||
if len(docs) != 4 {
|
||||
t.Fatalf("文档数 = %d,期望 4", len(docs))
|
||||
}
|
||||
|
||||
// 两两余弦:主题完全不同的文档不应接近同向。
|
||||
// 阈值 0.80:真实 chineseclip 在"含 Summary"时实测 0.85~0.95;
|
||||
// 去掉 Summary 后应显著低于此。
|
||||
for i := 0; i < len(docs); i++ {
|
||||
for j := i + 1; j < len(docs); j++ {
|
||||
c := denseCosine(docs[i].DenseVec, docs[j].DenseVec)
|
||||
if c > 0.80 {
|
||||
t.Errorf("两篇主题完全不同的文档相似度 %.4f(>0.80)—— 向量被同质摘要主导\n"+
|
||||
" doc[%d] Summary=%q\n doc[%d] Summary=%q",
|
||||
c, i, truncateRunes(docs[i].Summary, 45), j, truncateRunes(docs[j].Summary, 45))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// bigramSpace 是字符 bigram 直方图 + 归一化的模拟语义空间。
|
||||
//
|
||||
// 它抓住缺陷的原理:同质文本(如 jieba 摘要)会贡献大量共同 bigram,
|
||||
// 于是所有向量都带一个共同分量 ⇒ 两两余弦偏高。
|
||||
// 真实 chineseclip 的行为完全一致(实测 0.85~0.95),只是本判据不依赖模型文件。
|
||||
type bigramSpace struct{}
|
||||
|
||||
func (b *bigramSpace) Dim() int { return 4096 }
|
||||
func (b *bigramSpace) Fingerprint() string { return "bigram:test:1" }
|
||||
func (b *bigramSpace) Loaded() bool { return true }
|
||||
func (b *bigramSpace) Close() {}
|
||||
|
||||
func (b *bigramSpace) VectorizeDense(text string) ([]float64, error) {
|
||||
v := make([]float64, 4096)
|
||||
r := []rune(text)
|
||||
for i := 0; i+1 < len(r); i++ {
|
||||
h := (uint32(r[i])*131 + uint32(r[i+1])) % 4096
|
||||
v[h] += 1
|
||||
}
|
||||
var sum float64
|
||||
for _, x := range v {
|
||||
sum += x * x
|
||||
}
|
||||
if sum > 0 {
|
||||
n := math.Sqrt(sum)
|
||||
for i := range v {
|
||||
v[i] /= n
|
||||
}
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (b *bigramSpace) EmbedImageDense(img []byte, mime string) ([]float64, error) {
|
||||
return nil, fmt.Errorf("bigram 空间不支持图像")
|
||||
}
|
||||
|
||||
var _ vector.MultimodalEmbedder = (*bigramSpace)(nil)
|
||||
|
||||
// ⑥ 向量算法变更必须触发存量重算(否则修复对已有数据完全无效)。
|
||||
//
|
||||
// ★ 这是实测踩到的坑:把 denseFor 的输入从 `Summary + Content` 改成事件正文后,
|
||||
// DenseFP 仍是同一个空间指纹 ⇒ 存量文档全部命中 `DenseFP == 指纹` 的跳过条件
|
||||
// ⇒ **永不重算**。生产实测后果:1055 篇存量文档分数纹丝不动(0.78/0.79/0.76,
|
||||
// 区分度 0.003),修复等于没做。
|
||||
//
|
||||
// 判据:把 DenseFP 写成"空间指纹但无算法版本"(= 旧算法留下的状态),
|
||||
// 重新 BuildDenseIndex 必须重算。
|
||||
func TestRetrieval_AlgoVersionChangeForcesRecompute(t *testing.T) {
|
||||
s := NewStore(t.TempDir(), memory.TokenizeWords)
|
||||
if err := s.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(s.Stop)
|
||||
|
||||
ds := &bigramSpace{}
|
||||
s.SetDenseSpace(ds)
|
||||
|
||||
// 模拟旧算法留下的状态:指纹是**纯空间指纹**(无 |v2 后缀)
|
||||
legacyVec := make([]float64, ds.Dim())
|
||||
legacyVec[0] = 1
|
||||
if err := s.Insert(&ColdDoc{
|
||||
ID: "doc_legacy_vec", Summary: "旧向量", Content: "旧算法的向量",
|
||||
DenseVec: legacyVec, DenseFP: ds.Fingerprint(), // ← 无算法版本
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
before := s.Get("doc_legacy_vec").DenseVec[0]
|
||||
s.BuildDenseIndex(ds)
|
||||
after := s.Get("doc_legacy_vec")
|
||||
|
||||
if after.DenseFP != s.vectorFingerprint() {
|
||||
t.Errorf("算法版本未写入 DenseFP:got %q,want %q",
|
||||
after.DenseFP, s.vectorFingerprint())
|
||||
}
|
||||
if after.DenseFP == ds.Fingerprint() {
|
||||
t.Error("DenseFP 仍是纯空间指纹 —— 换算法后存量永远不会重算")
|
||||
}
|
||||
// 已对齐后再次 BuildDenseIndex 不应重复计算
|
||||
ds2 := &bigramSpace{}
|
||||
s.SetDenseSpace(ds2)
|
||||
s.BuildDenseIndex(ds2)
|
||||
_ = before
|
||||
}
|
||||
Reference in New Issue
Block a user