mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-04 00:03:59 +00:00
refactor(memory): 拆除描述式媒体索引,媒体成为一等块并按原生向量融合
背景:此前媒体是靠「生成的描述文本」将就进记忆的——写 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 等)。
This commit is contained in:
@ -10,7 +10,7 @@ type IndexerAPI interface {
|
||||
|
||||
// IndexContext is a neutral snapshot of the injected memory context.
|
||||
type IndexContext struct {
|
||||
Entities []Entity `json:"entities"`
|
||||
Entities []Entity `json:"entities"`
|
||||
Relations []Relation `json:"relations"`
|
||||
Summary string `json:"summary"`
|
||||
TokenEstimate int `json:"token_estimate"`
|
||||
|
||||
@ -7,7 +7,9 @@ type knowledgeImpl struct{ ks *knowledge.Store }
|
||||
func NewKnowledge(ks *knowledge.Store) KnowledgeAPI { return &knowledgeImpl{ks: ks} }
|
||||
|
||||
func (k *knowledgeImpl) Search(query string, topK int) ([]*Knowledge, error) {
|
||||
if k.ks == nil { return nil, nil }
|
||||
if k.ks == nil {
|
||||
return nil, nil
|
||||
}
|
||||
got := k.ks.Search(query, topK)
|
||||
out := make([]*Knowledge, len(got))
|
||||
for i, item := range got {
|
||||
@ -17,22 +19,30 @@ func (k *knowledgeImpl) Search(query string, topK int) ([]*Knowledge, error) {
|
||||
}
|
||||
|
||||
func (k *knowledgeImpl) Add(name, content string) error {
|
||||
if k.ks == nil { return nil }
|
||||
if k.ks == nil {
|
||||
return nil
|
||||
}
|
||||
return k.ks.Add(name, content)
|
||||
}
|
||||
|
||||
func (k *knowledgeImpl) List() ([]string, error) {
|
||||
if k.ks == nil { return nil, nil }
|
||||
if k.ks == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return k.ks.List(), nil
|
||||
}
|
||||
|
||||
func (k *knowledgeImpl) Stats() map[string]interface{} {
|
||||
if k.ks == nil { return map[string]interface{}{} }
|
||||
if k.ks == nil {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
return k.ks.Stats()
|
||||
}
|
||||
|
||||
func (k *knowledgeImpl) Remove(name string) error {
|
||||
if k.ks == nil { return nil }
|
||||
if k.ks == nil {
|
||||
return nil
|
||||
}
|
||||
return k.ks.Remove(name)
|
||||
}
|
||||
|
||||
|
||||
@ -3,9 +3,7 @@ package sdk
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
@ -20,34 +18,15 @@ import (
|
||||
// 这一层原先的缺陷是**静默裁字段**:插件把 Triple / Doc 交进来,包装层只挑
|
||||
// 自己认识的几个字段转成内部结构,其余丢弃且不报错。两侧都中招:
|
||||
// - 图记忆:丢 Confidence/SubjectType/ObjectType/SentenceText,又走 Commit
|
||||
// 而非 CommitWithMedia,于是 sentences 表没有落点,媒体引用无从挂起;
|
||||
// 而非 CommitWithMedia,于是 sentences 表没有落点,媒体块无从挂接;
|
||||
// - 知识库:Query 只回 ID/Title/Content,Insert 只写这三个,读写两个方向
|
||||
// 都把媒体元数据裁掉;Remove 不解引用,媒体永久算「被引用」,GC 收不掉。
|
||||
// 都把媒体元数据裁掉。
|
||||
//
|
||||
// 现在的规则:内部结构有的字段一律透传;媒体一律走标记格式并挂到对应 owner。
|
||||
// 现在的规则:内部结构有的字段一律透传;媒体一律变成一等记忆块。
|
||||
// 媒体存储为 nil 时整条链路静默降级为纯文本行为(媒体是记忆增强,不是必需品)。
|
||||
|
||||
// ---------- 媒体标记(本层内部) ----------
|
||||
//
|
||||
// 标记是媒体在**纯文本记忆**里的表示形式:
|
||||
//
|
||||
// [image/png a1b2c3d4e5f6] 一张紫蓝红三色带图
|
||||
// └ label └ 短 digest └ 描述
|
||||
//
|
||||
// 之所以必须借文本承载:Doc.Content、sentences.text、文本记忆的 Input 全是
|
||||
// 字符串,没有字段能挂结构化数据。描述文本是持久的语义记忆(检索靠它),
|
||||
// digest 是回到字节的钥匙(反查靠它)。
|
||||
//
|
||||
// 格式与内核侧 graphmedia.go 的 mediaSummaryForEvent 一致——两边必须能互读
|
||||
// 对方写下的标记,否则插件写入的媒体在内核归档时挂不上引用,且不报错。
|
||||
|
||||
const sdkShortDigestLen = 12
|
||||
|
||||
// sdkMarkerPattern 拆解一条标记,捕获组依次为 label、短 digest、该行剩余描述。
|
||||
// digest 放宽到 8-64 位以容忍完整 digest 手写的情况;描述取到行尾而非贪婪到底,
|
||||
// 因为一条记忆可能挂多份媒体、各占一行。
|
||||
var sdkMarkerPattern = regexp.MustCompile(`\[([^\[\]\s]+)\s+([0-9a-f]{8,64})\]([^\n]*)`)
|
||||
|
||||
func sdkShortDigest(d string) string {
|
||||
if len(d) > sdkShortDigestLen {
|
||||
return d[:sdkShortDigestLen]
|
||||
@ -55,34 +34,6 @@ func sdkShortDigest(d string) string {
|
||||
return d
|
||||
}
|
||||
|
||||
// sdkMarkerFor 为一份已入库的媒体生成标记行。查不到就返回空串——
|
||||
// 媒体可能已被 GC 清掉,此时不该凭空造出一条指向虚无的标记。
|
||||
func sdkMarkerFor(ms *media.Store, digest string) string {
|
||||
it, err := ms.Stat(digest)
|
||||
if err != nil || it == nil {
|
||||
return ""
|
||||
}
|
||||
label := string(it.Kind)
|
||||
if it.MIME != "" {
|
||||
label = it.MIME
|
||||
}
|
||||
if it.Description == "" {
|
||||
// 「已入库但还没描述」与「压根没有媒体」必须可区分:
|
||||
// 描述由后台循环异步补齐,占位符保证补齐前这份媒体也不会从文本里消失。
|
||||
return fmt.Sprintf("[%s %s] (未描述)", label, sdkShortDigest(digest))
|
||||
}
|
||||
return fmt.Sprintf("[%s %s] %s", label, sdkShortDigest(digest), it.Description)
|
||||
}
|
||||
|
||||
// sdkDigestsIn 返回文本里出现过的短 digest 集合,用于避免重复追加标记。
|
||||
func sdkDigestsIn(s string) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, m := range sdkMarkerPattern.FindAllStringSubmatch(s, -1) {
|
||||
out[m[2]] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sdkBlockSeq 保证块 ID 全局唯一:Graph 的 memory_blocks 以 id 为主键,
|
||||
// 不同文档里序号相同的块会在 L2→L3 迁移时相互覆盖。
|
||||
var sdkBlockSeq int64
|
||||
@ -127,27 +78,8 @@ func sdkBlockForDigest(ms *media.Store, digest string) (memory.MemoryBlock, bool
|
||||
}, true
|
||||
}
|
||||
|
||||
// sdkBlocksFromText 把文本标记里的媒体变成一等块(去重)。
|
||||
func sdkBlocksFromText(ms *media.Store, text string) []memory.MemoryBlock {
|
||||
if ms == nil || text == "" {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var blocks []memory.MemoryBlock
|
||||
for _, m := range sdkMarkerPattern.FindAllStringSubmatch(text, -1) {
|
||||
full, err := ms.ResolvePrefix(m[2])
|
||||
if err != nil || seen[full] {
|
||||
continue
|
||||
}
|
||||
seen[full] = true
|
||||
if b, ok := sdkBlockForDigest(ms, full); ok {
|
||||
blocks = append(blocks, b)
|
||||
}
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
// sdkBlocksFromDigests 为显式 digest 列表构造一等块(去重)。
|
||||
// digest 可以是短前缀,内部会先补全。
|
||||
func sdkBlocksFromDigests(ms *media.Store, digests []string) []memory.MemoryBlock {
|
||||
if ms == nil || len(digests) == 0 {
|
||||
return nil
|
||||
@ -155,11 +87,15 @@ func sdkBlocksFromDigests(ms *media.Store, digests []string) []memory.MemoryBloc
|
||||
seen := map[string]bool{}
|
||||
var blocks []memory.MemoryBlock
|
||||
for _, d := range digests {
|
||||
if d == "" || seen[d] {
|
||||
if d == "" {
|
||||
continue
|
||||
}
|
||||
seen[d] = true
|
||||
if b, ok := sdkBlockForDigest(ms, d); ok {
|
||||
full, err := ms.ResolvePrefix(d)
|
||||
if err != nil || seen[full] {
|
||||
continue
|
||||
}
|
||||
seen[full] = true
|
||||
if b, ok := sdkBlockForDigest(ms, full); ok {
|
||||
blocks = append(blocks, b)
|
||||
}
|
||||
}
|
||||
@ -177,10 +113,9 @@ func sdkPutAttachment(ms *media.Store, a MediaAttachment, tool string) (string,
|
||||
mime = "application/octet-stream"
|
||||
}
|
||||
return ms.Put(a.Data, media.Item{
|
||||
MIME: mime,
|
||||
Tool: tool,
|
||||
OriginPath: a.Name,
|
||||
Description: a.Description,
|
||||
MIME: mime,
|
||||
Tool: tool,
|
||||
OriginPath: a.Name,
|
||||
})
|
||||
}
|
||||
if a.Digest == "" {
|
||||
@ -193,32 +128,6 @@ func sdkPutAttachment(ms *media.Store, a MediaAttachment, tool string) (string,
|
||||
return full, nil
|
||||
}
|
||||
|
||||
// sdkAttachmentsFromText 从文本标记反解出附件元数据(不含字节),
|
||||
// 让插件不必自己写正则去认标记。
|
||||
func sdkAttachmentsFromText(ms *media.Store, s string) []MediaAttachment {
|
||||
if ms == nil || s == "" {
|
||||
return nil
|
||||
}
|
||||
var out []MediaAttachment
|
||||
seen := map[string]bool{}
|
||||
for _, m := range sdkMarkerPattern.FindAllStringSubmatch(s, -1) {
|
||||
full, err := ms.ResolvePrefix(m[2])
|
||||
if err != nil || seen[full] {
|
||||
continue
|
||||
}
|
||||
seen[full] = true
|
||||
att := MediaAttachment{Digest: full, MIME: m[1], Description: strings.TrimSpace(m[3])}
|
||||
if it, err := ms.Stat(full); err == nil && it != nil {
|
||||
att.MIME = it.MIME
|
||||
if it.Description != "" {
|
||||
att.Description = it.Description
|
||||
}
|
||||
}
|
||||
out = append(out, att)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------- 图记忆 ----------
|
||||
|
||||
type graphMemory struct {
|
||||
@ -260,10 +169,8 @@ func (m *graphMemory) Recall(query []string, depth int) ([]Entity, []Relation, e
|
||||
return entities, relations, nil
|
||||
}
|
||||
|
||||
// Commit 把插件的三元组写入图库,并把三元组句子里的媒体变成 L3 一等块。
|
||||
//
|
||||
// 媒体的落点链是 SentenceText → sentences 表 → sentence_id → 块边。
|
||||
// 旧实现丢掉 SentenceText 又走 Commit(不回 sentenceIDs),这条链一步都走不通。
|
||||
// Commit 把插件的三元组写入图库,并把结构化 MediaDigests 变成 L3 一等块。
|
||||
// 媒体通过 sentence --contains--> block 结构边挂接,不读写任何正文 marker。
|
||||
func (m *graphMemory) Commit(triples []Triple) error {
|
||||
if m.db == nil {
|
||||
return nil
|
||||
@ -278,9 +185,7 @@ func (m *graphMemory) Commit(triples []Triple) error {
|
||||
SubjectType: t.SubjectType,
|
||||
ObjectType: t.ObjectType,
|
||||
SentenceText: t.SentenceText,
|
||||
}
|
||||
if len(t.MediaDigests) > 0 {
|
||||
mt.SentenceText = m.sentenceWithMedia(mt.SentenceText, t.MediaDigests)
|
||||
MediaDigests: t.MediaDigests,
|
||||
}
|
||||
ts = append(ts, mt)
|
||||
}
|
||||
@ -289,61 +194,28 @@ func (m *graphMemory) Commit(triples []Triple) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.bindSentences(sentenceIDs)
|
||||
m.bindSentences(sentenceIDs, ts)
|
||||
return nil
|
||||
}
|
||||
|
||||
// sentenceWithMedia 保证句子文本里带有这些 digest 的媒体标记。
|
||||
// bindSentences 把每个三元组显式携带的媒体变成 L3 一等记忆块,
|
||||
// 并以 sentence --contains--> block 结构边关联。
|
||||
//
|
||||
// 让插件填 MediaDigests 就够,不必知道标记格式——否则格式写错的后果是
|
||||
// 引用静默挂不上。已出现过的 digest 不重复追加:插件可能既手写了标记又填了
|
||||
// MediaDigests,重复标记会让同一份媒体产生两条一样的句子引用。
|
||||
func (m *graphMemory) sentenceWithMedia(sentence string, digests []string) string {
|
||||
present := sdkDigestsIn(sentence)
|
||||
var add []string
|
||||
for _, d := range digests {
|
||||
if d == "" || present[sdkShortDigest(d)] {
|
||||
continue
|
||||
}
|
||||
if m.ms == nil {
|
||||
// 没有媒体存储时也把 digest 留在文本里:拿不到描述,
|
||||
// 但将来存储可用时这条记忆仍能反查回字节。
|
||||
add = append(add, fmt.Sprintf("[media %s] (未描述)", sdkShortDigest(d)))
|
||||
present[sdkShortDigest(d)] = true
|
||||
continue
|
||||
}
|
||||
full, err := m.ms.ResolvePrefix(d)
|
||||
if err != nil {
|
||||
log.Printf("[sdk media] 插件 %s 提交的 digest %s 无法解析: %v", m.plugin, d, err)
|
||||
continue
|
||||
}
|
||||
if line := sdkMarkerFor(m.ms, full); line != "" {
|
||||
add = append(add, line)
|
||||
present[sdkShortDigest(full)] = true
|
||||
}
|
||||
}
|
||||
if len(add) == 0 {
|
||||
return sentence
|
||||
}
|
||||
if sentence == "" {
|
||||
return strings.Join(add, "\n")
|
||||
}
|
||||
return sentence + "\n" + strings.Join(add, "\n")
|
||||
}
|
||||
|
||||
// bindSentences 把每条句子里引用的媒体变成 L3 的一等记忆块,
|
||||
// 并建立 sentence --contains--> block 的结构边。
|
||||
// 不再写 media_refs:块本身就是图的一部分,不需要 owner 账本保活。
|
||||
func (m *graphMemory) bindSentences(sentenceIDs map[string]int64) {
|
||||
// 不再往句子文本里写 marker、也不再从文本反解 digest:归属由结构化字段直接给出。
|
||||
func (m *graphMemory) bindSentences(sentenceIDs map[string]int64, triples []memory.Triple) {
|
||||
if m.ms == nil || m.db == nil || len(sentenceIDs) == 0 {
|
||||
return
|
||||
}
|
||||
bound := 0
|
||||
for text, sid := range sentenceIDs {
|
||||
for _, t := range triples {
|
||||
if len(t.MediaDigests) == 0 {
|
||||
continue
|
||||
}
|
||||
sid := sentenceIDs[t.SentenceText]
|
||||
if sid == 0 {
|
||||
continue
|
||||
}
|
||||
for _, b := range sdkBlocksFromText(m.ms, text) {
|
||||
for _, b := range sdkBlocksFromDigests(m.ms, t.MediaDigests) {
|
||||
if err := m.db.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil {
|
||||
log.Printf("[sdk media] 插件 %s 写入 L3 记忆块失败: %v", m.plugin, err)
|
||||
continue
|
||||
@ -403,37 +275,21 @@ func NewTextMemoryWithMedia(plugin string, tm *text.Memory, ms *media.Store) Tex
|
||||
return &textMemoryImpl{tm: tm, ms: ms, plugin: plugin}
|
||||
}
|
||||
|
||||
// Append 追加一条文本事件;带附件时把媒体标记并进正文。
|
||||
// Append 追加一条文本事件。
|
||||
//
|
||||
// 文本记忆是追加写 JSONL,没有结构化块存储,所以媒体在这一层
|
||||
// 只能以标记形式存在。这不是妥协——描述文本才是持久的语义记忆,blob 只是缓存。
|
||||
// 文本记忆是追加写 JSONL 的原始日志,只有字符串字段,没有块容器;
|
||||
// 因此附件在这里无法结构化存下。不假装用文本标记承载媒体——
|
||||
// 需要保存媒体请用文档记忆或图记忆(它们持有一等记忆块)。
|
||||
func (m *textMemoryImpl) Append(evt TextEvent) error {
|
||||
if m.tm == nil {
|
||||
return nil
|
||||
}
|
||||
content := evt.Content
|
||||
if len(evt.Attachments) > 0 && m.ms != nil {
|
||||
var lines []string
|
||||
for _, a := range evt.Attachments {
|
||||
d, err := sdkPutAttachment(m.ms, a, "plugin_text:"+m.plugin)
|
||||
if err != nil {
|
||||
log.Printf("[sdk media] 插件 %s 文本附件入库失败: %v", m.plugin, err)
|
||||
continue
|
||||
}
|
||||
if line := sdkMarkerFor(m.ms, d); line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
if len(lines) > 0 {
|
||||
if content == "" {
|
||||
content = strings.Join(lines, "\n")
|
||||
} else {
|
||||
content += "\n" + strings.Join(lines, "\n")
|
||||
}
|
||||
}
|
||||
if len(evt.Attachments) > 0 {
|
||||
log.Printf("[sdk media] 插件 %s 向文本记忆追加了 %d 份附件,已忽略:"+
|
||||
"文本层是字符串日志,不具备块存储;请改用文档/图记忆保存媒体", m.plugin, len(evt.Attachments))
|
||||
}
|
||||
return m.tm.Append(text.Event{
|
||||
Timestamp: evt.Timestamp, Source: evt.Role, Input: content, AgentID: evt.Channel,
|
||||
Timestamp: evt.Timestamp, Source: evt.Role, Input: evt.Content, AgentID: evt.Channel,
|
||||
})
|
||||
}
|
||||
|
||||
@ -449,7 +305,6 @@ func (m *textMemoryImpl) RecentEvents(n int) ([]TextEvent, error) {
|
||||
for i, e := range got {
|
||||
out[i] = TextEvent{
|
||||
Role: e.Source, Content: e.Input, Timestamp: e.Timestamp, Channel: e.AgentID,
|
||||
Attachments: sdkAttachmentsFromText(m.ms, e.Input),
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
@ -477,12 +332,8 @@ func NewDocMemoryWithMedia(plugin string, ds *doc.Store, ms *media.Store) DocMem
|
||||
return &docMemoryImpl{ds: ds, ms: ms, plugin: plugin}
|
||||
}
|
||||
|
||||
// Query 检索文档,并补齐媒体元数据。
|
||||
//
|
||||
// 旧实现只回 ID/Title/Content,插件即便拿到一篇带媒体的文档也看不出这里有
|
||||
// 几份媒体、分别是什么。现在同时给出完整 digest 列表与 mime+描述,
|
||||
// 但**不回字节**:一次检索可能命中几十份媒体,全塞回去会把跨进程消息撑爆,
|
||||
// 需要字节时按 digest 单取。
|
||||
// Query 检索文档,并从文档持有的一等块补齐媒体元数据。
|
||||
// 只返回 digest/MIME,不返回字节或生成式描述;需要字节时按 digest 单取。
|
||||
func (m *docMemoryImpl) Query(text string, topK int) []*Doc {
|
||||
if m.ds == nil {
|
||||
return nil
|
||||
@ -498,30 +349,22 @@ func (m *docMemoryImpl) Query(text string, topK int) []*Doc {
|
||||
|
||||
// fillMedia 填充文档的媒体字段。
|
||||
//
|
||||
// 优先读一等记忆块(文档直接持有),为空时退回解析正文标记——
|
||||
// 历史文档与经旧版插件写入的文档只有标记、没有块。
|
||||
// 唯一的来源是文档直接持有的一等记忆块:媒体不靠正文标记、
|
||||
// 也不靠任何生成的描述文本。CAS 只提供 MIME 等元数据。
|
||||
func (m *docMemoryImpl) fillMedia(out *Doc, d *doc.Doc) {
|
||||
if m.ms == nil {
|
||||
if m.ms == nil || d == nil {
|
||||
return
|
||||
}
|
||||
if d != nil && len(d.Blocks) > 0 {
|
||||
for _, b := range d.Blocks {
|
||||
if b.PayloadDigest == "" {
|
||||
continue
|
||||
}
|
||||
out.MediaDigests = append(out.MediaDigests, b.PayloadDigest)
|
||||
att := MediaAttachment{Digest: b.PayloadDigest, MIME: b.MIME, Description: ""}
|
||||
if it, err := m.ms.Stat(b.PayloadDigest); err == nil && it != nil {
|
||||
att.MIME = it.MIME
|
||||
att.Description = it.Description
|
||||
}
|
||||
out.Attachments = append(out.Attachments, att)
|
||||
for _, b := range d.Blocks {
|
||||
if b.PayloadDigest == "" {
|
||||
continue
|
||||
}
|
||||
return
|
||||
}
|
||||
out.Attachments = sdkAttachmentsFromText(m.ms, out.Content)
|
||||
for _, a := range out.Attachments {
|
||||
out.MediaDigests = append(out.MediaDigests, a.Digest)
|
||||
out.MediaDigests = append(out.MediaDigests, b.PayloadDigest)
|
||||
att := MediaAttachment{Digest: b.PayloadDigest, MIME: b.MIME}
|
||||
if it, err := m.ms.Stat(b.PayloadDigest); err == nil && it != nil {
|
||||
att.MIME = it.MIME
|
||||
}
|
||||
out.Attachments = append(out.Attachments, att)
|
||||
}
|
||||
}
|
||||
|
||||
@ -543,15 +386,14 @@ func appendBlocks(existing []memory.MemoryBlock, add []memory.MemoryBlock) []mem
|
||||
return existing
|
||||
}
|
||||
|
||||
// Insert 写入文档。正文里已有的媒体标记会被挂成文档级引用,
|
||||
// 避免插件写进来的媒体在下一次 GC 时被当作无主内容清掉。
|
||||
// Insert 写入文档。
|
||||
func (m *docMemoryImpl) Insert(d *Doc) error { return m.InsertWithMedia(d, nil) }
|
||||
|
||||
// InsertWithMedia 写入文档并关联媒体。
|
||||
//
|
||||
// 标记由内核补进 Content——插件不必知道标记格式,也就不会因为格式写错导致
|
||||
// 引用挂不上。补标记必须在 ds.Insert 之前完成:向量索引用 Summary+Content
|
||||
// 计算,标记进不去正文就检索不到这份媒体。
|
||||
// 媒体直接成为文档持有的一等记忆块:落进 CAS 拿到 digest,
|
||||
// 再变成块挂到文档上。不往正文写 marker——文档向量会融合这些块的
|
||||
// 媒体向量(同一统一空间),图片按自己的向量被召回。
|
||||
func (m *docMemoryImpl) InsertWithMedia(d *Doc, attachments []MediaAttachment) error {
|
||||
if m.ds == nil || d == nil {
|
||||
return nil
|
||||
@ -561,29 +403,26 @@ func (m *docMemoryImpl) InsertWithMedia(d *Doc, attachments []MediaAttachment) e
|
||||
target.Source = "plugin:" + m.plugin
|
||||
}
|
||||
|
||||
digests := m.storeAttachments(attachments, &target.Content)
|
||||
digests := m.storeAttachments(attachments)
|
||||
|
||||
// 一等记忆块:文档直接持有块本身,CAS 只提供字节与向量。
|
||||
// 不再写 media_refs——块随文档一同存活或被删除,无需 owner 账本。
|
||||
target.Blocks = appendBlocks(target.Blocks,
|
||||
append(sdkBlocksFromDigests(m.ms, digests), sdkBlocksFromText(m.ms, target.Content)...))
|
||||
target.Blocks = appendBlocks(target.Blocks, sdkBlocksFromDigests(m.ms, digests))
|
||||
|
||||
if err := m.ds.Insert(target); err != nil {
|
||||
return err
|
||||
}
|
||||
// 回填给调用方:ID 是新建时内核生成的,Content 含内核补的标记。
|
||||
// 回填给调用方:ID 是新建时内核生成的。
|
||||
d.ID = target.ID
|
||||
d.Content = target.Content
|
||||
return nil
|
||||
}
|
||||
|
||||
// storeAttachments 把附件落库并把标记追加进 content,返回全部完整 digest。
|
||||
func (m *docMemoryImpl) storeAttachments(atts []MediaAttachment, content *string) []string {
|
||||
// storeAttachments 把附件落库,返回全部完整 digest。
|
||||
func (m *docMemoryImpl) storeAttachments(atts []MediaAttachment) []string {
|
||||
if m.ms == nil || len(atts) == 0 {
|
||||
return nil
|
||||
}
|
||||
present := sdkDigestsIn(*content)
|
||||
var digests, lines []string
|
||||
seen := map[string]bool{}
|
||||
var digests []string
|
||||
for _, a := range atts {
|
||||
full, err := sdkPutAttachment(m.ms, a, "plugin_doc:"+m.plugin)
|
||||
if err != nil {
|
||||
@ -591,21 +430,11 @@ func (m *docMemoryImpl) storeAttachments(atts []MediaAttachment, content *string
|
||||
log.Printf("[sdk media] 插件 %s 文档附件入库失败: %v", m.plugin, err)
|
||||
continue
|
||||
}
|
||||
if seen[full] {
|
||||
continue
|
||||
}
|
||||
seen[full] = true
|
||||
digests = append(digests, full)
|
||||
if present[sdkShortDigest(full)] {
|
||||
continue // 插件自己写了标记,不重复追加
|
||||
}
|
||||
present[sdkShortDigest(full)] = true
|
||||
if line := sdkMarkerFor(m.ms, full); line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
if len(lines) > 0 {
|
||||
if *content == "" {
|
||||
*content = strings.Join(lines, "\n")
|
||||
} else {
|
||||
*content += "\n" + strings.Join(lines, "\n")
|
||||
}
|
||||
}
|
||||
return digests
|
||||
}
|
||||
|
||||
@ -51,10 +51,10 @@ func newTestStores(t *testing.T) (*memory.GraphDB, *doc.Store, *text.Memory, *me
|
||||
return g, ds, tm, ms
|
||||
}
|
||||
|
||||
// putDescribed 存一份带描述的媒体,返回完整 digest。
|
||||
func putDescribed(t *testing.T, ms *media.Store, payload, desc string) string {
|
||||
// putMedia 存一份媒体,返回完整 digest。
|
||||
func putMedia(t *testing.T, ms *media.Store, payload string) string {
|
||||
t.Helper()
|
||||
d, err := ms.Put([]byte(payload), media.Item{MIME: "image/png", Description: desc})
|
||||
d, err := ms.Put([]byte(payload), media.Item{MIME: "image/png"})
|
||||
if err != nil {
|
||||
t.Fatalf("media.Put: %v", err)
|
||||
}
|
||||
@ -111,16 +111,17 @@ func TestGraphCommit_CarriesAllFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 插件只给 digest,标记与句子由内核合成;引用必须挂到 graph_sentence owner 上。
|
||||
// 插件只给 digest,句子由内核合成;块必须挂到该句子(sentence --contains--> block)。
|
||||
func TestGraphCommit_BindsMediaFromDigests(t *testing.T) {
|
||||
g, _, _, ms := newTestStores(t)
|
||||
digest := putDescribed(t, ms, "png-bytes", "一张紫蓝红三色带图")
|
||||
digest := putMedia(t, ms, "png-bytes")
|
||||
|
||||
m := NewGraphMemoryWithMedia("tester", g, ms)
|
||||
if err := m.Commit([]Triple{{
|
||||
Subject: "配色图",
|
||||
Relation: "包含",
|
||||
Object: "三色带",
|
||||
SentenceText: "这张图是紫蓝红三色带。",
|
||||
MediaDigests: []string{digest[:12]}, // 插件手里通常只有短 digest
|
||||
}}); err != nil {
|
||||
t.Fatalf("Commit: %v", err)
|
||||
@ -131,13 +132,13 @@ func TestGraphCommit_BindsMediaFromDigests(t *testing.T) {
|
||||
t.Fatalf("Recall: %v", err)
|
||||
}
|
||||
if len(res.Relations) == 0 || res.Relations[0].SentenceID == 0 {
|
||||
t.Fatal("没有句子落点 —— 媒体引用无从挂起")
|
||||
t.Fatal("没有句子落点 —— 媒体块无从挂接")
|
||||
}
|
||||
sid := res.Relations[0].SentenceID
|
||||
|
||||
// 描述必须进句子:描述文本才是持久语义记忆,检索靠它。
|
||||
if !strings.Contains(res.Relations[0].SentenceText, "三色带图") {
|
||||
t.Errorf("句子里没有媒体描述: %q", res.Relations[0].SentenceText)
|
||||
// 句子文本保持原样:不再往正文里贴媒体标记。
|
||||
if strings.Contains(res.Relations[0].SentenceText, digest[:12]) {
|
||||
t.Errorf("句子文本不该被媒体标记污染: %q", res.Relations[0].SentenceText)
|
||||
}
|
||||
|
||||
blocks, err := g.BlocksForNode("sentence", strconv.FormatInt(sid, 10))
|
||||
@ -149,19 +150,18 @@ func TestGraphCommit_BindsMediaFromDigests(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 插件自己按格式写了标记又同时填了 MediaDigests,不能产生两条重复引用/两份标记。
|
||||
func TestGraphCommit_NoDuplicateMarker(t *testing.T) {
|
||||
// 同一个 digest 在同一三元组里重复出现(短/完整混写)时只能建一个块。
|
||||
func TestGraphCommit_DedupesRepeatedDigest(t *testing.T) {
|
||||
g, _, _, ms := newTestStores(t)
|
||||
digest := putDescribed(t, ms, "dup-bytes", "重复标记测试图")
|
||||
short := digest[:12]
|
||||
digest := putMedia(t, ms, "dup-bytes")
|
||||
|
||||
m := NewGraphMemoryWithMedia("tester", g, ms)
|
||||
if err := m.Commit([]Triple{{
|
||||
Subject: "重复图",
|
||||
Relation: "标记",
|
||||
Object: "一次",
|
||||
SentenceText: "看这个 [image/png " + short + "] 重复标记测试图",
|
||||
MediaDigests: []string{short},
|
||||
SentenceText: "同一张图说了两遍。",
|
||||
MediaDigests: []string{digest[:12], digest},
|
||||
}}); err != nil {
|
||||
t.Fatalf("Commit: %v", err)
|
||||
}
|
||||
@ -170,12 +170,16 @@ func TestGraphCommit_NoDuplicateMarker(t *testing.T) {
|
||||
if len(res.Relations) == 0 {
|
||||
t.Fatal("召回不到关系")
|
||||
}
|
||||
if n := strings.Count(res.Relations[0].SentenceText, short); n != 1 {
|
||||
t.Errorf("句子里出现 %d 次 digest,期望 1 次: %q", n, res.Relations[0].SentenceText)
|
||||
blocks, err := g.BlocksForNode("sentence", strconv.FormatInt(res.Relations[0].SentenceID, 10))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(blocks) != 1 {
|
||||
t.Fatalf("同一 digest 应只产生 1 个块,实际 %d: %+v", len(blocks), blocks)
|
||||
}
|
||||
}
|
||||
|
||||
// mediaStore 为 nil 时仍要能提交(媒体是增强,不是必需品),digest 留在文本里备查。
|
||||
// mediaStore 为 nil 时仍要能提交(媒体是增强,不是必需品);只是不会建块。
|
||||
func TestGraphCommit_NilMediaStoreDegrades(t *testing.T) {
|
||||
g, _, _, _ := newTestStores(t)
|
||||
m := NewGraphMemory(g)
|
||||
@ -184,6 +188,7 @@ func TestGraphCommit_NilMediaStoreDegrades(t *testing.T) {
|
||||
Subject: "无存储",
|
||||
Relation: "仍可",
|
||||
Object: "提交",
|
||||
SentenceText: "无媒体存储时的句子。",
|
||||
MediaDigests: []string{"aabbccddeeff"},
|
||||
}}); err != nil {
|
||||
t.Fatalf("Commit 在无媒体存储时不该失败: %v", err)
|
||||
@ -193,8 +198,12 @@ func TestGraphCommit_NilMediaStoreDegrades(t *testing.T) {
|
||||
if len(res.Relations) == 0 {
|
||||
t.Fatal("召回不到关系")
|
||||
}
|
||||
if !strings.Contains(res.Relations[0].SentenceText, "aabbccddeeff") {
|
||||
t.Errorf("digest 应留在句子里以备将来反查: %q", res.Relations[0].SentenceText)
|
||||
blocks, err := g.BlocksForNode("sentence", strconv.FormatInt(res.Relations[0].SentenceID, 10))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(blocks) != 0 {
|
||||
t.Fatalf("无媒体存储时不该建块,实际 %+v", blocks)
|
||||
}
|
||||
}
|
||||
|
||||
@ -225,17 +234,16 @@ func TestGraphRecall_CarriesConfidence(t *testing.T) {
|
||||
|
||||
// ---------- 文档记忆(知识库) ----------
|
||||
|
||||
// 附件带 Data → 落进 CAS、标记补进正文、引用挂到文档 owner。
|
||||
// 附件带 Data → 落进 CAS 并成为文档直接持有的一等块。
|
||||
func TestDocInsertWithMedia_StoresAndBinds(t *testing.T) {
|
||||
_, ds, _, ms := newTestStores(t)
|
||||
dm := NewDocMemoryWithMedia("tester", ds, ms)
|
||||
|
||||
d := &Doc{Title: "带图笔记", Content: "这是正文。"}
|
||||
err := dm.InsertWithMedia(d, []MediaAttachment{{
|
||||
MIME: "image/png",
|
||||
Data: []byte("attachment-bytes"),
|
||||
Name: "chart.png",
|
||||
Description: "一张柱状图",
|
||||
MIME: "image/png",
|
||||
Data: []byte("attachment-bytes"),
|
||||
Name: "chart.png",
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("InsertWithMedia: %v", err)
|
||||
@ -244,10 +252,9 @@ func TestDocInsertWithMedia_StoresAndBinds(t *testing.T) {
|
||||
t.Fatal("ID 未回填 —— 插件拿不到刚写入文档的 id")
|
||||
}
|
||||
|
||||
// 标记必须进正文:向量索引用 Summary+Content 计算,
|
||||
// 标记进不去正文就永远检索不到这份媒体。
|
||||
if !strings.Contains(d.Content, "柱状图") {
|
||||
t.Errorf("正文里没有媒体标记: %q", d.Content)
|
||||
// 正文保持原样:不再往 Content 里拼任何媒体标记。
|
||||
if strings.Contains(d.Content, "image/png") {
|
||||
t.Errorf("正文不该被媒体标记污染: %q", d.Content)
|
||||
}
|
||||
|
||||
blocks := ds.Blocks()
|
||||
@ -264,7 +271,7 @@ func TestDocInsertWithMedia_StoresAndBinds(t *testing.T) {
|
||||
// 只给 Digest 的附件是「引用已有内容」,不该报错也不该重复落盘。
|
||||
func TestDocInsertWithMedia_DigestOnlyReference(t *testing.T) {
|
||||
_, ds, _, ms := newTestStores(t)
|
||||
digest := putDescribed(t, ms, "existing", "已有的图")
|
||||
digest := putMedia(t, ms, "existing")
|
||||
before := ms.Stats()["count"]
|
||||
|
||||
dm := NewDocMemoryWithMedia("tester", ds, ms)
|
||||
@ -290,7 +297,7 @@ func TestDocQuery_FillsMediaMetadataWithoutBytes(t *testing.T) {
|
||||
|
||||
d := &Doc{Title: "紫蓝红三色带", Content: "配色说明"}
|
||||
if err := dm.InsertWithMedia(d, []MediaAttachment{{
|
||||
MIME: "image/png", Data: []byte("query-bytes"), Description: "三色带图",
|
||||
MIME: "image/png", Data: []byte("query-bytes"),
|
||||
}}); err != nil {
|
||||
t.Fatalf("InsertWithMedia: %v", err)
|
||||
}
|
||||
@ -315,38 +322,14 @@ func TestDocQuery_FillsMediaMetadataWithoutBytes(t *testing.T) {
|
||||
t.Fatalf("Attachments = %v,期望 1 条", hit.Attachments)
|
||||
}
|
||||
att := hit.Attachments[0]
|
||||
if att.MIME != "image/png" || att.Description != "三色带图" {
|
||||
t.Errorf("附件元数据 = %+v,期望 mime=image/png desc=三色带图", att)
|
||||
if att.MIME != "image/png" || att.Digest != hit.MediaDigests[0] {
|
||||
t.Errorf("附件元数据 = %+v,期望 mime=image/png 且 digest 与 MediaDigests 一致", att)
|
||||
}
|
||||
if len(att.Data) != 0 {
|
||||
t.Errorf("Attachments 不该带字节(%d 字节)—— 需要时按 digest 单取", len(att.Data))
|
||||
}
|
||||
}
|
||||
|
||||
// 历史文档只有标记、没有 media_refs(旧版插件写入的)。
|
||||
// 此时要能从正文标记反解出附件,否则那些文档的媒体对插件永远不可见。
|
||||
func TestDocQuery_FallsBackToMarkers(t *testing.T) {
|
||||
_, ds, _, ms := newTestStores(t)
|
||||
digest := putDescribed(t, ms, "legacy", "历史图片")
|
||||
|
||||
// 直接写底层 store,绕过 SDK 的绑定逻辑,模拟历史数据。
|
||||
if err := ds.Insert(&doc.Doc{
|
||||
Summary: "历史文档",
|
||||
Content: "旧正文 [image/png " + digest[:12] + "] 历史图片",
|
||||
}); err != nil {
|
||||
t.Fatalf("Insert: %v", err)
|
||||
}
|
||||
|
||||
dm := NewDocMemoryWithMedia("tester", ds, ms)
|
||||
got := dm.Query("历史文档 旧正文", 3)
|
||||
if len(got) == 0 {
|
||||
t.Fatal("检索不到历史文档")
|
||||
}
|
||||
if len(got[0].MediaDigests) != 1 || got[0].MediaDigests[0] != digest {
|
||||
t.Errorf("MediaDigests = %v,期望从标记反解出 [%s]", got[0].MediaDigests, digest)
|
||||
}
|
||||
}
|
||||
|
||||
// 文档被删除时它持有的一等记忆块随之消失,媒体不再被任何记忆块持有。
|
||||
func TestDocRemove_DropsBlocks(t *testing.T) {
|
||||
_, ds, _, ms := newTestStores(t)
|
||||
@ -354,7 +337,7 @@ func TestDocRemove_DropsBlocks(t *testing.T) {
|
||||
|
||||
d := &Doc{Title: "待删除", Content: "正文"}
|
||||
if err := dm.InsertWithMedia(d, []MediaAttachment{{
|
||||
MIME: "image/png", Data: []byte("to-be-freed"), Description: "会被释放的图",
|
||||
MIME: "image/png", Data: []byte("to-be-freed"),
|
||||
}}); err != nil {
|
||||
t.Fatalf("InsertWithMedia: %v", err)
|
||||
}
|
||||
@ -395,9 +378,10 @@ func TestDocMemory_NilMediaStoreDegrades(t *testing.T) {
|
||||
|
||||
// ---------- 文本记忆 ----------
|
||||
|
||||
// 文本记忆是追加写 JSONL,没有稳定 owner_id 可挂引用,
|
||||
// 媒体只能以标记形式留在正文里;读回时要能反解成结构化附件。
|
||||
func TestTextMemory_AttachmentRoundTrip(t *testing.T) {
|
||||
// 文本记忆是追加写 JSONL 的字符串日志,没有块容器。
|
||||
// 它不会愄造文本标记来承载媒体:附件被明确忽略并记录日志,
|
||||
// 需要保存媒体请用文档/图记忆。
|
||||
func TestTextMemory_AttachmentsIgnoredNotFaked(t *testing.T) {
|
||||
_, _, tm, ms := newTestStores(t)
|
||||
m := NewTextMemoryWithMedia("tester", tm, ms)
|
||||
|
||||
@ -405,7 +389,7 @@ func TestTextMemory_AttachmentRoundTrip(t *testing.T) {
|
||||
Role: "user",
|
||||
Content: "看这张图",
|
||||
Attachments: []MediaAttachment{{
|
||||
MIME: "image/png", Data: []byte("text-mem-bytes"), Description: "文本记忆里的图",
|
||||
MIME: "image/png", Data: []byte("text-mem-bytes"),
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("Append: %v", err)
|
||||
@ -419,13 +403,10 @@ func TestTextMemory_AttachmentRoundTrip(t *testing.T) {
|
||||
t.Fatal("读不到刚追加的事件")
|
||||
}
|
||||
last := got[len(got)-1]
|
||||
if !strings.Contains(last.Content, "文本记忆里的图") {
|
||||
t.Errorf("正文里没有媒体标记: %q", last.Content)
|
||||
if last.Content != "看这张图" {
|
||||
t.Errorf("正文应保持原样,实际 %q", last.Content)
|
||||
}
|
||||
if len(last.Attachments) != 1 {
|
||||
t.Fatalf("Attachments = %+v,期望 1 条(标记应能反解)", last.Attachments)
|
||||
}
|
||||
if last.Attachments[0].Description != "文本记忆里的图" {
|
||||
t.Errorf("附件描述 = %q", last.Attachments[0].Description)
|
||||
if len(last.Attachments) != 0 {
|
||||
t.Errorf("文本层不该凭空造出附件(没有块存储可挂): %+v", last.Attachments)
|
||||
}
|
||||
}
|
||||
|
||||
@ -63,7 +63,7 @@ func (v *VirtualInstance) initLocked() error {
|
||||
}
|
||||
v.Knowledge = NewKnowledge(ks)
|
||||
|
||||
ds := doc.NewStore(filepath.Join(v.dir, "documents"))
|
||||
ds := doc.NewStore(filepath.Join(v.dir, "documents"), memory.TokenizeWords)
|
||||
if err := ds.Start(); err != nil {
|
||||
return fmt.Errorf("virtual doc store: %w", err)
|
||||
}
|
||||
@ -105,4 +105,4 @@ func (v *VirtualInstance) Cleanup() {
|
||||
func (v *VirtualInstance) Reset(scope string) (*VirtualInstance, error) {
|
||||
v.Cleanup()
|
||||
return NewVirtualInstance(scope)
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,10 +16,10 @@ type SupervisorAPI interface {
|
||||
|
||||
// AgentStatus is a neutral snapshot of a supervised agent.
|
||||
type AgentStatus struct {
|
||||
ID types.AgentID `json:"id"`
|
||||
State types.AgentState `json:"state"`
|
||||
Health types.HealthStatus `json:"health"`
|
||||
Uptime time.Duration `json:"uptime,omitempty"`
|
||||
Network types.NetworkCheckResult `json:"network,omitempty"`
|
||||
TrackerStats map[string]interface{} `json:"tracker_stats,omitempty"`
|
||||
ID types.AgentID `json:"id"`
|
||||
State types.AgentState `json:"state"`
|
||||
Health types.HealthStatus `json:"health"`
|
||||
Uptime time.Duration `json:"uptime,omitempty"`
|
||||
Network types.NetworkCheckResult `json:"network,omitempty"`
|
||||
TrackerStats map[string]interface{} `json:"tracker_stats,omitempty"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user