mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 06:47:29 +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:客户端与内核版本一致
413 lines
13 KiB
Go
413 lines
13 KiB
Go
package memory
|
||
|
||
import (
|
||
"fmt"
|
||
"os"
|
||
"testing"
|
||
)
|
||
|
||
// ★★ 存量清理的前置判据:清理**之前**必须能证明块侧数据完整。
|
||
//
|
||
// 清理是不可逆动作(删表),所以判据必须回答:
|
||
//
|
||
// ① 每个 entity 都有对应的块吗?
|
||
// ② 每条 relation 都有对应的边吗?(去重口径)
|
||
// ③ 每条 sentence 都有对应的原句块吗?
|
||
// ④ 有没有边指向将被删除的节点?(悬空边)
|
||
func TestRetire_清理前置_块侧覆盖完整(t *testing.T) {
|
||
path := os.Getenv("RETIRE_DB")
|
||
if path == "" {
|
||
t.Skip("需要 RETIRE_DB(生产库快照副本)")
|
||
}
|
||
g, err := NewGraphDB(path)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer func() { _ = g.Close() }()
|
||
|
||
// ① entities → legacy-entity 块
|
||
var entCount, entBlocks int
|
||
mustScalar(t, g, `SELECT COUNT(*) FROM entities`, &entCount)
|
||
mustScalar(t, g, `SELECT COUNT(*) FROM memory_blocks WHERE source='legacy-entity'`, &entBlocks)
|
||
fmt.Printf(" ① entities %d → legacy-entity 块 %d %s\n",
|
||
entCount, entBlocks, markEq(entCount, entBlocks))
|
||
|
||
// 每个 entity 的名字都能找到一个块(不只数数量)
|
||
missing := missingBlocks(t, g)
|
||
fmt.Printf(" 缺块的 entity: %d 个%s\n", len(missing),
|
||
excerpt(missing))
|
||
if len(missing) > 0 {
|
||
t.Errorf("★ 有 %d 个 entity 没有对应块 —— 清理会丢数据", len(missing))
|
||
}
|
||
|
||
// ② relations → 关系边
|
||
var relCount, relDistinct int
|
||
mustScalar(t, g, `SELECT COUNT(*) FROM relations`, &relCount)
|
||
mustScalar(t, g, `SELECT COUNT(*) FROM (
|
||
SELECT DISTINCT source_id, target_id, COALESCE(relation_type,'') FROM relations)`,
|
||
&relDistinct)
|
||
mustScalar(t, g, `SELECT COUNT(*) FROM memory_block_edges WHERE edge_type<>'contains'`, &relBlocks)
|
||
fmt.Printf(" ② relations %d(去重后 %d)→ 关系边 %d %s\n",
|
||
relCount, relDistinct, relBlocks, markEq(relDistinct, relBlocks))
|
||
if relDistinct != relBlocks {
|
||
t.Errorf("★ 关系边 %d ≠ 去重后关系数 %d —— 清理会丢边", relBlocks, relDistinct)
|
||
}
|
||
|
||
// ③ sentences → 原句块
|
||
//
|
||
// ★ 块 ID 是 sha256(Text[:12]),**SQL 里算不了** ⇒ 在 Go 侧比对。
|
||
// (SQLite 没有 sha256 函数,尝试 `SentenceBlockID(...)` 会报
|
||
// "no such function" —— 判据要自己算,别指望 SQL 能做哈希。)
|
||
allBlocks := map[string]bool{}
|
||
bs, err := g.MemoryBlocks()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for _, b := range bs {
|
||
allBlocks[b.ID] = true
|
||
}
|
||
sents := g.sentenceTexts(t)
|
||
var sentWithBlock int
|
||
var noBlock []string
|
||
for _, txt := range sents {
|
||
if allBlocks[SentenceBlockID(txt)] {
|
||
sentWithBlock++
|
||
} else {
|
||
noBlock = append(noBlock, txt)
|
||
}
|
||
}
|
||
fmt.Printf(" ③ sentences %d → 有原句块的 %d %s%s\n",
|
||
len(sents), sentWithBlock, markEq(len(sents), sentWithBlock),
|
||
excerpt(noBlock))
|
||
if len(noBlock) > 0 {
|
||
t.Errorf("★ %d 条 sentence 没有原句块 —— 清理会丢原句", len(noBlock))
|
||
}
|
||
|
||
// ④ 悬空边:指向 sentence 表的边
|
||
var dangling int
|
||
mustScalar(t, g, `SELECT COUNT(*) FROM memory_block_edges e
|
||
WHERE e.source_kind='sentence'
|
||
AND NOT EXISTS (SELECT 1 FROM sentences s WHERE CAST(s.id AS TEXT)=e.source_id)`,
|
||
&dangling)
|
||
fmt.Printf(" ④ 悬空的 sentence→块 边: %d\n", dangling)
|
||
if dangling > 0 {
|
||
t.Errorf("★ %d 条边指向不存在的 sentence —— 清理后会成为悬空边", dangling)
|
||
}
|
||
|
||
// ⑤ 块边的两端都必须存在(通用悬空检查)
|
||
var badEnds int
|
||
mustScalar(t, g, `SELECT COUNT(*) FROM memory_block_edges e
|
||
WHERE (e.source_kind='block' AND NOT EXISTS
|
||
(SELECT 1 FROM memory_blocks b WHERE b.id=e.source_id))
|
||
OR (e.target_kind='block' AND NOT EXISTS
|
||
(SELECT 1 FROM memory_blocks b WHERE b.id=e.target_id))`, &badEnds)
|
||
fmt.Printf(" ⑤ 端点不存在的块边: %d\n", badEnds)
|
||
if badEnds > 0 {
|
||
t.Errorf("★ %d 条块边的一端不存在", badEnds)
|
||
}
|
||
}
|
||
|
||
var relBlocks int
|
||
|
||
func mustScalar(t *testing.T, g *GraphDB, q string, dst *int) {
|
||
t.Helper()
|
||
g.mu.RLock()
|
||
defer g.mu.RUnlock()
|
||
if err := g.db.QueryRow(q).Scan(dst); err != nil {
|
||
t.Fatalf("%s: %v", q, err)
|
||
}
|
||
}
|
||
|
||
// sentenceTexts 读出 sentences 表的原文。
|
||
func (g *GraphDB) sentenceTexts(t *testing.T) []string {
|
||
t.Helper()
|
||
g.mu.RLock()
|
||
defer g.mu.RUnlock()
|
||
rows, err := g.db.Query(`SELECT text FROM sentences`)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer func() { _ = rows.Close() }()
|
||
var out []string
|
||
for rows.Next() {
|
||
var s string
|
||
_ = rows.Scan(&s)
|
||
out = append(out, s)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// missingBlocks 找出没有对应块的 entity
|
||
func missingBlocks(t *testing.T, g *GraphDB) []string {
|
||
t.Helper()
|
||
g.mu.RLock()
|
||
defer g.mu.RUnlock()
|
||
rows, err := g.db.Query(`SELECT e.name FROM entities e
|
||
WHERE NOT EXISTS (SELECT 1 FROM memory_blocks b
|
||
WHERE b.source='legacy-entity' AND b.text_content = e.name)`)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer func() { _ = rows.Close() }()
|
||
var out []string
|
||
for rows.Next() {
|
||
var n string
|
||
_ = rows.Scan(&n)
|
||
out = append(out, n)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func markEq(a, b int) string {
|
||
if a == b {
|
||
return "✓"
|
||
}
|
||
return "✘"
|
||
}
|
||
|
||
func excerpt(ss []string) string {
|
||
if len(ss) == 0 {
|
||
return ""
|
||
}
|
||
s := " 例: " + ss[0]
|
||
if len(ss) > 1 {
|
||
s += fmt.Sprintf(" …(共 %d)", len(ss))
|
||
}
|
||
return s
|
||
}
|
||
|
||
// ★★ scene 读方适配:TagSceneByEntityGlob 改走块体系
|
||
//
|
||
// 现状(scene.go:260):按实体名 GLOB 查 relations:
|
||
//
|
||
// SELECT r.id, r.source_id, r.target_id, r.confidence
|
||
// FROM relations r JOIN entities e1 ... JOIN entities e2 ...
|
||
// WHERE r.status='active' AND (e1.name GLOB ? OR e2.name GLOB ?)
|
||
//
|
||
// 块体系里等价于:按**块文本** GLOB 找关系边的两端,
|
||
// 再把两端块都挂到场景上(边本身也挂,kind='edge')。
|
||
//
|
||
// ★ 这条路径有真实调用方(cmd/memgc),所以必须有判据。
|
||
func TestSceneAdapt_ByEntityGlob走块体系(t *testing.T) {
|
||
g := newTestGraph(t)
|
||
defer func() { _ = g.Close() }()
|
||
|
||
// 织一张网:值班室分机号 --是--> 4324
|
||
for _, b := range []MemoryBlock{
|
||
{ID: "blk_subject", Modality: BlockText, Text: "值班室分机号"},
|
||
{ID: "blk_value", Modality: BlockText, Text: "4324"},
|
||
{ID: "blk_other", Modality: BlockText, Text: "billing服务"},
|
||
} {
|
||
if err := g.PutMemoryBlocks([]MemoryBlock{b}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
if err := g.AddRelationBlockEdge("blk_subject", "blk_value", "是",
|
||
RelationEdgeData{SessionID: "s1", Confidence: 0.9, Status: EdgeActive}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
// 按名字 GLOB 找关系(dryRun,不真写)
|
||
n, err := g.TagSceneByEntityGlob("值班场景", "*分机号*", true)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
fmt.Printf(" GLOB '*分机号*' 匹配 %d 条关系\n", n)
|
||
if n != 1 {
|
||
t.Errorf("★ 应匹配 1 条(值班室分机号 --是--> 4324),实际 %d", n)
|
||
}
|
||
|
||
// 不匹配的名字不该匹配
|
||
n2, err := g.TagSceneByEntityGlob("值班场景", "*不存在的名字*", true)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if n2 != 0 {
|
||
t.Errorf("★ 不该匹配任何关系,实际 %d", n2)
|
||
}
|
||
|
||
// ★ 真写(dryRun=false)后,场景应挂到块上
|
||
if _, err := g.TagSceneByEntityGlob("值班场景", "*分机号*", false); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
counts := sceneRefCounts(t, g)
|
||
fmt.Printf(" 写入后 scene_refs: %v\n", counts)
|
||
if counts["block"] == 0 {
|
||
t.Error("★ 场景应挂到块上(kind='block'),实际无")
|
||
}
|
||
if counts["edge"] == 0 {
|
||
t.Error("★ 场景应挂到边上(kind='edge'),实际无")
|
||
}
|
||
// ★ 旧表读方不应再被使用 —— 但引用不应退回 entity/relation
|
||
if counts["entity"] != 0 || counts["relation"] != 0 {
|
||
t.Errorf("★ 不该产生旧表引用,实际 %v", counts)
|
||
}
|
||
}
|
||
|
||
// ★★ 悬空引用清理要认 kind='edge'
|
||
//
|
||
// 旧实现只清 kind='relation'/'entity'/'block'/'document',
|
||
// 而 scene_refs 现在多了一种:**kind='edge'**(指向 memory_block_edges.id)。
|
||
//
|
||
// ★ 不加这个分支的后果:边被删后那批 edge 引用永远悬空,
|
||
//
|
||
// 而且是**静默**的 —— purgeStaleSceneRefs 看着跑成功了,
|
||
// 实际没清掉任何东西。
|
||
func TestSceneAdapt_悬空清理认edge(t *testing.T) {
|
||
g := newTestGraph(t)
|
||
defer func() { _ = g.Close() }()
|
||
|
||
if err := g.PutMemoryBlocks([]MemoryBlock{
|
||
{ID: "blk_a", Modality: BlockText, Text: "甲"},
|
||
{ID: "blk_b", Modality: BlockText, Text: "乙"},
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := g.AddRelationBlockEdge("blk_a", "blk_b", "是",
|
||
RelationEdgeData{SessionID: "s1", Confidence: 0.9}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
// 挂三条引用:两个有效、一个指向不存在的块
|
||
g.mu.Lock()
|
||
for _, ref := range []struct{ kind, text, id string }{
|
||
{"block", "blk_a", "0"},
|
||
{"edge", "0", "99999"}, // 指向不存在的边
|
||
{"block", "blk_gone", "0"},
|
||
} {
|
||
if _, err := g.db.Exec(
|
||
`INSERT INTO scene_refs (scene_id, kind, ref_id, ref_text, weight)
|
||
VALUES (1, ?, CAST(? AS INTEGER), ?, 1.0)`,
|
||
ref.kind, ref.id, ref.text); err != nil {
|
||
g.mu.Unlock()
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
g.mu.Unlock()
|
||
|
||
n, err := g.PurgeStaleSceneRefs()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
fmt.Printf(" 清理 %d 条悬空引用\n", n)
|
||
if n != 2 {
|
||
t.Errorf("★ 应清理 2 条(悬空的 edge 与 block),实际 %d", n)
|
||
}
|
||
|
||
counts := sceneRefCounts(t, g)
|
||
fmt.Printf(" 清理后: %v\n", counts)
|
||
if counts["edge"] != 0 {
|
||
t.Errorf("★ 悬空的 kind='edge' 未被清理(缺该分支)")
|
||
}
|
||
if counts["block"] != 1 {
|
||
t.Errorf("★ 有效引用应保留,实际 %v", counts)
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// 停旧表双写 —— 判据(2026-10-04)
|
||
//
|
||
// ★ 目标不是「旧表被删」,而是「旧表停止增长、冻结为历史」。
|
||
// 删表是不可逆的,且读方虽已全切块,仍需要一段时间观察。
|
||
//
|
||
// ★ 判据的核心是**增长量**,不是「有没有 INSERT 语句」——
|
||
// 后者是文本检查,前者才是行为检查。
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
// Test停双写_旧表不再增长
|
||
func Test停双写_旧表不再增长(t *testing.T) {
|
||
g := newTestGraph(t)
|
||
defer func() { _ = g.Close() }()
|
||
|
||
// 先写一批,让旧表有内容
|
||
if _, _, err := g.Commit([]Triple{
|
||
{Subject: "初始甲", Relation: "属于", Object: "初始乙", Confidence: 1.0},
|
||
}, "sess-0", 0); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
snap := legacyCounts(t, g)
|
||
fmt.Printf(" 首批后(旧表应已冻结): %v\n", snap)
|
||
|
||
// ★ 首批之后旧表就应该是 0 —— 双写已停(2026-10-04)。
|
||
// 原断言是「首批应写入旧表」,那是**停双写之前**的前提,
|
||
// 改完之后它必然失败 ⇒ 判据自己抓出了自己过期。
|
||
//
|
||
// ★ 这正是「行为判据优于文本判据」的又一例:
|
||
// 我们不是在查「有没有 INSERT 语句」,
|
||
// 而是在看 Commit 之后旧表**实际长没长**。
|
||
for _, table := range []string{"entities", "relations", "sentences"} {
|
||
if snap[table] != 0 {
|
||
t.Fatalf("★ 首批 Commit 后旧表 %s 仍有 %d 行(双写未停)",
|
||
table, snap[table])
|
||
}
|
||
}
|
||
|
||
// 再写一批 —— 旧表**不应**再增长
|
||
for i := 0; i < 5; i++ {
|
||
if _, _, err := g.Commit([]Triple{
|
||
{Subject: fmt.Sprintf("新主体%d", i), Relation: "属性",
|
||
Object: fmt.Sprintf("值%d", i), Confidence: 1.0,
|
||
SentenceText: fmt.Sprintf("第 %d 句测试", i)},
|
||
}, fmt.Sprintf("sess-%d", i+1), i+1); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
after := legacyCounts(t, g)
|
||
fmt.Printf(" 五批后: %v\n", after)
|
||
for _, table := range []string{"entities", "relations", "sentences"} {
|
||
if after[table] != snap[table] {
|
||
t.Errorf("★ 旧表 %s 仍在增长: %d → %d(应冻结)",
|
||
table, snap[table], after[table])
|
||
}
|
||
}
|
||
|
||
// ★ 但块侧**必须**照常增长 —— 双写停了,单写不能停
|
||
if blk, err := g.MemoryBlocks(); err != nil {
|
||
t.Fatal(err)
|
||
} else {
|
||
fmt.Printf(" 块数 %d(应 ≥ 12:首批 2 + 新批 10)\n", len(blk))
|
||
if len(blk) < 12 {
|
||
t.Errorf("★ 块侧写入被误伤,只剩 %d 个块", len(blk))
|
||
}
|
||
}
|
||
}
|
||
|
||
// Test停双写_召回不受影响:旧表冻结后读方仍要工作
|
||
func Test停双写_召回不受影响(t *testing.T) {
|
||
g := newTestGraph(t)
|
||
defer func() { _ = g.Close() }()
|
||
for i := 0; i < 3; i++ {
|
||
if _, _, err := g.Commit([]Triple{
|
||
{Subject: "召回主体", Relation: "属性", Object: fmt.Sprintf("召回值%d", i),
|
||
Confidence: 1.0},
|
||
}, "sess", i); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
res, err := g.Recall([]string{"召回主体"}, nil, 1, "")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
fmt.Printf(" 召回实体 %d,关系 %d\n", len(res.Entities), len(res.Relations))
|
||
if len(res.Entities) == 0 {
|
||
t.Error("★ 旧表冻结后召回不该失效(读方已全切块)")
|
||
}
|
||
if len(res.Relations) == 0 {
|
||
t.Error("★ 旧表冻结后关系召回不该失效")
|
||
}
|
||
}
|
||
|
||
func legacyCounts(t *testing.T, g *GraphDB) map[string]int {
|
||
t.Helper()
|
||
out := map[string]int{}
|
||
for _, tbl := range []string{"entities", "relations", "sentences"} {
|
||
n, err := g.LegacyRowCount("SELECT COUNT(*) FROM " + tbl)
|
||
if err != nil {
|
||
t.Fatalf("count %s: %v", tbl, err)
|
||
}
|
||
out[tbl] = n
|
||
}
|
||
return out
|
||
}
|