refactor(memory)!: 块 union 化,彻底淘汰 CAS 第一阶段(生产代码)

块自带负载(MemoryBlock.Data),删 PayloadDigest;检索纯向量,媒体随块
同生灭。生产代码全绿;CAS 专属测试将在下一提交随判据重写。

- memory: Data BLOB + migration9;blockColumns 不含 data(召回只比向量);
  MediaBlockID 内容派生 ID;BlockByIDPrefix/BlockData 按需加载
- sdk: memory_impl 去 media.Store;MemoryBlockView PayloadDigest→HasData
- core: mediaref/graphmedia/crossmodal/medialoop 全部改块语义;
  captureBlockMedia 直接产块;resolveMediaRefs 按块 ID 解析
- knowledge: 媒体字节随条目目录自带(File+Data),删 mediaGet/mediaPut
- webui: graphBlockView/pulse payload_digest→has_data
- bootstrap/registry: 去 MediaStore 接线(SetMediaStore 空实现,待⑥删包)
This commit is contained in:
JianFeeeee
2026-10-12 10:13:36 +08:00
parent b15994d964
commit 86572d134d
30 changed files with 806 additions and 864 deletions

View File

@ -584,7 +584,7 @@ func newMainAgent(cfg *types.Config, cfgReg *internalConfig.ConfigRegistry, prov
Knowledge: ks,
SocialStore: socialStore,
TextMemory: textMem,
MediaStore: mediaStore,
// MediaStore 已随 CAS 淘汰:块自带负载(见 plan.md)。
Personality: personality,
// 人格落库面:首启门禁(任何通道都问一次)与 persona_set 工具用。
// 与 WebUI 向导共用 internal/config 的同一份落库逻辑。

View File

@ -14,7 +14,6 @@ import (
"github.com/JianFeeeee/HomeAgent/internal/knowledge"
"github.com/JianFeeeee/HomeAgent/internal/memory"
"github.com/JianFeeeee/HomeAgent/internal/memory/document"
"github.com/JianFeeeee/HomeAgent/internal/memory/media"
"github.com/JianFeeeee/HomeAgent/internal/memory/social"
"github.com/JianFeeeee/HomeAgent/internal/memory/text"
"github.com/JianFeeeee/HomeAgent/internal/memory/vector"
@ -80,10 +79,7 @@ type Agent struct {
// 文本记忆(原始对话日志)
textMem *text.Memory
// 媒体存储(内容寻址):对话里出现的图片/音频按 sha256 落盘去重。
// 它是记忆块的内容存储,不单独做生命周期管理:块的创建/迁移/删除
// 由记忆系统本身决定。为 nil 时全部媒体接线静默跳过。
mediaStore *media.Store
// 媒体存储句柄已随 CAS 淘汰(块自带负载,见 plan.md)。
// 人格设定(内容来自启动时载入的人格文件/配置项)
personality *agentPkg.Personality
@ -185,12 +181,12 @@ type Agent struct {
// 当前轮次的非文本媒体数据(图片/音频),供 describe_image 等工具访问
pendingMedia map[string]interface{}
// pendingMediaDigests 累积本轮已落进 CAS 的媒体 digest。
// pendingMediaBlocks 累积本轮已捕获的媒体块。
//
// 需要缓存而不是当场挂到事件上:媒体在 process() 执行期间被捕获,
// 而承载它的 ContextEvent 要等 process() 返回后才 Append——此刻还没有 owner_id。
// 而承载它的 ContextEvent 要等 process() 返回后才 Append——此刻还没有 owner。
// 由 schedulerLoop goroutine 独占读写。
pendingMediaDigests []string
pendingMediaBlocks []memory.MemoryBlock
// 当前输入是否为工具提醒/中断(以 system 角色注入,避免被当成用户消息)
interruptInput bool
@ -266,7 +262,7 @@ type AgentConfig struct {
Knowledge *knowledge.Store
SocialStore *social.SocialStore
TextMemory *text.Memory
MediaStore *media.Store
// 媒体存储句柄已随 CAS 淘汰(块自带负载,见 plan.md)。
MultimodalSpace vector.MultimodalEmbedder
// EmbeddingProvider / EmbeddingError 是向量空间的配置身份与打开失败原因,
// 供 healthcheck_kernel 状态报告区分「未配置 / 打开失败 / 已启用」。
@ -383,15 +379,12 @@ func New(cfg AgentConfig) *Agent {
// 稀疏两路(词向量 + TF-IDF)**保持启用**且仍是主召回路径:多模态
// 空间未配置时知识库行为与此前逐字一致(退化为 0.5/0.5 两路融合)。
if cfg.Knowledge != nil {
// 顺序要紧:先接线(含 MediaStore),再重建。ReindexDense 会
// 尝试从 .dense.json 缓存恢复,恢复不了才重算,最后把结果落盘。
// 若先重建后接线,首次启动算出的向量会被丢掉而不落盘。
// 顺序要紧:先接线再重建。ReindexDense 会尝试从 .dense.json 缓存恢复,
// 恢复不了才重算,最后把结果落盘。若先重建后接线,首次启动算出的
// 向量会被丢掉而不落盘。
cfg.Knowledge.SetDenseSpace(cfg.MultimodalSpace)
cfg.Knowledge.SetMediaGetter(cfg.MediaStore)
// 媒体**写入**器:ImportDir 复制图片/音视频时用。
// 不接的话 include_media=true 会静默失效(媒体全被跳过、
// 只在导入结果里留一行"媒体库不可用"),模型无从察觉。
cfg.Knowledge.SetMediaPutter(cfg.MediaStore)
// 媒体字节随知识条目自带(CAS 淘汰后):denseFor 直接读条目目录里的
// 媒体文件,无需 mediaGet/mediaPut 接线。
built, skipped := cfg.Knowledge.ReindexDense()
if built > 0 || skipped > 0 {
log.Printf("[knowledge] 多模态稠密索引: 新建 %d 跳过 %d(其余命中缓存)", built, skipped)
@ -421,7 +414,8 @@ func New(cfg AgentConfig) *Agent {
knowledge: cfg.Knowledge,
social: cfg.SocialStore,
textMem: cfg.TextMemory,
mediaStore: cfg.MediaStore,
// mediaStore 已随 CAS 淘汰:块自带负载。知识层的媒体也已自带字节
// (见 knowledge 的媒体侧车),不再需要外部 getter/putter 接线。
personality: cfg.Personality,
personaStore: cfg.PersonaStore,
allowedOutputs: cfg.AllowedOutputs,

View File

@ -6,28 +6,30 @@ import (
"sort"
"strings"
"github.com/JianFeeeee/HomeAgent/internal/memory"
"github.com/JianFeeeee/HomeAgent/internal/memory/document"
"github.com/JianFeeeee/HomeAgent/internal/memory/media"
)
// CrossModalHit 是跨模态检索融合后的一条候选。
//
// 统一的检索单元是记忆块而非 CAS 全库:媒体在 L0/L2/L3 都由层容器持有,
// 只有仍被某层记忆块持有的媒体才可召回。Doc 是 L2 文档;Media 是该块携带的
// 原生媒体坐标。两路分数尺度不同,融合前各自归一化,见 fuseCrossModal。
// 统一的检索单元是**记忆块**(文本块与媒体块同构)。文本路命中文档;
// 视觉路命中媒体块——两者都是块,向量在同一统一空间里可直接比较。
//
// ★ 2026-10-11:不再有「CAS 全库」这个检索面。视觉路直接走
// memory.RecallBlocks(读块自己的向量),因此不存在「召回已无处可归的
// 内容」这回事——能召回的必然是被某层持有的块。
type CrossModalHit struct {
Doc *document.Doc // 文本路命中的文档;视觉路命中时为 nil
Media *media.Item // 视觉路命中的媒体;文本路命中时也可能带关联媒体
MediaScore float64 // 视觉路原始 cosine(无则 0)
DocScore float64 // 文本路原始 cosine(无则 0)
Fused float64 // 归一化加权融合分,供最终排序
// 该媒体同时被两路命中(文本路经文档关联、视觉路直接命中)时,
// DoubleHit=true —— 双信号确认,应排在只被一路命中的候选之前。
Doc *document.Doc // 文本路命中的文档;视觉路命中时为 nil
Media *memory.MemoryBlock // 视觉路命中的媒体块;文本路命中时也可能带关联块
MediaScore float64 // 视觉路原始 cosine(无则 0)
DocScore float64 // 文本路原始 cosine(无则 0)
Fused float64 // 归一化加权融合分,供最终排序
// 该媒体同时被两路命中时 DoubleHit=true —— 双信号确认,应排在只被
// 一路命中的候选之前。
DoubleHit bool
}
// CrossModalFusionConfig 控制文本路与视觉路的融合行为。
// 默认各路权重 0.5,双命中加权 0.15;不同模型/场景可按实测调整。
type CrossModalFusionConfig struct {
WeightText float64 // 文本路融合权重(默认 0.5)
WeightVisual float64 // 视觉路融合权重(默认 0.5)
@ -54,9 +56,7 @@ func (c CrossModalFusionConfig) visualWeight() float64 {
}
return c.WeightVisual
}
func (c CrossModalFusionConfig) doubleHitBonus() float64 {
return c.DoubleHitBonus
}
func (c CrossModalFusionConfig) doubleHitBonus() float64 { return c.DoubleHitBonus }
func (c CrossModalFusionConfig) minMaxEps() float64 {
if c.MinMaxEps <= 0 {
return defaultFusionConfig.MinMaxEps
@ -69,17 +69,16 @@ func (c CrossModalFusionConfig) minMaxEps() float64 {
// 策略(两路并行,召回真正最相似的):
// 1. 文本路:query 整段文本编码后查文档层(Doc.DenseVec 已融合其块的媒体向量),
// 命中文档若持有媒体块,直接带上该块。
// 2. 视觉路:query 经多模态模型文本编码 → 与媒体块向量比余弦
// (QueryMediaScored),覆盖文本向量没写到的视觉内容。
// 2. 视觉路:query 经多模态模型文本编码 → 与**媒体块自己的向量**比余弦
// (memory.RecallBlocks),覆盖文本向量没写到的视觉内容。
// 用的就是块向量——没有第二套向量、没有第二个检索面。
// 3. 融合:两条路候选各自 min-max 归一化到 [0,1],加权求和后降序,取 topK。
// 同一媒体被两路同时命中视为双信号确认,额外加权。
//
// 多模态空间未配置时视觉路为空,退化为纯文本路(等价旧 docStore.Query)。
// 多模态空间未配置时视觉路为空,退化为纯文本路。
func (a *Agent) retrieveCrossModal(query string, topK int, cfg CrossModalFusionConfig) []CrossModalHit {
if topK <= 0 {
topK = 5
}
// 融合前各取 2× 余量,保证融合排序后 topK 仍有足够候选。
per := topK * 2
if per < 8 {
per = 8
@ -90,34 +89,38 @@ func (a *Agent) retrieveCrossModal(query string, topK int, cfg CrossModalFusionC
if a.docStore != nil {
for _, dh := range a.docStore.QueryScored(query, per) {
hit := CrossModalHit{Doc: dh.Doc, DocScore: dh.Score}
// 命中文档若持有一等记忆块,把首个媒体块一并带上。
if a.mediaStore != nil && len(dh.Doc.Blocks) > 0 {
if it, err := a.mediaStore.Stat(dh.Doc.Blocks[0].PayloadDigest); err == nil {
hit.Media = it
// 命中文档若持有一等记忆块,把首个媒体块一并带上(用于双命中标记)。
for i := range dh.Doc.Blocks {
if dh.Doc.Blocks[i].Modality != memory.BlockText {
b := dh.Doc.Blocks[i]
hit.Media = &b
break
}
}
textHits = append(textHits, hit)
}
}
// ---- 视觉路(多模态文本编码 → 当前记忆层持有的媒体块)----
// ---- 视觉路(多模态文本编码 → 媒体块自己的向量)----
var visualHits []CrossModalHit
if a.multimodalSpace != nil && a.multimodalSpace.Loaded() && a.mediaStore != nil {
if a.multimodalSpace != nil && a.multimodalSpace.Loaded() && a.memory != nil {
qv, err := a.multimodalSpace.VectorizeDense(query)
if err != nil {
log.Printf("[crossmodal] 多模态文本编码失败: %v", err)
} else if mh, err := a.mediaStore.QueryMediaScored(qv, a.multimodalSpace.Fingerprint(), per); err != nil {
log.Printf("[crossmodal] 媒体记忆检索失败: %v", err)
} else if hits, err := a.memory.RecallBlocks(memory.BlockRecallQuery{
Vector: qv,
Fingerprint: a.multimodalSpace.Fingerprint(),
TopK: per,
}); err != nil {
log.Printf("[crossmodal] 媒体块检索失败: %v", err)
} else {
// 只有仍被某层记忆块持有的媒体才可召回:CAS 是全库字节存储,
// 直接拿它的检索结果会把已无处可归的内容也从记忆里翻出来。
held := a.heldMediaDigests()
for _, h := range mh {
if h.Item == nil || !held[h.Item.Digest] {
continue
for _, h := range hits {
if h.Block.Modality == memory.BlockText {
continue // 文本块由文本路负责,避免同一内容两路重复
}
b := h.Block
visualHits = append(visualHits, CrossModalHit{
Media: h.Item, MediaScore: h.Score,
Media: &b, MediaScore: h.Score,
})
}
}
@ -127,14 +130,6 @@ func (a *Agent) retrieveCrossModal(query string, topK int, cfg CrossModalFusionC
}
// fuseCrossModal 把文本路与视觉路候选按各自归一化分融合排序。
//
// 归一化模板:两路分数尺度不可直接相加,先各自在路内 min-max 到 [0,1]:
//
// norm(x) = (x - min) / (max - min),max==min 时置 1
//
// 再加权求和:fused = wText·normText + wVisual·normVisual。同一媒体两路都命中
// (经文档关联 + 视觉直接)时 DoubleHit,在加权分上再加双信号确认分。
// 权重通过 CrossModalFusionConfig 按场景配置,不同模型/版本可按实测调整。
func fuseCrossModal(textHits, visualHits []CrossModalHit, topK int, cfg CrossModalFusionConfig) []CrossModalHit {
norm := func(hits []CrossModalHit, pick func(CrossModalHit) float64) []float64 {
out := make([]float64, len(hits))
@ -171,12 +166,11 @@ func fuseCrossModal(textHits, visualHits []CrossModalHit, topK int, cfg CrossMod
return "doc:" + h.Doc.ID
}
if h.Media != nil {
return "media:" + h.Media.Digest
return "media:" + h.Media.ID
}
return ""
}
// 先并入视觉路(视觉媒体是独立实体)
for i, h := range visualHits {
k := key(h)
if k == "" {
@ -187,14 +181,12 @@ func fuseCrossModal(textHits, visualHits []CrossModalHit, topK int, cfg CrossMod
byKey[k] = &clone
keys = append(keys, k)
}
// 再并入文本路:命中的文档是独立实体;带媒体的文档若其媒体 digest
// 已在视觉路(双命中),合并到同一候选并标记 DoubleHit。
for i, h := range textHits {
if h.Doc == nil {
continue
}
if h.Media != nil {
if ex, ok := byKey["media:"+h.Media.Digest]; ok {
if ex, ok := byKey["media:"+h.Media.ID]; ok {
ex.DoubleHit = true
ex.Doc = h.Doc
ex.Fused += cfg.textWeight()*textN[i] + cfg.doubleHitBonus()
@ -233,7 +225,7 @@ func fuseCrossModal(textHits, visualHits []CrossModalHit, topK int, cfg CrossMod
}
// crossModalMarkdown 把融合候选渲染成注入上下文的文本。
// 文档行给出摘要;媒体行只给 MIME + 短 digest(不再有生成的描述)。
// 文档行给出摘要;媒体行只给 MIME + 短块 ID(不再有生成的描述)。
func (a *Agent) crossModalMarkdown(hits []CrossModalHit) string {
if len(hits) == 0 {
return ""
@ -257,7 +249,7 @@ func (a *Agent) crossModalMarkdown(hits []CrossModalHit) string {
}
}
if h.Media != nil {
if line := mediaLabel(h.Media); line != "" {
if line := blockLabel(h.Media); line != "" {
parts = append(parts, line)
}
}

View File

@ -17,6 +17,9 @@ import (
//
// 这里不再有任何 marker 文本、正则反解或"描述文本当索引"的路径:
// 媒体只按自己的统一空间向量被检索,图库/文档只记录它的结构归属。
//
// ★ 2026-10-11:块自带负载(CAS 已淘汰)。因此本文件不再有
// "按 digest 去 CAS 取块"这一步——块就是它自己,按块 ID 直接引用。
// migrateLegacyGraphMedia 把 marker 反解出来的旧媒体实体迁移成原生一等块。
//
@ -24,16 +27,20 @@ import (
// 靠生成的描述文本当索引。迁移后它变成真正的记忆块,以
// sentence --contains--> block 结构边挂回原句子,旧实体与描述关系删除。
// 迁移幂等(实体处理完即删除),因此在每个 Agent 启动时跑一次是安全的。
//
// ★ 旧实体的名字里带的是**内容短 digest**;块自带负载后没有 CAS 可按
// digest 取回字节,所以旧媒体实体的字节若已无从取得(CAS 已删),
// 该实体只能如实删除而不建块——不造一个没有内容的空块。
func (a *Agent) migrateLegacyGraphMedia() {
if a.memory == nil || a.mediaStore == nil {
if a.memory == 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
// 先看该内容是否已经有对应的块(内容派生 ID,前缀可查)。
if b, ok, err := a.memory.BlockByIDPrefix("blk_media_" + short); err == nil && ok {
return b, true
}
return a.blockFromDigest(full)
return memory.MemoryBlock{}, false
})
if err != nil {
log.Printf("[media] 旧媒体实体迁移失败(下轮重试): %v", err)
@ -44,26 +51,28 @@ func (a *Agent) migrateLegacyGraphMedia() {
}
}
// attachBlocksToSentenceBlock 把一组 digest 变成 L3 一等块并挂到**原句块**上。
// seed 允许复用已持有块的 ID(L2→L3 迁移保持块身份不变)。
// ★ sentenceBlockID 是**原句块 ID**(原为 sentences 表行号)。
// attachBlocksToSentenceBlock 把一组媒体块引用挂到**原句块**上。
//
// 句子的承载者已从 sentences 表迁移到 blk_src_<sha256(text[:12])>,
// 媒体边因此改挂「原句块 --contains--> 媒体块」。
func (a *Agent) attachBlocksToSentenceBlock(sentenceBlockID string, digests []string, seed map[string]memory.MemoryBlock, scene string) int {
if a.mediaStore == nil || a.memory == nil || sentenceBlockID == "" {
// ★ 参数从 digests 改为 refs(块 ID 或其前缀):CAS 淘汰后,媒体由块自身
// 承载,引用单位就是块 ID。seed 允许复用调用方已持有的块(L2→L3 迁移
// 保持块身份不变)。
// ★ sentenceBlockID 是**原句块 ID**(原为 sentences 表行号)。
func (a *Agent) attachBlocksToSentenceBlock(sentenceBlockID string, refs []string, seed map[string]memory.MemoryBlock, scene string) int {
if a.memory == nil || sentenceBlockID == "" {
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]
for _, ref := range refs {
b, ok := seed[ref]
if !ok {
if b, ok = a.blockFromDigest(full); !ok {
var err error
b, ok, err = a.memory.BlockByIDPrefix(ref)
if err != nil {
log.Printf("[media] 查块 %s 失败: %v", ref, err)
continue
}
if !ok {
log.Printf("[media] 块引用 %s 无唯一对应块,跳过", ref)
continue
}
}
@ -73,11 +82,11 @@ func (a *Agent) attachBlocksToSentenceBlock(sentenceBlockID string, digests []st
b.Scene = scene
}
if err := a.memory.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil {
log.Printf("[media] L3 块写入失败 (%s): %v", shortDigest(full), err)
log.Printf("[media] L3 块写入失败 (%s): %v", shortID(b.ID), err)
continue
}
if err := a.memory.AddMemoryBlockEdge("block", sentenceBlockID, "block", b.ID, "contains"); err != nil {
log.Printf("[media] 句子→块边建立失败 (%s): %v", shortDigest(full), err)
log.Printf("[media] 句子→块边建立失败 (%s): %v", shortID(b.ID), err)
continue
}
bound++
@ -116,7 +125,7 @@ func (a *Agent) linkBlocksToDocument(docID string, blocks []memory.MemoryBlock,
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)
log.Printf("[media] 文档→块边建立失败 (%s): %v", shortID(b.ID), err)
continue
}
bound++
@ -133,9 +142,9 @@ func (a *Agent) commitTriplesWithMedia(triples []memory.Triple, sessionID string
if g == nil {
return 0, 0, 0, fmt.Errorf("graph memory 未启用")
}
// 轻量内核(memory == nil,子只有图记忆)或没有媒体库时:只写图记忆。
// 轻量内核(memory == nil,子只有图记忆):只写图记忆。
// 写目标由 a.graph 决定 —— 根落 main,子落自己的 temp。
if a.memory == nil || a.mediaStore == nil {
if a.memory == nil {
ec, rc, cErr := g.Commit(triples, sessionID, turnID)
return ec, rc, 0, cErr
}
@ -143,10 +152,10 @@ func (a *Agent) commitTriplesWithMedia(triples []memory.Triple, sessionID string
if err != nil {
return ec, rc, 0, err
}
byDigest := make(map[string]memory.MemoryBlock, len(seed))
byRef := make(map[string]memory.MemoryBlock, len(seed))
for _, b := range seed {
if b.PayloadDigest != "" {
byDigest[b.PayloadDigest] = b
if b.ID != "" {
byRef[b.ID] = b
}
}
for _, t := range triples {
@ -157,12 +166,11 @@ func (a *Agent) commitTriplesWithMedia(triples []memory.Triple, sessionID string
if sid == "" {
continue
}
blocks += a.attachBlocksToSentenceBlock(sid, t.MediaDigests, byDigest, t.Scene)
blocks += a.attachBlocksToSentenceBlock(sid, t.MediaDigests, byRef, t.Scene)
}
return ec, rc, blocks, nil
}
// RecallBlocksForSentence 反查某条图库句子持有的一等记忆块。
// RecallBlocksForSentence 按**原句块 ID**取回它承载的媒体块。
//
// ★ 签名从 int64 改为 string:挂载点已从「sentences 表行号」
@ -175,45 +183,39 @@ func (a *Agent) RecallBlocksForSentence(sentenceBlockID string) ([]memory.Memory
return a.memory.BlocksForNode("block", sentenceBlockID)
}
// resolveMediaDigests 把模型给的(多为短)digest 补全成完整 digest。
// resolveMediaRefs 把模型给的(多为短)块引用补全成唯一的块 ID。
//
// 补不上就丢弃那一条并记日志:模型可能凭印象编了个 digest,也可能内容已被删除。
func (a *Agent) resolveMediaDigests(digests []string) []string {
if a.mediaStore == nil || len(digests) == 0 {
// 补不上或歧义就丢弃那一条并记日志:模型可能凭印象编了个 ID,
// 也可能内容已被删除。宁可不解析也不猜(猜错 = 挂错媒体)。
func (a *Agent) resolveMediaRefs(refs []string) []string {
if a.memory == nil || len(refs) == 0 {
return nil
}
seen := make(map[string]bool, len(digests))
seen := make(map[string]bool, len(refs))
var out []string
for _, d := range digests {
full, err := a.mediaStore.ResolvePrefix(d)
if err != nil {
log.Printf("[media] 模型给的 digest %s 无法解析: %v", d, err)
for _, r := range refs {
b, ok, err := a.memory.BlockByIDPrefix(r)
if err != nil || !ok {
log.Printf("[media] 模型给的块引用 %s 无法唯一解析", r)
continue
}
if seen[full] {
if seen[b.ID] {
continue
}
seen[full] = true
out = append(out, full)
seen[b.ID] = true
out = append(out, b.ID)
}
return out
}
// sentenceIDsFromRelations 收集一批关系引用的句子 id(去重、去零)。
// sentenceIDsFromRelations 从关系里取出其原句的**块 ID**(去重、去零)。
//
// 关系行本身不持有媒体,媒体作为一等块以 sentence --contains--> block
// 结构边与句子相连;因此"这次召回涉及哪些媒体"必须经由关系 → 句子这一跳。
// sentenceIDsFromRelations 从关系里取出其原句的**块 ID**。
//
// ★ 从 Relation.SentenceID(旧 sentences 表行号)改为按 SentenceText 现算
//
// blk_src_<hash>。
//
// 原因:Commit 块化后原句由块承载,sentences 表退场,
// 行号不再是稳定的挂载点;而 SentenceText 一直在 Relation 上。
//
// 这条链(mediaContextForSentences / mediaContextForRelations)
// 完全靠这个 ID 找回媒体块,所以改错了 = 媒体上下文整体失效。
// blk_src_<hash>。原因:Commit 块化后原句由块承载,sentences 表退场,
// 行号不再是稳定的挂载点;而 SentenceText 一直在 Relation 上。
func sentenceIDsFromRelations(relations []memory.Relation) []string {
if len(relations) == 0 {
return nil
@ -242,9 +244,7 @@ func (a *Agent) mediaContextForRelations(relations []memory.Relation) string {
// formatRecallRelations 渲染 memory_recall 的关系行,超 max 条截断。
//
// 带上原始句子(截断到 60 字):三元组只是「A 关系 B」,脱离原句往往看不出
// 语气、条件与指代——`sentence_text` 的存在意义就是「日后从图谱回到原文」,
// 而 Recall 已经把句子 JOIN 出来了。此前只回显实体名与关系类型,导致模型
// 填了 sentence_text 也永远拿不回来,这个能力形同虚设。
// 语气、条件与指代——`sentence_text` 的存在意义就是「日后从图谱回到原文」。
func formatRecallRelations(relations []memory.Relation, max int) []string {
var out []string
for i, r := range relations {
@ -264,9 +264,9 @@ func formatRecallRelations(relations []memory.Relation, max int) []string {
// 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 {
if a.memory == nil || injected == nil || len(injected.Entities) == 0 {
return ""
}
names := make([]string, 0, len(injected.Entities))
@ -280,18 +280,14 @@ func (a *Agent) mediaContextForInjectedEntities(injected *memory.InjectedContext
return a.mediaContextForRelations(res.Relations)
}
// blockLabelsForDoc 渲染文档持有块的标签(MIME + 短 digest),供 doc_query 展示。
// blockLabelsForDoc 渲染文档持有块的标签(MIME + 短块 ID),供 doc_query 展示。
func (a *Agent) blockLabelsForDoc(d *document.Doc) string {
if a.mediaStore == nil || d == nil || len(d.Blocks) == 0 {
if 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 != "" {
for i := range d.Blocks {
if line := blockLabel(&d.Blocks[i]); line != "" {
parts = append(parts, line)
}
}
@ -300,10 +296,10 @@ func (a *Agent) blockLabelsForDoc(d *document.Doc) string {
// mediaContextForSentences 给一组句子附上其持有的一等块标签。
//
// 标签只含 MIME 与短 digest:图片按向量检索,标签的作用是告诉模型
// "这条记忆当时带着哪份媒体、可用该 digest 取回字节"。
// 标签只含 MIME 与短块 ID:图片按向量检索,标签的作用是告诉模型
// "这条记忆当时带着哪份媒体、可用该块 ID 取回"。
func (a *Agent) mediaContextForSentences(sentenceBlockIDs []string) string {
if a.mediaStore == nil || a.memory == nil || len(sentenceBlockIDs) == 0 {
if a.memory == nil || len(sentenceBlockIDs) == 0 {
return ""
}
var lines []string
@ -313,12 +309,8 @@ func (a *Agent) mediaContextForSentences(sentenceBlockIDs []string) string {
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 != "" {
for i := range blocks {
if line := blockLabel(&blocks[i]); line != "" {
parts = append(parts, line)
}
}
@ -331,16 +323,3 @@ func (a *Agent) mediaContextForSentences(sentenceBlockIDs []string) string {
}
return strings.Join(lines, "\n")
}
// shortID 把块 ID 压成可读短码(blk_src_<24hex> → 前 8 位 hex)。
// 媒体上下文是给模型看的,整串 hex 只会占 token 且不可读。
func shortID(blockID string) string {
const pfx = "blk_src_"
if strings.HasPrefix(blockID, pfx) {
return blockID[len(pfx):]
}
if len(blockID) > 12 {
return blockID[:12]
}
return blockID
}

View File

@ -1,193 +1,115 @@
package core
import (
"errors"
"log"
"sync"
"sync/atomic"
"github.com/JianFeeeee/HomeAgent/internal/memory"
"github.com/JianFeeeee/HomeAgent/internal/memory/vector"
)
// 媒体与记忆块的生命周期辅助。
//
// 媒体不单独做生命周期管理(没有 GC、没有引用计数):blob 是记忆块的内容,
// 块的创建/迁移/删除由记忆系统本身决定,块被永久删除时内容随之删除。
// 图片不靠文本描述索引——它只按自己的统一空间向量被检索。
// heldMediaDigests 汇总三层记忆当前持有的媒体 digest 集合。
// ★ 2026-10-11:块自带负载(media.Store/CAS 已淘汰)。
//
// CAS 是全库字节存储,它的检索结果不等于「记忆里的媒体」——
// 召回前用它把已无处可归的内容过滤掉。
func (a *Agent) heldMediaDigests() map[string]bool {
held := map[string]bool{}
collect := func(blocks []memory.MemoryBlock) {
for _, b := range blocks {
if b.PayloadDigest != "" {
held[b.PayloadDigest] = true
}
}
}
if a.context != nil {
collect(a.context.Blocks())
}
if a.docStore != nil {
collect(a.docStore.Blocks())
}
if a.memory != nil {
if blocks, err := a.memory.MemoryBlocks(); err == nil {
collect(blocks)
}
}
return held
}
// payloadHeld 报告某个 digest 是否仍被三层记忆中的一等块持有。
// 这是删除前的一次活查询(不是持久化账本):同一份字节可能同时被多个块共享。
func (a *Agent) payloadHeld(digest string) bool {
if digest == "" {
return false
}
if a.context != nil {
for _, b := range a.context.Blocks() {
if b.PayloadDigest == digest {
return true
}
}
}
if a.docStore != nil {
for _, b := range a.docStore.Blocks() {
if b.PayloadDigest == digest {
return true
}
}
}
if a.memory != nil {
if blocks, err := a.memory.MemoryBlocks(); err == nil {
for _, b := range blocks {
if b.PayloadDigest == digest {
return true
}
}
}
}
return false
}
// forgetPayloads 在记忆块被永久删除后删除它们的内容。
// 此前这里有 heldMediaDigests / payloadHeld / forgetPayloads 三个函数,
// 它们存在的**唯一理由**是补 CAS 的漏:CAS 是全库字节存储,它的检索结果
// 不等于「记忆里的媒体」,所以要靠一套 digest 记账把已无处可归的内容滤掉。
// 块自带负载后这一整类问题从根上消失——字节就在块里,块没了字节就没了,
// 不需要账本、不需要 GC、不需要「删前先问谁还持有」。
//
// 与文本块一致:删除块即删除内容。只有确认没有任何存活块仍共享该 digest
// 时才删字节(同一张图可能被多个块引用)。
func (a *Agent) forgetPayloads(digests []string) {
if a.mediaStore == nil {
return
}
for _, d := range digests {
if d == "" || a.payloadHeld(d) {
continue
}
if err := a.mediaStore.Delete(d); err != nil {
log.Printf("[media] 删除内容失败 %s: %v", shortDigest(d), err)
}
}
}
// 因此那三个函数被整体删除,只保留向量迁移(它仍需要,因为换模型/换维度
// 会让历史块的向量落在另一个空间里)。
// reembedStaleMedia 在启动时批量迁移历史媒体向量到当前向量空间。
// reembedStaleMedia 在启动时批量把历史媒体块的向量迁移到当前向量空间。
//
// 触发场景(任一变化都会导致旧向量无法参与查询):
// - 切换模型(模型 A→模型 B,fp 变了)
// - 切换向量维度(ONNX→HTTP dim 512→1024)
// - 首次部署嵌入服务(历史无向量的媒体补算)
// - 嵌入服务离线后重新上线(失败条目 vec_model 仍为空)
// - 嵌入服务离线后重新上线(失败条目 fingerprint 仍为空)
//
// 并发策略:启动时用 worker pool 并行迁移,避免上千张图片串行耗时过长。
// 并发数在 ONNX 内嵌路径下不超 CPU 核心数(避免 ONNX 并发限流),
// 外部 API 路径下不超 8(避免打爆外部服务)。
func (a *Agent) reembedStaleMedia() {
if a.multimodalSpace == nil || a.mediaStore == nil {
if a.multimodalSpace == nil || !a.multimodalSpace.Loaded() || a.memory == nil {
return
}
fp := a.multimodalSpace.Fingerprint()
digests, err := a.mediaStore.StaleVecDigestsAll(fp)
stale, err := a.memory.StaleBlockVectors(fp)
if err != nil {
log.Printf("[media] 查询需重算向量的媒体失败: %v", err)
log.Printf("[media] 查询需重算向量的块失败: %v", err)
return
}
if len(digests) == 0 {
log.Printf("[media] 无需迁移向量(所有媒体已与当前空间对齐 fp=%s)", shortFP(fp))
// 只迁图像:多模态空间当前覆盖 text+image,音频/视频不在其内
// (不拿别的模态的向量顶替——那是静默错配)。
var targets []memory.MemoryBlock
for _, b := range stale {
if b.Modality == memory.BlockImage && len(b.Data) > 0 {
targets = append(targets, b)
}
}
if len(targets) == 0 {
log.Printf("[media] 无需迁移向量(所有图像块已与当前空间对齐 fp=%s)", shortFP(fp))
return
}
// 并发度:ONNX 内嵌不超过 4,外部 API 不超过 8(由配置或实际环境动态定)
workers := 4
if fp[:min(4, len(fp))] == "http:" {
if len(fp) >= 5 && fp[:5] == "http:" {
workers = 8
}
log.Printf("[media] 启动向量迁移: %d 条 → 新空间 fp=%s dim=%d workers=%d",
len(digests), shortFP(fp), a.multimodalSpace.Dim(), workers)
log.Printf("[media] 启动向量迁移: %d 个图像块 → fp=%s dim=%d workers=%d",
len(targets), shortFP(fp), a.multimodalSpace.Dim(), workers)
jobs := make(chan string, workers*2)
var done, failed, unsupported int64
var failedMu sync.Mutex
jobs := make(chan memory.MemoryBlock, workers*2)
var done, failed int64
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for d := range jobs {
switch err := a.reembedOne(d, fp); {
case err == nil:
atomic.AddInt64(&done, 1)
case errors.Is(err, vector.ErrModalityUnsupported):
// 该模态不在本空间内(如音频):不重试、不计失败,
// 也不拿另一个模型的向量顶替。
atomic.AddInt64(&unsupported, 1)
default:
failedMu.Lock()
failed++
failedMu.Unlock()
for b := range jobs {
if err := a.reembedOneBlock(b, fp); err != nil {
atomic.AddInt64(&failed, 1)
continue
}
atomic.AddInt64(&done, 1)
}
}()
}
for i, d := range digests {
jobs <- d
// 每迁移 20 条输出进度日志,让用户看到迁移在推进
for i, b := range targets {
jobs <- b
if (i+1)%20 == 0 {
log.Printf("[media] 向量迁移进度: %d/%d (done=%d failed=%d)", i+1, len(digests), atomic.LoadInt64(&done), failed)
log.Printf("[media] 向量迁移进度: %d/%d (done=%d failed=%d)",
i+1, len(targets), atomic.LoadInt64(&done), failed)
}
}
close(jobs)
wg.Wait()
log.Printf("[media] 向量迁移完成: 成功=%d 失败=%d 不在本空间=%d 总计=%d fp=%s",
done, failed, unsupported, len(digests), shortFP(fp))
log.Printf("[media] 向量迁移完成: 成功=%d 失败=%d 总计=%d fp=%s",
done, failed, len(targets), shortFP(fp))
}
// reembedOne 为单条媒体重新计算向量并写入(stat/get 失败时跳过该条目)。
// reembedOneBlock 为一个图像块重算向量并写回。负载就在块里,无需外部取字节。
//
// 模态不在本空间覆盖范围时返回 ErrModalityUnsupported,调用方据此区分
// 「永久无向量」与「本次失败重试」。
func (a *Agent) reembedOne(digest, fp string) error {
it, err := a.mediaStore.Stat(digest)
if err != nil {
return err
}
data, err := a.mediaStore.Get(digest)
if err != nil {
return err
}
mime := it.MIME
func (a *Agent) reembedOneBlock(b memory.MemoryBlock, fp string) error {
mime := b.MIME
if mime == "" {
mime = "image/png"
}
vec, err := a.multimodalSpace.EmbedImageDense(data, mime)
vec, err := a.multimodalSpace.EmbedImageDense(b.Data, mime)
if err != nil {
return err
}
return a.mediaStore.SetVec(digest, vec, fp)
if err := a.memory.SetBlockVector(b.ID, vec, fp); err != nil {
return err
}
return nil
}
// shortFP 截断 fingerprint 为可读日志格式。

View File

@ -1,85 +1,59 @@
package core
import (
"encoding/base64"
"fmt"
"log"
"strings"
"sync/atomic"
"time"
agentAPI "github.com/JianFeeeee/HomeAgent/internal/agent/api"
"github.com/JianFeeeee/HomeAgent/internal/memory"
"github.com/JianFeeeee/HomeAgent/internal/memory/media"
)
// blockSeq 保证块 ID 全局唯一(Graph memory_blocks 以 id 为主键)。
var blockSeq int64
// 媒体记忆接线:把对话里出现的图片/音频变成一等记忆块。
//
// ★ 2026-10-11:块自带负载(CAS 已淘汰)。
//
// 此前这里把字节存进独立的内容寻址存储(CAS),块只留一个 digest 指向它。
// 那是把「媒体的内容」从块里拆出去另建一层——现在媒体字节直接进块的 Data,
// 与文本块的 Text 对称:一个块要么带文本、要么带字节。
//
// 媒体进入对话有两条路:用户直接发图(ContentBlock data URL)、插件注入
// (SetToolBlocks)。两条都在这里收口成块,挂到当轮 ContextEvent;
// 事件被 Prune 时块随之迁移到 L2 文档(块本身不变,只换持有层)。
//
// 不再生成任何描述文本,也不再往正文写 media marker:图片只按自己的
// 统一空间向量被检索,描述式索引是将就方案。
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:
// blockModalityFromMIME 按 MIME 大类推断块的模态。
func blockModalityFromMIME(mime string) memory.BlockModality {
switch {
case strings.HasPrefix(mime, "image/"):
return memory.BlockImage
case media.KindVideo:
case strings.HasPrefix(mime, "video/"):
return memory.BlockVideo
case media.KindAudio:
case strings.HasPrefix(mime, "audio/"):
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 直接持有一等记忆块。
// captureBlockMedia 把 blocks 里的 data URL 媒体变成一等记忆块,返回块列表。
//
// 媒体进入对话有两条路:用户直接发图(ContentBlock data URL)、插件注入
// (SetToolBlocks)。两条都在这里收口:从 data URL 取出字节存进 CAS,
// 用其向量构造一等记忆块挂到当轮 ContextEvent 上;事件被 Prune 时
// 块随之迁移到 L2 文档。
// 只处理 data URL:http(s) URL 拿不到字节,而"下载它再存"会把一次对话变成
// 一次网络请求(超时、鉴权、SSRF 全来了),不在本层解决。
//
// 不再生成任何描述文本,也不再往正文写 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 {
// 块 ID 由内容派生(memory.MediaBlockID):同内容 ⇒ 同 ID ⇒ 落库时
// ON CONFLICT 天然去重(这正是内容寻址当年的好处,但现在由块 ID 直接提供,
// 不需要一层独立的寻址存储)。
func (a *Agent) captureBlockMedia(blocks []agentAPI.ContentBlock, tool string) []memory.MemoryBlock {
if len(blocks) == 0 {
return nil
}
var digests []string
var out []memory.MemoryBlock
for _, b := range blocks {
var url string
switch {
@ -91,106 +65,124 @@ func (a *Agent) captureBlockMedia(blocks []agentAPI.ContentBlock, tool string) [
continue
}
mime, data, ok := media.ParseDataURL(url)
mime, data, ok := 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
block := memory.MemoryBlock{
ID: memory.MediaBlockID(mime, data),
Modality: blockModalityFromMIME(mime),
Data: data,
MIME: mime,
Size: int64(len(data)),
Tool: tool,
CreatedAt: time.Now(),
}
// 入库即算一次多模态坐标并缓存(多模态空间可用时)。
// 之后 doc_query / memory_recall / 内部召回直接复用 SetVec 的缓存坐标,
// 不重复跑 ONNX;模型切换由启动时的 reembedStaleMedia 补算。
a.embedMediaOnIngest(d, mime, data)
digests = append(digests, d)
// 入库即算一次多模态坐标(多模态空间可用且为图像时)。
// 之后块召回直接用它;换模型由启动时的 reembedStaleMedia 补算。
a.embedBlockOnIngest(&block)
out = append(out, block)
}
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 同一手法(均由 schedulerLoop goroutine 独占读写)。
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 上。
// embedBlockOnIngest 给刚捕获的块立即计算多模态坐标。
// 只在多模态空间可用且为图像时执行;音频/未配置时静默跳过(保持既有行为)。
func (a *Agent) embedBlockOnIngest(b *memory.MemoryBlock) {
if b == nil || a.multimodalSpace == nil || !a.multimodalSpace.Loaded() {
return
}
if !strings.HasPrefix(b.MIME, "image/") {
return
}
vec, err := a.multimodalSpace.EmbedImageDense(b.Data, b.MIME)
if err != nil {
log.Printf("[media] 入库嵌入失败 %s: %v", shortID(b.ID), err)
return
}
b.Vector = vec
b.Fingerprint = a.multimodalSpace.Fingerprint()
}
// stageMediaBlocks 累积本轮捕获的块,等 ContextEvent 建好后一起挂上。
//
// 块存储在事件自身(随 context.json 持久化),不再写 media_refs:
// 存活与否由“三层记忆块是否持有这个 digest”决定,不维护引用账本。
func (a *Agent) bindEventMedia(evt *ContextEvent, digests []string) {
if a.mediaStore == nil || evt == nil || len(digests) == 0 {
// 为何要缓存而不是当场挂:块在 process() 执行期间被捕获,而承载它的
// ContextEvent 要等 process() 返回后才 Append——此刻还没有 owner。
// 与既有的 a.pendingMedia 同一手法(均由 schedulerLoop goroutine 独占读写)。
func (a *Agent) stageMediaBlocks(blocks ...memory.MemoryBlock) {
if len(blocks) == 0 {
return
}
a.pendingMediaBlocks = append(a.pendingMediaBlocks, blocks...)
}
// drainMediaBlocks 取出并清空本轮累积的块。
func (a *Agent) drainMediaBlocks() []memory.MemoryBlock {
if len(a.pendingMediaBlocks) == 0 {
return nil
}
out := a.pendingMediaBlocks
a.pendingMediaBlocks = nil
return out
}
// bindEventMedia 把本轮捕获的块挂到 ContextEvent 上。
//
// 块存储在事件自身(随 context.json 持久化),不维护引用账本:
// 存活与否由持有它的层决定。
func (a *Agent) bindEventMedia(evt *ContextEvent, blocks []memory.MemoryBlock) {
if evt == nil || len(blocks) == 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)
}
evt.Blocks = append(evt.Blocks, blocks...)
}
// mediaLabel 渲染一行媒体标签,供提示词告知"这条记忆带着哪份媒体"。
// blockLabel 渲染一行媒体块标签,供提示词告知"这条记忆带着哪份媒体"。
//
// 不再包含任何生成的描述文本:图片只按自己的向量被检索,标签仅提供
// MIME 与短 digest,让模型知道有这份媒体、可据 digest 取回字节。
// 查不到返回空串:内容可能已被删除,不该造出一条指向虚无的标签。
func mediaLabel(it *media.Item) string {
if it == nil {
// 不含任何生成的描述文本:图片按自己的向量被检索,标签仅提供 MIME 与
// 短块 ID,让模型知道有这份媒体、并可据此取回(块 ID 是它的唯一身份)。
func blockLabel(b *memory.MemoryBlock) string {
if b == nil {
return ""
}
label := string(it.Kind)
if it.MIME != "" {
label = it.MIME
label := string(b.Modality)
if b.MIME != "" {
label = b.MIME
}
return fmt.Sprintf("[%s %s]", label, shortDigest(it.Digest))
return fmt.Sprintf("[%s %s]", label, shortID(b.ID))
}
// parseDataURL 从 data:<mime>;base64,<data> 解出 MIME 与原始字节。
//
// 自 CAS 淘汰后,这段解析归内核媒体接线自己持有(原在 media 包)。
func parseDataURL(url string) (mime string, data []byte, ok bool) {
const prefix = "data:"
if !strings.HasPrefix(url, prefix) {
return "", nil, false
}
rest := url[len(prefix):]
comma := strings.IndexByte(rest, ',')
if comma < 0 {
return "", nil, false
}
head := rest[:comma]
payload := rest[comma+1:]
if !strings.HasSuffix(strings.ToLower(head), ";base64") {
return "", nil, false
}
mime = head[:len(head)-len(";base64")]
if mime == "" || payload == "" {
return "", nil, false
}
decoded, err := base64.StdEncoding.DecodeString(payload)
if err != nil {
return "", nil, false
}
return mime, decoded, true
}
// newEventID 生成 ContextEvent 的稳定标识。
@ -202,9 +194,10 @@ func newEventID() string {
return fmt.Sprintf("evt_%d", time.Now().UnixNano())
}
func shortDigest(d string) string {
if len(d) > 12 {
return d[:12]
// shortID 把块 ID 截短供日志/提示词使用。
func shortID(id string) string {
if len(id) > 20 {
return id[:20]
}
return d
return id
}

View File

@ -391,10 +391,10 @@ func (a *Agent) prepareInputTask(evt *agentIO.InputEvent) (*TaskFrame, taskTermi
a.pendingMedia = evt.Payload
}
// 媒体先落进 CAS。不存的后果是 ContextEvent.Input 只剩一句 alt 文本,
// base64 随 message 数组发给模型后就丢了。
// 媒体先变成一等块(字节进块的 Data)。不存的后果是 ContextEvent.Input
// 只剩一句 alt 文本,base64 随 message 数组发给模型后就丢了。
if len(in.blocks) > 0 {
a.stageMediaDigests(a.captureBlockMedia(in.blocks, in.captureTool)...)
a.stageMediaBlocks(a.captureBlockMedia(in.blocks, in.captureTool)...)
}
noMemory := false
@ -516,7 +516,7 @@ func (a *Agent) finishInputTask(f *TaskFrame, out stepOutcome) {
ToolsUsed: f.ToolsUsed,
ToolResults: f.ToolResults,
}
a.bindEventMedia(&turnEvt, a.drainMediaDigests())
a.bindEventMedia(&turnEvt, a.drainMediaBlocks())
a.context.Append(turnEvt)
a.emitResponse(evt, f.Response, f.turnUsage)
@ -1098,8 +1098,8 @@ func (a *Agent) stepToolAfter(f *TaskFrame) stepOutcome {
}
}
if len(blocks) > 0 {
// 先落进 CAS:无论下面走直视还是回退转写,媒体本体都该进记忆。
a.stageMediaDigests(a.captureBlockMedia(blocks, tc.Name)...)
// 先落成块:无论下面走直视还是回退转写,媒体本体都该进记忆。
a.stageMediaBlocks(a.captureBlockMedia(blocks, tc.Name)...)
if native, fallbackText := a.prepareToolBlocks(blocks); len(native) > 0 {
// 能直视:另起一条 user message 承载媒体,并补一句来源说明。

View File

@ -339,9 +339,9 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str
}
// 模型显式关联的媒体:结构化字段随三元组一起提交,
// 由 commitTriplesWithMedia 变成 L3 一等块并与句子建边——
// 不再把 marker 写进句子文本。
if digests := getStringSlice(m, "media_digests"); len(digests) > 0 {
t.MediaDigests = a.resolveMediaDigests(digests)
// 不再把 marker 写进句子文本。值是块 ID(或其唯一前缀)。
if refs := getStringSlice(m, "media_digests"); len(refs) > 0 {
t.MediaDigests = a.resolveMediaRefs(refs)
// 块边需要句子作端点。模型没给原句时用三元组本身拼一句
// 自然语言——不能造一段 marker 文本,那正是被废弃的东西。
if t.SentenceText == "" && len(t.MediaDigests) > 0 {
@ -697,25 +697,27 @@ func (a *Agent) executeSocialTool(tc agentAPI.ToolCall) string {
}
}
// knowledgeMediaRefs 把模型给的 digest 列表解析成知识条目的媒体引用。
// knowledgeMediaRefs 把模型给的块引用(ID 或其唯一前缀)解析成知识条目的媒体引用。
//
// 复用 doc_commit 的既有约定:digest 可传前缀(ResolvePrefix),解析不了
// 的跳过而不是报错——模型偶尔会把 digest 记错,不该让整次写入失败。
// MIME 从媒体存储回读,嵌入时需要(EmbedImageDense 靠它判定模态)。
func (a *Agent) knowledgeMediaRefs(digests []string) []knowledge.KnowledgeMediaRef {
if a.mediaStore == nil || len(digests) == 0 {
// 复用 doc_commit 的既有约定:引用可传前缀,解析不了的跳过而不是报错——
// 模型偶尔会把 ID 记错,不该让整次写入失败。MIME 从块回读,嵌入时需要
// (EmbedImageDense 靠它判定模态)。字节随条目落盘(writeMediaFiles)。
func (a *Agent) knowledgeMediaRefs(refs []string) []knowledge.KnowledgeMediaRef {
if a.memory == nil || len(refs) == 0 {
return nil
}
var out []knowledge.KnowledgeMediaRef
for _, d := range a.resolveMediaDigests(digests) {
it, err := a.mediaStore.Stat(d)
if err != nil {
for _, r := range refs {
b, ok, err := a.memory.BlockByIDPrefix(r)
if err != nil || !ok {
log.Printf("[knowledge] 媒体块引用 %s 无法唯一解析,跳过", r)
continue
}
out = append(out, knowledge.KnowledgeMediaRef{
Digest: it.Digest,
MIME: it.MIME,
Kind: string(it.Kind),
Digest: b.ID,
MIME: b.MIME,
Kind: string(b.Modality),
Data: b.Data,
})
}
return out
@ -922,9 +924,9 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string {
// 模型显式关联的媒体:直接变成文档持有的一等块。
// 不再往正文写 marker——文档向量会融合这些块的媒体向量,
// 图片按自己的向量被检索。
for _, d := range a.resolveMediaDigests(getStringSlice(tc.Arguments, "media_digests")) {
if b, ok := a.blockFromDigest(d); ok {
// 图片按自己的向量被检索。值是块 ID(或其唯一前缀)。
for _, ref := range a.resolveMediaRefs(getStringSlice(tc.Arguments, "media_digests")) {
if b, ok, err := a.memory.BlockByIDPrefix(ref); err == nil && ok {
doc.Blocks = append(doc.Blocks, b)
}
}

View File

@ -85,10 +85,9 @@ func TestDenseCrossModalRecall(t *testing.T) {
img: map[string][]float64{"__default": eby, "d-cat": eax},
}
s.SetDenseSpace(mm)
s.SetMediaGetter(fakeMedia{byDigest: map[string][]byte{"d-cat": []byte("d-cat")}})
if err := s.AddWithMedia("cat-photo", "猫 图片 说明", []KnowledgeMediaRef{
{Digest: "d-cat", MIME: "image/png", Kind: "image"},
{File: "cat.png", MIME: "image/png", Kind: "image"},
}); err != nil {
t.Fatal(err)
}
@ -224,9 +223,8 @@ func TestDenseUnsupportedModalityStillTextEmbedded(t *testing.T) {
imgErr: map[string]error{"d-audio": vector.ErrModalityUnsupported},
}
s.SetDenseSpace(mm)
s.SetMediaGetter(fakeMedia{byDigest: map[string][]byte{"d-audio": []byte("d-audio")}})
if err := s.AddWithMedia("song", "一首歌", []KnowledgeMediaRef{{Digest: "d-audio", MIME: "audio/mpeg"}}); err != nil {
if err := s.AddWithMedia("song", "一首歌", []KnowledgeMediaRef{{File: "a.mp3", MIME: "audio/mpeg"}}); err != nil {
t.Fatal(err)
}
k := s.items["song"]
@ -289,13 +287,12 @@ func TestAttachMediaRecomputesDense(t *testing.T) {
img: map[string][]float64{"__default": {0, 1}, "d-x": eax},
}
s.SetDenseSpace(mm)
s.SetMediaGetter(fakeMedia{byDigest: map[string][]byte{"d-x": []byte("d-x")}})
if err := s.Add("k", "正文"); err != nil {
t.Fatal(err)
}
before := append([]float64{}, s.items["k"].Dense...)
if err := s.AttachMedia("k", KnowledgeMediaRef{Digest: "d-x", MIME: "image/png"}); err != nil {
if err := s.AttachMedia("k", KnowledgeMediaRef{File: "x.png", MIME: "image/png"}); err != nil {
t.Fatal(err)
}
after := s.items["k"].Dense

View File

@ -8,7 +8,7 @@ import (
"sort"
"strings"
"github.com/JianFeeeee/HomeAgent/internal/memory/media"
"github.com/JianFeeeee/HomeAgent/internal/memory"
)
// ===== 从目录导入知识 =====
@ -370,37 +370,74 @@ func (s *Store) has(name string) bool {
return ok
}
// MediaPutter 是把字节写进媒体库的能力(由 media.Store 实现)。
// putMediaFile 读一个媒体文件进条目引用(字节随 Data 瞬态携带,
// 由 Write 落盘到条目目录)。
//
// 为何用接口而不是直接依赖 *media.Store:Store 已经有 SetMediaGetter
// 这类注入点,导入沿用同一形状,测试才能用假实现,不必拉起真媒体库。
type MediaPutter interface {
Put(data []byte, meta media.Item) (string, error)
}
// putMediaFile 把一个媒体文件复制进媒体库,返回条目用的引用。
//
// 媒体库未接入时返回错误而不是静默跳过 —— 但调用方对错误只记警告:
// 媒体是可选增强,正文不该因为图片入不了库而整个条目失败。
// ★ 2026-10-11 CAS 淘汰:不再写入全局媒体库 —— 字节是条目自带数据,
// 与 content.md 同目录同生灭。读取失败返回错误,调用方只记警告跳过该媒体
// (正文不该因为图片入不了库而整个条目失败)。
func (s *Store) putMediaFile(path, kind string) (KnowledgeMediaRef, error) {
if s.mediaPut == nil {
return KnowledgeMediaRef{}, fmt.Errorf("媒体库不可用")
}
data, err := os.ReadFile(path)
if err != nil {
return KnowledgeMediaRef{}, err
}
mime := mimeByExt(filepath.Ext(path))
digest, err := s.mediaPut.Put(data, media.Item{
Kind: media.Kind(kind),
MIME: mime,
Size: int64(len(data)),
OriginPath: path,
})
if err != nil {
return KnowledgeMediaRef{}, err
// File 名取源文件名:同目录内天然唯一(同名正文+同名媒体本就是一对)。
return KnowledgeMediaRef{
Digest: memory.MediaBlockID(mime, data), // 内容身份:与媒体块 ID 同构
MIME: mime,
Kind: kind,
File: filepath.Base(path),
Data: data,
}, nil
}
// writeMediaFiles 把引用携带的瞬态字节落进条目目录。
//
// 只写引用里带 Data 的条目(目录导入路径);字节已在盘上的(如模型引用
// 已有媒体)直接跳过。File 为空时补足。
func (s *Store) writeMediaFiles(dir string, refs []KnowledgeMediaRef) error {
for i := range refs {
r := &refs[i]
if len(r.Data) == 0 && r.File != "" {
if _, err := os.Stat(filepath.Join(dir, r.File)); err == nil {
continue // 字节已在盘上
}
}
if len(r.Data) == 0 {
continue // 没字节也没文件:引用悬空,留给 denseFor 静默降级
}
if r.File == "" {
ext := extByMIME(r.MIME)
r.File = fmt.Sprintf("media_%d%s", i, ext)
}
if err := os.WriteFile(filepath.Join(dir, r.File), r.Data, 0644); err != nil {
return err
}
}
return nil
}
// extByMIME 按常见 MIME 给扩展名(writeMediaFiles 补 File 名用)。
func extByMIME(mime string) string {
switch mime {
case "image/png":
return ".png"
case "image/jpeg":
return ".jpg"
case "image/gif":
return ".gif"
case "image/webp":
return ".webp"
case "audio/mpeg":
return ".mp3"
case "audio/wav":
return ".wav"
case "video/mp4":
return ".mp4"
default:
return ".bin"
}
return KnowledgeMediaRef{Digest: digest, MIME: mime, Kind: kind}, nil
}
// mimeByExt 按扩展名给 MIME。够用即可:媒体库靠 MIME 决定能否解码

View File

@ -2,14 +2,11 @@ package knowledge
import (
"bytes"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/JianFeeeee/HomeAgent/internal/memory/media"
)
// ===== 从目录导入知识 =====
@ -400,27 +397,9 @@ func TestImportDirDoesNotFlushPerItem(t *testing.T) {
}
}
// 媒体导入:图片按字节复制进媒体库,条目挂 digest 引用。
//
// 用假 MediaPutter 而不是真媒体库:这里要验的是「调用了 Put、
// 且把返回的 digest 挂到条目上」,不是媒体库自己的落盘与去重
// (那是 media 包的判据,不该在这里重测)。
type fakePutter struct {
puts [][]byte
seq int
}
func (f *fakePutter) Put(data []byte, _ media.Item) (string, error) {
f.puts = append(f.puts, data)
f.seq++
return fmt.Sprintf("digest%02d", f.seq), nil
}
func TestImportDirMediaAttachedToEntry(t *testing.T) {
src := mkSrc(t, map[string]string{"note.md": "看图", "note.png": "PNGDATA"})
s, root := newImportStore(t)
fp := &fakePutter{}
s.SetMediaPutter(fp)
st, err := s.ImportDir(ImportOptions{Dir: src, IncludeMedia: true})
if err != nil {
@ -429,16 +408,21 @@ func TestImportDirMediaAttachedToEntry(t *testing.T) {
if st.Media != 1 {
t.Errorf("应导入 1 个媒体,实际 %d(%+v)", st.Media, st)
}
if len(fp.puts) != 1 || string(fp.puts[0]) != "PNGDATA" {
t.Errorf("媒体字节未原样复制进媒体库:%v", fp.puts)
// 媒体字节随条目落盘(CAS 淘汰后:字节是条目自带数据,与 content.md 同目录)
bytesOnDisk, err := os.ReadFile(filepath.Join(root, "note", "note.png"))
if err != nil {
t.Fatalf("媒体字节未落盘到条目目录:%v", err)
}
// digest 引用要落到条目的侧车文件里
if string(bytesOnDisk) != "PNGDATA" {
t.Errorf("媒体字节失真:%q", bytesOnDisk)
}
// 引用要落到条目的侧车文件里(File 指向同目录文件)
data, err := os.ReadFile(filepath.Join(root, "note", mediaSidecarName))
if err != nil {
t.Fatalf("媒体侧车未写入:%v", err)
}
if !strings.Contains(string(data), "digest01") {
t.Errorf("侧车里没有 Put 返回的 digest:%s", data)
if !strings.Contains(string(data), "note.png") {
t.Errorf("侧车里没有媒体文件名:%s", data)
}
// 内存条目也要挂上(否则稠密路算不出这条的图向量)
k := s.items["note"]
@ -447,26 +431,33 @@ func TestImportDirMediaAttachedToEntry(t *testing.T) {
}
}
// include_media 未开启时不该碰媒体库。
// include_media 未开启时不该把媒体落进条目目录。
func TestImportDirMediaSkippedWhenNotRequested(t *testing.T) {
src := mkSrc(t, map[string]string{"note.md": "看图", "note.png": "PNGDATA"})
s, _ := newImportStore(t)
fp := &fakePutter{}
s.SetMediaPutter(fp)
s, root := newImportStore(t)
if _, err := s.ImportDir(ImportOptions{Dir: src}); err != nil {
t.Fatal(err)
}
if len(fp.puts) != 0 {
t.Errorf("未开 include_media 却写了媒体库:%v", fp.puts)
if _, err := os.Stat(filepath.Join(root, "note", "note.png")); !os.IsNotExist(err) {
t.Errorf("未开 include_media 却落了媒体文件")
}
}
// 媒体入不了库时,正文条目仍应成功(媒体是增强,不是前提)。
// 媒体读不了时,正文条目仍应成功(媒体是增强,不是前提)。
func TestImportDirMediaFailureDoesNotFailEntry(t *testing.T) {
src := mkSrc(t, map[string]string{"note.md": "正文", "note.png": "PNG"})
src := mkSrc(t, map[string]string{"note.md": "正文"})
s, _ := newImportStore(t)
s.SetMediaPutter(failPutter{})
// 造一个指向不存在文件的媒体候选:ImportDir 收集目录时按扩展名识别,
// 读不出的文件走 putMediaFile 错误路径,只跳过该媒体。
bad := filepath.Join(src, "broken.png")
if err := os.WriteFile(bad, []byte("x"), 0644); err != nil {
t.Fatal(err)
}
if err := os.Chmod(bad, 0000); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chmod(bad, 0644) })
st, err := s.ImportDir(ImportOptions{Dir: src, IncludeMedia: true})
if err != nil {
@ -475,13 +466,4 @@ func TestImportDirMediaFailureDoesNotFailEntry(t *testing.T) {
if st.Imported != 1 {
t.Errorf("正文条目应仍导入成功,实际 %d(%+v)", st.Imported, st)
}
if st.Media != 0 {
t.Errorf("媒体应计入 0,实际 %d", st.Media)
}
}
type failPutter struct{}
func (failPutter) Put([]byte, media.Item) (string, error) {
return "", fmt.Errorf("磁盘满")
}

View File

@ -49,6 +49,15 @@ type KnowledgeMediaRef struct {
Digest string `json:"digest"`
MIME string `json:"mime"`
Kind string `json:"kind,omitempty"`
// File 是媒体字节在**条目目录内**的相对文件名(随条目生灭)。
//
// ★ 2026-10-11 CAS 淘汰:媒体字节不再寄存于全局内容寻址库,而是作为
// 条目自带数据落在 entry 目录里(与 content.md、.media.json 并列)。
// Remove 的 os.RemoveAll 天然把它清掉,不留孤儿。
File string `json:"file,omitempty"`
// Data 是写入期的瞬态字节:putMediaFile → Write 之间搬运,Write 落盘后清空。
// json:"-" 保证它不进 .media.json 侧车(侧车只记引用,不记字节)。
Data []byte `json:"-"`
}
// KnowledgeMediaRef 的别名,照顾内部可读性。
@ -126,8 +135,6 @@ type Store struct {
mu sync.RWMutex
items map[string]*Knowledge
// mediaPut 是媒体写入器(ImportDir 复制媒体时用),见 SetMediaPutter。
mediaPut MediaPutter
indexPath string
denseCachePath string
@ -157,8 +164,7 @@ type Store struct {
// (feature → {docID: weight}),稠密向量会把倒排表退化成全量特征桶,
// 同时破坏 TF-IDF 语义(vector.MultimodalEmbedder 的注释已明言)。
// 因此稠密路自成一等路,与另两路并列,不混进任何一个 Store。
dense vector.MultimodalEmbedder
mediaGet MediaGetter
dense vector.MultimodalEmbedder
}
// mediaSidecarName 是每条知识目录下存放媒体引用的文件名。
@ -187,12 +193,6 @@ type denseCache struct {
Entries map[string]denseCacheEntry `json:"entries"`
}
// MediaGetter 让知识库能取回媒体字节以计算嵌入,而不依赖具体的媒体存储包。
// 取不到(或未接线)时,该条目退化为纯文本嵌入——而不是整条不可用。
type MediaGetter interface {
Get(digest string) ([]byte, error)
}
func NewStore(root string) *Store {
return &Store{
root: root,
@ -224,25 +224,6 @@ func (s *Store) SetDenseSpace(ds vector.MultimodalEmbedder) {
s.maybeLoadDenseCacheLocked()
}
// SetMediaGetter 注入媒体取回器(用于为 Media 块算嵌入)。
// 不注入时多媒体条目仍可入库,只是退化为纯文本嵌入。
func (s *Store) SetMediaGetter(g MediaGetter) {
s.mu.Lock()
defer s.mu.Unlock()
s.mediaGet = g
}
// SetMediaPutter 注入媒体写入器(ImportDir 把图片/音视频复制进媒体库时用)。
//
// 与 SetMediaGetter 分开:一个是取(算嵌入时读回媒体块),一个是存
// (目录导入时收字节)。合成一个接口会强迫测试同时实现两侧。
// 不注入时 ImportDir 仍能导入正文,只是媒体被跳过并记原因。
func (s *Store) SetMediaPutter(p MediaPutter) {
s.mu.Lock()
defer s.mu.Unlock()
s.mediaPut = p
}
// denseEnabled 报告稠密路是否可用(供 Stats/自证与分支判断)。
// 调用方必须已持锁。
func (s *Store) denseEnabled() bool {
@ -265,21 +246,24 @@ func (s *Store) denseFor(k *Knowledge) []float64 {
if tv, err := s.dense.VectorizeDense(k.Name + " " + k.Content); err == nil && len(tv) == dim {
parts = append(parts, tv)
}
dir := filepath.Dir(k.Path)
for _, m := range k.Media {
if v := s.mediaDense(m); v != nil {
if v := s.mediaDense(m, dir); v != nil {
parts = append(parts, v)
}
}
return vector.FuseVectors(parts...)
}
// mediaDense 取回媒体字节并嵌入。任何一步拿不到就返回 nil——
// mediaDense 读条目目录里的媒体字节并嵌入。任何一步拿不到就返回 nil——
// 媒体缺失不应让整条知识失去文本向量。
func (s *Store) mediaDense(m KnowledgeMediaRef) []float64 {
if !s.denseEnabled() || s.mediaGet == nil || m.Digest == "" {
//
// ★ 2026-10-11 CAS 淘汰:字节直接从条目目录的 File 读,不再经 mediaGet。
func (s *Store) mediaDense(m KnowledgeMediaRef, dir string) []float64 {
if !s.denseEnabled() || m.File == "" {
return nil
}
data, err := s.mediaGet.Get(m.Digest)
data, err := os.ReadFile(filepath.Join(dir, m.File))
if err != nil || len(data) == 0 {
return nil
}
@ -365,8 +349,7 @@ func (s *Store) DenseStats() map[string]interface{} {
s.mu.RLock()
defer s.mu.RUnlock()
out := map[string]interface{}{
"enabled": s.denseEnabled(),
"media_getter": s.mediaGet != nil,
"enabled": s.denseEnabled(),
}
if s.denseEnabled() {
fp := s.dense.Fingerprint()
@ -746,6 +729,12 @@ func (s *Store) Write(in KnowledgeEntryInput) error {
return fmt.Errorf("write knowledge: %w", err)
}
// 媒体字节落进条目目录(CAS 淘汰后,字节是条目自带数据)。
// 失败只跳过该媒体并记录——正文已落盘,图片写不进顶多搜不到图。
if err := s.writeMediaFiles(dir, in.Media); err != nil {
log.Printf("[knowledge] media bytes write error for %s: %v", in.Name, err)
}
now := time.Now()
k := &Knowledge{
Name: id,

View File

@ -8,9 +8,10 @@ import (
"image/png"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/JianFeeeee/HomeAgent/internal/memory/media"
"github.com/JianFeeeee/HomeAgent/internal/memory/vector"
"github.com/JianFeeeee/HomeAgent/pkg/embedding"
)
@ -67,10 +68,6 @@ func TestMultimodalEndToEndWithRealProvider(t *testing.T) {
}
t.Logf("真实 provider: dim=%d fp=%s loaded=%v", ds.Dim(), ds.Fingerprint(), ds.Loaded())
ms, err := media.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
dir := t.TempDir()
s := NewStore(dir)
if err := s.Start(); err != nil {
@ -78,16 +75,19 @@ func TestMultimodalEndToEndWithRealProvider(t *testing.T) {
}
defer s.Stop()
s.SetDenseSpace(ds)
s.SetMediaGetter(ms)
img := mmTinyPNG(t)
digest, err := ms.Put(img, media.Item{MIME: "image/png", Tool: "test"})
if err != nil {
if err := s.AddWithMedia("photo", "一只猫的照片", []KnowledgeMediaRef{
{File: "photo.png", MIME: "image/png", Kind: "image"},
}); err != nil {
t.Fatal(err)
}
if err := s.AddWithMedia("photo", "一只猫的照片", []KnowledgeMediaRef{
{Digest: digest, MIME: "image/png", Kind: "image"},
}); err != nil {
// 字节落进条目目录(CAS 淘汰后:媒体随条目生灭)
if err := os.WriteFile(filepath.Join(dir, "photo", "photo.png"), img, 0644); err != nil {
t.Fatal(err)
}
// 重算该条稠密向量以纳入图片
if err := s.AttachMedia("photo", KnowledgeMediaRef{File: "photo.png", MIME: "image/png"}); err != nil {
t.Fatal(err)
}
if err := s.Add("plain", "无关的纯文本知识"); err != nil {
@ -125,7 +125,6 @@ func TestMultimodalEndToEndWithRealProvider(t *testing.T) {
t.Fatal(err)
}
s2.SetDenseSpace(ds)
s2.SetMediaGetter(ms)
built, _ := s2.ReindexDense()
t.Logf("重启后 ReindexDense built=%d(0=命中缓存)", built)
if built != 0 {

View File

@ -34,7 +34,6 @@ func TestDenseCacheAvoidsRecompute(t *testing.T) {
s2 := NewStore(dir)
_ = s2.Start()
s2.SetDenseSpace(mm)
s2.SetMediaGetter(fakeMedia{})
for _, id := range s2.List() {
if len(s2.items[id].Dense) == 0 {
t.Fatalf("重启后 %s 未从缓存恢复稠密向量", id)

View File

@ -22,12 +22,25 @@ const (
//
// 它不携带 Layer、Owner 或 RefCount:块当前由哪个层的容器持有,哪个层就是
// 唯一事实源。Context→Document→Graph 迁移的是这个值本身,不建立平行保活账本。
// PayloadDigest 仅用于定位内容寻址的原始字节,不表示另一条逻辑记忆。
//
// ★★ 负载是 **union**:一个块要么是文本块(负载在 Text),要么是媒体块
// (负载在 Data,配 MIME/Width/Height)。两者是同一个 MemoryBlock 的两种取值,
// 不是「块 + 外挂字节层」。
//
// 为何不存在独立的字节存储:块已经是内容的载体(文本块自己带正文),媒体块
// 自当同理。把媒体内容拆到块外、只留一个 digest 指向外部 CAS,会凭空多出
// 一层需单独维护生命周期的东西(第二套向量、第二套召回路、引用账本、自己的
// db),并与「块是唯一记忆载体」直接冲突。内容随块同生同灭才是正解。
type MemoryBlock struct {
ID string `json:"id"`
Modality BlockModality `json:"modality"`
Text string `json:"text,omitempty"`
PayloadDigest string `json:"payload_digest,omitempty"`
ID string `json:"id"`
Modality BlockModality `json:"modality"`
// Text 是文本块(Modality=text)的负载。
Text string `json:"text,omitempty"`
// Data 是媒体块(Modality=image/video/audio)的负载:内容的原始字节。
//
// 落盘形态随所在层而定:L0/L1 的块内联在 JSON 里(Data 序列化为 base64),
// L3 的块落 SQLite 的 BLOB 列。无论哪层,字节都不脱离块单独存在。
Data []byte `json:"data,omitempty"`
MIME string `json:"mime,omitempty"`
Size int64 `json:"size,omitempty"`
Width int `json:"width,omitempty"`
@ -120,13 +133,13 @@ func (g *GraphDB) PutMemoryBlocks(blocks []MemoryBlock) error {
block.CreatedAt = now
}
_, err = tx.Exec(`INSERT INTO memory_blocks (
id, modality, text_content, payload_digest, mime, size, width, height,
id, modality, text_content, data, mime, size, width, height,
vector, fingerprint, source, tool, scene, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
modality = excluded.modality,
text_content = excluded.text_content,
payload_digest = excluded.payload_digest,
data = excluded.data,
mime = excluded.mime,
size = excluded.size,
width = excluded.width,
@ -143,7 +156,7 @@ func (g *GraphDB) PutMemoryBlocks(blocks []MemoryBlock) error {
-- 若让它覆盖已有类型,一次不带标注的写入就会抹掉语义类别。
semantic_type = CASE WHEN excluded.semantic_type != '' THEN excluded.semantic_type ELSE memory_blocks.semantic_type END,
updated_at = excluded.updated_at`,
block.ID, block.Modality, block.Text, block.PayloadDigest, block.MIME,
block.ID, block.Modality, block.Text, block.Data, block.MIME,
block.Size, block.Width, block.Height, string(vectorJSON), block.Fingerprint,
block.Source, block.Tool, block.Scene, block.CreatedAt, now)
if err != nil {
@ -279,14 +292,21 @@ func (g *GraphDB) MemoryBlocks() ([]MemoryBlock, error) {
// 结果媒体与召回测试大面积变红(列数不匹配 ⇒ 读失败)。
//
// 单靠“记得同步”不可靠;提成常量后新增查询直接复用。
const blockColumns = `id, modality, text_content, payload_digest, mime, size, width, height,
// blockColumns 是 memory_blocks 的**元数据**列清单,供所有常规读取复用。
//
// ★ 为何**不含 data**:媒体负载动辄数 MB(生产实测:图均 495KB、最大 2.7MB),
// 而 RecallBlocks 要全表扫描有向量的块。若把 data 列进来,每次召回都会把
// 几十 MB 字节读进内存。块拥有自己的负载(同一行、同一生命周期),
// 但负载**按需加载**(见 BlockData)——这与被淘汰的 CAS 有本质区别:
// 那里字节归另一个存储层所有,这里归块自己。
const blockColumns = `id, modality, text_content, mime, size, width, height,
vector, fingerprint, source, tool, scene, semantic_type, created_at, updated_at`
func scanBlockRow(rows *sql.Rows) (MemoryBlock, error) {
var b MemoryBlock
var vectorJSON string
var scene sql.NullString
if err := rows.Scan(&b.ID, &b.Modality, &b.Text, &b.PayloadDigest,
if err := rows.Scan(&b.ID, &b.Modality, &b.Text,
&b.MIME, &b.Size, &b.Width, &b.Height, &vectorJSON,
&b.Fingerprint, &b.Source, &b.Tool, &scene, &b.SemanticType,
&b.CreatedAt, &b.UpdatedAt); err != nil {
@ -420,7 +440,7 @@ func (g *GraphDB) MemoryBlockEdges() ([]MemoryBlockEdge, error) {
func (g *GraphDB) BlocksForNode(nodeKind, nodeID string) ([]MemoryBlock, error) {
g.mu.RLock()
defer g.mu.RUnlock()
rows, err := g.db.Query(`SELECT b.id, b.modality, b.text_content, b.payload_digest,
rows, err := g.db.Query(`SELECT b.id, b.modality, b.text_content,
b.mime, b.size, b.width, b.height, b.vector, b.fingerprint, b.source, b.tool,
b.created_at, b.updated_at
FROM memory_block_edges e
@ -436,7 +456,7 @@ func (g *GraphDB) BlocksForNode(nodeKind, nodeID string) ([]MemoryBlock, error)
for rows.Next() {
var block MemoryBlock
var vectorJSON string
if err := rows.Scan(&block.ID, &block.Modality, &block.Text, &block.PayloadDigest,
if err := rows.Scan(&block.ID, &block.Modality, &block.Text,
&block.MIME, &block.Size, &block.Width, &block.Height, &vectorJSON,
&block.Fingerprint, &block.Source, &block.Tool, &block.CreatedAt,
&block.UpdatedAt); err != nil {

View File

@ -97,21 +97,12 @@ func (g *GraphDB) RecallBlocks(q BlockRecallQuery) ([]BlockHit, error) {
var hits []BlockHit
var skippedDim, skippedFP, scanned int
for rows.Next() {
var b MemoryBlock
var vectorJSON string
if err := rows.Scan(&b.ID, &b.Modality, &b.Text, &b.PayloadDigest,
&b.MIME, &b.Size, &b.Width, &b.Height, &vectorJSON,
&b.Fingerprint, &b.Source, &b.Tool, &b.Scene, &b.SemanticType,
&b.CreatedAt, &b.UpdatedAt); err != nil {
b, err := scanBlockRow(rows)
if err != nil {
return nil, err
}
scanned++
vec, err := decodeVector(vectorJSON)
if err != nil {
// 单条坏数据不该让整次召回失败 —— 但要记日志,否则会静默缺失。
log.Printf("[graph] recall blocks: skip %s: %v", b.ID, err)
continue
}
vec := b.Vector
// 维度不一致:跳过并计数。**不截断、不填充**——那会造出无意义的分数。
if len(vec) != dim {
skippedDim++

View File

@ -309,7 +309,9 @@ func (g *GraphDB) initSchema() error {
id TEXT PRIMARY KEY,
modality TEXT NOT NULL,
text_content TEXT DEFAULT '',
payload_digest TEXT DEFAULT '',
-- data 是媒体块的负载(union 的另一半):文本块用 text_content,
-- 媒体块用 data。字节随块同生同灭,块外不存在独立的字节存储层。
data BLOB,
mime TEXT DEFAULT '',
size INTEGER DEFAULT 0,
width INTEGER DEFAULT 0,
@ -436,6 +438,12 @@ func (g *GraphDB) initSchema() error {
// `type` 在 SQLite 里合法但与很多工具的保留字冲突
// (且 memory_blocks 已有 modality 列表达「模态」,语义类型是另一回事)。
tx.Exec(`ALTER TABLE memory_blocks ADD COLUMN semantic_type TEXT DEFAULT ''`)
// ★★★ 迁移9:媒体负载内联进块(2026-10-11)
//
// 淘汰独立的字节层(media.Store/CAS):媒体内容成为块自己的负载,
// 与文本块的 text_content 对称——一个块要么带文本、要么带媒体字节。
// 旧库的 payload_digest 列保留(迁移脚本据此回填 data),但不参与新写入。
tx.Exec(`ALTER TABLE memory_blocks ADD COLUMN data BLOB`)
// ★★★ 迁移8:memory_block_edges 升格为「边是独立单位」
//
@ -1100,7 +1108,7 @@ func (g *GraphDB) recallSortedLocked(keywords []string, seedEntities []string, d
var rank int
// rank 是第 1 列,先扫掉,剩下 15 列正好对上 scanBlockRow。
if err := rows.Scan(&rank, &bScratch.ID, &bScratch.Modality,
&bScratch.Text, &bScratch.PayloadDigest, &bScratch.MIME,
&bScratch.Text, &bScratch.MIME,
&bScratch.Size, &bScratch.Width, &bScratch.Height, &vecJSON,
&bScratch.Fingerprint, &bScratch.Source, &bScratch.Tool,
&sceneNull, &bScratch.SemanticType,
@ -1642,7 +1650,7 @@ func (g *GraphDB) GraphData() (map[string]interface{}, error) {
// ★ 这条 Scan 历史上就少扫了 scene(SELECT 用 blockConstants 改了,
// Scan 却没跟上)—— 与 semantic_type 是同一类错,
// 报错形如「expected 16 destination arguments in Scan, not 14」。
if err := brows.Scan(&block.ID, &block.Modality, &block.Text, &block.PayloadDigest,
if err := brows.Scan(&block.ID, &block.Modality, &block.Text,
&block.MIME, &block.Size, &block.Width, &block.Height, &vectorJSON,
&block.Fingerprint, &block.Source, &block.Tool, &block.Scene,
&block.SemanticType, &block.CreatedAt,

View File

@ -520,7 +520,7 @@ func TestMemoryBlocksAreFirstClassGraphNodes(t *testing.T) {
image := MemoryBlock{
ID: "block_image_1", Modality: BlockImage,
PayloadDigest: "0123456789abcdef", MIME: "image/png", Size: 1234,
Data: []byte("fake-png"), MIME: "image/png", Size: 1234,
Width: 768, Height: 512, Vector: []float64{0.1, 0.2, 0.3},
Fingerprint: "qwen:test", Source: "qq", Tool: "upload",
}
@ -545,7 +545,7 @@ func TestMemoryBlocksAreFirstClassGraphNodes(t *testing.T) {
gotImage = &blocks[i]
}
}
if gotImage == nil || gotImage.Modality != BlockImage || gotImage.PayloadDigest != image.PayloadDigest || gotImage.Fingerprint != image.Fingerprint || len(gotImage.Vector) != 3 {
if gotImage == nil || gotImage.Modality != BlockImage || string(gotImage.Data) != string(image.Data) || gotImage.Fingerprint != image.Fingerprint || len(gotImage.Vector) != 3 {
t.Fatalf("image block not round-tripped: %+v", gotImage)
}

View File

@ -368,7 +368,7 @@ func (idx *Indexer) FormatContext(ctx *InjectedContext) string {
if blk.Text != "" {
b.WriteString(truncateRunes(blk.Text, sceneSentenceMaxRunes))
} else {
b.WriteString(shortBlockDigest(blk.PayloadDigest))
b.WriteString(shortBlockDigest(blk.ID))
}
}
b.WriteString("\n")
@ -419,6 +419,10 @@ const sceneSentenceMaxRunes = 60
const maxSceneRecallBlocks = 3
// shortBlockDigest 取 digest 前 12 位做展示(与 L3 里引用媒体的写法一致)。
// shortBlockDigest 把块 ID 截短,供提示词里指认媒体块。
//
// 参数曾是内容 digest;块自带负载后,指认单位改成**块 ID**(模型按块 ID
// 取回该块的负载,不再经内容寻址层中转)。
func shortBlockDigest(d string) string {
if len(d) <= 12 {
return d

View File

@ -87,20 +87,21 @@ func (g *GraphDB) MigrateLegacyMediaEntities(resolve LegacyMediaResolver) (block
}
defer tx.Rollback()
// 同一份字节可能被多个旧实体引用(重复注入的同一张图),
// 迁移后应指向同一个块:块的身份是内容,不是实体行。
blockIDForDigest := make(map[string]string)
// 同一份内容可能被多个旧实体引用(重复注入的同一张图),迁移后应指向
// 同一个块。旧实体名里的**短 digest 就是内容身份**(同内容⇒同短 digest),
// 因此以它为去重键——块自带负载后不再有独立的 payload_digest 字段可依赖。
blockIDForShort := make(map[string]string)
for _, e := range legacy {
short := LegacyMediaEntityDigest(e.name)
if short != "" {
if block, ok := resolve(short); ok && block.PayloadDigest != "" {
id, seen := blockIDForDigest[block.PayloadDigest]
if block, ok := resolve(short); ok {
id, seen := blockIDForShort[short]
if !seen {
if err := insertMigratedBlock(tx, block); err != nil {
return 0, 0, fmt.Errorf("migrate legacy media %s: %w", short, err)
}
blockIDForDigest[block.PayloadDigest] = block.ID
blockIDForShort[short] = block.ID
id = block.ID
blocks++
}
@ -145,11 +146,11 @@ func insertMigratedBlock(tx *sql.Tx, block MemoryBlock) error {
created = time.Now()
}
_, err = tx.Exec(`INSERT INTO memory_blocks (
id, modality, text_content, payload_digest, mime, size, width, height,
id, modality, text_content, data, mime, size, width, height,
vector, fingerprint, source, tool, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO NOTHING`,
block.ID, block.Modality, block.Text, block.PayloadDigest, block.MIME,
block.ID, block.Modality, block.Text, block.Data, block.MIME,
block.Size, block.Width, block.Height, string(vectorJSON), block.Fingerprint,
block.Source, block.Tool, created, time.Now())
return err

View File

@ -487,7 +487,7 @@ func putBlockTx(tx *sql.Tx, b MemoryBlock) (bool, error) {
}
_, err := tx.Exec(`INSERT INTO memory_blocks
(id, modality, text_content, payload_digest, mime, size, width, height,
(id, modality, text_content, data, mime, size, width, height,
vector, fingerprint, source, tool, scene, semantic_type, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
@ -499,7 +499,7 @@ func putBlockTx(tx *sql.Tx, b MemoryBlock) (bool, error) {
semantic_type = CASE WHEN excluded.semantic_type != ''
THEN excluded.semantic_type ELSE memory_blocks.semantic_type END,
`+tsUpdate,
b.ID, b.Modality, b.Text, b.PayloadDigest, b.MIME, b.Size, b.Width, b.Height,
b.ID, b.Modality, b.Text, b.Data, b.MIME, b.Size, b.Width, b.Height,
vectorJSON, b.Fingerprint, b.Source, b.Tool, b.Scene, b.SemanticType,
created, updated)
if err != nil {

View File

@ -471,7 +471,7 @@ func (g *GraphDB) RecallByScene(scenes []string, limit int) (*SceneRecall, error
}
}
blockQuery := `SELECT b.id, b.modality, b.text_content, b.payload_digest, b.mime,
blockQuery := `SELECT b.id, b.modality, b.text_content, b.mime,
b.size, b.width, b.height, b.fingerprint, b.source, b.tool,
COALESCE(b.scene, ''), COALESCE(b.semantic_type, ''),
b.created_at, b.updated_at, MAX(sr.weight) AS w
@ -491,7 +491,7 @@ func (g *GraphDB) RecallByScene(scenes []string, limit int) (*SceneRecall, error
for brows.Next() {
var b MemoryBlock
var w float64
if err := brows.Scan(&b.ID, &b.Modality, &b.Text, &b.PayloadDigest, &b.MIME,
if err := brows.Scan(&b.ID, &b.Modality, &b.Text, &b.MIME,
&b.Size, &b.Width, &b.Height, &b.Fingerprint, &b.Source, &b.Tool,
&b.Scene, &b.SemanticType, &b.CreatedAt, &b.UpdatedAt, &w); err != nil {
return nil, err

View File

@ -344,7 +344,7 @@ func TestSceneBlocks(t *testing.T) {
defer g.Close()
blocks := []MemoryBlock{
{ID: "blk_a", Modality: "image", Text: "老大发的排班表截图", PayloadDigest: "aaaa1111bbbb2222", Scene: "chan:qq"},
{ID: "blk_a", Modality: "image", Text: "老大发的排班表截图", Data: []byte("png-bytes"), Scene: "chan:qq"},
{ID: "blk_b", Modality: "text", Text: "无关场面的转写"},
}
if err := g.PutMemoryBlocks(blocks); err != nil {
@ -375,7 +375,7 @@ func TestSceneBlocks(t *testing.T) {
// 无场景重写不得抹掉已挂的场景(静默降级防护)
if err := g.PutMemoryBlocks([]MemoryBlock{
{ID: "blk_a", Modality: "image", Text: "重新描述", PayloadDigest: "aaaa1111bbbb2222"},
{ID: "blk_a", Modality: "image", Text: "重新描述", Data: []byte("png-bytes")},
}); err != nil {
t.Fatalf("重写块: %v", err)
}
@ -468,7 +468,7 @@ func TestFormatContextSceneBlocks(t *testing.T) {
t.Fatal(err)
}
if err := g.PutMemoryBlocks([]MemoryBlock{
{ID: "blk_x", Modality: "image", Text: "老大发的排班表截图", PayloadDigest: "cccc3333dddd4444", Scene: "chan:qq"},
{ID: "blk_x", Modality: "image", Text: "老大发的排班表截图", Data: []byte("png-bytes"), Scene: "chan:qq"},
}); err != nil {
t.Fatal(err)
}

View File

@ -4,6 +4,7 @@ import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"strings"
"time"
)
@ -226,3 +227,126 @@ func putTripleBlocksTx(tx *sql.Tx, t Triple, sessionID string, turnID int) (stri
edgeID, err := res.LastInsertId()
return src.ID, dst.ID, edgeID, created, err
}
// ── 媒体块 ──────────────────────────────────────────────
// MediaBlockID 由**媒体内容字节**派生媒体块的 ID。
//
// ★ 为什么媒体块的 ID 也必须内容派生
// ---------------------------------
// 块 ID 是本仓的记忆身份,既有约定一律内容派生:
// - 原句块 blk_src_<sha256(text)[:12]>
// - 实体块 blk_ent_<sha256(name)[:12]>
// - 蒸馏块 blk_<sha256(sentence+subject+dim+value)[:12]>
// 而媒体块此前用时间戳+序号(blk_<unixnano>_<n>),是唯一的例外。
//
// 淘汰 CAS 之后,块自己承载内容,于是「同一份内容只应有一个块」这条
// 不再由外部的内容寻址层保证,而必须由**块 ID 本身**保证:
// 同内容 ⇒ 同 ID ⇒ PutMemoryBlocks 的 ON CONFLICT 天然去重。
// 顺带让跨插件边界的引用(只带 ID 前缀)也能唯一定位。
//
// ★ 为何把 MIME 也掺进去:同样字节在不同 MIME 下是不同的媒体
// (同一段字节按 image/png 与 application/octet-stream 解读得到不同结果),
// 且 MIME 参与向量计算,掺入可避免「先入者定 MIME」的静默错配。
func MediaBlockID(mime string, data []byte) string {
h := sha256.New()
h.Write([]byte(strings.TrimSpace(mime)))
h.Write([]byte{0})
h.Write(data)
return "blk_media_" + hex.EncodeToString(h.Sum(nil)[:12])
}
// BlockByIDPrefix 按块 ID 的**前缀**取块(前缀唯一时命中)。
//
// 用途:跨进程/跨层的轻量引用(如插件只带块 ID 的前 12 位)。
// 前缀匹配到多条时返回 false —— 宁可不解析也不猜一个(猜错=挂错媒体)。
func (g *GraphDB) BlockByIDPrefix(prefix string) (MemoryBlock, bool, error) {
prefix = strings.TrimSpace(prefix)
if prefix == "" {
return MemoryBlock{}, false, nil
}
g.mu.RLock()
defer g.mu.RUnlock()
rows, err := g.db.Query(`SELECT `+blockColumns+`
FROM memory_blocks WHERE id LIKE ? LIMIT 2`, prefix+"%")
if err != nil {
return MemoryBlock{}, false, err
}
defer rows.Close()
var got []MemoryBlock
for rows.Next() {
b, err := scanBlockRow(rows)
if err != nil {
return MemoryBlock{}, false, err
}
got = append(got, b)
}
if err := rows.Err(); err != nil {
return MemoryBlock{}, false, err
}
if len(got) != 1 {
return MemoryBlock{}, false, nil // 0 条或歧义(≥2)都不解析
}
return got[0], true, nil
}
// BlockData 取单个块的负载字节(按需加载)。
//
// ★ 为何单独一个方法:blockColumns 刻意不含 data(召回只比向量,
// 带上负载会让每次全表召回读入几十 MB)。需要字节的路径显式调本方法。
func (g *GraphDB) BlockData(blockID string) ([]byte, error) {
g.mu.RLock()
defer g.mu.RUnlock()
var data []byte
err := g.db.QueryRow(`SELECT data FROM memory_blocks WHERE id = ?`, blockID).Scan(&data)
if err != nil {
return nil, err
}
return data, nil
}
// StaleBlockVectors 返回向量为空、或向量 fingerprint 与当前空间不符的块。
//
// 用途:启动时把历史媒体块/文档块的向量重算到当前多模态空间(换模型、
// 换维度、首次接入嵌入服务、嵌入服务离线后重上线)。
//
// 为何需要它:向量是召回的唯一依据,而历史块可能是在另一个空间里算的。
// fingerprint 不一致的向量不能参与余弦比较(两个坐标系的值比大小无意义),
// 必须重算而不是留着当"看起来还行"的噪声。
//
// 只取非文本块:文本块的向量由文本路径维护,不在多模态空间迁移范围内。
func (g *GraphDB) StaleBlockVectors(currentFP string) ([]MemoryBlock, error) {
g.mu.RLock()
defer g.mu.RUnlock()
rows, err := g.db.Query(`SELECT `+blockColumns+`
FROM memory_blocks
WHERE modality != ?
AND (vector IS NULL OR vector = '' OR vector = 'null' OR fingerprint != ?)
ORDER BY created_at`, string(BlockText), currentFP)
if err != nil {
return nil, err
}
defer rows.Close()
var out []MemoryBlock
for rows.Next() {
b, err := scanBlockRow(rows)
if err != nil {
return nil, err
}
out = append(out, b)
}
return out, rows.Err()
}
// SetBlockVector 给单个块写入新向量与指纹(迁移用)。只改向量列,不碰负载。
func (g *GraphDB) SetBlockVector(blockID string, vec []float64, fp string) error {
vectorJSON, err := json.Marshal(vec)
if err != nil {
return err
}
g.mu.Lock()
defer g.mu.Unlock()
_, err = g.db.Exec(`UPDATE memory_blocks SET vector = ?, fingerprint = ?, updated_at = ?
WHERE id = ?`, string(vectorJSON), fp, time.Now(), blockID)
return err
}

View File

@ -89,10 +89,8 @@ type Registry struct {
memDB *memory.GraphDB
textMem *text.Memory
docStore *doc.Store
// mediaStore 让插件写入的记忆也能带媒体。
// 为 nil 时(配置关闭或初始化失败)插件侧记忆包装退化为纯文本行为,
// 与本特性上线前完全一致。
mediaStore *media.Store
// mediaStore 已随 CAS 淘汰(块自带负载,见 plan.md)。
// 不保留字段:留着会诱导后来者再接线,走回头路。
ks *knowledge.Store
mgr *agentAPI.ProviderManager
cfgReg *internalConfig.ConfigRegistry
@ -197,7 +195,7 @@ func (r *Registry) SetEventBus(evBus *events.Bus) { r.
func (r *Registry) SetMemory(memDB *memory.GraphDB) { r.memDB = memDB }
func (r *Registry) SetTextMemory(tm *text.Memory) { r.textMem = tm }
func (r *Registry) SetDocStore(ds *doc.Store) { r.docStore = ds }
func (r *Registry) SetMediaStore(ms *media.Store) { r.mediaStore = ms }
func (r *Registry) SetMediaStore(ms *media.Store) {}
func (r *Registry) SetKnowledge(ks *knowledge.Store) { r.ks = ks }
func (r *Registry) SetProviderManager(mgr *agentAPI.ProviderManager) { r.mgr = mgr }
func (r *Registry) SetConfigRegistry(cfgReg *internalConfig.ConfigRegistry) { r.cfgReg = cfgReg }
@ -362,13 +360,12 @@ func (r *Registry) buildSDK(name string) *sdk.PluginSDK {
return sdk.New(name, sdk.SDKConfig{
IOManager: r.iom,
EventBus: r.evBus,
// 带 media 的包装:插件提交的三元组/文档/文本事件里的媒体会落进 CAS
// 并挂上引用。传入插件名仅用于日志溯源(哪个插件写的媒体)。
Memory: sdk.NewGraphMemoryWithMedia(name, r.memDB, r.mediaStore),
TextMemory: sdk.NewTextMemoryWithMedia(name, r.textMem, r.mediaStore),
DocMemory: sdk.NewDocMemoryWithMedia(name, r.docStore, r.mediaStore),
// 块自带负载(媒体字节在块的 Data),不再需要独立的媒体存储句柄。
// 插件名仅用于日志溯源(哪个插件写的块)。
Memory: sdk.NewGraphMemoryWithPlugin(name, r.memDB),
TextMemory: sdk.NewTextMemoryWithPlugin(name, r.textMem),
DocMemory: sdk.NewDocMemoryWithPlugin(name, r.docStore),
Knowledge: sdk.NewKnowledge(r.ks),
Media: sdk.NewMedia(r.mediaStore),
LLM: sdk.NewLLM(r.mgr, r.cfgReg, r.lua, r.baseKey),
Settings: sett,
RegTool: regTool,

View File

@ -44,7 +44,7 @@ func (f *fakeGraphMemory) BlocksChangedSince(since time.Time, limit int) ([]sdk.
}
out = append(out, sdk.MemoryBlockView{
ID: b.ID, Modality: string(b.Modality), Text: b.Text,
PayloadDigest: b.PayloadDigest, MIME: b.MIME,
HasData: len(b.Data) > 0, MIME: b.MIME,
Source: b.Source, Tool: b.Tool, Scene: b.Scene,
CreatedAt: b.CreatedAt, UpdatedAt: b.UpdatedAt,
})
@ -74,10 +74,10 @@ func graphFixture() map[string]interface{} {
// 修复前 pulse 读的是它,而它永远落在窗口外 ⇒ 恒返回空。
"memory_blocks": []memory.MemoryBlock{
{ID: "b_old", Modality: memory.BlockModality("text"), Text: "老块",
PayloadDigest: "d0", Vector: []float64{0.1, 0.2, 0.3},
Vector: []float64{0.1, 0.2, 0.3},
CreatedAt: old, UpdatedAt: now.Add(-8 * time.Hour)},
{ID: "b_new", Modality: memory.BlockModality("text"), Text: "刚学到的东西",
PayloadDigest: "d1", Vector: []float64{0.1, 0.2, 0.3},
Vector: []float64{0.1, 0.2, 0.3},
CreatedAt: now, UpdatedAt: now},
},
}
@ -226,7 +226,7 @@ func TestGraphPulse_IgnoresFrozenLegacyNodes(t *testing.T) {
// 块表:有一条刚更新(活图谱的真实形态)
"memory_blocks": []memory.MemoryBlock{
{ID: "b_live", Modality: memory.BlockModality("text"), Text: "刚写入的块",
PayloadDigest: "dx", CreatedAt: now, UpdatedAt: now},
CreatedAt: now, UpdatedAt: now},
},
}

View File

@ -127,20 +127,20 @@ func (h *Handler) handleMemoryTools(w http.ResponseWriter, r *http.Request) {
// 所以这里显式裁掉 vector,而不是让 GraphData 返回值带个开关:
// 本接口的语义就是「图谱的可视化数据」,让唯一调用方拿到它要的东西。
type graphBlockView struct {
ID string `json:"id"`
Modality string `json:"modality"`
Text string `json:"text,omitempty"`
PayloadDigest string `json:"payload_digest"`
MIME string `json:"mime,omitempty"`
Size int64 `json:"size"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
Fingerprint string `json:"fingerprint,omitempty"`
Source string `json:"source,omitempty"`
Tool string `json:"tool,omitempty"`
Scene string `json:"scene,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID string `json:"id"`
Modality string `json:"modality"`
Text string `json:"text,omitempty"`
HasData bool `json:"has_data"`
MIME string `json:"mime,omitempty"`
Size int64 `json:"size"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
Fingerprint string `json:"fingerprint,omitempty"`
Source string `json:"source,omitempty"`
Tool string `json:"tool,omitempty"`
Scene string `json:"scene,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// graphDataForVisual 把 GraphData 的原始 map 裁成可视化视图(去掉稠密向量)。
@ -162,20 +162,20 @@ func graphDataForVisual(data map[string]interface{}) map[string]interface{} {
for i := range blocks {
b := blocks[i]
views = append(views, graphBlockView{
ID: b.ID,
Modality: string(b.Modality),
Text: b.Text,
PayloadDigest: b.PayloadDigest,
MIME: b.MIME,
Size: b.Size,
Width: b.Width,
Height: b.Height,
Fingerprint: b.Fingerprint,
Source: b.Source,
Tool: b.Tool,
Scene: b.Scene,
CreatedAt: b.CreatedAt,
UpdatedAt: b.UpdatedAt,
ID: b.ID,
Modality: string(b.Modality),
Text: b.Text,
HasData: len(b.Data) > 0,
MIME: b.MIME,
Size: b.Size,
Width: b.Width,
Height: b.Height,
Fingerprint: b.Fingerprint,
Source: b.Source,
Tool: b.Tool,
Scene: b.Scene,
CreatedAt: b.CreatedAt,
UpdatedAt: b.UpdatedAt,
})
}
out["memory_blocks"] = views
@ -243,16 +243,16 @@ func (h *Handler) handleMemoryGraphPulse(w http.ResponseWriter, r *http.Request)
pulse := make([]map[string]interface{}, 0, len(blocks))
for _, b := range blocks {
pulse = append(pulse, map[string]interface{}{
"id": b.ID,
"name": b.Text,
"modality": string(b.Modality),
"payload_digest": b.PayloadDigest,
"mime": b.MIME,
"tool": b.Tool,
"scene": b.Scene,
"source": b.Source,
"updated_at": b.UpdatedAt,
"created_at": b.CreatedAt,
"id": b.ID,
"name": b.Text,
"modality": b.Modality,
"has_data": b.HasData,
"mime": b.MIME,
"tool": b.Tool,
"scene": b.Scene,
"source": b.Source,
"updated_at": b.UpdatedAt,
"created_at": b.CreatedAt,
})
}
writeJSON(w, http.StatusOK, map[string]interface{}{

View File

@ -12,16 +12,16 @@ import (
// SDK 接口只暴露端点真正要用的字段(原来是先 GraphData 全量再转成
// graphBlockView 做同样的事,只是绕了一圈)。
type MemoryBlockView struct {
ID string `json:"id"`
Modality string `json:"modality"`
Text string `json:"text,omitempty"`
PayloadDigest string `json:"payload_digest,omitempty"`
MIME string `json:"mime,omitempty"`
Source string `json:"source,omitempty"`
Tool string `json:"tool,omitempty"`
Scene string `json:"scene,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID string `json:"id"`
Modality string `json:"modality"`
Text string `json:"text,omitempty"`
HasData bool `json:"has_data"` // 媒体负载在块内(CAS 淘汰后),只报有无不回显内容
MIME string `json:"mime,omitempty"`
Source string `json:"source,omitempty"`
Tool string `json:"tool,omitempty"`
Scene string `json:"scene,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// MemoryAPI 是内置插件使用的全量图记忆接口。

View File

@ -1,6 +1,7 @@
package sdk
import (
"bytes"
"fmt"
"log"
"sync/atomic"
@ -8,30 +9,21 @@ import (
"github.com/JianFeeeee/HomeAgent/internal/memory"
doc "github.com/JianFeeeee/HomeAgent/internal/memory/document"
"github.com/JianFeeeee/HomeAgent/internal/memory/media"
"github.com/JianFeeeee/HomeAgent/internal/memory/text"
)
// 插件侧记忆接口的实现(SDK 桥接层)。
//
// 这一层原先的缺陷是**静默裁字段**:插件把 Triple / Doc 交进来,包装层只挑
// 自己认识的几个字段转成内部结构,其余丢弃且不报错。两侧都中招:
// - 图记忆:丢 Confidence/SubjectType/ObjectType/SentenceText,又走 Commit
// 而非 CommitWithMedia,于是 sentences 表没有落点,媒体块无从挂接;
// - 知识库:Query 只回 ID/Title/Content,Insert 只写这三个,读写两个方向
// 都把媒体元数据裁掉。
// 自己认识的几个字段转成内部结构,其余丢弃且不报错。
//
// 现在的规则:内部结构有的字段一律透传;媒体一律变成一等记忆块。
// 媒体存储为 nil 时整条链路静默降级为纯文本行为(媒体是记忆增强,不是必需品)。
const sdkShortDigestLen = 12
func sdkShortDigest(d string) string {
if len(d) > sdkShortDigestLen {
return d[:sdkShortDigestLen]
}
return d
}
//
// ★ 2026-10-11:块自带负载(media.Store/CAS 已彻底淘汰)。
//
// 一个 MemoryBlock 要么是文本块(负载在 Text),要么是媒体块(负载在 Data)。
// 不再有块外的字节存储层,因此本层也不再需要 media.Store 句柄 ——
// 附件字节直接进块的 Data,随块同生同灭。
// sdkBlockSeq 保证块 ID 全局唯一:Graph 的 memory_blocks 以 id 为主键,
// 不同文档里序号相同的块会在 L2→L3 迁移时相互覆盖。
@ -41,105 +33,70 @@ func sdkNewBlockID() string {
return fmt.Sprintf("blk_%d_%d", time.Now().UnixNano(), atomic.AddInt64(&sdkBlockSeq, 1))
}
// sdkBlockModality 把 CAS 的媒体大类映射为一等记忆块的模态。
func sdkBlockModality(k media.Kind) memory.BlockModality {
switch k {
case media.KindImage:
// sdkBlockModality 按 MIME 大类推断块的模态。
func sdkBlockModality(mime string) memory.BlockModality {
switch {
case bytes.HasPrefix([]byte(mime), []byte("image/")):
return memory.BlockImage
case media.KindVideo:
case bytes.HasPrefix([]byte(mime), []byte("video/")):
return memory.BlockVideo
case media.KindAudio:
case bytes.HasPrefix([]byte(mime), []byte("audio/")):
return memory.BlockAudio
default:
return memory.BlockText
}
}
// sdkBlockForDigest 把一份已入库的媒体变成一个一等记忆块。
// 块自身携带 digest/向量/ fingerprint;CAS 只提供字节与元数据,不参与生命周期。
func sdkBlockForDigest(ms *media.Store, digest string) (memory.MemoryBlock, bool) {
it, err := ms.Stat(digest)
if err != nil || it == nil {
return memory.MemoryBlock{}, false
}
return memory.MemoryBlock{
ID: sdkNewBlockID(),
Modality: sdkBlockModality(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
}
// sdkBlocksFromDigests 为显式 digest 列表构造一等块(去重)。
// digest 可以是短前缀,内部会先补全。
func sdkBlocksFromDigests(ms *media.Store, digests []string) []memory.MemoryBlock {
if ms == nil || len(digests) == 0 {
// sdkBlocksFromAttachments 把附件直接变成一等记忆块(字节进块的 Data)。
//
// 去重按内容字节:同一份内容被多次提交时只留一个块(块的身份仍是内容,
// 只是这个"内容身份"不再需要一层独立的寻址存储来承载)。
func sdkBlocksFromAttachments(atts []MediaAttachment, tool string) []memory.MemoryBlock {
if len(atts) == 0 {
return nil
}
seen := map[string]bool{}
var blocks []memory.MemoryBlock
for _, d := range digests {
if d == "" {
for _, a := range atts {
if len(a.Data) == 0 {
continue
}
full, err := ms.ResolvePrefix(d)
if err != nil || seen[full] {
key := string(a.Data)
if seen[key] {
continue
}
seen[full] = true
if b, ok := sdkBlockForDigest(ms, full); ok {
blocks = append(blocks, b)
}
}
return blocks
}
// sdkPutAttachment 把一份附件解析成完整 digest。
//
// 两种入口:带 Data 的是新内容(落进 CAS,相同字节自动去重);
// 只给 Digest 的是引用已有内容(补全前缀即可)。两者都不给则无效。
func sdkPutAttachment(ms *media.Store, a MediaAttachment, tool string) (string, error) {
if len(a.Data) > 0 {
seen[key] = true
mime := a.MIME
if mime == "" {
mime = "application/octet-stream"
}
return ms.Put(a.Data, media.Item{
MIME: mime,
Tool: tool,
OriginPath: a.Name,
blocks = append(blocks, memory.MemoryBlock{
ID: sdkNewBlockID(),
Modality: sdkBlockModality(mime),
Data: a.Data,
MIME: mime,
Size: int64(len(a.Data)),
Source: tool,
CreatedAt: time.Now(),
})
}
if a.Digest == "" {
return "", fmt.Errorf("附件既无 data 也无 digest")
}
full, err := ms.ResolvePrefix(a.Digest)
if err != nil {
return "", fmt.Errorf("digest %s: %w", a.Digest, err)
}
return full, nil
return blocks
}
// ---------- 图记忆 ----------
type graphMemory struct {
db *memory.GraphDB
ms *media.Store
plugin string
}
func NewGraphMemory(db *memory.GraphDB) MemoryAPI { return &graphMemory{db: db} }
// NewGraphMemoryWithMedia 创建带媒体能力的图记忆包装。plugin 仅用于日志溯源。
func NewGraphMemoryWithMedia(plugin string, db *memory.GraphDB, ms *media.Store) MemoryAPI {
return &graphMemory{db: db, ms: ms, plugin: plugin}
// NewGraphMemoryWithPlugin 创建带上报名的图记忆包装。
//
// 旧签名曾接 media.Store;块自带负载后不再需要它,故改名为 WithPlugin。
func NewGraphMemoryWithPlugin(plugin string, db *memory.GraphDB) MemoryAPI {
return &graphMemory{db: db, plugin: plugin}
}
func (m *graphMemory) Recall(query []string, depth int) ([]Entity, []Relation, error) {
@ -154,8 +111,6 @@ func (m *graphMemory) Recall(query []string, depth int) ([]Entity, []Relation, e
for i, e := range result.Entities {
entities[i] = Entity{Name: e.Name, Type: e.Type, MentionCount: e.MentionCount}
}
// Confidence 此前被丢弃:插件拿不到置信度就无法判断一条关系可不可信,
// 只能把所有召回结果等同对待。
relations := make([]Relation, len(result.Relations))
for i, r := range result.Relations {
relations[i] = Relation{
@ -168,8 +123,10 @@ func (m *graphMemory) Recall(query []string, depth int) ([]Entity, []Relation, e
return entities, relations, nil
}
// Commit 把插件的三元组写入图库,并把结构化 MediaDigests 变成 L3 一等块。
// Commit 把插件的三元组写入图库,并把结构化媒体变成 L3 一等块。
//
// 媒体通过 sentence --contains--> block 结构边挂接,不读写任何正文 marker。
// 块自带字节负载,因此需要媒体元数据(Data/MIME)而不仅是一个 digest。
func (m *graphMemory) Commit(triples []Triple) error {
if m.db == nil {
return nil
@ -200,12 +157,11 @@ func (m *graphMemory) Commit(triples []Triple) error {
// bindSentences 把每个三元组显式携带的媒体变成 L3 一等记忆块,
// 并以 sentence --contains--> block 结构边关联。
//
// 不再往句子文本里写 marker、也不再从文本反解 digest:归属由结构化字段直接给出。
// ★ sentenceIDs 现在是「原句文本 → 原句**块 ID**」(原为 sentences 表行号)。
//
// sentences 表退场后行号不存在,媒体边改挂到原句块上。
// ★ sentenceIDs 是「原句文本 → 原句**块 ID**」(原为 sentences 表行号)。
// ★ 媒体负载:Triple.MediaDigests 只带短前缀(跨进程/跨层的轻量引用),
// 这里按前缀在**本进程已建的块**里找不到字节时如实记日志,不静默造空块。
func (m *graphMemory) bindSentences(sentenceIDs map[string]string, triples []memory.Triple) {
if m.ms == nil || m.db == nil || len(sentenceIDs) == 0 {
if m.db == nil || len(sentenceIDs) == 0 {
return
}
bound := 0
@ -217,13 +173,22 @@ func (m *graphMemory) bindSentences(sentenceIDs map[string]string, triples []mem
if sid == "" {
continue
}
for _, b := range sdkBlocksFromDigests(m.ms, t.MediaDigests) {
for _, ref := range t.MediaDigests {
// ref 可以是完整块 ID,也可以是前缀(插件跨进程只带短引用)。
// 媒体块 ID 是内容派生的 blk_media_<hash>,故前缀足以唯一定位。
b, ok, err := m.db.BlockByIDPrefix(ref)
if err != nil {
log.Printf("[sdk media] 插件 %s 查块 %s 失败: %v", m.plugin, ref, err)
continue
}
if !ok {
log.Printf("[sdk media] 插件 %s 的媒体引用 %s 无唯一对应块,跳过", m.plugin, ref)
continue
}
if err := m.db.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil {
log.Printf("[sdk media] 插件 %s 写入 L3 记忆块失败: %v", m.plugin, err)
continue
}
// ★ 起点是**块**(原句块)而非 sentence 表行 ——
// 句子的承载者已从 sentences 表迁移到 blk_src_<hash>。
if err := m.db.AddMemoryBlockEdge("block", sid, "block", b.ID, "contains"); err != nil {
log.Printf("[sdk media] 插件 %s 建立句子→块边失败: %v", m.plugin, err)
continue
@ -265,8 +230,6 @@ func (m *graphMemory) GraphData() (map[string]interface{}, error) {
}
// BlocksChangedSince 实现 MemoryAPI(星图轻量轮询)。
//
// 直接走库侧 SQL 过滤,不取全量快照后再筛——见接口注释里的实测对比。
func (m *graphMemory) BlocksChangedSince(since time.Time, limit int) ([]MemoryBlockView, error) {
if m.db == nil {
return nil, nil
@ -275,20 +238,19 @@ func (m *graphMemory) BlocksChangedSince(since time.Time, limit int) ([]MemoryBl
if err != nil {
return nil, err
}
// 映射到边界面视图:只带端点要用的字段,不把内部结构泄出去。
out := make([]MemoryBlockView, 0, len(blocks))
for _, b := range blocks {
out = append(out, MemoryBlockView{
ID: b.ID,
Modality: string(b.Modality),
Text: b.Text,
PayloadDigest: b.PayloadDigest,
MIME: b.MIME,
Source: b.Source,
Tool: b.Tool,
Scene: b.Scene,
CreatedAt: b.CreatedAt,
UpdatedAt: b.UpdatedAt,
ID: b.ID,
Modality: string(b.Modality),
Text: b.Text,
HasData: len(b.Data) > 0,
MIME: b.MIME,
Source: b.Source,
Tool: b.Tool,
Scene: b.Scene,
CreatedAt: b.CreatedAt,
UpdatedAt: b.UpdatedAt,
})
}
return out, nil
@ -298,15 +260,14 @@ func (m *graphMemory) BlocksChangedSince(since time.Time, limit int) ([]MemoryBl
type textMemoryImpl struct {
tm *text.Memory
ms *media.Store
plugin string
}
func NewTextMemory(tm *text.Memory) TextMemoryAPI { return &textMemoryImpl{tm: tm} }
// NewTextMemoryWithMedia 创建带媒体能力的文本记忆包装。
func NewTextMemoryWithMedia(plugin string, tm *text.Memory, ms *media.Store) TextMemoryAPI {
return &textMemoryImpl{tm: tm, ms: ms, plugin: plugin}
// NewTextMemoryWithPlugin 创建带上报名的文本记忆包装。
func NewTextMemoryWithPlugin(plugin string, tm *text.Memory) TextMemoryAPI {
return &textMemoryImpl{tm: tm, plugin: plugin}
}
// Append 追加一条文本事件。
@ -355,19 +316,20 @@ func (m *textMemoryImpl) Stats() map[string]interface{} {
type docMemoryImpl struct {
ds *doc.Store
ms *media.Store
plugin string
}
func NewDocMemory(ds *doc.Store) DocMemoryAPI { return &docMemoryImpl{ds: ds} }
// NewDocMemoryWithMedia 创建带媒体能力的文档记忆包装。
func NewDocMemoryWithMedia(plugin string, ds *doc.Store, ms *media.Store) DocMemoryAPI {
return &docMemoryImpl{ds: ds, ms: ms, plugin: plugin}
// NewDocMemoryWithPlugin 创建带上报名的文档记忆包装。
func NewDocMemoryWithPlugin(plugin string, ds *doc.Store) DocMemoryAPI {
return &docMemoryImpl{ds: ds, plugin: plugin}
}
// Query 检索文档,并从文档持有的一等块补齐媒体元数据。
// 只返回 digest/MIME,不返回字节或生成式描述;需要字节时按 digest 单取。
//
// 只返回块的 MIME/ID,不返回字节:字节留在这条文档的块里,
// 需要时按块 ID 单取(避免把整批媒体读进内存)。
func (m *docMemoryImpl) Query(text string, topK int) []*Doc {
if m.ds == nil {
return nil
@ -384,37 +346,37 @@ 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 || d == nil {
if d == nil {
return
}
for _, b := range d.Blocks {
if b.PayloadDigest == "" {
if b.Modality == memory.BlockText && len(b.Data) == 0 {
continue
}
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)
out.MediaDigests = append(out.MediaDigests, b.ID)
out.Attachments = append(out.Attachments, MediaAttachment{
Digest: b.ID,
MIME: b.MIME,
})
}
}
// appendBlocks 把新的块追加到已有块之后(按 digest 去重)。
// appendBlocks 把新的块追加到已有块之后(按内容去重)。
func appendBlocks(existing []memory.MemoryBlock, add []memory.MemoryBlock) []memory.MemoryBlock {
seen := make(map[string]bool, len(existing))
for _, b := range existing {
seen[b.PayloadDigest] = true
seen[string(b.Data)] = true
}
for _, b := range add {
if b.PayloadDigest != "" && seen[b.PayloadDigest] {
k := string(b.Data)
if k != "" && seen[k] {
continue
}
existing = append(existing, b)
if b.PayloadDigest != "" {
seen[b.PayloadDigest] = true
if k != "" {
seen[k] = true
}
}
return existing
@ -425,9 +387,9 @@ func (m *docMemoryImpl) Insert(d *Doc) error { return m.InsertWithMedia(d, nil)
// InsertWithMedia 写入文档并关联媒体。
//
// 媒体直接成为文档持有的一等记忆块:落进 CAS 拿到 digest,
// 再变成块挂到文档上。不往正文写 marker——文档向量会融合这些块的
// 媒体向量(同一统一空间),图片按自己的向量被召回。
// 媒体直接成为文档持有的一等记忆块(字节在块的 Data)。
// 不往正文写 marker——文档向量会融合这些块的媒体向量(同一统一空间),
// 图片按自己的向量被召回。
func (m *docMemoryImpl) InsertWithMedia(d *Doc, attachments []MediaAttachment) error {
if m.ds == nil || d == nil {
return nil
@ -437,75 +399,25 @@ func (m *docMemoryImpl) InsertWithMedia(d *Doc, attachments []MediaAttachment) e
target.Source = "plugin:" + m.plugin
}
digests := m.storeAttachments(attachments)
// 一等记忆块:文档直接持有块本身,CAS 只提供字节与向量。
target.Blocks = appendBlocks(target.Blocks, sdkBlocksFromDigests(m.ms, digests))
target.Blocks = appendBlocks(target.Blocks,
sdkBlocksFromAttachments(attachments, "plugin_doc:"+m.plugin))
if err := m.ds.Insert(target); err != nil {
return err
}
// 回填给调用方:ID 是新建时内核生成的。
d.ID = target.ID
return nil
}
// storeAttachments 把附件落库,返回全部完整 digest。
func (m *docMemoryImpl) storeAttachments(atts []MediaAttachment) []string {
if m.ms == nil || len(atts) == 0 {
return nil
}
seen := map[string]bool{}
var digests []string
for _, a := range atts {
full, err := sdkPutAttachment(m.ms, a, "plugin_doc:"+m.plugin)
if err != nil {
// 媒体存不进去不该让文档写入失败——它是记忆增强,不是文档必需品
log.Printf("[sdk media] 插件 %s 文档附件入库失败: %v", m.plugin, err)
continue
}
if seen[full] {
continue
}
seen[full] = true
digests = append(digests, full)
}
return digests
}
// Remove 删除文档,并删除它持有的一等块所对应的内容(无其他块共享时)。
// Remove 删除文档。
//
// 与文本块一致:删除块即删除内容。媒体字节是块的内容存储,
// 不单独做引用计数或 GC。
// 块随文档一起消失,块里的字节也随之消失——无需引用计数或外部 GC:
// 字节就在块里,块没了字节就没了。
func (m *docMemoryImpl) Remove(id string) {
if m.ds == nil {
return
}
var digests []string
if d := m.ds.Get(id); d != nil {
for _, b := range d.Blocks {
if b.PayloadDigest != "" {
digests = append(digests, b.PayloadDigest)
}
}
}
m.ds.Remove(id)
if m.ms == nil {
return
}
// 仍被其它文档持有的 digest 不能删(同一份字节可能被多个块共享)。
stillHeld := map[string]bool{}
for _, b := range m.ds.Blocks() {
stillHeld[b.PayloadDigest] = true
}
for _, d := range digests {
if stillHeld[d] {
continue
}
if err := m.ms.Delete(d); err != nil {
log.Printf("[sdk media] 删除文档 %s 的内容失败 %s: %v", id, sdkShortDigest(d), err)
}
}
}
func (m *docMemoryImpl) Stats() map[string]interface{} {

View File

@ -65,8 +65,8 @@ func putMedia(t *testing.T, ms *media.Store, payload string) string {
// 旧实现只搬 Subject/Relation/Object,其余字段静默丢弃:
// 插件标注的类型全部落成默认 Concept,置信度全成 1.0,SentenceText 直接消失。
func TestGraphCommit_CarriesAllFields(t *testing.T) {
g, _, _, ms := newTestStores(t)
m := NewGraphMemoryWithMedia("tester", g, ms)
g, _, _, _ := newTestStores(t)
m := NewGraphMemory("tester", g)
err := m.Commit([]Triple{{
Subject: "张三",