Files
HomeAgent/internal/memory/light_memory.go
JianFeeeee f0562915db 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。
2026-09-13 09:38:54 +08:00

192 lines
5.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 报告是否允许写 tempfalse ⇒ 子对图记忆完全只读)。
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
}