mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 01:18:08 +00:00
按用户确认的形态:**独立存储实例**(不给共享记忆层加 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。
192 lines
5.9 KiB
Go
192 lines
5.9 KiB
Go
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
|
||
}
|