Files
HomeAgent/internal/agent/core/graphmedia.go
JianFeeeee 48cfa8fb6c feat(lightkernel): N2c —— 轻量内核 profile(窄接口 GraphMemory + nil 即禁用整理面)
按用户指出的关键点(a.memory 多数使用点属"主 agent 整理记忆"与"记忆整理流水线",
子不该有那些路径)实现,方案见设计 §16.0。

## 窄接口:只有 Recall + Commit

新增 `GraphMemory` 接口(memoryface.go)—— 按调用方实测分类后,真正"根与子都要"的只有这两个:
- `Recall`:上下文检索 / memory_recall
- `Commit`:自动写入路径的图部分 / memory_commit

新增 `Agent.graph`(共同面)与 `Agent.graphMem()` 访问器:
- `graph` 显式为 nil 时**回落**到 `memory` ⇒ 既有"只用 Agent 字面量设 memory"的测试无需改动
  (原本会出现"必须同时设两个字段"的脚坑,实测踩到后去掉了)
- 轻量内核:`graph = *memory.LightMemory`,`memory = nil`

## nil 即禁用:整理面自动消失,不需要受限包装

子的 `a.memory == nil` ⇒ 既有的 22 处 `if a.memory != nil` 关卡自动禁掉全部整理面:
- 记忆整理流水线(distill.go 的 archive/review/merge 循环)
- 记忆块 + 媒体桥(graphmedia.go / medialoop.go)
- 记忆整理工具(memory_merge / memory_delete_entity / memory_block_merge /
  memory_purge / memory_edit / memory_introspect)—— 它们本就在 `if a.memory != nil` 块内

唯一拆开的一处是自动写入 `commitTriplesWithMedia`:
图部分走 `graphMem().Commit`(父落 main、子落 temp),块/媒体部分仍由 `a.memory != nil` 守卫。
`executeMemoryTool` 的读路径改走 `graphMem().Recall`;整理类 case 加 `requireFull()` 闸门,
被直调时明确报"本 agent 是轻量内核:记忆整理不可用",不静默降级。

## 验收(3 项新测试)

- `TestLightProfile_MemoryFaceWiring`:整理面为 nil、写入只落 temp(主库无子痕迹)、
  读是并集(主库实体 + temp 实体都看得到)
- `TestLightProfile_OrganizeToolsAbsentAndRefused`:整理类工具**不进工具表**;
  即便被直调也明确报"轻量内核不支持"
- `TestFullProfile_KeepsOrganizeFace`:对照,根 agent 仍保留整理面与整理工具

全仓 go test ./... 37 包 ok / 0 FAIL;-race(agent/memory)干净;gofmt 干净。
2026-09-13 10:02:02 +08:00

270 lines
8.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 core
import (
"fmt"
"log"
"strconv"
"strings"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
)
// L3 图库的媒体绑定。
//
// 媒体在 L3 是一等记忆块memory_blocks以结构边与承载它的节点相连
// sentence --contains--> block对话/三元组产生的记忆)
// document --contains--> blockL2 文档归档进 L3
//
// 这里不再有任何 marker 文本、正则反解或"描述文本当索引"的路径:
// 媒体只按自己的统一空间向量被检索,图库/文档只记录它的结构归属。
// migrateLegacyGraphMedia 把 marker 反解出来的旧媒体实体迁移成原生一等块。
//
// 旧数据里媒体是 type=Media 的普通实体(「图片 a1b2c3d4e5f6」
// 靠生成的描述文本当索引。迁移后它变成真正的记忆块,以
// sentence --contains--> block 结构边挂回原句子,旧实体与描述关系删除。
// 迁移幂等(实体处理完即删除),因此在每个 Agent 启动时跑一次是安全的。
func (a *Agent) migrateLegacyGraphMedia() {
if a.memory == nil || a.mediaStore == nil {
return
}
blocks, entities, err := a.memory.MigrateLegacyMediaEntities(func(short string) (memory.MemoryBlock, bool) {
full, err := a.mediaStore.ResolvePrefix(short)
if err != nil {
return memory.MemoryBlock{}, false
}
return a.blockFromDigest(full)
})
if err != nil {
log.Printf("[media] 旧媒体实体迁移失败(下轮重试): %v", err)
return
}
if blocks > 0 || entities > 0 {
log.Printf("[media] 旧媒体实体迁移完成: 新建 %d 个原生块,删除 %d 个描述式实体", blocks, entities)
}
}
// attachBlocksToSentence 把一组 digest 变成 L3 一等块并挂到句子上。
// seed 允许复用已持有块的 IDL2→L3 迁移保持块身份不变)。
func (a *Agent) attachBlocksToSentence(sentenceID int64, digests []string, seed map[string]memory.MemoryBlock) int {
if a.mediaStore == nil || a.memory == nil || sentenceID == 0 {
return 0
}
bound := 0
for _, d := range digests {
full, err := a.mediaStore.ResolvePrefix(d)
if err != nil {
log.Printf("[media] digest %s 无法解析: %v", d, err)
continue
}
b, ok := seed[full]
if !ok {
if b, ok = a.blockFromDigest(full); !ok {
continue
}
}
if err := a.memory.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil {
log.Printf("[media] L3 块写入失败 (%s): %v", shortDigest(full), err)
continue
}
if err := a.memory.AddMemoryBlockEdge("sentence", strconv.FormatInt(sentenceID, 10), "block", b.ID, "contains"); err != nil {
log.Printf("[media] 句子→块边建立失败 (%s): %v", shortDigest(full), err)
continue
}
bound++
}
return bound
}
// linkBlocksToDocument 把文档持有的块写入 L3并建立
// document --contains--> block 边。块的 ID 原样保留(迁移而非重建)。
func (a *Agent) linkBlocksToDocument(docID string, blocks []memory.MemoryBlock) int {
if a.memory == nil || docID == "" || len(blocks) == 0 {
return 0
}
if err := a.memory.PutDocumentNode(docID, ""); err != nil {
log.Printf("[media] 写入 L3 文档节点失败 (%s): %v", docID, err)
return 0
}
if err := a.memory.PutMemoryBlocks(blocks); err != nil {
log.Printf("[media] 写入 L3 记忆块失败 (doc %s): %v", docID, err)
return 0
}
bound := 0
for _, b := range blocks {
if err := a.memory.AddMemoryBlockEdge("document", docID, "block", b.ID, "contains"); err != nil {
log.Printf("[media] 文档→块边建立失败 (%s): %v", shortDigest(b.PayloadDigest), err)
continue
}
bound++
}
return bound
}
// commitTriplesWithMedia 提交三元组并把三元组显式携带的媒体变成 L3 一等块。
//
// seed 是调用方已持有的一等块(如 L2 文档的 Blocks用于保持块身份
// 普通对话路径传 nil。blocks 是本次写入 L3 的块数。
func (a *Agent) commitTriplesWithMedia(triples []memory.Triple, sessionID string, turnID int, seed []memory.MemoryBlock) (entities, relations, blocks int, err error) {
g := a.graphMem()
if g == nil {
return 0, 0, 0, fmt.Errorf("graph memory 未启用")
}
// 轻量内核memory == nil子只有图记忆或没有媒体库时只写图记忆。
// 写目标由 a.graph 决定 —— 根落 main子落自己的 temp。
if a.memory == nil || a.mediaStore == nil {
ec, rc, cErr := g.Commit(triples, sessionID, turnID)
return ec, rc, 0, cErr
}
sentenceIDs, ec, rc, err := a.memory.CommitWithMedia(triples, sessionID, turnID)
if err != nil {
return ec, rc, 0, err
}
byDigest := make(map[string]memory.MemoryBlock, len(seed))
for _, b := range seed {
if b.PayloadDigest != "" {
byDigest[b.PayloadDigest] = b
}
}
for _, t := range triples {
if len(t.MediaDigests) == 0 {
continue
}
sid := sentenceIDs[t.SentenceText]
if sid == 0 {
continue
}
blocks += a.attachBlocksToSentence(sid, t.MediaDigests, byDigest)
}
return ec, rc, blocks, nil
}
// RecallBlocksForSentence 反查某条图库句子持有的一等记忆块。
func (a *Agent) RecallBlocksForSentence(sentenceID int64) ([]memory.MemoryBlock, error) {
if a.memory == nil {
return nil, nil
}
return a.memory.BlocksForNode("sentence", strconv.FormatInt(sentenceID, 10))
}
// resolveMediaDigests 把模型给的多为短digest 补全成完整 digest。
//
// 补不上就丢弃那一条并记日志:模型可能凭印象编了个 digest也可能内容已被删除。
func (a *Agent) resolveMediaDigests(digests []string) []string {
if a.mediaStore == nil || len(digests) == 0 {
return nil
}
seen := make(map[string]bool, len(digests))
var out []string
for _, d := range digests {
full, err := a.mediaStore.ResolvePrefix(d)
if err != nil {
log.Printf("[media] 模型给的 digest %s 无法解析: %v", d, err)
continue
}
if seen[full] {
continue
}
seen[full] = true
out = append(out, full)
}
return out
}
// sentenceIDsFromRelations 收集一批关系引用的句子 id去重、去零
//
// 关系行本身不持有媒体,媒体作为一等块以 sentence --contains--> block
// 结构边与句子相连;因此"这次召回涉及哪些媒体"必须经由关系 → 句子这一跳。
func sentenceIDsFromRelations(relations []memory.Relation) []int64 {
if len(relations) == 0 {
return nil
}
seen := make(map[int64]bool, len(relations))
var out []int64
for _, r := range relations {
if r.SentenceID == 0 || seen[r.SentenceID] {
continue
}
seen[r.SentenceID] = true
out = append(out, r.SentenceID)
}
return out
}
// mediaContextForRelations 是 mediaContextForSentences 的关系入口。
func (a *Agent) mediaContextForRelations(relations []memory.Relation) string {
return a.mediaContextForSentences(sentenceIDsFromRelations(relations))
}
// mediaContextForInjectedEntities 为自动注入路径产出媒体说明。
//
// Indexer.BuildContext 刻意不返回关系(只给实体索引以省 token
// 因此这里用命中的实体名再查一次关系,只为拿到 sentence_id。
func (a *Agent) mediaContextForInjectedEntities(injected *memory.InjectedContext) string {
if a.mediaStore == nil || a.memory == nil || injected == nil || len(injected.Entities) == 0 {
return ""
}
names := make([]string, 0, len(injected.Entities))
for _, e := range injected.Entities {
names = append(names, e.Name)
}
res, err := a.memory.Recall(nil, names, 1, "")
if err != nil || res == nil {
return ""
}
return a.mediaContextForRelations(res.Relations)
}
// blockLabelsForDoc 渲染文档持有块的标签MIME + 短 digest供 doc_query 展示。
func (a *Agent) blockLabelsForDoc(d *document.Doc) string {
if a.mediaStore == nil || d == nil || len(d.Blocks) == 0 {
return ""
}
var parts []string
for _, b := range d.Blocks {
it, err := a.mediaStore.Stat(b.PayloadDigest)
if err != nil || it == nil {
continue
}
if line := mediaLabel(it); line != "" {
parts = append(parts, line)
}
}
return strings.Join(parts, "")
}
// mediaContextForSentences 给一组句子附上其持有的一等块标签。
//
// 标签只含 MIME 与短 digest图片按向量检索标签的作用是告诉模型
// "这条记忆当时带着哪份媒体、可用该 digest 取回字节"。
func (a *Agent) mediaContextForSentences(sentenceIDs []int64) string {
if a.mediaStore == nil || a.memory == nil || len(sentenceIDs) == 0 {
return ""
}
var lines []string
for _, sid := range sentenceIDs {
blocks, err := a.memory.BlocksForNode("sentence", strconv.FormatInt(sid, 10))
if err != nil || len(blocks) == 0 {
continue
}
var parts []string
for _, b := range blocks {
it, err := a.mediaStore.Stat(b.PayloadDigest)
if err != nil || it == nil {
continue
}
if line := mediaLabel(it); line != "" {
parts = append(parts, line)
}
}
if len(parts) > 0 {
lines = append(lines, fmt.Sprintf("句子 #%d 关联媒体:%s", sid, strings.Join(parts, "")))
}
}
if len(lines) == 0 {
return ""
}
return strings.Join(lines, "\n")
}