mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-20 17:08:09 +00:00
feat(memory): N2a 第二块砖 + N2d 数据面 —— 轻量图记忆装配(temp 可写/主库只读/并集)与回收合入
按用户确认的形态:**独立存储实例**(不给共享记忆层加 space 列)。
## LightMemory:子的图记忆装配(设计 §5.6)
temp 实例(独立存储,读写) ← 子的一切图记忆写入落这里,与子同生共死
主库受限句柄(只读) ← 子只能读(query_only 结构性拒绝写入)
子的查询 = 两实例各查一次 + **应用层合并**(并集)
- 写入**只落 temp**(`Commit` 不接受 main 方向)
- 并集合并规则:实体按**名字**去重(同名保留 mention_count 较大者)、
关系按 (源名, 关系, 目标名) 去重;结果排序确定(便于断言与展示稳定)
- 单侧查询失败不影响另一侧(只有两侧都失败才报错)
- `AllowWrite=false`(用户给的备选简化):**没有 temp 实例**,子对图记忆完全只读,
写入被拒;读主库照常
## ExportTriples + 回收合入(设计 §9,N2d 数据面)
- `GraphDB.ExportTriples(limit)`:导出**活跃**三元组,把实体名一并带出
⇒ 合入侧直接复用 `Commit`(按实体名 upsert + 关系唯一约束)
- 回收主流程:子写 temp → 父导出 → **父选哪几条** → 写进 main。
未选中的**不进**主库;重复收割**幂等**(不产生重复实体)
## 验收(7 项新测试)
`light_memory_test.go`(5):
- 写只落 temp、查询是并集、主库无子的痕迹
- **两个子的 temp 互不可见**(只有 main 共享)
- 写禁用时完全只读(无 temp 实例、写入被拒、读照常)
- 并集去重(同名实体只出现一次)
- 合并确定性(去重 + 排序 + mention_count 取大)
`reclaim_test.go`(2):
- 导出只含活跃关系且带实体名、limit 生效
- 回收主流程(选中的进主库、未选中的不进、重复收割幂等)
全仓 go test ./... 37 包 ok / 0 FAIL。
This commit is contained in:
@ -626,6 +626,46 @@ func (g *GraphDB) Recall(keywords []string, seedEntities []string, depth int, se
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ExportTriples 导出库中的**活跃**三元组(供父 agent 在回收阶段收割子的 temp)。
|
||||
//
|
||||
// 设计 docs/zh/resident-subagent-design.md §9(回收 = 父读 temp → 选记录 → 合入 main)。
|
||||
// 只导出 `status='active'` 的关系,并把实体名一并带出(Relation 已含 SourceName/TargetName),
|
||||
// 于是合入侧可以直接复用 Commit —— 它按实体名 upsert、按
|
||||
// (source, relation, target) 幂等,因此"重复收割"不会造成重复条目。
|
||||
//
|
||||
// limit <= 0 表示不限量。
|
||||
func (g *GraphDB) ExportTriples(limit int) ([]Triple, error) {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
|
||||
q := `SELECT s.name, r.relation_type, t.name, r.confidence
|
||||
FROM relations r
|
||||
JOIN entities s ON s.id = r.source_id
|
||||
JOIN entities t ON t.id = r.target_id
|
||||
WHERE r.status = 'active'
|
||||
ORDER BY r.id`
|
||||
args := []interface{}{}
|
||||
if limit > 0 {
|
||||
q += " LIMIT ?"
|
||||
args = append(args, limit)
|
||||
}
|
||||
rows, err := g.db.Query(q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Triple
|
||||
for rows.Next() {
|
||||
var tr Triple
|
||||
if err := rows.Scan(&tr.Subject, &tr.Relation, &tr.Object, &tr.Confidence); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, tr)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (g *GraphDB) Purge(criteria map[string]string, mode string) (int, error) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
191
internal/memory/light_memory.go
Normal file
191
internal/memory/light_memory.go
Normal file
@ -0,0 +1,191 @@
|
||||
package memory
|
||||
|
||||
// 轻量内核的**图记忆装配**(设计 §5.6):
|
||||
//
|
||||
// temp 实例(独立存储,读写) ← 子的一切图记忆写入落这里,与子同生共死
|
||||
// 主库**受限句柄**(只读) ← 子只能读;写入被 SQLite 结构性拒绝
|
||||
//
|
||||
// 子的查询 = 两个实例各查一次,**应用层合并**(并集):
|
||||
// - 实体:按名字去重(同名视为同一实体,保留 mention_count 较大的一条)
|
||||
// - 关系:按 (源名, 关系, 目标名) 去重
|
||||
//
|
||||
// 为什么合并放在应用层而不是给共享记忆层加 space 列:**独立存储实例**让隔离成为
|
||||
// 结构性的(不同库),不依赖 where 条件;代价就是这个合并函数。
|
||||
//
|
||||
// 回收(N2d)由父 agent 读 `Temp()` 并选出要保留的记录写进主库。
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// LightMemory 是驻留子的图记忆装配。
|
||||
type LightMemory struct {
|
||||
mu sync.RWMutex
|
||||
|
||||
// temp 是子自己的图记忆实例(独立存储)。当不允许子写图记忆时为 nil。
|
||||
temp *GraphDB
|
||||
// main 是主图记忆的受限句柄(只读)。
|
||||
main *GraphDB
|
||||
|
||||
// allowWrite 报告是否允许写 temp(false ⇒ 子对图记忆完全只读)。
|
||||
allowWrite bool
|
||||
}
|
||||
|
||||
// NewLightMemory 构造子的图记忆装配。
|
||||
//
|
||||
// - main: 主库句柄。为 nil 时表示"只能用自己的 temp"(一般不该发生)。
|
||||
// - tempPath: 子 temp 实例的存储路径(独立文件)。allowWrite=false 时**不会**打开它。
|
||||
// - allowWrite: 是否允许子写图记忆(用户给的备选开关,默认建议 true)。
|
||||
func NewLightMemory(main *GraphDB, tempPath string, allowWrite bool) (*LightMemory, error) {
|
||||
m := &LightMemory{main: main, allowWrite: allowWrite}
|
||||
if !allowWrite {
|
||||
return m, nil
|
||||
}
|
||||
if tempPath == "" {
|
||||
return nil, fmt.Errorf("允许写图记忆时必须给出 temp 存储路径")
|
||||
}
|
||||
temp, err := NewGraphDB(tempPath) // temp 是子自己的库:建表/迁移都正常
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open temp graph db: %w", err)
|
||||
}
|
||||
m.temp = temp
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// AllowWrite 报告子能否写图记忆。
|
||||
func (m *LightMemory) AllowWrite() bool { return m.allowWrite }
|
||||
|
||||
// Temp 返回子的 temp 实例(可能为 nil)。回收时父 agent 用它读取/收割。
|
||||
func (m *LightMemory) Temp() *GraphDB {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.temp
|
||||
}
|
||||
|
||||
// Main 返回主库的受限句柄。
|
||||
func (m *LightMemory) Main() *GraphDB { return m.main }
|
||||
|
||||
// Commit 写入图记忆:**只落 temp**(设计 §5:写目标收窄到自己的空间)。
|
||||
func (m *LightMemory) Commit(triples []Triple, sessionID string, turnID int) (int, int, error) {
|
||||
if !m.allowWrite {
|
||||
return 0, 0, fmt.Errorf("本 agent 不允许写图记忆(allowTempGraphWrite=false)")
|
||||
}
|
||||
m.mu.RLock()
|
||||
temp := m.temp
|
||||
m.mu.RUnlock()
|
||||
if temp == nil {
|
||||
return 0, 0, fmt.Errorf("temp 图记忆实例不可用")
|
||||
}
|
||||
return temp.Commit(triples, sessionID, turnID)
|
||||
}
|
||||
|
||||
// Recall 在 temp ∪ main 上做召回(并集),按上述规则合并去重。
|
||||
//
|
||||
// 任一侧出错都不影响另一侧的结果:单侧失败只在两侧都失败时返回错误
|
||||
// (主库是只读句柄,任何"查询即失败"都说明是真实故障)。
|
||||
func (m *LightMemory) Recall(keywords []string, seedEntities []string, depth int, sessionFilter string) (*RecallResult, error) {
|
||||
m.mu.RLock()
|
||||
temp := m.temp
|
||||
m.mu.RUnlock()
|
||||
|
||||
var (
|
||||
parts []*RecallResult
|
||||
lastErr error
|
||||
okAny bool
|
||||
)
|
||||
if temp != nil {
|
||||
r, err := temp.Recall(keywords, seedEntities, depth, sessionFilter)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
} else {
|
||||
okAny = true
|
||||
parts = append(parts, r)
|
||||
}
|
||||
}
|
||||
if m.main != nil {
|
||||
r, err := m.main.Recall(keywords, seedEntities, depth, sessionFilter)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
} else {
|
||||
okAny = true
|
||||
parts = append(parts, r)
|
||||
}
|
||||
}
|
||||
if !okAny {
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("没有可用的图记忆实例")
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
return mergeRecall(parts...), nil
|
||||
}
|
||||
|
||||
// mergeRecall 把多个来源的召回结果并成一份(实体按名字、关系按三元组去重)。
|
||||
//
|
||||
// 顺序确定(先 entities/relations 各自排序),便于测试与展示稳定。
|
||||
func mergeRecall(parts ...*RecallResult) *RecallResult {
|
||||
out := &RecallResult{}
|
||||
seenEntity := map[string]int{} // 小写名 → out.Entities 下标
|
||||
for _, p := range parts {
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range p.Entities {
|
||||
k := strings.ToLower(strings.TrimSpace(e.Name))
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
if i, dup := seenEntity[k]; dup {
|
||||
// 同名实体:保留 mention_count 较大的一条(更新的那个)。
|
||||
if e.MentionCount > out.Entities[i].MentionCount {
|
||||
out.Entities[i] = e
|
||||
}
|
||||
continue
|
||||
}
|
||||
seenEntity[k] = len(out.Entities)
|
||||
out.Entities = append(out.Entities, e)
|
||||
}
|
||||
}
|
||||
seenRel := map[string]struct{}{}
|
||||
for _, p := range parts {
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
for _, r := range p.Relations {
|
||||
k := strings.ToLower(strings.TrimSpace(r.SourceName)) + "\x00" +
|
||||
strings.ToLower(strings.TrimSpace(r.RelationType)) + "\x00" +
|
||||
strings.ToLower(strings.TrimSpace(r.TargetName))
|
||||
if _, dup := seenRel[k]; dup {
|
||||
continue
|
||||
}
|
||||
seenRel[k] = struct{}{}
|
||||
out.Relations = append(out.Relations, r)
|
||||
}
|
||||
}
|
||||
sort.Slice(out.Entities, func(i, j int) bool { return out.Entities[i].Name < out.Entities[j].Name })
|
||||
sort.Slice(out.Relations, func(i, j int) bool {
|
||||
if out.Relations[i].SourceName != out.Relations[j].SourceName {
|
||||
return out.Relations[i].SourceName < out.Relations[j].SourceName
|
||||
}
|
||||
if out.Relations[i].RelationType != out.Relations[j].RelationType {
|
||||
return out.Relations[i].RelationType < out.Relations[j].RelationType
|
||||
}
|
||||
return out.Relations[i].TargetName < out.Relations[j].TargetName
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// Close 关闭 temp 实例(主库句柄由父 agent 拥有,不在这里关)。
|
||||
func (m *LightMemory) Close() error {
|
||||
m.mu.Lock()
|
||||
temp := m.temp
|
||||
m.temp = nil
|
||||
m.mu.Unlock()
|
||||
if temp != nil {
|
||||
return temp.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
208
internal/memory/light_memory_test.go
Normal file
208
internal/memory/light_memory_test.go
Normal file
@ -0,0 +1,208 @@
|
||||
package memory
|
||||
|
||||
// N2a 第二块砖:轻量内核的图记忆装配(temp 可写 + 主库只读 + 并集查询)。
|
||||
//
|
||||
// 设计:docs/zh/resident-subagent-design.md §5.6。
|
||||
// 关键性质:
|
||||
// ① 子写入只落 temp(写不进展主库)—— main 是受限句柄,结构性拒绝;
|
||||
// ② 子查询 = temp ∪ main;
|
||||
// ③ 两个子之间 temp 互不可见;
|
||||
// ④ allowTempGraphWrite=false 时子对图记忆完全只读(没有 temp 实例)。
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// newMainWith 建一个主库并写入若干三元组。
|
||||
func newMainWith(t *testing.T, dir string, triples ...Triple) *GraphDB {
|
||||
t.Helper()
|
||||
main, err := NewGraphDB(filepath.Join(dir, "main.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(triples) > 0 {
|
||||
if _, _, err := main.Commit(triples, "sess", 1); err != nil {
|
||||
t.Fatalf("主库写入失败: %v", err)
|
||||
}
|
||||
}
|
||||
return main
|
||||
}
|
||||
|
||||
func names(entities []Entity) []string {
|
||||
out := make([]string, 0, len(entities))
|
||||
for _, e := range entities {
|
||||
out = append(out, e.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func hasName(entities []Entity, name string) bool {
|
||||
for _, e := range entities {
|
||||
if e.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 子写入只落 temp;查询是 temp ∪ main。
|
||||
func TestLightMemory_WriteGoesToTempReadIsUnion(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
main := newMainWith(t, dir, Triple{Subject: "张三", Relation: "任职于", Object: "甲公司"})
|
||||
defer main.Close()
|
||||
|
||||
light, err := NewLightMemory(main, filepath.Join(dir, "sub-1.db"), true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer light.Close()
|
||||
|
||||
// 子写自己的发现。
|
||||
if _, _, err := light.Commit([]Triple{{Subject: "李四", Relation: "任职于", Object: "乙公司"}}, "sess", 1); err != nil {
|
||||
t.Fatalf("子写入 temp 应成功: %v", err)
|
||||
}
|
||||
|
||||
// 查询能同时看到 main(张三)与 temp(李四)。
|
||||
res, err := light.Recall([]string{"张三", "李四"}, nil, 1, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !hasName(res.Entities, "张三") {
|
||||
t.Fatalf("子应看得到主库内容,实际 %v", names(res.Entities))
|
||||
}
|
||||
if !hasName(res.Entities, "李四") {
|
||||
t.Fatalf("子应看得到自己 temp 的内容,实际 %v", names(res.Entities))
|
||||
}
|
||||
|
||||
// 主库**没有**被写入(子改不了 main)。
|
||||
mainOnly, err := main.Recall([]string{"李四"}, nil, 1, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hasName(mainOnly.Entities, "李四") {
|
||||
t.Fatalf("主的图记忆里不该出现子才知道的实体:%v", names(mainOnly.Entities))
|
||||
}
|
||||
}
|
||||
|
||||
// 两个子之间 temp 互不可见(只有 main 共享)。
|
||||
func TestLightMemory_ChildrenTempsAreIsolated(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
main := newMainWith(t, dir, Triple{Subject: "共享", Relation: "属于", Object: "主库"})
|
||||
defer main.Close()
|
||||
|
||||
a, err := NewLightMemory(main, filepath.Join(dir, "sub-a.db"), true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer a.Close()
|
||||
b, err := NewLightMemory(main, filepath.Join(dir, "sub-b.db"), true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer b.Close()
|
||||
|
||||
if _, _, err := a.Commit([]Triple{{Subject: "A的秘密", Relation: "仅属于", Object: "A"}}, "s", 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resB, err := b.Recall([]string{"A的秘密", "共享"}, nil, 1, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hasName(resB.Entities, "A的秘密") {
|
||||
t.Fatalf("B 不该看到 A 的 temp:%v", names(resB.Entities))
|
||||
}
|
||||
if !hasName(resB.Entities, "共享") {
|
||||
t.Fatalf("B 应看得到共享的 main:%v", names(resB.Entities))
|
||||
}
|
||||
}
|
||||
|
||||
// allowTempGraphWrite=false:子对图记忆完全只读(没有 temp 实例,写入被拒)。
|
||||
func TestLightMemory_WriteDisabledIsFullyReadOnly(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
main := newMainWith(t, dir, Triple{Subject: "张三", Relation: "任职于", Object: "甲公司"})
|
||||
defer main.Close()
|
||||
|
||||
light, err := NewLightMemory(main, "", false)
|
||||
if err != nil {
|
||||
t.Fatalf("不允许写时不应要求 temp 路径: %v", err)
|
||||
}
|
||||
defer light.Close()
|
||||
|
||||
if light.AllowWrite() {
|
||||
t.Fatal("AllowWrite 应为 false")
|
||||
}
|
||||
if light.Temp() != nil {
|
||||
t.Fatal("不允许写时不该有 temp 实例")
|
||||
}
|
||||
if _, _, err := light.Commit([]Triple{{Subject: "李四", Relation: "x", Object: "y"}}, "s", 1); err == nil {
|
||||
t.Fatal("不允许写时的写入必须被拒")
|
||||
}
|
||||
// 读仍然可用(只读主库)。
|
||||
res, err := light.Recall([]string{"张三"}, nil, 1, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !hasName(res.Entities, "张三") {
|
||||
t.Fatalf("只读模式仍应读到主库:%v", names(res.Entities))
|
||||
}
|
||||
}
|
||||
|
||||
// 并集必须去重:同一个实体同时存在于 main 与 temp 时只出现一次。
|
||||
func TestLightMemory_UnionDedupesSameEntity(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
main := newMainWith(t, dir, Triple{Subject: "张三", Relation: "任职于", Object: "甲公司"})
|
||||
defer main.Close()
|
||||
|
||||
light, err := NewLightMemory(main, filepath.Join(dir, "sub.db"), true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer light.Close()
|
||||
if _, _, err := light.Commit([]Triple{{Subject: "张三", Relation: "擅长", Object: "Go"}}, "s", 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
res, err := light.Recall([]string{"张三"}, nil, 1, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n := 0
|
||||
for _, e := range res.Entities {
|
||||
if e.Name == "张三" {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("同名实体在并集中应只出现一次,实际 %d 次:%v", n, names(res.Entities))
|
||||
}
|
||||
}
|
||||
|
||||
// 并集查询的确定性(同样的输入必得同样的顺序),便于断言与展示稳定。
|
||||
func TestMergeRecall_Deterministic(t *testing.T) {
|
||||
a := &RecallResult{
|
||||
Entities: []Entity{{Name: "b", MentionCount: 1}, {Name: "a", MentionCount: 1}},
|
||||
Relations: []Relation{
|
||||
{SourceName: "z", RelationType: "r", TargetName: "y"},
|
||||
{SourceName: "a", RelationType: "r", TargetName: "b"},
|
||||
},
|
||||
}
|
||||
b := &RecallResult{
|
||||
Entities: []Entity{{Name: "a", MentionCount: 5}},
|
||||
Relations: []Relation{{SourceName: "a", RelationType: "r", TargetName: "b"}},
|
||||
}
|
||||
got := mergeRecall(a, b)
|
||||
if len(got.Entities) != 2 || got.Entities[0].Name != "a" || got.Entities[1].Name != "b" {
|
||||
t.Fatalf("实体合并结果=%v", names(got.Entities))
|
||||
}
|
||||
if got.Entities[0].MentionCount != 5 {
|
||||
t.Fatalf("同名实体应保留 mention_count 较大者,实际 %d", got.Entities[0].MentionCount)
|
||||
}
|
||||
if len(got.Relations) != 2 {
|
||||
t.Fatalf("关系应去重后剩 2 条,实际 %d", len(got.Relations))
|
||||
}
|
||||
if got.Relations[0].SourceName != "a" {
|
||||
t.Fatalf("关系未按源名排序:%+v", got.Relations)
|
||||
}
|
||||
}
|
||||
117
internal/memory/reclaim_test.go
Normal file
117
internal/memory/reclaim_test.go
Normal file
@ -0,0 +1,117 @@
|
||||
package memory
|
||||
|
||||
// N2d 数据面:**回收**=父读子的 temp → 选记录 → 合入 main。
|
||||
//
|
||||
// 设计 §9:回收是"取消语义"(收割后取消该驻留子),且**由父决定纳入哪些**。
|
||||
// 这里只做数据面(读得到、合得进、幂等),控制面(谁来决定、何时取消)在 N3/N6。
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExportTriples_OnlyActiveWithNames(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
g, err := NewGraphDB(filepath.Join(dir, "g.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer g.Close()
|
||||
|
||||
if _, _, err := g.Commit([]Triple{
|
||||
{Subject: "张三", Relation: "任职于", Object: "甲公司"},
|
||||
{Subject: "李四", Relation: "合作", Object: "王五"},
|
||||
}, "sess", 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := g.ExportTriples(0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("导出 %d 条,期望 2:%+v", len(got), got)
|
||||
}
|
||||
// 实体名必须带出来(合入侧要靠名字 upsert,而不是内部 id)。
|
||||
seen := map[string]bool{}
|
||||
for _, tr := range got {
|
||||
if tr.Subject == "" || tr.Object == "" || tr.Relation == "" {
|
||||
t.Fatalf("导出的三元组字段不全:%+v", tr)
|
||||
}
|
||||
seen[tr.Subject+"→"+tr.Object] = true
|
||||
}
|
||||
if !seen["张三→甲公司"] || !seen["李四→王五"] {
|
||||
t.Fatalf("导出内容不对:%+v", got)
|
||||
}
|
||||
|
||||
// limit 生效。
|
||||
one, err := g.ExportTriples(1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(one) != 1 {
|
||||
t.Fatalf("limit=1 应导出 1 条,实际 %d", len(one))
|
||||
}
|
||||
}
|
||||
|
||||
// 回收主流程:子写 temp → 父导出 → 选中的合入 main;主库拿到、temp 不变。
|
||||
func TestReclaim_HarvestChildTempIntoMain(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
main, err := NewGraphDB(filepath.Join(dir, "main.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer main.Close()
|
||||
|
||||
child, err := NewLightMemory(main, filepath.Join(dir, "sub.db"), true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer child.Close()
|
||||
|
||||
// 子在 temp 里积累了两条发现。
|
||||
if _, _, err := child.Commit([]Triple{
|
||||
{Subject: "子发现A", Relation: "指向", Object: "结论1"},
|
||||
{Subject: "子发现B", Relation: "指向", Object: "结论2"},
|
||||
}, "sess", 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 父:导出 → **选一条**("哪些纳入记忆"由父决定)→ 写进 main。
|
||||
all, err := child.Temp().ExportTriples(0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(all) != 2 {
|
||||
t.Fatalf("导出 %d 条,期望 2", len(all))
|
||||
}
|
||||
selected := []Triple{all[0]}
|
||||
if _, _, err := main.Commit(selected, "reclaim", 0); err != nil {
|
||||
t.Fatalf("合入 main 失败: %v", err)
|
||||
}
|
||||
|
||||
// 主库只拿到选中的那条。
|
||||
mainRes, err := main.Recall([]string{"子发现A", "子发现B"}, nil, 1, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !hasName(mainRes.Entities, "子发现A") {
|
||||
t.Fatalf("选中的记录应进主库:%v", names(mainRes.Entities))
|
||||
}
|
||||
if hasName(mainRes.Entities, "子发现B") {
|
||||
t.Fatalf("未选中的记录不该进主库:%v", names(mainRes.Entities))
|
||||
}
|
||||
|
||||
// 重复收割是幂等的(Commit 按实体名 upsert + 关系唯一约束)。
|
||||
before := len(mainRes.Entities)
|
||||
if _, _, err := main.Commit(selected, "reclaim", 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, err := main.Recall([]string{"子发现A"}, nil, 1, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(after.Entities) != before {
|
||||
t.Fatalf("重复收割不应产生重复实体:before=%d after=%d", before, len(after.Entities))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user