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, Knowledge: ks,
SocialStore: socialStore, SocialStore: socialStore,
TextMemory: textMem, TextMemory: textMem,
MediaStore: mediaStore, // MediaStore 已随 CAS 淘汰:块自带负载(见 plan.md)。
Personality: personality, Personality: personality,
// 人格落库面:首启门禁(任何通道都问一次)与 persona_set 工具用。 // 人格落库面:首启门禁(任何通道都问一次)与 persona_set 工具用。
// 与 WebUI 向导共用 internal/config 的同一份落库逻辑。 // 与 WebUI 向导共用 internal/config 的同一份落库逻辑。

View File

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

View File

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

View File

@ -17,6 +17,9 @@ import (
// //
// 这里不再有任何 marker 文本、正则反解或"描述文本当索引"的路径: // 这里不再有任何 marker 文本、正则反解或"描述文本当索引"的路径:
// 媒体只按自己的统一空间向量被检索,图库/文档只记录它的结构归属。 // 媒体只按自己的统一空间向量被检索,图库/文档只记录它的结构归属。
//
// ★ 2026-10-11:块自带负载(CAS 已淘汰)。因此本文件不再有
// "按 digest 去 CAS 取块"这一步——块就是它自己,按块 ID 直接引用。
// migrateLegacyGraphMedia 把 marker 反解出来的旧媒体实体迁移成原生一等块。 // migrateLegacyGraphMedia 把 marker 反解出来的旧媒体实体迁移成原生一等块。
// //
@ -24,16 +27,20 @@ import (
// 靠生成的描述文本当索引。迁移后它变成真正的记忆块,以 // 靠生成的描述文本当索引。迁移后它变成真正的记忆块,以
// sentence --contains--> block 结构边挂回原句子,旧实体与描述关系删除。 // sentence --contains--> block 结构边挂回原句子,旧实体与描述关系删除。
// 迁移幂等(实体处理完即删除),因此在每个 Agent 启动时跑一次是安全的。 // 迁移幂等(实体处理完即删除),因此在每个 Agent 启动时跑一次是安全的。
//
// ★ 旧实体的名字里带的是**内容短 digest**;块自带负载后没有 CAS 可按
// digest 取回字节,所以旧媒体实体的字节若已无从取得(CAS 已删),
// 该实体只能如实删除而不建块——不造一个没有内容的空块。
func (a *Agent) migrateLegacyGraphMedia() { func (a *Agent) migrateLegacyGraphMedia() {
if a.memory == nil || a.mediaStore == nil { if a.memory == nil {
return return
} }
blocks, entities, err := a.memory.MigrateLegacyMediaEntities(func(short string) (memory.MemoryBlock, bool) { blocks, entities, err := a.memory.MigrateLegacyMediaEntities(func(short string) (memory.MemoryBlock, bool) {
full, err := a.mediaStore.ResolvePrefix(short) // 先看该内容是否已经有对应的块(内容派生 ID,前缀可查)。
if err != nil { if b, ok, err := a.memory.BlockByIDPrefix("blk_media_" + short); err == nil && ok {
return memory.MemoryBlock{}, false return b, true
} }
return a.blockFromDigest(full) return memory.MemoryBlock{}, false
}) })
if err != nil { if err != nil {
log.Printf("[media] 旧媒体实体迁移失败(下轮重试): %v", err) log.Printf("[media] 旧媒体实体迁移失败(下轮重试): %v", err)
@ -44,26 +51,28 @@ func (a *Agent) migrateLegacyGraphMedia() {
} }
} }
// attachBlocksToSentenceBlock 把一组 digest 变成 L3 一等块并挂到**原句块**上。 // attachBlocksToSentenceBlock 把一组媒体块引用挂到**原句块**上。
// seed 允许复用已持有块的 ID(L2→L3 迁移保持块身份不变)。
// ★ sentenceBlockID 是**原句块 ID**(原为 sentences 表行号)。
// //
// 句子的承载者已从 sentences 表迁移到 blk_src_<sha256(text[:12])>, // ★ 参数从 digests 改为 refs(块 ID 或其前缀):CAS 淘汰后,媒体由块自身
// 媒体边因此改挂「原句块 --contains--> 媒体块」。 // 承载,引用单位就是块 ID。seed 允许复用调用方已持有的块(L2→L3 迁移
func (a *Agent) attachBlocksToSentenceBlock(sentenceBlockID string, digests []string, seed map[string]memory.MemoryBlock, scene string) int { // 保持块身份不变)。
if a.mediaStore == nil || a.memory == nil || sentenceBlockID == "" { // ★ 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 return 0
} }
bound := 0 bound := 0
for _, d := range digests { for _, ref := range refs {
full, err := a.mediaStore.ResolvePrefix(d) b, ok := seed[ref]
if !ok {
var err error
b, ok, err = a.memory.BlockByIDPrefix(ref)
if err != nil { if err != nil {
log.Printf("[media] digest %s 无法解析: %v", d, err) log.Printf("[media] 查块 %s 失败: %v", ref, err)
continue continue
} }
b, ok := seed[full]
if !ok { if !ok {
if b, ok = a.blockFromDigest(full); !ok { log.Printf("[media] 块引用 %s 无唯一对应块,跳过", ref)
continue continue
} }
} }
@ -73,11 +82,11 @@ func (a *Agent) attachBlocksToSentenceBlock(sentenceBlockID string, digests []st
b.Scene = scene b.Scene = scene
} }
if err := a.memory.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil { 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 continue
} }
if err := a.memory.AddMemoryBlockEdge("block", sentenceBlockID, "block", b.ID, "contains"); err != nil { 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 continue
} }
bound++ bound++
@ -116,7 +125,7 @@ func (a *Agent) linkBlocksToDocument(docID string, blocks []memory.MemoryBlock,
bound := 0 bound := 0
for _, b := range blocks { for _, b := range blocks {
if err := a.memory.AddMemoryBlockEdge("document", docID, "block", b.ID, "contains"); err != nil { 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 continue
} }
bound++ bound++
@ -133,9 +142,9 @@ func (a *Agent) commitTriplesWithMedia(triples []memory.Triple, sessionID string
if g == nil { if g == nil {
return 0, 0, 0, fmt.Errorf("graph memory 未启用") return 0, 0, 0, fmt.Errorf("graph memory 未启用")
} }
// 轻量内核(memory == nil,子只有图记忆)或没有媒体库时:只写图记忆。 // 轻量内核(memory == nil,子只有图记忆):只写图记忆。
// 写目标由 a.graph 决定 —— 根落 main,子落自己的 temp。 // 写目标由 a.graph 决定 —— 根落 main,子落自己的 temp。
if a.memory == nil || a.mediaStore == nil { if a.memory == nil {
ec, rc, cErr := g.Commit(triples, sessionID, turnID) ec, rc, cErr := g.Commit(triples, sessionID, turnID)
return ec, rc, 0, cErr return ec, rc, 0, cErr
} }
@ -143,10 +152,10 @@ func (a *Agent) commitTriplesWithMedia(triples []memory.Triple, sessionID string
if err != nil { if err != nil {
return ec, rc, 0, err 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 { for _, b := range seed {
if b.PayloadDigest != "" { if b.ID != "" {
byDigest[b.PayloadDigest] = b byRef[b.ID] = b
} }
} }
for _, t := range triples { for _, t := range triples {
@ -157,12 +166,11 @@ func (a *Agent) commitTriplesWithMedia(triples []memory.Triple, sessionID string
if sid == "" { if sid == "" {
continue continue
} }
blocks += a.attachBlocksToSentenceBlock(sid, t.MediaDigests, byDigest, t.Scene) blocks += a.attachBlocksToSentenceBlock(sid, t.MediaDigests, byRef, t.Scene)
} }
return ec, rc, blocks, nil return ec, rc, blocks, nil
} }
// RecallBlocksForSentence 反查某条图库句子持有的一等记忆块。
// RecallBlocksForSentence 按**原句块 ID**取回它承载的媒体块。 // RecallBlocksForSentence 按**原句块 ID**取回它承载的媒体块。
// //
// ★ 签名从 int64 改为 string:挂载点已从「sentences 表行号」 // ★ 签名从 int64 改为 string:挂载点已从「sentences 表行号」
@ -175,45 +183,39 @@ func (a *Agent) RecallBlocksForSentence(sentenceBlockID string) ([]memory.Memory
return a.memory.BlocksForNode("block", sentenceBlockID) return a.memory.BlocksForNode("block", sentenceBlockID)
} }
// resolveMediaDigests 把模型给的(多为短)digest 补全成完整 digest。 // resolveMediaRefs 把模型给的(多为短)块引用补全成唯一的块 ID。
// //
// 补不上就丢弃那一条并记日志:模型可能凭印象编了个 digest,也可能内容已被删除。 // 补不上或歧义就丢弃那一条并记日志:模型可能凭印象编了个 ID,
func (a *Agent) resolveMediaDigests(digests []string) []string { // 也可能内容已被删除。宁可不解析也不猜(猜错 = 挂错媒体)。
if a.mediaStore == nil || len(digests) == 0 { func (a *Agent) resolveMediaRefs(refs []string) []string {
if a.memory == nil || len(refs) == 0 {
return nil return nil
} }
seen := make(map[string]bool, len(digests)) seen := make(map[string]bool, len(refs))
var out []string var out []string
for _, d := range digests { for _, r := range refs {
full, err := a.mediaStore.ResolvePrefix(d) b, ok, err := a.memory.BlockByIDPrefix(r)
if err != nil { if err != nil || !ok {
log.Printf("[media] 模型给的 digest %s 无法解析: %v", d, err) log.Printf("[media] 模型给的块引用 %s 无法唯一解析", r)
continue continue
} }
if seen[full] { if seen[b.ID] {
continue continue
} }
seen[full] = true seen[b.ID] = true
out = append(out, full) out = append(out, b.ID)
} }
return out return out
} }
// sentenceIDsFromRelations 收集一批关系引用的句子 id(去重、去零)。 // sentenceIDsFromRelations 从关系里取出其原句的**块 ID**(去重、去零)。
// //
// 关系行本身不持有媒体,媒体作为一等块以 sentence --contains--> block // 关系行本身不持有媒体,媒体作为一等块以 sentence --contains--> block
// 结构边与句子相连;因此"这次召回涉及哪些媒体"必须经由关系 → 句子这一跳。 // 结构边与句子相连;因此"这次召回涉及哪些媒体"必须经由关系 → 句子这一跳。
// sentenceIDsFromRelations 从关系里取出其原句的**块 ID**。
// //
// ★ 从 Relation.SentenceID(旧 sentences 表行号)改为按 SentenceText 现算 // ★ 从 Relation.SentenceID(旧 sentences 表行号)改为按 SentenceText 现算
// // blk_src_<hash>。原因:Commit 块化后原句由块承载,sentences 表退场,
// blk_src_<hash>。
//
// 原因:Commit 块化后原句由块承载,sentences 表退场,
// 行号不再是稳定的挂载点;而 SentenceText 一直在 Relation 上。 // 行号不再是稳定的挂载点;而 SentenceText 一直在 Relation 上。
//
// 这条链(mediaContextForSentences / mediaContextForRelations)
// 完全靠这个 ID 找回媒体块,所以改错了 = 媒体上下文整体失效。
func sentenceIDsFromRelations(relations []memory.Relation) []string { func sentenceIDsFromRelations(relations []memory.Relation) []string {
if len(relations) == 0 { if len(relations) == 0 {
return nil return nil
@ -242,9 +244,7 @@ func (a *Agent) mediaContextForRelations(relations []memory.Relation) string {
// formatRecallRelations 渲染 memory_recall 的关系行,超 max 条截断。 // formatRecallRelations 渲染 memory_recall 的关系行,超 max 条截断。
// //
// 带上原始句子(截断到 60 字):三元组只是「A 关系 B」,脱离原句往往看不出 // 带上原始句子(截断到 60 字):三元组只是「A 关系 B」,脱离原句往往看不出
// 语气、条件与指代——`sentence_text` 的存在意义就是「日后从图谱回到原文」, // 语气、条件与指代——`sentence_text` 的存在意义就是「日后从图谱回到原文」。
// 而 Recall 已经把句子 JOIN 出来了。此前只回显实体名与关系类型,导致模型
// 填了 sentence_text 也永远拿不回来,这个能力形同虚设。
func formatRecallRelations(relations []memory.Relation, max int) []string { func formatRecallRelations(relations []memory.Relation, max int) []string {
var out []string var out []string
for i, r := range relations { for i, r := range relations {
@ -264,9 +264,9 @@ func formatRecallRelations(relations []memory.Relation, max int) []string {
// mediaContextForInjectedEntities 为自动注入路径产出媒体说明。 // mediaContextForInjectedEntities 为自动注入路径产出媒体说明。
// //
// Indexer.BuildContext 刻意不返回关系(只给实体索引以省 token), // Indexer.BuildContext 刻意不返回关系(只给实体索引以省 token),
// 因此这里用命中的实体名再查一次关系,只为拿到 sentence_id。 // 因此这里用命中的实体名再查一次关系,只为拿到原句。
func (a *Agent) mediaContextForInjectedEntities(injected *memory.InjectedContext) string { 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 "" return ""
} }
names := make([]string, 0, len(injected.Entities)) names := make([]string, 0, len(injected.Entities))
@ -280,18 +280,14 @@ func (a *Agent) mediaContextForInjectedEntities(injected *memory.InjectedContext
return a.mediaContextForRelations(res.Relations) return a.mediaContextForRelations(res.Relations)
} }
// blockLabelsForDoc 渲染文档持有块的标签(MIME + 短 digest),供 doc_query 展示。 // blockLabelsForDoc 渲染文档持有块的标签(MIME + 短块 ID),供 doc_query 展示。
func (a *Agent) blockLabelsForDoc(d *document.Doc) string { 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 "" return ""
} }
var parts []string var parts []string
for _, b := range d.Blocks { for i := range d.Blocks {
it, err := a.mediaStore.Stat(b.PayloadDigest) if line := blockLabel(&d.Blocks[i]); line != "" {
if err != nil || it == nil {
continue
}
if line := mediaLabel(it); line != "" {
parts = append(parts, line) parts = append(parts, line)
} }
} }
@ -300,10 +296,10 @@ func (a *Agent) blockLabelsForDoc(d *document.Doc) string {
// mediaContextForSentences 给一组句子附上其持有的一等块标签。 // mediaContextForSentences 给一组句子附上其持有的一等块标签。
// //
// 标签只含 MIME 与短 digest:图片按向量检索,标签的作用是告诉模型 // 标签只含 MIME 与短块 ID:图片按向量检索,标签的作用是告诉模型
// "这条记忆当时带着哪份媒体、可用该 digest 取回字节"。 // "这条记忆当时带着哪份媒体、可用该块 ID 取回"。
func (a *Agent) mediaContextForSentences(sentenceBlockIDs []string) string { 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 "" return ""
} }
var lines []string var lines []string
@ -313,12 +309,8 @@ func (a *Agent) mediaContextForSentences(sentenceBlockIDs []string) string {
continue continue
} }
var parts []string var parts []string
for _, b := range blocks { for i := range blocks {
it, err := a.mediaStore.Stat(b.PayloadDigest) if line := blockLabel(&blocks[i]); line != "" {
if err != nil || it == nil {
continue
}
if line := mediaLabel(it); line != "" {
parts = append(parts, line) parts = append(parts, line)
} }
} }
@ -331,16 +323,3 @@ func (a *Agent) mediaContextForSentences(sentenceBlockIDs []string) string {
} }
return strings.Join(lines, "\n") 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 package core
import ( import (
"errors"
"log" "log"
"sync" "sync"
"sync/atomic" "sync/atomic"
"github.com/JianFeeeee/HomeAgent/internal/memory" "github.com/JianFeeeee/HomeAgent/internal/memory"
"github.com/JianFeeeee/HomeAgent/internal/memory/vector"
) )
// 媒体与记忆块的生命周期辅助。 // 媒体与记忆块的生命周期辅助。
// //
// 媒体不单独做生命周期管理(没有 GC、没有引用计数):blob 是记忆块的内容, // ★ 2026-10-11:块自带负载(media.Store/CAS 已淘汰)。
// 块的创建/迁移/删除由记忆系统本身决定,块被永久删除时内容随之删除。
// 图片不靠文本描述索引——它只按自己的统一空间向量被检索。
// heldMediaDigests 汇总三层记忆当前持有的媒体 digest 集合。
// //
// CAS 是全库字节存储,它的检索结果不等于「记忆里的媒体」—— // 此前这里有 heldMediaDigests / payloadHeld / forgetPayloads 三个函数,
// 召回前用它把已无处可归的内容过滤掉。 // 它们存在的**唯一理由**是补 CAS 的漏:CAS 是全库字节存储,它的检索结果
func (a *Agent) heldMediaDigests() map[string]bool { // 不等于「记忆里的媒体」,所以要靠一套 digest 记账把已无处可归的内容滤掉。
held := map[string]bool{} // 块自带负载后这一整类问题从根上消失——字节就在块里,块没了字节就没了,
collect := func(blocks []memory.MemoryBlock) { // 不需要账本、不需要 GC、不需要「删前先问谁还持有」。
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 在记忆块被永久删除后删除它们的内容。
// //
// 与文本块一致:删除块即删除内容。只有确认没有任何存活块仍共享该 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 变了) // - 切换模型(模型 A→模型 B,fp 变了)
// - 切换向量维度(ONNX→HTTP dim 512→1024) // - 切换向量维度(ONNX→HTTP dim 512→1024)
// - 首次部署嵌入服务(历史无向量的媒体补算) // - 首次部署嵌入服务(历史无向量的媒体补算)
// - 嵌入服务离线后重新上线(失败条目 vec_model 仍为空) // - 嵌入服务离线后重新上线(失败条目 fingerprint 仍为空)
// //
// 并发策略:启动时用 worker pool 并行迁移,避免上千张图片串行耗时过长。 // 并发策略:启动时用 worker pool 并行迁移,避免上千张图片串行耗时过长。
// 并发数在 ONNX 内嵌路径下不超 CPU 核心数(避免 ONNX 并发限流), // 并发数在 ONNX 内嵌路径下不超 CPU 核心数(避免 ONNX 并发限流),
// 外部 API 路径下不超 8(避免打爆外部服务)。 // 外部 API 路径下不超 8(避免打爆外部服务)。
func (a *Agent) reembedStaleMedia() { func (a *Agent) reembedStaleMedia() {
if a.multimodalSpace == nil || a.mediaStore == nil { if a.multimodalSpace == nil || !a.multimodalSpace.Loaded() || a.memory == nil {
return return
} }
fp := a.multimodalSpace.Fingerprint() fp := a.multimodalSpace.Fingerprint()
digests, err := a.mediaStore.StaleVecDigestsAll(fp) stale, err := a.memory.StaleBlockVectors(fp)
if err != nil { if err != nil {
log.Printf("[media] 查询需重算向量的媒体失败: %v", err) log.Printf("[media] 查询需重算向量的块失败: %v", err)
return return
} }
if len(digests) == 0 { // 只迁图像:多模态空间当前覆盖 text+image,音频/视频不在其内
log.Printf("[media] 无需迁移向量(所有媒体已与当前空间对齐 fp=%s)", shortFP(fp)) // (不拿别的模态的向量顶替——那是静默错配)。
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 return
} }
// 并发度:ONNX 内嵌不超过 4,外部 API 不超过 8(由配置或实际环境动态定)
workers := 4 workers := 4
if fp[:min(4, len(fp))] == "http:" { if len(fp) >= 5 && fp[:5] == "http:" {
workers = 8 workers = 8
} }
log.Printf("[media] 启动向量迁移: %d 条 → 新空间 fp=%s dim=%d workers=%d", log.Printf("[media] 启动向量迁移: %d 个图像块 → fp=%s dim=%d workers=%d",
len(digests), shortFP(fp), a.multimodalSpace.Dim(), workers) len(targets), shortFP(fp), a.multimodalSpace.Dim(), workers)
jobs := make(chan string, workers*2) jobs := make(chan memory.MemoryBlock, workers*2)
var done, failed, unsupported int64 var done, failed int64
var failedMu sync.Mutex
var wg sync.WaitGroup var wg sync.WaitGroup
for i := 0; i < workers; i++ { for i := 0; i < workers; i++ {
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
for d := range jobs { for b := range jobs {
switch err := a.reembedOne(d, fp); { if err := a.reembedOneBlock(b, fp); err != nil {
case err == nil: atomic.AddInt64(&failed, 1)
atomic.AddInt64(&done, 1) continue
case errors.Is(err, vector.ErrModalityUnsupported):
// 该模态不在本空间内(如音频):不重试、不计失败,
// 也不拿另一个模型的向量顶替。
atomic.AddInt64(&unsupported, 1)
default:
failedMu.Lock()
failed++
failedMu.Unlock()
} }
atomic.AddInt64(&done, 1)
} }
}() }()
} }
for i, d := range digests { for i, b := range targets {
jobs <- d jobs <- b
// 每迁移 20 条输出进度日志,让用户看到迁移在推进
if (i+1)%20 == 0 { 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) close(jobs)
wg.Wait() wg.Wait()
log.Printf("[media] 向量迁移完成: 成功=%d 失败=%d 不在本空间=%d 总计=%d fp=%s", log.Printf("[media] 向量迁移完成: 成功=%d 失败=%d 总计=%d fp=%s",
done, failed, unsupported, len(digests), shortFP(fp)) done, failed, len(targets), shortFP(fp))
} }
// reembedOne 为单条媒体重新计算向量并写入(stat/get 失败时跳过该条目)。 // reembedOneBlock 为一个图像块重算向量并写回。负载就在块里,无需外部取字节。
// //
// 模态不在本空间覆盖范围时返回 ErrModalityUnsupported,调用方据此区分 // 模态不在本空间覆盖范围时返回 ErrModalityUnsupported,调用方据此区分
// 「永久无向量」与「本次失败重试」。 // 「永久无向量」与「本次失败重试」。
func (a *Agent) reembedOne(digest, fp string) error { func (a *Agent) reembedOneBlock(b memory.MemoryBlock, fp string) error {
it, err := a.mediaStore.Stat(digest) mime := b.MIME
if err != nil {
return err
}
data, err := a.mediaStore.Get(digest)
if err != nil {
return err
}
mime := it.MIME
if mime == "" { if mime == "" {
mime = "image/png" mime = "image/png"
} }
vec, err := a.multimodalSpace.EmbedImageDense(data, mime) vec, err := a.multimodalSpace.EmbedImageDense(b.Data, mime)
if err != nil { if err != nil {
return err 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 为可读日志格式。 // shortFP 截断 fingerprint 为可读日志格式。

View File

@ -1,85 +1,59 @@
package core package core
import ( import (
"encoding/base64"
"fmt" "fmt"
"log" "log"
"strings" "strings"
"sync/atomic"
"time" "time"
agentAPI "github.com/JianFeeeee/HomeAgent/internal/agent/api" agentAPI "github.com/JianFeeeee/HomeAgent/internal/agent/api"
"github.com/JianFeeeee/HomeAgent/internal/memory" "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 { // blockModalityFromMIME 按 MIME 大类推断块的模态。
return fmt.Sprintf("blk_%d_%d", time.Now().UnixNano(), atomic.AddInt64(&blockSeq, 1)) func blockModalityFromMIME(mime string) memory.BlockModality {
} switch {
case strings.HasPrefix(mime, "image/"):
// blockModalityOf 把 CAS 媒体大类映射为一等记忆块模态。
func blockModalityOf(k media.Kind) memory.BlockModality {
switch k {
case media.KindImage:
return memory.BlockImage return memory.BlockImage
case media.KindVideo: case strings.HasPrefix(mime, "video/"):
return memory.BlockVideo return memory.BlockVideo
case media.KindAudio: case strings.HasPrefix(mime, "audio/"):
return memory.BlockAudio return memory.BlockAudio
default: default:
return memory.BlockText return memory.BlockText
} }
} }
// blockFromDigest 把一份已入库媒体变成一等记忆块。 // captureBlockMedia 把 blocks 里的 data URL 媒体变成一等记忆块,返回块列表。
// 块携带 digest/向量/fingerprint;CAS 只提供字节与元数据,不参与生命周期。
func (a *Agent) blockFromDigest(digest string) (memory.MemoryBlock, bool) {
if a.mediaStore == nil || digest == "" {
return memory.MemoryBlock{}, false
}
it, err := a.mediaStore.Stat(digest)
if err != nil || it == nil {
return memory.MemoryBlock{}, false
}
return memory.MemoryBlock{
ID: newBlockID(),
Modality: blockModalityOf(it.Kind),
PayloadDigest: it.Digest,
MIME: it.MIME,
Size: it.Size,
Width: it.Width,
Height: it.Height,
Vector: it.Vec,
Fingerprint: it.VecModel,
Tool: it.Tool,
CreatedAt: it.FirstSeen,
}, true
}
// 媒体记忆接线:把对话里出现的图片/音频落进内容寻址存储(CAS),
// 并让 L0 的 ContextEvent 直接持有一等记忆块。
// //
// 媒体进入对话有两条路:用户直接发图(ContentBlock data URL)、插件注入 // 只处理 data URL:http(s) URL 拿不到字节,而"下载它再存"会把一次对话变成
// (SetToolBlocks)。两条都在这里收口:从 data URL 取出字节存进 CAS, // 一次网络请求(超时、鉴权、SSRF 全来了),不在本层解决。
// 用其向量构造一等记忆块挂到当轮 ContextEvent 上;事件被 Prune 时
// 块随之迁移到 L2 文档。
// //
// 不再生成任何描述文本,也不再往正文写 media marker:图片只按自己的 // 块 ID 由内容派生(memory.MediaBlockID):同内容 ⇒ 同 ID ⇒ 落库时
// 统一空间向量被检索,描述式索引是将就方案。 // ON CONFLICT 天然去重(这正是内容寻址当年的好处,但现在由块 ID 直接提供,
// 不需要一层独立的寻址存储)。
// captureBlockMedia 把 blocks 里的 data URL 媒体落进 CAS,返回 digest 列表。 func (a *Agent) captureBlockMedia(blocks []agentAPI.ContentBlock, tool string) []memory.MemoryBlock {
// if len(blocks) == 0 {
// 只处理 data URL:http(s) URL 拿不到字节就无法做内容寻址,
// 而"下载它再存"会把一次对话变成一次网络请求(超时、鉴权、SSRF 全来了),
// 不在本层解决。
func (a *Agent) captureBlockMedia(blocks []agentAPI.ContentBlock, tool string) []string {
if a.mediaStore == nil || len(blocks) == 0 {
return nil return nil
} }
var digests []string var out []memory.MemoryBlock
for _, b := range blocks { for _, b := range blocks {
var url string var url string
switch { switch {
@ -91,106 +65,124 @@ func (a *Agent) captureBlockMedia(blocks []agentAPI.ContentBlock, tool string) [
continue continue
} }
mime, data, ok := media.ParseDataURL(url) mime, data, ok := parseDataURL(url)
if !ok { if !ok {
continue // http(s) URL 或格式不认,跳过 continue // http(s) URL 或格式不认,跳过
} }
d, err := a.mediaStore.Put(data, media.Item{ block := memory.MemoryBlock{
ID: memory.MediaBlockID(mime, data),
Modality: blockModalityFromMIME(mime),
Data: data,
MIME: mime, MIME: mime,
Size: int64(len(data)),
Tool: tool, Tool: tool,
}) CreatedAt: time.Now(),
if err != nil {
// 媒体存不进去不该让对话失败——它是记忆增强,不是对话必需品
log.Printf("[media] 落盘失败 (tool=%s mime=%s): %v", tool, mime, err)
continue
} }
// 入库即算一次多模态坐标(多模态空间可用且为图像时)。
// 入库即算一次多模态坐标并缓存(多模态空间可用时)。 // 之后块召回直接用它;换模型由启动时的 reembedStaleMedia 补算。
// 之后 doc_query / memory_recall / 内部召回直接复用 SetVec 的缓存坐标, a.embedBlockOnIngest(&block)
// 不重复跑 ONNX;模型切换由启动时的 reembedStaleMedia 补算。 out = append(out, block)
a.embedMediaOnIngest(d, mime, data)
digests = append(digests, d)
} }
return digests
}
// embedMediaOnIngest 给刚入库的图片立即计算多模态坐标并缓存。
// 只在 多模态空间可用且为图像时执行;音频/未配置时静默跳过(保持既有行为)。
func (a *Agent) embedMediaOnIngest(digest, mime string, data []byte) {
if a.multimodalSpace == nil || !a.multimodalSpace.Loaded() {
return
}
if !strings.HasPrefix(mime, "image/") {
return
}
vec, err := a.multimodalSpace.EmbedImageDense(data, mime)
if err != nil {
log.Printf("[media] 入库嵌入失败 %s: %v", shortDigest(digest), err)
return
}
if err := a.mediaStore.SetVec(digest, vec, a.multimodalSpace.Fingerprint()); err != nil {
log.Printf("[media] 入库写向量失败 %s: %v", shortDigest(digest), err)
}
}
// stageMediaDigests 累积本轮捕获的 digest,等 ContextEvent 建好后一起挂上。
//
// 为何要缓存而不是当场建块:媒体在 process() 执行期间被捕获,而承载它的
// ContextEvent 要等 process() 返回后才 Append——此刻还没有 owner_id。
// 与既有的 a.pendingMedia 同一手法(均由 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 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: // 为何要缓存而不是当场挂:块在 process() 执行期间被捕获,而承载它的
// 存活与否由“三层记忆块是否持有这个 digest”决定,不维护引用账本。 // ContextEvent 要等 process() 返回后才 Append——此刻还没有 owner。
func (a *Agent) bindEventMedia(evt *ContextEvent, digests []string) { // 与既有的 a.pendingMedia 同一手法(均由 schedulerLoop goroutine 独占读写)。
if a.mediaStore == nil || evt == nil || len(digests) == 0 { 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 return
} }
if evt.ID == "" { if evt.ID == "" {
evt.ID = newEventID() evt.ID = newEventID()
} }
for _, d := range digests { evt.Blocks = append(evt.Blocks, blocks...)
b, ok := a.blockFromDigest(d)
if !ok {
log.Printf("[media] 块构造失败 (%s)", shortDigest(d))
continue
}
evt.Blocks = append(evt.Blocks, b)
}
} }
// mediaLabel 渲染一行媒体标签,供提示词告知"这条记忆带着哪份媒体"。 // blockLabel 渲染一行媒体块标签,供提示词告知"这条记忆带着哪份媒体"。
// //
// 不再包含任何生成的描述文本:图片只按自己的向量被检索,标签仅提供 // 不含任何生成的描述文本:图片按自己的向量被检索,标签仅提供 MIME 与
// MIME 与短 digest,让模型知道有这份媒体、可据 digest 取回字节。 // 短块 ID,让模型知道有这份媒体、并可据此取回(块 ID 是它的唯一身份)。
// 查不到返回空串:内容可能已被删除,不该造出一条指向虚无的标签。 func blockLabel(b *memory.MemoryBlock) string {
func mediaLabel(it *media.Item) string { if b == nil {
if it == nil {
return "" return ""
} }
label := string(it.Kind) label := string(b.Modality)
if it.MIME != "" { if b.MIME != "" {
label = it.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 的稳定标识。 // newEventID 生成 ContextEvent 的稳定标识。
@ -202,9 +194,10 @@ func newEventID() string {
return fmt.Sprintf("evt_%d", time.Now().UnixNano()) return fmt.Sprintf("evt_%d", time.Now().UnixNano())
} }
func shortDigest(d string) string { // shortID 把块 ID 截短供日志/提示词使用。
if len(d) > 12 { func shortID(id string) string {
return d[:12] 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 a.pendingMedia = evt.Payload
} }
// 媒体先落进 CAS。不存的后果是 ContextEvent.Input 只剩一句 alt 文本, // 媒体先变成一等块(字节进块的 Data)。不存的后果是 ContextEvent.Input
// base64 随 message 数组发给模型后就丢了。 // 只剩一句 alt 文本,base64 随 message 数组发给模型后就丢了。
if len(in.blocks) > 0 { if len(in.blocks) > 0 {
a.stageMediaDigests(a.captureBlockMedia(in.blocks, in.captureTool)...) a.stageMediaBlocks(a.captureBlockMedia(in.blocks, in.captureTool)...)
} }
noMemory := false noMemory := false
@ -516,7 +516,7 @@ func (a *Agent) finishInputTask(f *TaskFrame, out stepOutcome) {
ToolsUsed: f.ToolsUsed, ToolsUsed: f.ToolsUsed,
ToolResults: f.ToolResults, ToolResults: f.ToolResults,
} }
a.bindEventMedia(&turnEvt, a.drainMediaDigests()) a.bindEventMedia(&turnEvt, a.drainMediaBlocks())
a.context.Append(turnEvt) a.context.Append(turnEvt)
a.emitResponse(evt, f.Response, f.turnUsage) a.emitResponse(evt, f.Response, f.turnUsage)
@ -1098,8 +1098,8 @@ func (a *Agent) stepToolAfter(f *TaskFrame) stepOutcome {
} }
} }
if len(blocks) > 0 { 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 { if native, fallbackText := a.prepareToolBlocks(blocks); len(native) > 0 {
// 能直视:另起一条 user message 承载媒体,并补一句来源说明。 // 能直视:另起一条 user message 承载媒体,并补一句来源说明。

View File

@ -339,9 +339,9 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str
} }
// 模型显式关联的媒体:结构化字段随三元组一起提交, // 模型显式关联的媒体:结构化字段随三元组一起提交,
// 由 commitTriplesWithMedia 变成 L3 一等块并与句子建边—— // 由 commitTriplesWithMedia 变成 L3 一等块并与句子建边——
// 不再把 marker 写进句子文本。 // 不再把 marker 写进句子文本。值是块 ID(或其唯一前缀)。
if digests := getStringSlice(m, "media_digests"); len(digests) > 0 { if refs := getStringSlice(m, "media_digests"); len(refs) > 0 {
t.MediaDigests = a.resolveMediaDigests(digests) t.MediaDigests = a.resolveMediaRefs(refs)
// 块边需要句子作端点。模型没给原句时用三元组本身拼一句 // 块边需要句子作端点。模型没给原句时用三元组本身拼一句
// 自然语言——不能造一段 marker 文本,那正是被废弃的东西。 // 自然语言——不能造一段 marker 文本,那正是被废弃的东西。
if t.SentenceText == "" && len(t.MediaDigests) > 0 { 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),解析不了 // 复用 doc_commit 的既有约定:引用可传前缀,解析不了的跳过而不是报错——
// 的跳过而不是报错——模型偶尔会把 digest 记错,不该让整次写入失败。 // 模型偶尔会把 ID 记错,不该让整次写入失败。MIME 从块回读,嵌入时需要
// MIME 从媒体存储回读,嵌入时需要(EmbedImageDense 靠它判定模态)。 // (EmbedImageDense 靠它判定模态)。字节随条目落盘(writeMediaFiles)。
func (a *Agent) knowledgeMediaRefs(digests []string) []knowledge.KnowledgeMediaRef { func (a *Agent) knowledgeMediaRefs(refs []string) []knowledge.KnowledgeMediaRef {
if a.mediaStore == nil || len(digests) == 0 { if a.memory == nil || len(refs) == 0 {
return nil return nil
} }
var out []knowledge.KnowledgeMediaRef var out []knowledge.KnowledgeMediaRef
for _, d := range a.resolveMediaDigests(digests) { for _, r := range refs {
it, err := a.mediaStore.Stat(d) b, ok, err := a.memory.BlockByIDPrefix(r)
if err != nil { if err != nil || !ok {
log.Printf("[knowledge] 媒体块引用 %s 无法唯一解析,跳过", r)
continue continue
} }
out = append(out, knowledge.KnowledgeMediaRef{ out = append(out, knowledge.KnowledgeMediaRef{
Digest: it.Digest, Digest: b.ID,
MIME: it.MIME, MIME: b.MIME,
Kind: string(it.Kind), Kind: string(b.Modality),
Data: b.Data,
}) })
} }
return out return out
@ -922,9 +924,9 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string {
// 模型显式关联的媒体:直接变成文档持有的一等块。 // 模型显式关联的媒体:直接变成文档持有的一等块。
// 不再往正文写 marker——文档向量会融合这些块的媒体向量, // 不再往正文写 marker——文档向量会融合这些块的媒体向量,
// 图片按自己的向量被检索。 // 图片按自己的向量被检索。值是块 ID(或其唯一前缀)。
for _, d := range a.resolveMediaDigests(getStringSlice(tc.Arguments, "media_digests")) { for _, ref := range a.resolveMediaRefs(getStringSlice(tc.Arguments, "media_digests")) {
if b, ok := a.blockFromDigest(d); ok { if b, ok, err := a.memory.BlockByIDPrefix(ref); err == nil && ok {
doc.Blocks = append(doc.Blocks, b) 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}, img: map[string][]float64{"__default": eby, "d-cat": eax},
} }
s.SetDenseSpace(mm) s.SetDenseSpace(mm)
s.SetMediaGetter(fakeMedia{byDigest: map[string][]byte{"d-cat": []byte("d-cat")}})
if err := s.AddWithMedia("cat-photo", "猫 图片 说明", []KnowledgeMediaRef{ 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 { }); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -224,9 +223,8 @@ func TestDenseUnsupportedModalityStillTextEmbedded(t *testing.T) {
imgErr: map[string]error{"d-audio": vector.ErrModalityUnsupported}, imgErr: map[string]error{"d-audio": vector.ErrModalityUnsupported},
} }
s.SetDenseSpace(mm) 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) t.Fatal(err)
} }
k := s.items["song"] k := s.items["song"]
@ -289,13 +287,12 @@ func TestAttachMediaRecomputesDense(t *testing.T) {
img: map[string][]float64{"__default": {0, 1}, "d-x": eax}, img: map[string][]float64{"__default": {0, 1}, "d-x": eax},
} }
s.SetDenseSpace(mm) s.SetDenseSpace(mm)
s.SetMediaGetter(fakeMedia{byDigest: map[string][]byte{"d-x": []byte("d-x")}})
if err := s.Add("k", "正文"); err != nil { if err := s.Add("k", "正文"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
before := append([]float64{}, s.items["k"].Dense...) 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) t.Fatal(err)
} }
after := s.items["k"].Dense after := s.items["k"].Dense

View File

@ -8,7 +8,7 @@ import (
"sort" "sort"
"strings" "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 return ok
} }
// MediaPutter 是把字节写进媒体库的能力(由 media.Store 实现)。 // putMediaFile 读一个媒体文件进条目引用(字节随 Data 瞬态携带,
// 由 Write 落盘到条目目录)。
// //
// 为何用接口而不是直接依赖 *media.Store:Store 已经有 SetMediaGetter // ★ 2026-10-11 CAS 淘汰:不再写入全局媒体库 —— 字节是条目自带数据,
// 这类注入点,导入沿用同一形状,测试才能用假实现,不必拉起真媒体库。 // 与 content.md 同目录同生灭。读取失败返回错误,调用方只记警告跳过该媒体
type MediaPutter interface { // (正文不该因为图片入不了库而整个条目失败)。
Put(data []byte, meta media.Item) (string, error)
}
// putMediaFile 把一个媒体文件复制进媒体库,返回条目用的引用。
//
// 媒体库未接入时返回错误而不是静默跳过 —— 但调用方对错误只记警告:
// 媒体是可选增强,正文不该因为图片入不了库而整个条目失败。
func (s *Store) putMediaFile(path, kind string) (KnowledgeMediaRef, error) { func (s *Store) putMediaFile(path, kind string) (KnowledgeMediaRef, error) {
if s.mediaPut == nil {
return KnowledgeMediaRef{}, fmt.Errorf("媒体库不可用")
}
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
return KnowledgeMediaRef{}, err return KnowledgeMediaRef{}, err
} }
mime := mimeByExt(filepath.Ext(path)) mime := mimeByExt(filepath.Ext(path))
digest, err := s.mediaPut.Put(data, media.Item{ // File 名取源文件名:同目录内天然唯一(同名正文+同名媒体本就是一对)。
Kind: media.Kind(kind), return KnowledgeMediaRef{
Digest: memory.MediaBlockID(mime, data), // 内容身份:与媒体块 ID 同构
MIME: mime, MIME: mime,
Size: int64(len(data)), Kind: kind,
OriginPath: path, File: filepath.Base(path),
}) Data: data,
if err != nil { }, nil
return KnowledgeMediaRef{}, err }
// 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 决定能否解码 // mimeByExt 按扩展名给 MIME。够用即可:媒体库靠 MIME 决定能否解码

View File

@ -2,14 +2,11 @@ package knowledge
import ( import (
"bytes" "bytes"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"testing" "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) { func TestImportDirMediaAttachedToEntry(t *testing.T) {
src := mkSrc(t, map[string]string{"note.md": "看图", "note.png": "PNGDATA"}) src := mkSrc(t, map[string]string{"note.md": "看图", "note.png": "PNGDATA"})
s, root := newImportStore(t) s, root := newImportStore(t)
fp := &fakePutter{}
s.SetMediaPutter(fp)
st, err := s.ImportDir(ImportOptions{Dir: src, IncludeMedia: true}) st, err := s.ImportDir(ImportOptions{Dir: src, IncludeMedia: true})
if err != nil { if err != nil {
@ -429,16 +408,21 @@ func TestImportDirMediaAttachedToEntry(t *testing.T) {
if st.Media != 1 { if st.Media != 1 {
t.Errorf("应导入 1 个媒体,实际 %d(%+v)", st.Media, st) t.Errorf("应导入 1 个媒体,实际 %d(%+v)", st.Media, st)
} }
if len(fp.puts) != 1 || string(fp.puts[0]) != "PNGDATA" { // 媒体字节随条目落盘(CAS 淘汰后:字节是条目自带数据,与 content.md 同目录)
t.Errorf("媒体字节未原样复制进媒体库:%v", fp.puts) 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)) data, err := os.ReadFile(filepath.Join(root, "note", mediaSidecarName))
if err != nil { if err != nil {
t.Fatalf("媒体侧车未写入:%v", err) t.Fatalf("媒体侧车未写入:%v", err)
} }
if !strings.Contains(string(data), "digest01") { if !strings.Contains(string(data), "note.png") {
t.Errorf("侧车里没有 Put 返回的 digest:%s", data) t.Errorf("侧车里没有媒体文件名:%s", data)
} }
// 内存条目也要挂上(否则稠密路算不出这条的图向量) // 内存条目也要挂上(否则稠密路算不出这条的图向量)
k := s.items["note"] k := s.items["note"]
@ -447,26 +431,33 @@ func TestImportDirMediaAttachedToEntry(t *testing.T) {
} }
} }
// include_media 未开启时不该碰媒体库。 // include_media 未开启时不该把媒体落进条目目录。
func TestImportDirMediaSkippedWhenNotRequested(t *testing.T) { func TestImportDirMediaSkippedWhenNotRequested(t *testing.T) {
src := mkSrc(t, map[string]string{"note.md": "看图", "note.png": "PNGDATA"}) src := mkSrc(t, map[string]string{"note.md": "看图", "note.png": "PNGDATA"})
s, _ := newImportStore(t) s, root := newImportStore(t)
fp := &fakePutter{}
s.SetMediaPutter(fp)
if _, err := s.ImportDir(ImportOptions{Dir: src}); err != nil { if _, err := s.ImportDir(ImportOptions{Dir: src}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(fp.puts) != 0 { if _, err := os.Stat(filepath.Join(root, "note", "note.png")); !os.IsNotExist(err) {
t.Errorf("未开 include_media 却写了媒体库:%v", fp.puts) t.Errorf("未开 include_media 却落了媒体文件")
} }
} }
// 媒体入不了库时,正文条目仍应成功(媒体是增强,不是前提)。 // 媒体读不了时,正文条目仍应成功(媒体是增强,不是前提)。
func TestImportDirMediaFailureDoesNotFailEntry(t *testing.T) { 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, _ := 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}) st, err := s.ImportDir(ImportOptions{Dir: src, IncludeMedia: true})
if err != nil { if err != nil {
@ -475,13 +466,4 @@ func TestImportDirMediaFailureDoesNotFailEntry(t *testing.T) {
if st.Imported != 1 { if st.Imported != 1 {
t.Errorf("正文条目应仍导入成功,实际 %d(%+v)", st.Imported, st) 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"` Digest string `json:"digest"`
MIME string `json:"mime"` MIME string `json:"mime"`
Kind string `json:"kind,omitempty"` 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 的别名,照顾内部可读性。 // KnowledgeMediaRef 的别名,照顾内部可读性。
@ -126,8 +135,6 @@ type Store struct {
mu sync.RWMutex mu sync.RWMutex
items map[string]*Knowledge items map[string]*Knowledge
// mediaPut 是媒体写入器(ImportDir 复制媒体时用),见 SetMediaPutter。
mediaPut MediaPutter
indexPath string indexPath string
denseCachePath string denseCachePath string
@ -158,7 +165,6 @@ type Store struct {
// 同时破坏 TF-IDF 语义(vector.MultimodalEmbedder 的注释已明言)。 // 同时破坏 TF-IDF 语义(vector.MultimodalEmbedder 的注释已明言)。
// 因此稠密路自成一等路,与另两路并列,不混进任何一个 Store。 // 因此稠密路自成一等路,与另两路并列,不混进任何一个 Store。
dense vector.MultimodalEmbedder dense vector.MultimodalEmbedder
mediaGet MediaGetter
} }
// mediaSidecarName 是每条知识目录下存放媒体引用的文件名。 // mediaSidecarName 是每条知识目录下存放媒体引用的文件名。
@ -187,12 +193,6 @@ type denseCache struct {
Entries map[string]denseCacheEntry `json:"entries"` Entries map[string]denseCacheEntry `json:"entries"`
} }
// MediaGetter 让知识库能取回媒体字节以计算嵌入,而不依赖具体的媒体存储包。
// 取不到(或未接线)时,该条目退化为纯文本嵌入——而不是整条不可用。
type MediaGetter interface {
Get(digest string) ([]byte, error)
}
func NewStore(root string) *Store { func NewStore(root string) *Store {
return &Store{ return &Store{
root: root, root: root,
@ -224,25 +224,6 @@ func (s *Store) SetDenseSpace(ds vector.MultimodalEmbedder) {
s.maybeLoadDenseCacheLocked() 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/自证与分支判断)。 // denseEnabled 报告稠密路是否可用(供 Stats/自证与分支判断)。
// 调用方必须已持锁。 // 调用方必须已持锁。
func (s *Store) denseEnabled() bool { 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 { if tv, err := s.dense.VectorizeDense(k.Name + " " + k.Content); err == nil && len(tv) == dim {
parts = append(parts, tv) parts = append(parts, tv)
} }
dir := filepath.Dir(k.Path)
for _, m := range k.Media { for _, m := range k.Media {
if v := s.mediaDense(m); v != nil { if v := s.mediaDense(m, dir); v != nil {
parts = append(parts, v) parts = append(parts, v)
} }
} }
return vector.FuseVectors(parts...) 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 return nil
} }
data, err := s.mediaGet.Get(m.Digest) data, err := os.ReadFile(filepath.Join(dir, m.File))
if err != nil || len(data) == 0 { if err != nil || len(data) == 0 {
return nil return nil
} }
@ -366,7 +350,6 @@ func (s *Store) DenseStats() map[string]interface{} {
defer s.mu.RUnlock() defer s.mu.RUnlock()
out := map[string]interface{}{ out := map[string]interface{}{
"enabled": s.denseEnabled(), "enabled": s.denseEnabled(),
"media_getter": s.mediaGet != nil,
} }
if s.denseEnabled() { if s.denseEnabled() {
fp := s.dense.Fingerprint() fp := s.dense.Fingerprint()
@ -746,6 +729,12 @@ func (s *Store) Write(in KnowledgeEntryInput) error {
return fmt.Errorf("write knowledge: %w", err) 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() now := time.Now()
k := &Knowledge{ k := &Knowledge{
Name: id, Name: id,

View File

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

View File

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

View File

@ -22,12 +22,25 @@ const (
// //
// 它不携带 Layer、Owner 或 RefCount:块当前由哪个层的容器持有,哪个层就是 // 它不携带 Layer、Owner 或 RefCount:块当前由哪个层的容器持有,哪个层就是
// 唯一事实源。Context→Document→Graph 迁移的是这个值本身,不建立平行保活账本。 // 唯一事实源。Context→Document→Graph 迁移的是这个值本身,不建立平行保活账本。
// PayloadDigest 仅用于定位内容寻址的原始字节,不表示另一条逻辑记忆。 //
// ★★ 负载是 **union**:一个块要么是文本块(负载在 Text),要么是媒体块
// (负载在 Data,配 MIME/Width/Height)。两者是同一个 MemoryBlock 的两种取值,
// 不是「块 + 外挂字节层」。
//
// 为何不存在独立的字节存储:块已经是内容的载体(文本块自己带正文),媒体块
// 自当同理。把媒体内容拆到块外、只留一个 digest 指向外部 CAS,会凭空多出
// 一层需单独维护生命周期的东西(第二套向量、第二套召回路、引用账本、自己的
// db),并与「块是唯一记忆载体」直接冲突。内容随块同生同灭才是正解。
type MemoryBlock struct { type MemoryBlock struct {
ID string `json:"id"` ID string `json:"id"`
Modality BlockModality `json:"modality"` Modality BlockModality `json:"modality"`
// Text 是文本块(Modality=text)的负载。
Text string `json:"text,omitempty"` Text string `json:"text,omitempty"`
PayloadDigest string `json:"payload_digest,omitempty"` // Data 是媒体块(Modality=image/video/audio)的负载:内容的原始字节。
//
// 落盘形态随所在层而定:L0/L1 的块内联在 JSON 里(Data 序列化为 base64),
// L3 的块落 SQLite 的 BLOB 列。无论哪层,字节都不脱离块单独存在。
Data []byte `json:"data,omitempty"`
MIME string `json:"mime,omitempty"` MIME string `json:"mime,omitempty"`
Size int64 `json:"size,omitempty"` Size int64 `json:"size,omitempty"`
Width int `json:"width,omitempty"` Width int `json:"width,omitempty"`
@ -120,13 +133,13 @@ func (g *GraphDB) PutMemoryBlocks(blocks []MemoryBlock) error {
block.CreatedAt = now block.CreatedAt = now
} }
_, err = tx.Exec(`INSERT INTO memory_blocks ( _, 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 vector, fingerprint, source, tool, scene, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
modality = excluded.modality, modality = excluded.modality,
text_content = excluded.text_content, text_content = excluded.text_content,
payload_digest = excluded.payload_digest, data = excluded.data,
mime = excluded.mime, mime = excluded.mime,
size = excluded.size, size = excluded.size,
width = excluded.width, 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, semantic_type = CASE WHEN excluded.semantic_type != '' THEN excluded.semantic_type ELSE memory_blocks.semantic_type END,
updated_at = excluded.updated_at`, 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.Size, block.Width, block.Height, string(vectorJSON), block.Fingerprint,
block.Source, block.Tool, block.Scene, block.CreatedAt, now) block.Source, block.Tool, block.Scene, block.CreatedAt, now)
if err != nil { 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` vector, fingerprint, source, tool, scene, semantic_type, created_at, updated_at`
func scanBlockRow(rows *sql.Rows) (MemoryBlock, error) { func scanBlockRow(rows *sql.Rows) (MemoryBlock, error) {
var b MemoryBlock var b MemoryBlock
var vectorJSON string var vectorJSON string
var scene sql.NullString 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.MIME, &b.Size, &b.Width, &b.Height, &vectorJSON,
&b.Fingerprint, &b.Source, &b.Tool, &scene, &b.SemanticType, &b.Fingerprint, &b.Source, &b.Tool, &scene, &b.SemanticType,
&b.CreatedAt, &b.UpdatedAt); err != nil { &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) { func (g *GraphDB) BlocksForNode(nodeKind, nodeID string) ([]MemoryBlock, error) {
g.mu.RLock() g.mu.RLock()
defer g.mu.RUnlock() 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.mime, b.size, b.width, b.height, b.vector, b.fingerprint, b.source, b.tool,
b.created_at, b.updated_at b.created_at, b.updated_at
FROM memory_block_edges e FROM memory_block_edges e
@ -436,7 +456,7 @@ func (g *GraphDB) BlocksForNode(nodeKind, nodeID string) ([]MemoryBlock, error)
for rows.Next() { for rows.Next() {
var block MemoryBlock var block MemoryBlock
var vectorJSON string 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.MIME, &block.Size, &block.Width, &block.Height, &vectorJSON,
&block.Fingerprint, &block.Source, &block.Tool, &block.CreatedAt, &block.Fingerprint, &block.Source, &block.Tool, &block.CreatedAt,
&block.UpdatedAt); err != nil { &block.UpdatedAt); err != nil {

View File

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

View File

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

View File

@ -520,7 +520,7 @@ func TestMemoryBlocksAreFirstClassGraphNodes(t *testing.T) {
image := MemoryBlock{ image := MemoryBlock{
ID: "block_image_1", Modality: BlockImage, 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}, Width: 768, Height: 512, Vector: []float64{0.1, 0.2, 0.3},
Fingerprint: "qwen:test", Source: "qq", Tool: "upload", Fingerprint: "qwen:test", Source: "qq", Tool: "upload",
} }
@ -545,7 +545,7 @@ func TestMemoryBlocksAreFirstClassGraphNodes(t *testing.T) {
gotImage = &blocks[i] 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) 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 != "" { if blk.Text != "" {
b.WriteString(truncateRunes(blk.Text, sceneSentenceMaxRunes)) b.WriteString(truncateRunes(blk.Text, sceneSentenceMaxRunes))
} else { } else {
b.WriteString(shortBlockDigest(blk.PayloadDigest)) b.WriteString(shortBlockDigest(blk.ID))
} }
} }
b.WriteString("\n") b.WriteString("\n")
@ -419,6 +419,10 @@ const sceneSentenceMaxRunes = 60
const maxSceneRecallBlocks = 3 const maxSceneRecallBlocks = 3
// shortBlockDigest 取 digest 前 12 位做展示(与 L3 里引用媒体的写法一致)。 // shortBlockDigest 取 digest 前 12 位做展示(与 L3 里引用媒体的写法一致)。
// shortBlockDigest 把块 ID 截短,供提示词里指认媒体块。
//
// 参数曾是内容 digest;块自带负载后,指认单位改成**块 ID**(模型按块 ID
// 取回该块的负载,不再经内容寻址层中转)。
func shortBlockDigest(d string) string { func shortBlockDigest(d string) string {
if len(d) <= 12 { if len(d) <= 12 {
return d return d

View File

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

View File

@ -487,7 +487,7 @@ func putBlockTx(tx *sql.Tx, b MemoryBlock) (bool, error) {
} }
_, err := tx.Exec(`INSERT INTO memory_blocks _, 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) vector, fingerprint, source, tool, scene, semantic_type, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET 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 != '' semantic_type = CASE WHEN excluded.semantic_type != ''
THEN excluded.semantic_type ELSE memory_blocks.semantic_type END, THEN excluded.semantic_type ELSE memory_blocks.semantic_type END,
`+tsUpdate, `+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, vectorJSON, b.Fingerprint, b.Source, b.Tool, b.Scene, b.SemanticType,
created, updated) created, updated)
if err != nil { 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, b.size, b.width, b.height, b.fingerprint, b.source, b.tool,
COALESCE(b.scene, ''), COALESCE(b.semantic_type, ''), COALESCE(b.scene, ''), COALESCE(b.semantic_type, ''),
b.created_at, b.updated_at, MAX(sr.weight) AS w 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() { for brows.Next() {
var b MemoryBlock var b MemoryBlock
var w float64 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.Size, &b.Width, &b.Height, &b.Fingerprint, &b.Source, &b.Tool,
&b.Scene, &b.SemanticType, &b.CreatedAt, &b.UpdatedAt, &w); err != nil { &b.Scene, &b.SemanticType, &b.CreatedAt, &b.UpdatedAt, &w); err != nil {
return nil, err return nil, err

View File

@ -344,7 +344,7 @@ func TestSceneBlocks(t *testing.T) {
defer g.Close() defer g.Close()
blocks := []MemoryBlock{ 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: "无关场面的转写"}, {ID: "blk_b", Modality: "text", Text: "无关场面的转写"},
} }
if err := g.PutMemoryBlocks(blocks); err != nil { if err := g.PutMemoryBlocks(blocks); err != nil {
@ -375,7 +375,7 @@ func TestSceneBlocks(t *testing.T) {
// 无场景重写不得抹掉已挂的场景(静默降级防护) // 无场景重写不得抹掉已挂的场景(静默降级防护)
if err := g.PutMemoryBlocks([]MemoryBlock{ 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 { }); err != nil {
t.Fatalf("重写块: %v", err) t.Fatalf("重写块: %v", err)
} }
@ -468,7 +468,7 @@ func TestFormatContextSceneBlocks(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
if err := g.PutMemoryBlocks([]MemoryBlock{ 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 { }); err != nil {
t.Fatal(err) t.Fatal(err)
} }

View File

@ -4,6 +4,7 @@ import (
"crypto/sha256" "crypto/sha256"
"database/sql" "database/sql"
"encoding/hex" "encoding/hex"
"encoding/json"
"strings" "strings"
"time" "time"
) )
@ -226,3 +227,126 @@ func putTripleBlocksTx(tx *sql.Tx, t Triple, sessionID string, turnID int) (stri
edgeID, err := res.LastInsertId() edgeID, err := res.LastInsertId()
return src.ID, dst.ID, edgeID, created, err 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 memDB *memory.GraphDB
textMem *text.Memory textMem *text.Memory
docStore *doc.Store docStore *doc.Store
// mediaStore 让插件写入的记忆也能带媒体。 // mediaStore 已随 CAS 淘汰(块自带负载,见 plan.md)。
// 为 nil 时(配置关闭或初始化失败)插件侧记忆包装退化为纯文本行为, // 不保留字段:留着会诱导后来者再接线,走回头路。
// 与本特性上线前完全一致。
mediaStore *media.Store
ks *knowledge.Store ks *knowledge.Store
mgr *agentAPI.ProviderManager mgr *agentAPI.ProviderManager
cfgReg *internalConfig.ConfigRegistry 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) SetMemory(memDB *memory.GraphDB) { r.memDB = memDB }
func (r *Registry) SetTextMemory(tm *text.Memory) { r.textMem = tm } func (r *Registry) SetTextMemory(tm *text.Memory) { r.textMem = tm }
func (r *Registry) SetDocStore(ds *doc.Store) { r.docStore = ds } 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) SetKnowledge(ks *knowledge.Store) { r.ks = ks }
func (r *Registry) SetProviderManager(mgr *agentAPI.ProviderManager) { r.mgr = mgr } func (r *Registry) SetProviderManager(mgr *agentAPI.ProviderManager) { r.mgr = mgr }
func (r *Registry) SetConfigRegistry(cfgReg *internalConfig.ConfigRegistry) { r.cfgReg = cfgReg } 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{ return sdk.New(name, sdk.SDKConfig{
IOManager: r.iom, IOManager: r.iom,
EventBus: r.evBus, EventBus: r.evBus,
// 带 media 的包装:插件提交的三元组/文档/文本事件里的媒体会落进 CAS // 块自带负载(媒体字节在块的 Data),不再需要独立的媒体存储句柄。
// 并挂上引用。传入插件名仅用于日志溯源(哪个插件写的媒体)。 // 插件名仅用于日志溯源(哪个插件写的块)。
Memory: sdk.NewGraphMemoryWithMedia(name, r.memDB, r.mediaStore), Memory: sdk.NewGraphMemoryWithPlugin(name, r.memDB),
TextMemory: sdk.NewTextMemoryWithMedia(name, r.textMem, r.mediaStore), TextMemory: sdk.NewTextMemoryWithPlugin(name, r.textMem),
DocMemory: sdk.NewDocMemoryWithMedia(name, r.docStore, r.mediaStore), DocMemory: sdk.NewDocMemoryWithPlugin(name, r.docStore),
Knowledge: sdk.NewKnowledge(r.ks), Knowledge: sdk.NewKnowledge(r.ks),
Media: sdk.NewMedia(r.mediaStore),
LLM: sdk.NewLLM(r.mgr, r.cfgReg, r.lua, r.baseKey), LLM: sdk.NewLLM(r.mgr, r.cfgReg, r.lua, r.baseKey),
Settings: sett, Settings: sett,
RegTool: regTool, RegTool: regTool,

View File

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

View File

@ -130,7 +130,7 @@ type graphBlockView struct {
ID string `json:"id"` ID string `json:"id"`
Modality string `json:"modality"` Modality string `json:"modality"`
Text string `json:"text,omitempty"` Text string `json:"text,omitempty"`
PayloadDigest string `json:"payload_digest"` HasData bool `json:"has_data"`
MIME string `json:"mime,omitempty"` MIME string `json:"mime,omitempty"`
Size int64 `json:"size"` Size int64 `json:"size"`
Width int `json:"width,omitempty"` Width int `json:"width,omitempty"`
@ -165,7 +165,7 @@ func graphDataForVisual(data map[string]interface{}) map[string]interface{} {
ID: b.ID, ID: b.ID,
Modality: string(b.Modality), Modality: string(b.Modality),
Text: b.Text, Text: b.Text,
PayloadDigest: b.PayloadDigest, HasData: len(b.Data) > 0,
MIME: b.MIME, MIME: b.MIME,
Size: b.Size, Size: b.Size,
Width: b.Width, Width: b.Width,
@ -245,8 +245,8 @@ func (h *Handler) handleMemoryGraphPulse(w http.ResponseWriter, r *http.Request)
pulse = append(pulse, map[string]interface{}{ pulse = append(pulse, map[string]interface{}{
"id": b.ID, "id": b.ID,
"name": b.Text, "name": b.Text,
"modality": string(b.Modality), "modality": b.Modality,
"payload_digest": b.PayloadDigest, "has_data": b.HasData,
"mime": b.MIME, "mime": b.MIME,
"tool": b.Tool, "tool": b.Tool,
"scene": b.Scene, "scene": b.Scene,

View File

@ -15,7 +15,7 @@ type MemoryBlockView struct {
ID string `json:"id"` ID string `json:"id"`
Modality string `json:"modality"` Modality string `json:"modality"`
Text string `json:"text,omitempty"` Text string `json:"text,omitempty"`
PayloadDigest string `json:"payload_digest,omitempty"` HasData bool `json:"has_data"` // 媒体负载在块内(CAS 淘汰后),只报有无不回显内容
MIME string `json:"mime,omitempty"` MIME string `json:"mime,omitempty"`
Source string `json:"source,omitempty"` Source string `json:"source,omitempty"`
Tool string `json:"tool,omitempty"` Tool string `json:"tool,omitempty"`

View File

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