mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 14:57:38 +00:00
## ① 全面改名 HomeAgent → HologramAgent
仓库已改名,但代码与文档里仍有大量旧名。本次按"用户能看到的优先"全量替换:
- 用户可见:登录页标题/页脚、默认人格第一句、CLI 状态输出、启动日志、
plugin_install 的工具描述、AI 图片插件的 User-Agent
- 文档与脚本:README、assets/docs、docs/zh、deploy/*.sh、.github/workflows
- 鸿蒙工程:cmd/ohos/HomeAgent → cmd/ohos/HologramAgent(git mv)
目录内的 module/app 描述同步;**证书路径原样保留**
(default_HomeAgent_xxx.cer/p7b/p12 是签名材料的实际文件名,改了会找不到证书)
- 插件元数据:plugin.json 的 author
★ 必须保持原样的两处(改了会坏):
· legacySeededPromptSignature = "你是 HomeAgent 的看板娘「小宅」…"
—— 那是识别历史播种人格卡的**原文锚点**,改了就识别不出旧数据
· 公开 SDK 的 ProxyAuthHomeAgent = "homeagent"
—— 那是 SDK 契约里的值,属独立仓库,本仓不改
## ② 修 21 处乱码(U+FFFD)
这些是历史编辑留下的编码破坏,全在注释里,例如:
「赌注重下���遍」→「赌注重下一遍」
「「不��滤」」 →「「不过滤」」
「**待��退场**」→「**待退场**」
★ 保留 providers/chineseclip/tokenizer.go 里的 2 处:那是**故意**用
U+FFFD 演示"非法 UTF-8 字节被归一成 U+FFFD",不是乱码。
## ③ 提示词技术内容下沉(用户:技术性内容应该收敛到 skill 与 -help 中)
实测系统提示词 2309 字,其中两段几乎全是**模型无法据以行动的实现细节**:
【工具执行顺序】1272 字(ParallelSafe 声明、批内降级、batchRunnable 判据…)
【三层记忆】 1069 字(L1/L2/L3 编号、消费式删除、向量空间、指纹…)
收敛后 **2309 → 1723 字(-25%)**,保留的全是"模型据此做什么不同的事":
工具顺序:默认并行 / 不要依赖先后 / 同通道保序 / 写类工具可能串行
三层记忆:时序里能看最近 / doc_query 把历史事件按原始时间戳插回时序 /
关系用 memory_recall / 成文知识用 knowledge_search
判断口径写进判据:新加一段提示词前先问"模型会据此做什么不同的事",
答不上来就该进 skill 或工具描述。
## 判据
prompt_memory_test.go 新增 2 条:
- TestSystemPromptHasNoImplementationJargon
黑名单:ParallelSafe / batchRunnable / 消费式 / DenseCosine / fingerprint /
向量空间 / 批内降级 / 自激
- TestSystemPromptStaysCompact:上限 2000 字(当前 1723)
变异:把 "ParallelSafe 声明、批内降级" 加回工具顺序段 ⇒ 第一条立刻红。
## 验证
- go build ./... / go vet ./... 干净
- go test ./... -count=1:58 包 0 失败
- make check-client-versions:客户端与内核版本一致
924 lines
31 KiB
Go
924 lines
31 KiB
Go
package memory
|
||
|
||
import (
|
||
"fmt"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
func newMigrateGraph(t *testing.T) *GraphDB {
|
||
t.Helper()
|
||
g := newTestGraph(t)
|
||
t.Cleanup(func() { _ = g.Close() })
|
||
return g
|
||
}
|
||
|
||
// seedLegacy 为迁移测试造**旧表数据**。
|
||
//
|
||
// ★★ 2026-10-04:不再用 Commit(2026-10-04)
|
||
//
|
||
// 旧表双写已停 ⇒ Commit 不再写 entities/relations/sentences
|
||
// ⇒ 迁移测试的**输入天然为空**
|
||
// ⇒ 「迁出 0 块 0 边」竟然通过。
|
||
//
|
||
// ★★ 那不是「迁移正确」,是「测不到」——
|
||
//
|
||
// 它让「迁移还对不对」这个问题无法回答,
|
||
// 而这正是判据最不该失效的地方。
|
||
//
|
||
// ⇒ 直接写旧表:迁移的输入是旧表,测试也该直接构造旧表。
|
||
//
|
||
// ★ 同时写一份块侧数据(用 Commit),因为迁移后的断言查的是块。
|
||
func seedLegacy(t *testing.T, g *GraphDB, triples []Triple) {
|
||
t.Helper()
|
||
|
||
// 块侧:Commit 现在只写块与边
|
||
if _, _, err := g.Commit(triples, "s", 1); err != nil {
|
||
t.Fatalf("Commit: %v", err)
|
||
}
|
||
|
||
// 旧表:迁移的输入,逐条写入
|
||
for _, tr := range triples {
|
||
for _, name := range []string{tr.Subject, tr.Object} {
|
||
if name == "" {
|
||
continue
|
||
}
|
||
typ := tr.SubjectType
|
||
if name == tr.Object {
|
||
typ = tr.ObjectType
|
||
}
|
||
if typ == "" {
|
||
typ = "Concept"
|
||
}
|
||
if _, err := g.SeedLegacyEntity(name, typ); err != nil {
|
||
t.Fatalf("seed entity %q: %v", name, err)
|
||
}
|
||
}
|
||
// 关系(source=主语, target=宾语)
|
||
if _, _, err := g.SeedLegacyRelation(tr.Subject, tr.Object,
|
||
tr.Relation, tr.Confidence, "s", 1); err != nil {
|
||
t.Fatalf("seed relation %s/%s: %v", tr.Subject, tr.Relation, err)
|
||
}
|
||
// 原句
|
||
if tr.SentenceText != "" {
|
||
sid, err := g.SeedLegacySentenceRow(tr.SentenceText)
|
||
if err != nil {
|
||
t.Fatalf("seed sentence: %v", err)
|
||
}
|
||
if err := g.LinkLegacyTripleToSentence(
|
||
tr.Subject, tr.Object, tr.Relation, sid); err != nil {
|
||
t.Fatalf("link sentence: %v", err)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const fixedEmbed = "fp-migrate"
|
||
|
||
// ★ 迁移形态:实体 → 句子+块,关系 → 块边。旧表**不删**(由调用方决定)。
|
||
func TestMigrateLegacyTextEntities_形态(t *testing.T) {
|
||
g := newMigrateGraph(t)
|
||
seedLegacy(t, g, []Triple{
|
||
{Subject: "值班室分机号", Relation: "是", Object: "4324"},
|
||
})
|
||
|
||
res, err := g.MigrateLegacyTextEntities(func(string) ([]float64, string) {
|
||
return []float64{1, 0, 0}, fixedEmbed
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("Migrate: %v", err)
|
||
}
|
||
// ★ 2 个实体 → 2 个迁移块 + 2 个原句块(方案 A 后原句也是块)
|
||
//
|
||
// res.Sentences 不再递增:统计口径改为「迁移块数」,
|
||
// 因为 sentences 表已不再被写入。
|
||
if res.Blocks != 2 {
|
||
t.Errorf("2 个实体应得 2 个迁移块,实际 %+v", res)
|
||
}
|
||
if res.Sentences != 0 {
|
||
t.Errorf("迁移不应再创建 sentence 记录(方案 A 退场),实际 %d", res.Sentences)
|
||
}
|
||
// 1 条关系 → 1 条块边
|
||
if res.Edges != 1 {
|
||
t.Errorf("1 条关系应得 1 条边,实际 %d", res.Edges)
|
||
}
|
||
|
||
blocks, err := g.MemoryBlocks()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// ★ 只统计**迁移自己写的块**:legacy-entity(迁移块)与 sentence(原句块)。
|
||
//
|
||
// 不能用 len(blocks) —— 因为 seedLegacy 走 Commit,而 Commit 在
|
||
// 块化之后**也会写块**(source="triple"),那不是迁移的产物。
|
||
// 实测:未过滤时是 6 = 4 迁移 + 2 Commit 写的值块。
|
||
if n := countBySource(t, blocks, "legacy-entity"); n != 2 {
|
||
t.Errorf("应有 2 个迁移块,实际 %d", n)
|
||
}
|
||
if n := countBySource(t, blocks, SentenceBlockSource); n != 2 {
|
||
t.Errorf("应有 2 个原句块,实际 %d", n)
|
||
}
|
||
for _, b := range blocks {
|
||
// ★ 原句块刻意不带向量(溯源锚点,长整句会稀释召回)
|
||
// ★ 只检查迁移自己写的两种块:原句块(sentence)与迁移块(legacy-entity)。
|
||
//
|
||
// 库里有第三种来源 triple —— 那是 seedLegacy 走 Commit 时写的
|
||
// (Commit 块化之后),**不是迁移的产物**,不该被本测试断言。
|
||
//
|
||
// 原句块 blk_src_<hash> 无向量(溯源锚点)
|
||
// 迁移块 blk_ent_<id>_<hash> 有向量
|
||
// Commit 块 blk_ent_<hash24> 无向量、无时序
|
||
//
|
||
// 三者是**不同语义的东西**,不是同一个东西的重复。
|
||
switch b.Source {
|
||
case SentenceBlockSource:
|
||
if len(b.Vector) != 0 {
|
||
t.Errorf("原句块 %s 不该带向量,实际 %d 维", b.ID, len(b.Vector))
|
||
}
|
||
case "legacy-entity":
|
||
if len(b.Vector) != 3 || b.Fingerprint != fixedEmbed {
|
||
t.Errorf("迁移块 %s 应带 3 维向量与 %s,实际 %d 维 %q",
|
||
b.ID, fixedEmbed, len(b.Vector), b.Fingerprint)
|
||
}
|
||
case "triple":
|
||
// Commit 块化的产物,本测试不关心(见 TestBlockCommit_*)
|
||
default:
|
||
t.Errorf("块 %s 来源 %q 既不是迁移产物也不是 Commit 产物", b.ID, b.Source)
|
||
}
|
||
}
|
||
|
||
// 每块都应有一条 sentence--contains--> 边
|
||
edges, err := g.MemoryBlockEdges()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
containsCount := 0
|
||
for _, e := range edges {
|
||
// ★ 起点是**原句块**(block),不再是 sentence 表行 —— 方案 A
|
||
if e.Type == "contains" && e.SourceKind == "block" && e.TargetKind == "block" {
|
||
containsCount++
|
||
}
|
||
}
|
||
if containsCount != 2 {
|
||
t.Errorf("应有 2 条 contains 边,实际 %d(总边数 %d)", containsCount, len(edges))
|
||
}
|
||
|
||
// ★ 旧表还在(迁移不删数据,可回滚)
|
||
r, err := g.Recall([]string{"值班室分机号"}, nil, 1, "")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(r.Entities) == 0 {
|
||
t.Error("迁移不应删除 entities(由调用方在验证通过后决定清理)")
|
||
}
|
||
}
|
||
|
||
// ★ 幂等:重复迁移不产生重复块。
|
||
func TestMigrateLegacyTextEntities_幂等(t *testing.T) {
|
||
g := newMigrateGraph(t)
|
||
seedLegacy(t, g, []Triple{
|
||
{Subject: "主体甲", Relation: "是", Object: "客体乙"},
|
||
})
|
||
embed := func(string) ([]float64, string) { return []float64{1, 0}, fixedEmbed }
|
||
|
||
for i := 0; i < 3; i++ {
|
||
if _, err := g.MigrateLegacyTextEntities(embed); err != nil {
|
||
t.Fatalf("第 %d 次迁移: %v", i, err)
|
||
}
|
||
}
|
||
blocks, _ := g.MemoryBlocks()
|
||
// ★ 重复跑不增:迁移块与原句块各 2 个
|
||
// (不过滤 source='triple' —— 那是 seedLegacy 时 Commit 写的,
|
||
// 重复跑迁移不会再增加它们,但会出现在 blocks 里)
|
||
if n := countBySource(t, blocks, "legacy-entity"); n != 2 {
|
||
t.Fatalf("迁移 3 次后仍应只有 2 个迁移块,实际 %d", n)
|
||
}
|
||
edges, _ := g.MemoryBlockEdges()
|
||
contains := 0
|
||
for _, e := range edges {
|
||
if e.Type == "contains" {
|
||
contains++
|
||
}
|
||
}
|
||
if contains != 2 {
|
||
t.Fatalf("contains 边应仍是 2 条,实际 %d", contains)
|
||
}
|
||
}
|
||
|
||
// ★ 无 embed 时仍迁移,但块不带向量——不编造零向量。
|
||
func TestMigrateLegacyTextEntities_无embed不编造(t *testing.T) {
|
||
g := newMigrateGraph(t)
|
||
seedLegacy(t, g, []Triple{{Subject: "主体甲", Relation: "是", Object: "客体乙"}})
|
||
|
||
res, err := g.MigrateLegacyTextEntities(nil)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if res.Blocks != 2 {
|
||
t.Fatalf("无 embed 也应迁移,实际 %d 块", res.Blocks)
|
||
}
|
||
if res.SkippedNoVec != 2 {
|
||
t.Errorf("应统计 2 个无向量块,实际 %d", res.SkippedNoVec)
|
||
}
|
||
blocks, _ := g.MemoryBlocks()
|
||
for _, b := range blocks {
|
||
if len(b.Vector) != 0 {
|
||
t.Errorf("%s 不该有向量,实际 %d 维", b.ID, len(b.Vector))
|
||
}
|
||
}
|
||
}
|
||
|
||
// embed 返回 nil 向量时算「算不出」,迁移继续但不计入成功。
|
||
func TestMigrateLegacyTextEntities_embed失败仍迁移(t *testing.T) {
|
||
g := newMigrateGraph(t)
|
||
seedLegacy(t, g, []Triple{{Subject: "主体甲", Relation: "是", Object: "客体乙"}})
|
||
res, err := g.MigrateLegacyTextEntities(func(string) ([]float64, string) {
|
||
return nil, "" // 模型加载失败
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("embed 失败不该让迁移失败: %v", err)
|
||
}
|
||
if res.Blocks != 2 {
|
||
t.Fatalf("应仍迁移出 2 块,实际 %d", res.Blocks)
|
||
}
|
||
if res.SkippedNoVec != 2 {
|
||
t.Errorf("应统计 2 个无向量,实际 %d", res.SkippedNoVec)
|
||
}
|
||
}
|
||
|
||
// ★ 孤儿关系(端点实体为空名)被跳过并计数,不编造边。
|
||
func TestMigrateLegacyTextEntities_孤儿关系跳过(t *testing.T) {
|
||
g := newMigrateGraph(t)
|
||
// 直接构造一个指向空名实体的关系是不可能的(upsert 拒绝空名),
|
||
// 所以这里测的是「关系两端都存在」时边数正确,作为对照基线。
|
||
seedLegacy(t, g, []Triple{
|
||
{Subject: "主体甲", Relation: "是", Object: "客体乙"},
|
||
{Subject: "主体甲", Relation: "属于", Object: "客体丙"},
|
||
})
|
||
res, err := g.MigrateLegacyTextEntities(nil)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if res.Edges != 2 {
|
||
t.Errorf("2 条关系应得 2 条边,实际 %d", res.Edges)
|
||
}
|
||
if res.SkippedOrphan != 0 {
|
||
t.Errorf("端点齐全时不该有跳过,实际 %d", res.SkippedOrphan)
|
||
}
|
||
}
|
||
|
||
// 空关系类型用占位名而不是被拒绝(关系的**存在**本身是信息)。
|
||
func TestMigrateLegacyTextEntities_空关系类型用占位(t *testing.T) {
|
||
g := newMigrateGraph(t)
|
||
seedLegacy(t, g, []Triple{{Subject: "主体甲", Relation: " ", Object: "客体乙"}})
|
||
res, err := g.MigrateLegacyTextEntities(nil)
|
||
if err != nil {
|
||
t.Fatalf("空关系类型不该让迁移失败: %v", err)
|
||
}
|
||
if res.Edges != 1 {
|
||
t.Fatalf("空关系类型也应建成边,实际 %d", res.Edges)
|
||
}
|
||
edges, _ := g.MemoryBlockEdges()
|
||
found := false
|
||
for _, e := range edges {
|
||
if e.Type == "related_to" {
|
||
found = true
|
||
}
|
||
}
|
||
if !found {
|
||
t.Errorf("应有占位关系类型的边,实际 %+v", edges)
|
||
}
|
||
}
|
||
|
||
// ★ 事务回滚:迁移中途失败时,**库内容保持迁移前状态**。
|
||
//
|
||
// 失败注入方式:给两个实体起相同的块 ID 是不行的(ID 由 entity id 派生,
|
||
// 天然唯一)。改用「让 putBlockTx 失败」——预先占位一个**非块节点类型**
|
||
// 的行占住表结构,或直接删掉 memory_blocks 表。
|
||
//
|
||
// 删表是最直接的:阶段三第一次 putBlockTx 就会报 no such table,
|
||
// 此时前面的 ensureSentenceTx 已经在事务里写过句子 —— 若无回滚,
|
||
// 那些句子会留在库里。
|
||
func TestMigrateLegacyTextEntities_中途失败整体回滚(t *testing.T) {
|
||
g := newMigrateGraph(t)
|
||
seedLegacy(t, g, []Triple{
|
||
{Subject: "主体甲", Relation: "是", Object: "客体乙"},
|
||
})
|
||
beforeBlocks, err := g.MemoryBlocks()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
beforeSentences := countSentences(t, g)
|
||
|
||
// 在阶段三开始前破坏块表:此时阶段一(读)已完成、阶段二(embed)正在跑
|
||
calls := 0
|
||
_, err = g.MigrateLegacyTextEntities(func(string) ([]float64, string) {
|
||
calls++
|
||
if calls == 1 {
|
||
g.mu.Lock()
|
||
_, dbErr := g.db.Exec(`DROP TABLE memory_blocks`)
|
||
g.mu.Unlock()
|
||
if dbErr != nil {
|
||
t.Fatalf("构造失败: %v", dbErr)
|
||
}
|
||
t.Cleanup(func() {
|
||
g.mu.Lock()
|
||
_, _ = g.db.Exec(ddlMemoryBlocks)
|
||
g.mu.Unlock()
|
||
})
|
||
}
|
||
return []float64{1, 0}, "fp-x"
|
||
})
|
||
if err == nil {
|
||
t.Fatal("块表被删后迁移应失败")
|
||
}
|
||
t.Logf("迁移如预期失败: %v", err)
|
||
|
||
// 关键断言:块与句子都没留下(回滚生效)
|
||
afterSentences := countSentences(t, g)
|
||
if afterSentences != beforeSentences {
|
||
t.Errorf("失败后句子数应不变(回滚),实际 %d → %d",
|
||
beforeSentences, afterSentences)
|
||
}
|
||
_ = beforeBlocks
|
||
|
||
// 恢复表后确认库里确实没有残留的块
|
||
g.mu.Lock()
|
||
_, dbErr := g.db.Exec(ddlMemoryBlocks)
|
||
g.mu.Unlock()
|
||
if dbErr != nil {
|
||
t.Fatal(dbErr)
|
||
}
|
||
afterBlocks, err := g.MemoryBlocks()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(afterBlocks) != 0 {
|
||
t.Errorf("失败后不应留下块(回滚),实际 %d 块: %+v", len(afterBlocks), afterBlocks)
|
||
}
|
||
}
|
||
|
||
func countSentences(t *testing.T, g *GraphDB) int {
|
||
t.Helper()
|
||
g.mu.RLock()
|
||
defer g.mu.RUnlock()
|
||
var n int
|
||
if err := g.db.QueryRow(`SELECT COUNT(*) FROM sentences`).Scan(&n); err != nil {
|
||
t.Fatalf("count sentences: %v", err)
|
||
}
|
||
return n
|
||
}
|
||
|
||
// 块 id 含实体 id 前缀(可读 + 稳定)。
|
||
func TestLegacyEntityBlockID_稳定可读(t *testing.T) {
|
||
a := legacyEntityBlockID(42, "值班室分机号")
|
||
b := legacyEntityBlockID(42, "值班室分机号")
|
||
c := legacyEntityBlockID(43, "值班室分机号")
|
||
if a != b {
|
||
t.Error("同实体应得同块 ID")
|
||
}
|
||
if a == c {
|
||
t.Error("不同实体应得不同块 ID")
|
||
}
|
||
if !strings.Contains(a, "blk_ent_42") {
|
||
t.Errorf("块 ID 应含实体 id 便于排查,实际 %q", a)
|
||
}
|
||
}
|
||
|
||
// ★ 值覆盖维度依赖时序:迁移必须保住实体的先后关系。
|
||
//
|
||
// 背景:实测(真实 chineseclip + 真库)同一属性先后给两个值时,
|
||
// 「值班室分机号 4324」与「值班室分机号 4379」短句向量相似度是
|
||
// 0.9284 vs 0.9298 —— 数值上区分不了。若迁移把块的时间全写成 NOW(),
|
||
// overwrite 检索就彻底没救了。
|
||
//
|
||
// ★ 断言覆盖**全部**块:初版只查了含关键字的那一个,结果把
|
||
// 「CreatedAt 不传」的变异判成通过 —— 假绿。实际漏掉的是没被 UPDATE
|
||
// 到的那些实体(它们本就该落 NOW(),但保护要能区分两种情况)。
|
||
func TestMigrateLegacyTextEntities_保留时序(t *testing.T) {
|
||
g := newMigrateGraph(t)
|
||
seedLegacy(t, g, []Triple{
|
||
{Subject: "值班室分机号", Relation: "是", Object: "四三七九"},
|
||
})
|
||
// 给**两个**实体都写同一个历史时刻
|
||
g.mu.Lock()
|
||
_, err := g.db.Exec(`UPDATE entities SET created_at = '2026-01-01 10:00:00',
|
||
updated_at = '2026-01-01 10:00:00'`)
|
||
g.mu.Unlock()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
if _, err := g.MigrateLegacyTextEntities(nil); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
blocks, err := g.MemoryBlocks()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(blocks) == 0 {
|
||
t.Fatal("无块")
|
||
}
|
||
want := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC)
|
||
for _, b := range blocks {
|
||
// ★ 只检查**迁移块**(source=legacy-entity)。
|
||
//
|
||
// 库里有第三种来源 triple —— seedLegacy 走 Commit 时写的
|
||
// (Commit 块化之后)。那些块的 created_at 由 PutMemoryBlocks
|
||
// 填成 now,**本就不该继承实体时间**。
|
||
//
|
||
// 第一版没过滤 source,于是断言去检查 Commit 块,
|
||
// 报「时序被抹平」—— 而被抹平的是 Commit 块,不是迁移块。
|
||
// ★ 差点误判成「块化破坏了迁移时序」,那会推翻正确的实现。
|
||
if b.Source != "legacy-entity" {
|
||
continue
|
||
}
|
||
if b.CreatedAt.IsZero() {
|
||
t.Errorf("迁移块 %s created_at 为零值", b.ID)
|
||
continue
|
||
}
|
||
if !b.CreatedAt.Equal(want) {
|
||
t.Errorf("迁移块 %s 应继承实体时间 %v,实际 %v(时序被抹平/未传递)",
|
||
b.ID, want, b.CreatedAt)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 多个实体的时间戳必须**不同**(全都落成同一时刻等于抹平时序)。
|
||
func TestMigrateLegacyTextEntities_时序不被抹平(t *testing.T) {
|
||
g := newMigrateGraph(t)
|
||
seedLegacy(t, g, []Triple{
|
||
{Subject: "值班室分机号", Relation: "是", Object: "四三七九"},
|
||
{Subject: "新分机号码", Relation: "是", Object: "四三二四"},
|
||
})
|
||
// 主语(较早)与后写入的实体(较晚)
|
||
g.mu.Lock()
|
||
_, err := g.db.Exec(`UPDATE entities SET
|
||
created_at = CASE WHEN name IN ('值班室分机号', '四三七九')
|
||
THEN '2026-01-01 10:00:00' ELSE '2026-01-01 11:00:00' END,
|
||
updated_at = CASE WHEN name IN ('值班室分机号', '四三七九')
|
||
THEN '2026-01-01 10:00:00' ELSE '2026-01-01 11:00:00' END`)
|
||
g.mu.Unlock()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
if _, err := g.MigrateLegacyTextEntities(nil); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
blocks, _ := g.MemoryBlocks()
|
||
// 主语+宾语各成一块:「值班室分机号」「新分机号码」「四三七九」「四三二四」
|
||
// ★ 4 个实体 → 4 迁移块(+ 4 原句块)
|
||
//
|
||
// 不能用 len(blocks):seedLegacy 走 Commit,块化之后它也写块
|
||
// (source="triple",无向量、无实体时序)。实测混进来是 12。
|
||
// ⇒ 只数迁移自己的两种块。
|
||
if n := countBySource(t, blocks, "legacy-entity"); n != 4 {
|
||
t.Fatalf("应得 4 个迁移块,实际 %d(另含 triple 块 %d 个)", n,
|
||
countBySource(t, blocks, "triple"))
|
||
}
|
||
// 判据:4 个块必须分属两个不同时刻(主语+首宾语 10:00,
|
||
// 次宾语对 11:00)。若全落同一时刻,时序就被抹平了。
|
||
// ★ 注意:不能断言「排序后首两块不等」—— 10:00 那组本来就有 2 块,
|
||
// 同刻是正常的。判据是「时刻的分布」而不是「相邻两块是否相等」。
|
||
// ★ 只统计**迁移块**(source=legacy-entity)。
|
||
// Commit 块(source=triple)的 created_at 是 PutMemoryBlocks 填的 now
|
||
// —— 它会把 seen 变成 3 个时刻(多了 2026-10-04)而误判「时序被抹平」。
|
||
seen := map[string]int{}
|
||
for _, b := range blocks {
|
||
if b.Source != "legacy-entity" {
|
||
continue
|
||
}
|
||
seen[b.CreatedAt.UTC().Format("2006-01-02 15:04")]++
|
||
}
|
||
if len(seen) != 2 {
|
||
t.Errorf("块应分属 2 个不同时刻,实际 %d 个:%v", len(seen), seen)
|
||
}
|
||
// ★ 现在只数迁移块 ⇒ 每组 2 个实体 = 2 个迁移块
|
||
// (原句块不参与时序判据:它们的时间是 Commit 填的 now)
|
||
if seen["2026-01-01 10:00"] != 2 {
|
||
t.Errorf("10:00 组应有 2 个迁移块,实际 %d", seen["2026-01-01 10:00"])
|
||
}
|
||
if seen["2026-01-01 11:00"] != 2 {
|
||
t.Errorf("11:00 组应有 2 个迁移块,实际 %d", seen["2026-01-01 11:00"])
|
||
}
|
||
}
|
||
|
||
// 旧库时间格式不认得时:宁可零值兜底,也不编造错的时刻。
|
||
func TestParseLegacyTime(t *testing.T) {
|
||
cases := []struct {
|
||
in string
|
||
want string // 空 = 期望零值
|
||
}{
|
||
{"2026-01-01 10:00:00", "2026-01-01 10:00:00"},
|
||
{"2026-01-01T10:00:00Z", "2026-01-01 10:00:00"},
|
||
{"2026-01-01 10:00:00.123456", "2026-01-01 10:00:00.123456"},
|
||
{"2026-01-01", "2026-01-01 00:00:00"},
|
||
{"", ""},
|
||
{" ", ""},
|
||
{"去年某天", ""},
|
||
}
|
||
for _, c := range cases {
|
||
got := parseLegacyTime(c.in)
|
||
if c.want == "" {
|
||
if !got.IsZero() {
|
||
t.Errorf("parseLegacyTime(%q) 应为零值,实际 %v", c.in, got)
|
||
}
|
||
continue
|
||
}
|
||
want, err := time.Parse("2006-01-02 15:04:05", c.want)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !got.Equal(want) {
|
||
t.Errorf("parseLegacyTime(%q) = %v,期望 %v", c.in, got, want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ★★ 报告数必须等于实际写入数。
|
||
//
|
||
// 生产快照实测(1294 实体 / 980 relations):
|
||
//
|
||
// 迁移报告 边 980
|
||
// 边表实际 959 ← 差 21
|
||
//
|
||
// 根因:addBlockEdgeTx 用 `INSERT OR IGNORE` 且**不检查 RowsAffected**,
|
||
// 而 `res.Edges++` 照加。relations 表里有 20 组 (src,tgt,type) 完全重复
|
||
// (各 2 次),980 → 去重 959。
|
||
//
|
||
// ★ 与 941e6b9 同类:那是 relations 口径(active vs 全表),
|
||
//
|
||
// 这是边去重口径。两次都是「报告数 ≠ 实际写入数」,
|
||
// 而用户会把报告数当承诺。
|
||
func TestMigrate_报告数等于实际写入数(t *testing.T) {
|
||
g := newMigrateGraph(t)
|
||
seedLegacy(t, g, []Triple{
|
||
{Subject: "值班室分机号", Relation: "是", Object: "4324"},
|
||
})
|
||
// ★ 生产库里有 20 组 (src,tgt,type) 完全重复的关系行。
|
||
// seedLegacy 用 Upsert(第二次覆盖第一次)⇒ 造不出重复,
|
||
// 所以直接插库 —— 这也是之前判据「通过」却没测到问题的原因。
|
||
if _, err := g.db.Exec(`INSERT INTO relations
|
||
(source_id, target_id, relation_type, confidence, status)
|
||
SELECT source_id, target_id, relation_type, confidence, status
|
||
FROM relations LIMIT 1`); err != nil {
|
||
t.Fatalf("插入重复关系: %v", err)
|
||
}
|
||
|
||
embed := func(string) ([]float64, string) { return []float64{1, 0}, fixedEmbed }
|
||
res, err := g.MigrateLegacyTextEntities(embed)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
edges, err := g.MemoryBlockEdges()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// ★ 只数**迁移块之间**的边(两端都是 blk_ent_<entityID>_<hash> 形态)。
|
||
//
|
||
// 边表里还有 Commit 块化写的边(source=triple,两端是 blk_ent_<hash24>)
|
||
// —— 那不是迁移的产物,而 res.Edges 只数迁移自己写的。
|
||
// 第一版按「非 contains」统计,把 Commit 的边也算进去了 ⇒ 误报。
|
||
relEdges := 0
|
||
for _, e := range edges {
|
||
if e.Type == "contains" {
|
||
continue
|
||
}
|
||
if isLegacyEntityBlockID(e.SourceID) && isLegacyEntityBlockID(e.TargetID) {
|
||
relEdges++
|
||
}
|
||
}
|
||
|
||
if res.Edges != relEdges {
|
||
t.Errorf("★ 报告边数 %d ≠ 实际写入 %d —— 报告会骗人", res.Edges, relEdges)
|
||
}
|
||
}
|
||
|
||
// ★★ 迁移必须为 sentences 表的原文补建原句块。
|
||
//
|
||
// 实测缺口(清理前置判据,生产快照):
|
||
//
|
||
// sentences 66 条 → 原句块 0 个
|
||
//
|
||
// 而迁移只从 `entities` 读(readLegacySnapshot 里只有 entities 查询),
|
||
// 从没为 sentences 建过载体。sentences 表的价值就在**原文本身**
|
||
// (entities 是提炼后的名字)⇒ 直接清理该表会丢 66 条原句。
|
||
func TestMigrate_sentences补建原句块(t *testing.T) {
|
||
g := newMigrateGraph(t)
|
||
// 直接插 sentences(原句),不经过 Commit(Commit 只造 entity)
|
||
// ★ sentences.text 有 UNIQUE 约束 ⇒ 同一句只能插一次。
|
||
// 所以幂等性不能靠「重复插入」来测(那是数据库层的约束,
|
||
// 根本到不了迁移代码)—— 幂等要靠**迁移跑两遍**来测。
|
||
for _, txt := range []string{
|
||
"值班室分机号改为 4324,旧号 4379 停用",
|
||
"第 114 批周日凌晨停机 4 分,回滚 v2.28.4",
|
||
} {
|
||
if _, err := g.db.Exec(
|
||
`INSERT INTO sentences (text) VALUES (?)`, txt); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
embed := func(string) ([]float64, string) { return []float64{1, 0}, fixedEmbed }
|
||
res, err := g.MigrateLegacyTextEntities(embed)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if res.Sentences == 0 {
|
||
t.Error("迁移应统计 sentences 的原句块数(res.Sentences)")
|
||
}
|
||
|
||
blocks, err := g.MemoryBlocks()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// 幂等:迁移跑第二遍不应新增原句块
|
||
if _, err := g.MigrateLegacyTextEntities(embed); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
var srcBlocks int
|
||
seen := map[string]bool{}
|
||
for _, b := range blocks {
|
||
if b.Source == SentenceBlockSource {
|
||
if seen[b.ID] {
|
||
t.Errorf("原句块 ID 重复:%s", b.ID)
|
||
}
|
||
seen[b.ID] = true
|
||
srcBlocks++
|
||
}
|
||
}
|
||
if srcBlocks != 2 {
|
||
t.Errorf("应有 2 个原句块(迁移两遍仍不增),实际 %d", srcBlocks)
|
||
}
|
||
|
||
// ★ 每条 sentence 的原文都能由内容派生出对应的块
|
||
for _, txt := range []string{
|
||
"值班室分机号改为 4324,旧号 4379 停用",
|
||
"第 114 批周日凌晨停机 4 分,回滚 v2.28.4",
|
||
} {
|
||
id := SentenceBlockID(txt)
|
||
if !seen[id] {
|
||
t.Errorf("sentence %q 缺原句块 %s", truncT(txt, 24), id)
|
||
}
|
||
}
|
||
}
|
||
|
||
func truncT(s string, n int) string {
|
||
r := []rune(s)
|
||
if len(r) <= n {
|
||
return s
|
||
}
|
||
return string(r[:n]) + "…"
|
||
}
|
||
|
||
// countBySource 按 source 统计块数。
|
||
//
|
||
// ★ 迁移测试必须用它而不是 len(blocks):块化之后 Commit 也写块
|
||
//
|
||
// (source="triple"),那些不是迁移的产物。
|
||
func countBySource(t *testing.T, blocks []MemoryBlock, src string) int {
|
||
t.Helper()
|
||
n := 0
|
||
for _, b := range blocks {
|
||
if b.Source == src {
|
||
n++
|
||
}
|
||
}
|
||
return n
|
||
}
|
||
|
||
// isLegacyEntityBlockID 判断块 ID 是否是迁移块形态(blk_ent_<entityID>_<hash8>)。
|
||
//
|
||
// ★ 与 Commit 块化的 blk_ent_<hash24> 区分:
|
||
//
|
||
// 迁移块 blk_ent_1234_abcd1234 (中间有下划线 + 数字行号)
|
||
// Commit blk_ent_abcdef1234… (纯 hash,无下划线)
|
||
func isLegacyEntityBlockID(id string) bool {
|
||
if !strings.HasPrefix(id, "blk_ent_") {
|
||
return false
|
||
}
|
||
rest := strings.TrimPrefix(id, "blk_ent_")
|
||
return strings.Contains(rest, "_")
|
||
}
|
||
|
||
// ★ LegacyRowCount 的三条约束(2026-10-04)
|
||
//
|
||
// 它接收完整 SQL 串,所以必须证明「不该接受的被拒绝」。
|
||
// 判据直接来自它的三条 guard:前缀 / JOIN+GROUP BY / 分号。
|
||
func TestLegacyRowCount_只允许单表COUNT(t *testing.T) {
|
||
g := newTestGraph(t)
|
||
defer func() { _ = g.Close() }()
|
||
if _, _, err := g.Commit([]Triple{
|
||
{Subject: "甲一", Relation: "是", Object: "乙一", Confidence: 1.0},
|
||
}, "s", 0); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
// ★ 用 SeedLegacyEntity 造旧表数据(2026-10-04)
|
||
// Commit 已不写旧表(原判据用 Commit,断言恒失败)。
|
||
if _, err := g.SeedLegacyEntity("旧表实体", "Concept"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
n, err := g.LegacyRowCount("SELECT COUNT(*) FROM entities")
|
||
if err != nil {
|
||
t.Fatalf("合法查询应通过: %v", err)
|
||
}
|
||
fmt.Printf(" entities 计数 %d\n", n)
|
||
if n != 1 {
|
||
t.Errorf("★ 刚种下一个旧表实体,计数应为 1,实际 %d", n)
|
||
}
|
||
|
||
// ★ 非 COUNT 前缀必须拒绝
|
||
for _, bad := range []string{
|
||
"DELETE FROM entities",
|
||
"DROP TABLE entities",
|
||
"SELECT * FROM entities",
|
||
"UPDATE entities SET name = 'x'",
|
||
"INSERT INTO entities (name) VALUES ('x')",
|
||
} {
|
||
if _, err := g.LegacyRowCount(bad); err == nil {
|
||
t.Errorf("★ 危险查询 %q 被接受了", bad)
|
||
}
|
||
}
|
||
// ★ JOIN / GROUP BY / 分号必须拒绝
|
||
for _, bad := range []string{
|
||
"SELECT COUNT(*) FROM entities JOIN relations ON 1=1",
|
||
"SELECT COUNT(*) FROM entities GROUP BY type",
|
||
"SELECT COUNT(*) FROM entities; DROP TABLE entities",
|
||
} {
|
||
if _, err := g.LegacyRowCount(bad); err == nil {
|
||
t.Errorf("★ 越界查询 %q 被接受了", bad)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ★★★ 迁移必须带上旧关系的属性(2026-10-04)
|
||
//
|
||
// 缺陷:legacyRel 此前只读 id/src/tgt/type,confidence / session_id /
|
||
// turn_id / status **根本没被读取**,于是迁出的边全是空属性。
|
||
//
|
||
// ★ 生产快照实测后果:959 条边 confidence 全为 0 —— 而边表把
|
||
// confidence 当唯一的质量信号(RecallSorted 的相关性排序、
|
||
// 场景权重、蒸馏置信度传播都靠它)。
|
||
//
|
||
// ★ 更隐蔽的一层:迁移原先用 addBlockEdgeTx —— 那是**结构边**写入器,
|
||
// 去重条件带 `COALESCE(session_id,”)=”`。
|
||
// 而要迁移的关系**恰恰带 session_id** ⇒ 每一条都被判成「已存在」跳过。
|
||
// 这层缺陷只有在「测试数据带 session」时才暴露 —— 所以判据必须造它。
|
||
func TestMigrateLegacy_关系属性完整迁移(t *testing.T) {
|
||
// ★ 用 newTestGraph(干净库)而不是 newMigrateGraph(带种子旧表)——
|
||
// 后者的种子数据会让边计数翻倍,判据就测不到「跨会话并存」这件事。
|
||
g := newTestGraph(t)
|
||
defer func() { _ = g.Close() }()
|
||
|
||
// 造旧表数据:同一对实体在**不同会话**各有一条同类型关系。
|
||
// 迁移后必须是两条独立的边(关系边按设计允许并存)。
|
||
// ★ 同时写块侧(Commit)与旧表(Seed*)——
|
||
// 迁移的输入是旧表,而 Commit 已不写旧表(2026-10-04)。
|
||
for i, sid := range []string{"sess-A", "sess-B"} {
|
||
conf := 0.3 + 0.4*float64(i)
|
||
if _, _, err := g.Commit([]Triple{
|
||
{Subject: "迁移甲", Relation: "维护", Object: "迁移乙", Confidence: conf},
|
||
}, sid, i+1); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, _, err := g.SeedLegacyRelation("迁移甲", "迁移乙", "维护",
|
||
conf, sid, i+1); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
res, err := g.MigrateLegacyTextEntities(nil)
|
||
if err != nil {
|
||
t.Fatalf("迁移: %v", err)
|
||
}
|
||
fmt.Printf(" 迁移 %d 边(去重 %d)\n", res.Edges, res.DedupedEdges)
|
||
|
||
// ★ 基线数:Commit 自己已经写了块侧边(块化路径)。
|
||
// 判据要比的是「迁移之后」而不是「迁移贡献了多少」——
|
||
// 前者才是「迁移有没有搬过来」这个真问题。
|
||
var baseEdges int
|
||
if err := g.db.QueryRow(`SELECT COUNT(*) FROM memory_block_edges
|
||
WHERE edge_type='维护'`).Scan(&baseEdges); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
fmt.Printf(" 迁移后「维护」边共 %d 条(Commit 原有 %d + 迁移 %d)\n",
|
||
baseEdges, baseEdges-res.Edges, res.Edges)
|
||
|
||
g.mu.RLock()
|
||
defer g.mu.RUnlock()
|
||
|
||
// ★ 跨会话的两条都必须存在(不能被 session_id 的去重条件吃掉)。
|
||
//
|
||
// ★ 只数**迁移新增**的那些:Commit 本身也写了块侧边(块化),
|
||
// 所以库里本来就有 2 条。判据要测的是「迁移有没有把旧的 2 条
|
||
// 也搬过来」—— 两者 session_id 不同,共存正是关系边的设计意图。
|
||
// ★ 关键判据:迁移去重口径按 session。
|
||
//
|
||
// 若去重条件像 addBlockEdgeTx 那样带 `session_id=''`
|
||
// (结构边口径),要迁移的关系会因「带 session」而被判成已存在
|
||
// ⇒ 每一条都跳过 ⇒ res.Edges 为 0。
|
||
//
|
||
// 所以这里的判据是 **res.Edges == 旧表关系数**,
|
||
// 而不是「库里共有几条边」—— 后者会被 Commit 自己写的块侧边干扰。
|
||
if res.Edges != 2 {
|
||
t.Errorf("★ 迁移应新增 2 条边(带 session 的不得被去重吃掉),实际 %d", res.Edges)
|
||
}
|
||
|
||
// ★ 迁移出的边必须带 session_id(Recall 的 sessionFilter 依赖它)
|
||
var nMigratedSess int
|
||
if err := g.db.QueryRow(`
|
||
SELECT COUNT(*) FROM memory_block_edges
|
||
WHERE edge_type='维护' AND COALESCE(session_id,'') != ''
|
||
AND confidence > 0`).Scan(&nMigratedSess); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if nMigratedSess < 2 {
|
||
t.Errorf("★ 迁出的边应带 session_id 与 confidence,实际 %d 条符合", nMigratedSess)
|
||
}
|
||
|
||
// ★ confidence 必须迁过来
|
||
var confidences []float64
|
||
rows, err := g.db.Query(`
|
||
SELECT COALESCE(confidence, 0) FROM memory_block_edges WHERE edge_type = '维护'`)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for rows.Next() {
|
||
var c float64
|
||
_ = rows.Scan(&c)
|
||
confidences = append(confidences, c)
|
||
}
|
||
rows.Close()
|
||
fmt.Printf(" 迁出的 confidence: %v\n", confidences)
|
||
for _, c := range confidences {
|
||
if c == 0 {
|
||
t.Error("★ confidence 未迁移(生产实测 959 条边全 0)")
|
||
}
|
||
}
|
||
|
||
// ★ session_id / turn_id 必须迁过来(Recall 的 sessionFilter 依赖)
|
||
}
|
||
|
||
// ★ 非 active 的旧关系不得迁成 active(否则已删除的记忆会复活)
|
||
func TestMigrateLegacy_已删除关系不复活(t *testing.T) {
|
||
g := newTestGraph(t)
|
||
defer func() { _ = g.Close() }()
|
||
// ★ 同时写块侧与旧表(Commit 已不写旧表,2026-10-04)
|
||
if _, _, err := g.Commit([]Triple{
|
||
{Subject: "待删甲", Relation: "曾经", Object: "待删乙", Confidence: 1.0},
|
||
}, "sess-X", 1); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, _, err := g.SeedLegacyRelation("待删甲", "待删乙", "曾经",
|
||
1.0, "sess-X", 1); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// 软删**块侧**(Purge 现在只改块)⇒ 形成判据要验的混合态
|
||
if _, err := g.Purge(map[string]string{"subject_contains": "待删甲"}, "soft"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
// ★★ 判据要造的正是**混合态**:旧表行仍是 active,块侧已 deleted。
|
||
//
|
||
// 这不是人为 contrived —— 它是读侧切块后的**真实状态**:
|
||
// Purge 只改块侧,旧 relations 行停在 active。
|
||
// 生产库有 14 条 deleted 关系 + memory_purge 工具调用历史,
|
||
// 两者叠加就是这个形态。
|
||
//
|
||
// 若只造「旧表也标 deleted」,测的就只是 status 透传;
|
||
// 而真正要防的是「旧表说 active、块侧说 deleted,迁移信了谁」。
|
||
g.mu.Lock()
|
||
var nStillActive int
|
||
if err := g.db.QueryRow(
|
||
`SELECT COUNT(*) FROM relations WHERE status='active'`).Scan(&nStillActive); err != nil {
|
||
g.mu.Unlock()
|
||
t.Fatal(err)
|
||
}
|
||
if nStillActive == 0 {
|
||
g.mu.Unlock()
|
||
t.Fatal("★ 判据前提不成立:旧表不应有 active 行(否则测不到混合态)")
|
||
}
|
||
g.mu.Unlock()
|
||
if _, err := g.MigrateLegacyTextEntities(nil); err != nil {
|
||
t.Fatalf("迁移: %v", err)
|
||
}
|
||
g.mu.RLock()
|
||
defer g.mu.RUnlock()
|
||
|
||
var nActive int
|
||
if err := g.db.QueryRow(`
|
||
SELECT COUNT(*) FROM memory_block_edges
|
||
WHERE edge_type='曾经' AND COALESCE(status,'')='active'`).Scan(&nActive); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if nActive != 0 {
|
||
t.Errorf("★ 已删除的旧关系被迁成 active(记忆会复活),实际 %d 条", nActive)
|
||
}
|
||
}
|