mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 09:28:14 +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 等)。
211 lines
6.7 KiB
Go
211 lines
6.7 KiB
Go
package core
|
||
|
||
import (
|
||
"fmt"
|
||
"log"
|
||
"strings"
|
||
"sync/atomic"
|
||
"time"
|
||
|
||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||
)
|
||
|
||
// blockSeq 保证块 ID 全局唯一(Graph memory_blocks 以 id 为主键)。
|
||
var blockSeq int64
|
||
|
||
func newBlockID() string {
|
||
return fmt.Sprintf("blk_%d_%d", time.Now().UnixNano(), atomic.AddInt64(&blockSeq, 1))
|
||
}
|
||
|
||
// blockModalityOf 把 CAS 媒体大类映射为一等记忆块模态。
|
||
func blockModalityOf(k media.Kind) memory.BlockModality {
|
||
switch k {
|
||
case media.KindImage:
|
||
return memory.BlockImage
|
||
case media.KindVideo:
|
||
return memory.BlockVideo
|
||
case media.KindAudio:
|
||
return memory.BlockAudio
|
||
default:
|
||
return memory.BlockText
|
||
}
|
||
}
|
||
|
||
// blockFromDigest 把一份已入库媒体变成一等记忆块。
|
||
// 块携带 digest/向量/fingerprint;CAS 只提供字节与元数据,不参与生命周期。
|
||
func (a *Agent) blockFromDigest(digest string) (memory.MemoryBlock, bool) {
|
||
if a.mediaStore == nil || digest == "" {
|
||
return memory.MemoryBlock{}, false
|
||
}
|
||
it, err := a.mediaStore.Stat(digest)
|
||
if err != nil || it == nil {
|
||
return memory.MemoryBlock{}, false
|
||
}
|
||
return memory.MemoryBlock{
|
||
ID: newBlockID(),
|
||
Modality: blockModalityOf(it.Kind),
|
||
PayloadDigest: it.Digest,
|
||
MIME: it.MIME,
|
||
Size: it.Size,
|
||
Width: it.Width,
|
||
Height: it.Height,
|
||
Vector: it.Vec,
|
||
Fingerprint: it.VecModel,
|
||
Tool: it.Tool,
|
||
CreatedAt: it.FirstSeen,
|
||
}, true
|
||
}
|
||
|
||
// 媒体记忆接线:把对话里出现的图片/音频落进内容寻址存储(CAS),
|
||
// 并让 L0 的 ContextEvent 直接持有一等记忆块。
|
||
//
|
||
// 媒体进入对话有两条路:用户直接发图(ContentBlock data URL)、插件注入
|
||
// (SetToolBlocks)。两条都在这里收口:从 data URL 取出字节存进 CAS,
|
||
// 用其向量构造一等记忆块挂到当轮 ContextEvent 上;事件被 Prune 时
|
||
// 块随之迁移到 L2 文档。
|
||
//
|
||
// 不再生成任何描述文本,也不再往正文写 media marker:图片只按自己的
|
||
// 统一空间向量被检索,描述式索引是将就方案。
|
||
|
||
// captureBlockMedia 把 blocks 里的 data URL 媒体落进 CAS,返回 digest 列表。
|
||
//
|
||
// 只处理 data URL:http(s) URL 拿不到字节就无法做内容寻址,
|
||
// 而"下载它再存"会把一次对话变成一次网络请求(超时、鉴权、SSRF 全来了),
|
||
// 不在本层解决。
|
||
func (a *Agent) captureBlockMedia(blocks []agentAPI.ContentBlock, tool string) []string {
|
||
if a.mediaStore == nil || len(blocks) == 0 {
|
||
return nil
|
||
}
|
||
|
||
var digests []string
|
||
for _, b := range blocks {
|
||
var url string
|
||
switch {
|
||
case b.ImageURL != nil && b.ImageURL.URL != "":
|
||
url = b.ImageURL.URL
|
||
case b.AudioURL != nil && b.AudioURL.URL != "":
|
||
url = b.AudioURL.URL
|
||
default:
|
||
continue
|
||
}
|
||
|
||
mime, data, ok := media.ParseDataURL(url)
|
||
if !ok {
|
||
continue // http(s) URL 或格式不认,跳过
|
||
}
|
||
|
||
d, err := a.mediaStore.Put(data, media.Item{
|
||
MIME: mime,
|
||
Tool: tool,
|
||
})
|
||
if err != nil {
|
||
// 媒体存不进去不该让对话失败——它是记忆增强,不是对话必需品
|
||
log.Printf("[media] 落盘失败 (tool=%s mime=%s): %v", tool, mime, err)
|
||
continue
|
||
}
|
||
|
||
// 入库即算一次多模态坐标并缓存(多模态空间可用时)。
|
||
// 之后 doc_query / memory_recall / 内部召回直接复用 SetVec 的缓存坐标,
|
||
// 不重复跑 ONNX;模型切换由启动时的 reembedStaleMedia 补算。
|
||
a.embedMediaOnIngest(d, mime, data)
|
||
digests = append(digests, d)
|
||
}
|
||
return digests
|
||
}
|
||
|
||
// embedMediaOnIngest 给刚入库的图片立即计算多模态坐标并缓存。
|
||
// 只在 多模态空间可用且为图像时执行;音频/未配置时静默跳过(保持既有行为)。
|
||
func (a *Agent) embedMediaOnIngest(digest, mime string, data []byte) {
|
||
if a.multimodalSpace == nil || !a.multimodalSpace.Loaded() {
|
||
return
|
||
}
|
||
if !strings.HasPrefix(mime, "image/") {
|
||
return
|
||
}
|
||
vec, err := a.multimodalSpace.EmbedImageDense(data, mime)
|
||
if err != nil {
|
||
log.Printf("[media] 入库嵌入失败 %s: %v", shortDigest(digest), err)
|
||
return
|
||
}
|
||
if err := a.mediaStore.SetVec(digest, vec, a.multimodalSpace.Fingerprint()); err != nil {
|
||
log.Printf("[media] 入库写向量失败 %s: %v", shortDigest(digest), err)
|
||
}
|
||
}
|
||
|
||
// stageMediaDigests 累积本轮捕获的 digest,等 ContextEvent 建好后一起挂上。
|
||
//
|
||
// 为何要缓存而不是当场建块:媒体在 process() 执行期间被捕获,而承载它的
|
||
// ContextEvent 要等 process() 返回后才 Append——此刻还没有 owner_id。
|
||
// 与既有的 a.pendingMedia 同一手法(都在 a.mu 保护下)。
|
||
func (a *Agent) stageMediaDigests(digests ...string) {
|
||
if len(digests) == 0 {
|
||
return
|
||
}
|
||
a.pendingMediaDigests = append(a.pendingMediaDigests, digests...)
|
||
}
|
||
|
||
// drainMediaDigests 取出并清空本轮累积的 digest。
|
||
func (a *Agent) drainMediaDigests() []string {
|
||
if len(a.pendingMediaDigests) == 0 {
|
||
return nil
|
||
}
|
||
out := a.pendingMediaDigests
|
||
a.pendingMediaDigests = nil
|
||
return out
|
||
}
|
||
|
||
// bindEventMedia 把本轮捕获的媒体变成一等记忆块,直接挂到 ContextEvent 上。
|
||
//
|
||
// 块存储在事件自身(随 context.json 持久化),不再写 media_refs:
|
||
// 存活与否由“三层记忆块是否持有这个 digest”决定,不维护引用账本。
|
||
func (a *Agent) bindEventMedia(evt *ContextEvent, digests []string) {
|
||
if a.mediaStore == nil || evt == nil || len(digests) == 0 {
|
||
return
|
||
}
|
||
if evt.ID == "" {
|
||
evt.ID = newEventID()
|
||
}
|
||
for _, d := range digests {
|
||
b, ok := a.blockFromDigest(d)
|
||
if !ok {
|
||
log.Printf("[media] 块构造失败 (%s)", shortDigest(d))
|
||
continue
|
||
}
|
||
evt.Blocks = append(evt.Blocks, b)
|
||
}
|
||
}
|
||
|
||
// mediaLabel 渲染一行媒体标签,供提示词告知"这条记忆带着哪份媒体"。
|
||
//
|
||
// 不再包含任何生成的描述文本:图片只按自己的向量被检索,标签仅提供
|
||
// MIME 与短 digest,让模型知道有这份媒体、可据 digest 取回字节。
|
||
// 查不到返回空串:内容可能已被删除,不该造出一条指向虚无的标签。
|
||
func mediaLabel(it *media.Item) string {
|
||
if it == nil {
|
||
return ""
|
||
}
|
||
label := string(it.Kind)
|
||
if it.MIME != "" {
|
||
label = it.MIME
|
||
}
|
||
return fmt.Sprintf("[%s %s]", label, shortDigest(it.Digest))
|
||
}
|
||
|
||
// newEventID 生成 ContextEvent 的稳定标识。
|
||
//
|
||
// 沿用 document.Store 的 doc_<unixnano> 手法(同一份代码库里保持一致,
|
||
// 也避免为此引入 uuid 依赖)。纳秒精度足够:同一 Agent 的事件由
|
||
// a.mu 串行化 Append,不存在同纳秒两条。
|
||
func newEventID() string {
|
||
return fmt.Sprintf("evt_%d", time.Now().UnixNano())
|
||
}
|
||
|
||
func shortDigest(d string) string {
|
||
if len(d) > 12 {
|
||
return d[:12]
|
||
}
|
||
return d
|
||
}
|