mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 17:38:10 +00:00
背景:此前媒体是靠「生成的描述文本」将就进记忆的——写 marker 进正文、 再由正则反解成 media_refs 与图库里的 type=Media 实体。这条链路有三个 致命缺陷:描述由异步模型生成(未生成前媒体等于不存在)、语义检索实质上 只搜描述文字、图库里的「媒体节点」是描述文本的投影而不是媒体本身。 本提交把这条链路整体拆除,媒体改为按自己的原生向量参与记忆: 一、描述链彻底删除(无残留、无兼容分支) - media.Item 去掉 Description/DescribedBy 与对应列; - 删除 Store.Describe / Store.Search / Store.Pending; - 删除 Agent.mediaDescribeLoop / describePendingMedia 与配置项 core.memory.media.describe_on_ingest; - SDK 侧 MediaAttachment 去掉 Description(见 SDK 仓独立提交)。 二、marker 机制删除,媒体归属改为结构化块边 - 删除 mediaMarkerLine/parseMediaMarkers/mediaEntityName/mediaTriplesFromText/ extractMediaDigests/sentenceWithMediaMarkers/docMediaContext; - memory.Triple 新增 MediaDigests 结构化字段;句子文本保持原样, 不再被 marker 污染; - 块以 sentence --contains--> block / document --contains--> block 结构边 挂到承载节点(新增 documents 表与 document 节点种类); - 模型未给原句时用「主谓宾。」拼一句自然语言作落点,不造 marker 文本。 三、旧数据迁移(幂等) - 新增 GraphDB.MigrateLegacyMediaEntities:把 type=Media 的旧实体按短 digest 还原成原生块、挂回原句子、删除旧实体与描述关系;Agent 启动时执行; - CleanupOrphanedSentences 同时看关系引用与块边,避免把只靠块存活的句子 连同块边一起删掉。 四、向量融合:媒体按图本身被召回 - 新增 vector.FuseVectors(逐维求和 + L2 归一化); - Doc.DenseVec = 文本向量 ⊕ 文档块的媒体向量(同 fingerprint 才融合), 新增 Doc.DenseFP,指纹变化触发重算; - ContextEvent.DenseVec 同理融合事件块;事件新增 DenseFP,Prune 只在 同一统一空间内比稠密余弦; - 跨模态视觉路只召回「仍被某层记忆块持有」的媒体,CAS 全库字节不再 直接充当记忆检索结果。 五、同时纳入本分支既有的嵌入基础改造(此前工作区未提交,缺它 HEAD 不可构建) - internal/tfidf 懒回退包、千问三段式多模态 ONNX 空间的 Go 侧 (qwen/embedder.go、image.go、model_input.go)、CLIP 移除、 sdk.NewStore 分词器签名与调用点、embed 侧车 systemd 单元。 验证:go build ./... 、go vet ./...(含 -tags medialive)均通过; 在 HEAD 的独立 worktree 上重放本次暂存集后 go test -short ./internal/... 全部通过(端口冲突类用例在隔离环境中亦通过)。未提交工作区中与本改造 无关的改动(HarmonyOS、waiter、devicebridge、plan.md 等)。
267 lines
8.7 KiB
Go
267 lines
8.7 KiB
Go
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--> block(L2 文档归档进 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 允许复用已持有块的 ID(L2→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) {
|
||
if a.memory == nil {
|
||
return 0, 0, 0, fmt.Errorf("graph memory 未启用")
|
||
}
|
||
if a.mediaStore == nil {
|
||
ec, rc, cErr := a.memory.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")
|
||
}
|