mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
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:
@ -14,7 +14,6 @@ import (
|
||||
"github.com/JianFeeeee/HomeAgent/internal/knowledge"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/memory/social"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/memory/text"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/memory/vector"
|
||||
@ -80,10 +79,7 @@ type Agent struct {
|
||||
// 文本记忆(原始对话日志)
|
||||
textMem *text.Memory
|
||||
|
||||
// 媒体存储(内容寻址):对话里出现的图片/音频按 sha256 落盘去重。
|
||||
// 它是记忆块的内容存储,不单独做生命周期管理:块的创建/迁移/删除
|
||||
// 由记忆系统本身决定。为 nil 时全部媒体接线静默跳过。
|
||||
mediaStore *media.Store
|
||||
// 媒体存储句柄已随 CAS 淘汰(块自带负载,见 plan.md)。
|
||||
|
||||
// 人格设定(内容来自启动时载入的人格文件/配置项)
|
||||
personality *agentPkg.Personality
|
||||
@ -185,12 +181,12 @@ type Agent struct {
|
||||
// 当前轮次的非文本媒体数据(图片/音频),供 describe_image 等工具访问
|
||||
pendingMedia map[string]interface{}
|
||||
|
||||
// pendingMediaDigests 累积本轮已落进 CAS 的媒体 digest。
|
||||
// pendingMediaBlocks 累积本轮已捕获的媒体块。
|
||||
//
|
||||
// 需要缓存而不是当场挂到事件上:媒体在 process() 执行期间被捕获,
|
||||
// 而承载它的 ContextEvent 要等 process() 返回后才 Append——此刻还没有 owner_id。
|
||||
// 而承载它的 ContextEvent 要等 process() 返回后才 Append——此刻还没有 owner。
|
||||
// 由 schedulerLoop goroutine 独占读写。
|
||||
pendingMediaDigests []string
|
||||
pendingMediaBlocks []memory.MemoryBlock
|
||||
|
||||
// 当前输入是否为工具提醒/中断(以 system 角色注入,避免被当成用户消息)
|
||||
interruptInput bool
|
||||
@ -266,7 +262,7 @@ type AgentConfig struct {
|
||||
Knowledge *knowledge.Store
|
||||
SocialStore *social.SocialStore
|
||||
TextMemory *text.Memory
|
||||
MediaStore *media.Store
|
||||
// 媒体存储句柄已随 CAS 淘汰(块自带负载,见 plan.md)。
|
||||
MultimodalSpace vector.MultimodalEmbedder
|
||||
// EmbeddingProvider / EmbeddingError 是向量空间的配置身份与打开失败原因,
|
||||
// 供 healthcheck_kernel 状态报告区分「未配置 / 打开失败 / 已启用」。
|
||||
@ -383,15 +379,12 @@ func New(cfg AgentConfig) *Agent {
|
||||
// 稀疏两路(词向量 + TF-IDF)**保持启用**且仍是主召回路径:多模态
|
||||
// 空间未配置时知识库行为与此前逐字一致(退化为 0.5/0.5 两路融合)。
|
||||
if cfg.Knowledge != nil {
|
||||
// 顺序要紧:先接线(含 MediaStore),再重建。ReindexDense 会
|
||||
// 尝试从 .dense.json 缓存恢复,恢复不了才重算,最后把结果落盘。
|
||||
// 若先重建后接线,首次启动算出的向量会被丢掉而不落盘。
|
||||
// 顺序要紧:先接线再重建。ReindexDense 会尝试从 .dense.json 缓存恢复,
|
||||
// 恢复不了才重算,最后把结果落盘。若先重建后接线,首次启动算出的
|
||||
// 向量会被丢掉而不落盘。
|
||||
cfg.Knowledge.SetDenseSpace(cfg.MultimodalSpace)
|
||||
cfg.Knowledge.SetMediaGetter(cfg.MediaStore)
|
||||
// 媒体**写入**器:ImportDir 复制图片/音视频时用。
|
||||
// 不接的话 include_media=true 会静默失效(媒体全被跳过、
|
||||
// 只在导入结果里留一行"媒体库不可用"),模型无从察觉。
|
||||
cfg.Knowledge.SetMediaPutter(cfg.MediaStore)
|
||||
// 媒体字节随知识条目自带(CAS 淘汰后):denseFor 直接读条目目录里的
|
||||
// 媒体文件,无需 mediaGet/mediaPut 接线。
|
||||
built, skipped := cfg.Knowledge.ReindexDense()
|
||||
if built > 0 || skipped > 0 {
|
||||
log.Printf("[knowledge] 多模态稠密索引: 新建 %d 跳过 %d(其余命中缓存)", built, skipped)
|
||||
@ -421,7 +414,8 @@ func New(cfg AgentConfig) *Agent {
|
||||
knowledge: cfg.Knowledge,
|
||||
social: cfg.SocialStore,
|
||||
textMem: cfg.TextMemory,
|
||||
mediaStore: cfg.MediaStore,
|
||||
// mediaStore 已随 CAS 淘汰:块自带负载。知识层的媒体也已自带字节
|
||||
// (见 knowledge 的媒体侧车),不再需要外部 getter/putter 接线。
|
||||
personality: cfg.Personality,
|
||||
personaStore: cfg.PersonaStore,
|
||||
allowedOutputs: cfg.AllowedOutputs,
|
||||
|
||||
@ -6,28 +6,30 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||||
)
|
||||
|
||||
// CrossModalHit 是跨模态检索融合后的一条候选。
|
||||
//
|
||||
// 统一的检索单元是记忆块而非 CAS 全库:媒体在 L0/L2/L3 都由层容器持有,
|
||||
// 只有仍被某层记忆块持有的媒体才可召回。Doc 是 L2 文档;Media 是该块携带的
|
||||
// 原生媒体坐标。两路分数尺度不同,融合前各自归一化,见 fuseCrossModal。
|
||||
// 统一的检索单元是**记忆块**(文本块与媒体块同构)。文本路命中文档;
|
||||
// 视觉路命中媒体块——两者都是块,向量在同一统一空间里可直接比较。
|
||||
//
|
||||
// ★ 2026-10-11:不再有「CAS 全库」这个检索面。视觉路直接走
|
||||
// memory.RecallBlocks(读块自己的向量),因此不存在「召回已无处可归的
|
||||
// 内容」这回事——能召回的必然是被某层持有的块。
|
||||
type CrossModalHit struct {
|
||||
Doc *document.Doc // 文本路命中的文档;视觉路命中时为 nil
|
||||
Media *media.Item // 视觉路命中的媒体;文本路命中时也可能带关联媒体
|
||||
MediaScore float64 // 视觉路原始 cosine(无则 0)
|
||||
DocScore float64 // 文本路原始 cosine(无则 0)
|
||||
Fused float64 // 归一化加权融合分,供最终排序
|
||||
// 该媒体同时被两路命中(文本路经文档关联、视觉路直接命中)时,
|
||||
// DoubleHit=true —— 双信号确认,应排在只被一路命中的候选之前。
|
||||
Doc *document.Doc // 文本路命中的文档;视觉路命中时为 nil
|
||||
Media *memory.MemoryBlock // 视觉路命中的媒体块;文本路命中时也可能带关联块
|
||||
MediaScore float64 // 视觉路原始 cosine(无则 0)
|
||||
DocScore float64 // 文本路原始 cosine(无则 0)
|
||||
Fused float64 // 归一化加权融合分,供最终排序
|
||||
// 该媒体同时被两路命中时 DoubleHit=true —— 双信号确认,应排在只被
|
||||
// 一路命中的候选之前。
|
||||
DoubleHit bool
|
||||
}
|
||||
|
||||
// CrossModalFusionConfig 控制文本路与视觉路的融合行为。
|
||||
// 默认各路权重 0.5,双命中加权 0.15;不同模型/场景可按实测调整。
|
||||
type CrossModalFusionConfig struct {
|
||||
WeightText float64 // 文本路融合权重(默认 0.5)
|
||||
WeightVisual float64 // 视觉路融合权重(默认 0.5)
|
||||
@ -54,9 +56,7 @@ func (c CrossModalFusionConfig) visualWeight() float64 {
|
||||
}
|
||||
return c.WeightVisual
|
||||
}
|
||||
func (c CrossModalFusionConfig) doubleHitBonus() float64 {
|
||||
return c.DoubleHitBonus
|
||||
}
|
||||
func (c CrossModalFusionConfig) doubleHitBonus() float64 { return c.DoubleHitBonus }
|
||||
func (c CrossModalFusionConfig) minMaxEps() float64 {
|
||||
if c.MinMaxEps <= 0 {
|
||||
return defaultFusionConfig.MinMaxEps
|
||||
@ -69,17 +69,16 @@ func (c CrossModalFusionConfig) minMaxEps() float64 {
|
||||
// 策略(两路并行,召回真正最相似的):
|
||||
// 1. 文本路:query 整段文本编码后查文档层(Doc.DenseVec 已融合其块的媒体向量),
|
||||
// 命中文档若持有媒体块,直接带上该块。
|
||||
// 2. 视觉路:query 经多模态模型文本编码 → 与媒体块向量比余弦
|
||||
// (QueryMediaScored),覆盖文本向量没写到的视觉内容。
|
||||
// 2. 视觉路:query 经多模态模型文本编码 → 与**媒体块自己的向量**比余弦
|
||||
// (memory.RecallBlocks),覆盖文本向量没写到的视觉内容。
|
||||
// 用的就是块向量——没有第二套向量、没有第二个检索面。
|
||||
// 3. 融合:两条路候选各自 min-max 归一化到 [0,1],加权求和后降序,取 topK。
|
||||
// 同一媒体被两路同时命中视为双信号确认,额外加权。
|
||||
//
|
||||
// 多模态空间未配置时视觉路为空,退化为纯文本路(等价旧 docStore.Query)。
|
||||
// 多模态空间未配置时视觉路为空,退化为纯文本路。
|
||||
func (a *Agent) retrieveCrossModal(query string, topK int, cfg CrossModalFusionConfig) []CrossModalHit {
|
||||
if topK <= 0 {
|
||||
topK = 5
|
||||
}
|
||||
// 融合前各取 2× 余量,保证融合排序后 topK 仍有足够候选。
|
||||
per := topK * 2
|
||||
if per < 8 {
|
||||
per = 8
|
||||
@ -90,34 +89,38 @@ func (a *Agent) retrieveCrossModal(query string, topK int, cfg CrossModalFusionC
|
||||
if a.docStore != nil {
|
||||
for _, dh := range a.docStore.QueryScored(query, per) {
|
||||
hit := CrossModalHit{Doc: dh.Doc, DocScore: dh.Score}
|
||||
// 命中文档若持有一等记忆块,把首个媒体块一并带上。
|
||||
if a.mediaStore != nil && len(dh.Doc.Blocks) > 0 {
|
||||
if it, err := a.mediaStore.Stat(dh.Doc.Blocks[0].PayloadDigest); err == nil {
|
||||
hit.Media = it
|
||||
// 命中文档若持有一等记忆块,把首个媒体块一并带上(用于双命中标记)。
|
||||
for i := range dh.Doc.Blocks {
|
||||
if dh.Doc.Blocks[i].Modality != memory.BlockText {
|
||||
b := dh.Doc.Blocks[i]
|
||||
hit.Media = &b
|
||||
break
|
||||
}
|
||||
}
|
||||
textHits = append(textHits, hit)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 视觉路(多模态文本编码 → 当前记忆层持有的媒体块)----
|
||||
// ---- 视觉路(多模态文本编码 → 媒体块自己的向量)----
|
||||
var visualHits []CrossModalHit
|
||||
if a.multimodalSpace != nil && a.multimodalSpace.Loaded() && a.mediaStore != nil {
|
||||
if a.multimodalSpace != nil && a.multimodalSpace.Loaded() && a.memory != nil {
|
||||
qv, err := a.multimodalSpace.VectorizeDense(query)
|
||||
if err != nil {
|
||||
log.Printf("[crossmodal] 多模态文本编码失败: %v", err)
|
||||
} else if mh, err := a.mediaStore.QueryMediaScored(qv, a.multimodalSpace.Fingerprint(), per); err != nil {
|
||||
log.Printf("[crossmodal] 媒体记忆检索失败: %v", err)
|
||||
} else if hits, err := a.memory.RecallBlocks(memory.BlockRecallQuery{
|
||||
Vector: qv,
|
||||
Fingerprint: a.multimodalSpace.Fingerprint(),
|
||||
TopK: per,
|
||||
}); err != nil {
|
||||
log.Printf("[crossmodal] 媒体块检索失败: %v", err)
|
||||
} else {
|
||||
// 只有仍被某层记忆块持有的媒体才可召回:CAS 是全库字节存储,
|
||||
// 直接拿它的检索结果会把已无处可归的内容也从记忆里翻出来。
|
||||
held := a.heldMediaDigests()
|
||||
for _, h := range mh {
|
||||
if h.Item == nil || !held[h.Item.Digest] {
|
||||
continue
|
||||
for _, h := range hits {
|
||||
if h.Block.Modality == memory.BlockText {
|
||||
continue // 文本块由文本路负责,避免同一内容两路重复
|
||||
}
|
||||
b := h.Block
|
||||
visualHits = append(visualHits, CrossModalHit{
|
||||
Media: h.Item, MediaScore: h.Score,
|
||||
Media: &b, MediaScore: h.Score,
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -127,14 +130,6 @@ func (a *Agent) retrieveCrossModal(query string, topK int, cfg CrossModalFusionC
|
||||
}
|
||||
|
||||
// fuseCrossModal 把文本路与视觉路候选按各自归一化分融合排序。
|
||||
//
|
||||
// 归一化模板:两路分数尺度不可直接相加,先各自在路内 min-max 到 [0,1]:
|
||||
//
|
||||
// norm(x) = (x - min) / (max - min),max==min 时置 1
|
||||
//
|
||||
// 再加权求和:fused = wText·normText + wVisual·normVisual。同一媒体两路都命中
|
||||
// (经文档关联 + 视觉直接)时 DoubleHit,在加权分上再加双信号确认分。
|
||||
// 权重通过 CrossModalFusionConfig 按场景配置,不同模型/版本可按实测调整。
|
||||
func fuseCrossModal(textHits, visualHits []CrossModalHit, topK int, cfg CrossModalFusionConfig) []CrossModalHit {
|
||||
norm := func(hits []CrossModalHit, pick func(CrossModalHit) float64) []float64 {
|
||||
out := make([]float64, len(hits))
|
||||
@ -171,12 +166,11 @@ func fuseCrossModal(textHits, visualHits []CrossModalHit, topK int, cfg CrossMod
|
||||
return "doc:" + h.Doc.ID
|
||||
}
|
||||
if h.Media != nil {
|
||||
return "media:" + h.Media.Digest
|
||||
return "media:" + h.Media.ID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// 先并入视觉路(视觉媒体是独立实体)
|
||||
for i, h := range visualHits {
|
||||
k := key(h)
|
||||
if k == "" {
|
||||
@ -187,14 +181,12 @@ func fuseCrossModal(textHits, visualHits []CrossModalHit, topK int, cfg CrossMod
|
||||
byKey[k] = &clone
|
||||
keys = append(keys, k)
|
||||
}
|
||||
// 再并入文本路:命中的文档是独立实体;带媒体的文档若其媒体 digest
|
||||
// 已在视觉路(双命中),合并到同一候选并标记 DoubleHit。
|
||||
for i, h := range textHits {
|
||||
if h.Doc == nil {
|
||||
continue
|
||||
}
|
||||
if h.Media != nil {
|
||||
if ex, ok := byKey["media:"+h.Media.Digest]; ok {
|
||||
if ex, ok := byKey["media:"+h.Media.ID]; ok {
|
||||
ex.DoubleHit = true
|
||||
ex.Doc = h.Doc
|
||||
ex.Fused += cfg.textWeight()*textN[i] + cfg.doubleHitBonus()
|
||||
@ -233,7 +225,7 @@ func fuseCrossModal(textHits, visualHits []CrossModalHit, topK int, cfg CrossMod
|
||||
}
|
||||
|
||||
// crossModalMarkdown 把融合候选渲染成注入上下文的文本。
|
||||
// 文档行给出摘要;媒体行只给 MIME + 短 digest(不再有生成的描述)。
|
||||
// 文档行给出摘要;媒体行只给 MIME + 短块 ID(不再有生成的描述)。
|
||||
func (a *Agent) crossModalMarkdown(hits []CrossModalHit) string {
|
||||
if len(hits) == 0 {
|
||||
return ""
|
||||
@ -257,7 +249,7 @@ func (a *Agent) crossModalMarkdown(hits []CrossModalHit) string {
|
||||
}
|
||||
}
|
||||
if h.Media != nil {
|
||||
if line := mediaLabel(h.Media); line != "" {
|
||||
if line := blockLabel(h.Media); line != "" {
|
||||
parts = append(parts, line)
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,6 +17,9 @@ import (
|
||||
//
|
||||
// 这里不再有任何 marker 文本、正则反解或"描述文本当索引"的路径:
|
||||
// 媒体只按自己的统一空间向量被检索,图库/文档只记录它的结构归属。
|
||||
//
|
||||
// ★ 2026-10-11:块自带负载(CAS 已淘汰)。因此本文件不再有
|
||||
// "按 digest 去 CAS 取块"这一步——块就是它自己,按块 ID 直接引用。
|
||||
|
||||
// migrateLegacyGraphMedia 把 marker 反解出来的旧媒体实体迁移成原生一等块。
|
||||
//
|
||||
@ -24,16 +27,20 @@ import (
|
||||
// 靠生成的描述文本当索引。迁移后它变成真正的记忆块,以
|
||||
// sentence --contains--> block 结构边挂回原句子,旧实体与描述关系删除。
|
||||
// 迁移幂等(实体处理完即删除),因此在每个 Agent 启动时跑一次是安全的。
|
||||
//
|
||||
// ★ 旧实体的名字里带的是**内容短 digest**;块自带负载后没有 CAS 可按
|
||||
// digest 取回字节,所以旧媒体实体的字节若已无从取得(CAS 已删),
|
||||
// 该实体只能如实删除而不建块——不造一个没有内容的空块。
|
||||
func (a *Agent) migrateLegacyGraphMedia() {
|
||||
if a.memory == nil || a.mediaStore == nil {
|
||||
if a.memory == nil {
|
||||
return
|
||||
}
|
||||
blocks, entities, err := a.memory.MigrateLegacyMediaEntities(func(short string) (memory.MemoryBlock, bool) {
|
||||
full, err := a.mediaStore.ResolvePrefix(short)
|
||||
if err != nil {
|
||||
return memory.MemoryBlock{}, false
|
||||
// 先看该内容是否已经有对应的块(内容派生 ID,前缀可查)。
|
||||
if b, ok, err := a.memory.BlockByIDPrefix("blk_media_" + short); err == nil && ok {
|
||||
return b, true
|
||||
}
|
||||
return a.blockFromDigest(full)
|
||||
return memory.MemoryBlock{}, false
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[media] 旧媒体实体迁移失败(下轮重试): %v", err)
|
||||
@ -44,26 +51,28 @@ func (a *Agent) migrateLegacyGraphMedia() {
|
||||
}
|
||||
}
|
||||
|
||||
// attachBlocksToSentenceBlock 把一组 digest 变成 L3 一等块并挂到**原句块**上。
|
||||
// seed 允许复用已持有块的 ID(L2→L3 迁移保持块身份不变)。
|
||||
// ★ sentenceBlockID 是**原句块 ID**(原为 sentences 表行号)。
|
||||
// attachBlocksToSentenceBlock 把一组媒体块引用挂到**原句块**上。
|
||||
//
|
||||
// 句子的承载者已从 sentences 表迁移到 blk_src_<sha256(text[:12])>,
|
||||
// 媒体边因此改挂「原句块 --contains--> 媒体块」。
|
||||
func (a *Agent) attachBlocksToSentenceBlock(sentenceBlockID string, digests []string, seed map[string]memory.MemoryBlock, scene string) int {
|
||||
if a.mediaStore == nil || a.memory == nil || sentenceBlockID == "" {
|
||||
// ★ 参数从 digests 改为 refs(块 ID 或其前缀):CAS 淘汰后,媒体由块自身
|
||||
// 承载,引用单位就是块 ID。seed 允许复用调用方已持有的块(L2→L3 迁移
|
||||
// 保持块身份不变)。
|
||||
// ★ sentenceBlockID 是**原句块 ID**(原为 sentences 表行号)。
|
||||
func (a *Agent) attachBlocksToSentenceBlock(sentenceBlockID string, refs []string, seed map[string]memory.MemoryBlock, scene string) int {
|
||||
if a.memory == nil || sentenceBlockID == "" {
|
||||
return 0
|
||||
}
|
||||
bound := 0
|
||||
for _, d := range digests {
|
||||
full, err := a.mediaStore.ResolvePrefix(d)
|
||||
if err != nil {
|
||||
log.Printf("[media] digest %s 无法解析: %v", d, err)
|
||||
continue
|
||||
}
|
||||
b, ok := seed[full]
|
||||
for _, ref := range refs {
|
||||
b, ok := seed[ref]
|
||||
if !ok {
|
||||
if b, ok = a.blockFromDigest(full); !ok {
|
||||
var err error
|
||||
b, ok, err = a.memory.BlockByIDPrefix(ref)
|
||||
if err != nil {
|
||||
log.Printf("[media] 查块 %s 失败: %v", ref, err)
|
||||
continue
|
||||
}
|
||||
if !ok {
|
||||
log.Printf("[media] 块引用 %s 无唯一对应块,跳过", ref)
|
||||
continue
|
||||
}
|
||||
}
|
||||
@ -73,11 +82,11 @@ func (a *Agent) attachBlocksToSentenceBlock(sentenceBlockID string, digests []st
|
||||
b.Scene = scene
|
||||
}
|
||||
if err := a.memory.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil {
|
||||
log.Printf("[media] L3 块写入失败 (%s): %v", shortDigest(full), err)
|
||||
log.Printf("[media] L3 块写入失败 (%s): %v", shortID(b.ID), err)
|
||||
continue
|
||||
}
|
||||
if err := a.memory.AddMemoryBlockEdge("block", sentenceBlockID, "block", b.ID, "contains"); err != nil {
|
||||
log.Printf("[media] 句子→块边建立失败 (%s): %v", shortDigest(full), err)
|
||||
log.Printf("[media] 句子→块边建立失败 (%s): %v", shortID(b.ID), err)
|
||||
continue
|
||||
}
|
||||
bound++
|
||||
@ -116,7 +125,7 @@ func (a *Agent) linkBlocksToDocument(docID string, blocks []memory.MemoryBlock,
|
||||
bound := 0
|
||||
for _, b := range blocks {
|
||||
if err := a.memory.AddMemoryBlockEdge("document", docID, "block", b.ID, "contains"); err != nil {
|
||||
log.Printf("[media] 文档→块边建立失败 (%s): %v", shortDigest(b.PayloadDigest), err)
|
||||
log.Printf("[media] 文档→块边建立失败 (%s): %v", shortID(b.ID), err)
|
||||
continue
|
||||
}
|
||||
bound++
|
||||
@ -133,9 +142,9 @@ func (a *Agent) commitTriplesWithMedia(triples []memory.Triple, sessionID string
|
||||
if g == nil {
|
||||
return 0, 0, 0, fmt.Errorf("graph memory 未启用")
|
||||
}
|
||||
// 轻量内核(memory == nil,子只有图记忆)或没有媒体库时:只写图记忆。
|
||||
// 轻量内核(memory == nil,子只有图记忆):只写图记忆。
|
||||
// 写目标由 a.graph 决定 —— 根落 main,子落自己的 temp。
|
||||
if a.memory == nil || a.mediaStore == nil {
|
||||
if a.memory == nil {
|
||||
ec, rc, cErr := g.Commit(triples, sessionID, turnID)
|
||||
return ec, rc, 0, cErr
|
||||
}
|
||||
@ -143,10 +152,10 @@ func (a *Agent) commitTriplesWithMedia(triples []memory.Triple, sessionID string
|
||||
if err != nil {
|
||||
return ec, rc, 0, err
|
||||
}
|
||||
byDigest := make(map[string]memory.MemoryBlock, len(seed))
|
||||
byRef := make(map[string]memory.MemoryBlock, len(seed))
|
||||
for _, b := range seed {
|
||||
if b.PayloadDigest != "" {
|
||||
byDigest[b.PayloadDigest] = b
|
||||
if b.ID != "" {
|
||||
byRef[b.ID] = b
|
||||
}
|
||||
}
|
||||
for _, t := range triples {
|
||||
@ -157,12 +166,11 @@ func (a *Agent) commitTriplesWithMedia(triples []memory.Triple, sessionID string
|
||||
if sid == "" {
|
||||
continue
|
||||
}
|
||||
blocks += a.attachBlocksToSentenceBlock(sid, t.MediaDigests, byDigest, t.Scene)
|
||||
blocks += a.attachBlocksToSentenceBlock(sid, t.MediaDigests, byRef, t.Scene)
|
||||
}
|
||||
return ec, rc, blocks, nil
|
||||
}
|
||||
|
||||
// RecallBlocksForSentence 反查某条图库句子持有的一等记忆块。
|
||||
// RecallBlocksForSentence 按**原句块 ID**取回它承载的媒体块。
|
||||
//
|
||||
// ★ 签名从 int64 改为 string:挂载点已从「sentences 表行号」
|
||||
@ -175,45 +183,39 @@ func (a *Agent) RecallBlocksForSentence(sentenceBlockID string) ([]memory.Memory
|
||||
return a.memory.BlocksForNode("block", sentenceBlockID)
|
||||
}
|
||||
|
||||
// resolveMediaDigests 把模型给的(多为短)digest 补全成完整 digest。
|
||||
// resolveMediaRefs 把模型给的(多为短)块引用补全成唯一的块 ID。
|
||||
//
|
||||
// 补不上就丢弃那一条并记日志:模型可能凭印象编了个 digest,也可能内容已被删除。
|
||||
func (a *Agent) resolveMediaDigests(digests []string) []string {
|
||||
if a.mediaStore == nil || len(digests) == 0 {
|
||||
// 补不上或歧义就丢弃那一条并记日志:模型可能凭印象编了个 ID,
|
||||
// 也可能内容已被删除。宁可不解析也不猜(猜错 = 挂错媒体)。
|
||||
func (a *Agent) resolveMediaRefs(refs []string) []string {
|
||||
if a.memory == nil || len(refs) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]bool, len(digests))
|
||||
seen := make(map[string]bool, len(refs))
|
||||
var out []string
|
||||
for _, d := range digests {
|
||||
full, err := a.mediaStore.ResolvePrefix(d)
|
||||
if err != nil {
|
||||
log.Printf("[media] 模型给的 digest %s 无法解析: %v", d, err)
|
||||
for _, r := range refs {
|
||||
b, ok, err := a.memory.BlockByIDPrefix(r)
|
||||
if err != nil || !ok {
|
||||
log.Printf("[media] 模型给的块引用 %s 无法唯一解析", r)
|
||||
continue
|
||||
}
|
||||
if seen[full] {
|
||||
if seen[b.ID] {
|
||||
continue
|
||||
}
|
||||
seen[full] = true
|
||||
out = append(out, full)
|
||||
seen[b.ID] = true
|
||||
out = append(out, b.ID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sentenceIDsFromRelations 收集一批关系引用的句子 id(去重、去零)。
|
||||
// sentenceIDsFromRelations 从关系里取出其原句的**块 ID**(去重、去零)。
|
||||
//
|
||||
// 关系行本身不持有媒体,媒体作为一等块以 sentence --contains--> block
|
||||
// 结构边与句子相连;因此"这次召回涉及哪些媒体"必须经由关系 → 句子这一跳。
|
||||
// sentenceIDsFromRelations 从关系里取出其原句的**块 ID**。
|
||||
//
|
||||
// ★ 从 Relation.SentenceID(旧 sentences 表行号)改为按 SentenceText 现算
|
||||
//
|
||||
// blk_src_<hash>。
|
||||
//
|
||||
// 原因:Commit 块化后原句由块承载,sentences 表退场,
|
||||
// 行号不再是稳定的挂载点;而 SentenceText 一直在 Relation 上。
|
||||
//
|
||||
// 这条链(mediaContextForSentences / mediaContextForRelations)
|
||||
// 完全靠这个 ID 找回媒体块,所以改错了 = 媒体上下文整体失效。
|
||||
// blk_src_<hash>。原因:Commit 块化后原句由块承载,sentences 表退场,
|
||||
// 行号不再是稳定的挂载点;而 SentenceText 一直在 Relation 上。
|
||||
func sentenceIDsFromRelations(relations []memory.Relation) []string {
|
||||
if len(relations) == 0 {
|
||||
return nil
|
||||
@ -242,9 +244,7 @@ func (a *Agent) mediaContextForRelations(relations []memory.Relation) string {
|
||||
// formatRecallRelations 渲染 memory_recall 的关系行,超 max 条截断。
|
||||
//
|
||||
// 带上原始句子(截断到 60 字):三元组只是「A 关系 B」,脱离原句往往看不出
|
||||
// 语气、条件与指代——`sentence_text` 的存在意义就是「日后从图谱回到原文」,
|
||||
// 而 Recall 已经把句子 JOIN 出来了。此前只回显实体名与关系类型,导致模型
|
||||
// 填了 sentence_text 也永远拿不回来,这个能力形同虚设。
|
||||
// 语气、条件与指代——`sentence_text` 的存在意义就是「日后从图谱回到原文」。
|
||||
func formatRecallRelations(relations []memory.Relation, max int) []string {
|
||||
var out []string
|
||||
for i, r := range relations {
|
||||
@ -264,9 +264,9 @@ func formatRecallRelations(relations []memory.Relation, max int) []string {
|
||||
// mediaContextForInjectedEntities 为自动注入路径产出媒体说明。
|
||||
//
|
||||
// Indexer.BuildContext 刻意不返回关系(只给实体索引以省 token),
|
||||
// 因此这里用命中的实体名再查一次关系,只为拿到 sentence_id。
|
||||
// 因此这里用命中的实体名再查一次关系,只为拿到原句。
|
||||
func (a *Agent) mediaContextForInjectedEntities(injected *memory.InjectedContext) string {
|
||||
if a.mediaStore == nil || a.memory == nil || injected == nil || len(injected.Entities) == 0 {
|
||||
if a.memory == nil || injected == nil || len(injected.Entities) == 0 {
|
||||
return ""
|
||||
}
|
||||
names := make([]string, 0, len(injected.Entities))
|
||||
@ -280,18 +280,14 @@ func (a *Agent) mediaContextForInjectedEntities(injected *memory.InjectedContext
|
||||
return a.mediaContextForRelations(res.Relations)
|
||||
}
|
||||
|
||||
// blockLabelsForDoc 渲染文档持有块的标签(MIME + 短 digest),供 doc_query 展示。
|
||||
// blockLabelsForDoc 渲染文档持有块的标签(MIME + 短块 ID),供 doc_query 展示。
|
||||
func (a *Agent) blockLabelsForDoc(d *document.Doc) string {
|
||||
if a.mediaStore == nil || d == nil || len(d.Blocks) == 0 {
|
||||
if d == nil || len(d.Blocks) == 0 {
|
||||
return ""
|
||||
}
|
||||
var parts []string
|
||||
for _, b := range d.Blocks {
|
||||
it, err := a.mediaStore.Stat(b.PayloadDigest)
|
||||
if err != nil || it == nil {
|
||||
continue
|
||||
}
|
||||
if line := mediaLabel(it); line != "" {
|
||||
for i := range d.Blocks {
|
||||
if line := blockLabel(&d.Blocks[i]); line != "" {
|
||||
parts = append(parts, line)
|
||||
}
|
||||
}
|
||||
@ -300,10 +296,10 @@ func (a *Agent) blockLabelsForDoc(d *document.Doc) string {
|
||||
|
||||
// mediaContextForSentences 给一组句子附上其持有的一等块标签。
|
||||
//
|
||||
// 标签只含 MIME 与短 digest:图片按向量检索,标签的作用是告诉模型
|
||||
// "这条记忆当时带着哪份媒体、可用该 digest 取回字节"。
|
||||
// 标签只含 MIME 与短块 ID:图片按向量检索,标签的作用是告诉模型
|
||||
// "这条记忆当时带着哪份媒体、可用该块 ID 取回"。
|
||||
func (a *Agent) mediaContextForSentences(sentenceBlockIDs []string) string {
|
||||
if a.mediaStore == nil || a.memory == nil || len(sentenceBlockIDs) == 0 {
|
||||
if a.memory == nil || len(sentenceBlockIDs) == 0 {
|
||||
return ""
|
||||
}
|
||||
var lines []string
|
||||
@ -313,12 +309,8 @@ func (a *Agent) mediaContextForSentences(sentenceBlockIDs []string) string {
|
||||
continue
|
||||
}
|
||||
var parts []string
|
||||
for _, b := range blocks {
|
||||
it, err := a.mediaStore.Stat(b.PayloadDigest)
|
||||
if err != nil || it == nil {
|
||||
continue
|
||||
}
|
||||
if line := mediaLabel(it); line != "" {
|
||||
for i := range blocks {
|
||||
if line := blockLabel(&blocks[i]); line != "" {
|
||||
parts = append(parts, line)
|
||||
}
|
||||
}
|
||||
@ -331,16 +323,3 @@ func (a *Agent) mediaContextForSentences(sentenceBlockIDs []string) string {
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// shortID 把块 ID 压成可读短码(blk_src_<24hex> → 前 8 位 hex)。
|
||||
// 媒体上下文是给模型看的,整串 hex 只会占 token 且不可读。
|
||||
func shortID(blockID string) string {
|
||||
const pfx = "blk_src_"
|
||||
if strings.HasPrefix(blockID, pfx) {
|
||||
return blockID[len(pfx):]
|
||||
}
|
||||
if len(blockID) > 12 {
|
||||
return blockID[:12]
|
||||
}
|
||||
return blockID
|
||||
}
|
||||
|
||||
@ -1,193 +1,115 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/memory/vector"
|
||||
)
|
||||
|
||||
// 媒体与记忆块的生命周期辅助。
|
||||
//
|
||||
// 媒体不单独做生命周期管理(没有 GC、没有引用计数):blob 是记忆块的内容,
|
||||
// 块的创建/迁移/删除由记忆系统本身决定,块被永久删除时内容随之删除。
|
||||
// 图片不靠文本描述索引——它只按自己的统一空间向量被检索。
|
||||
|
||||
// heldMediaDigests 汇总三层记忆当前持有的媒体 digest 集合。
|
||||
// ★ 2026-10-11:块自带负载(media.Store/CAS 已淘汰)。
|
||||
//
|
||||
// CAS 是全库字节存储,它的检索结果不等于「记忆里的媒体」——
|
||||
// 召回前用它把已无处可归的内容过滤掉。
|
||||
func (a *Agent) heldMediaDigests() map[string]bool {
|
||||
held := map[string]bool{}
|
||||
collect := func(blocks []memory.MemoryBlock) {
|
||||
for _, b := range blocks {
|
||||
if b.PayloadDigest != "" {
|
||||
held[b.PayloadDigest] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if a.context != nil {
|
||||
collect(a.context.Blocks())
|
||||
}
|
||||
if a.docStore != nil {
|
||||
collect(a.docStore.Blocks())
|
||||
}
|
||||
if a.memory != nil {
|
||||
if blocks, err := a.memory.MemoryBlocks(); err == nil {
|
||||
collect(blocks)
|
||||
}
|
||||
}
|
||||
return held
|
||||
}
|
||||
|
||||
// payloadHeld 报告某个 digest 是否仍被三层记忆中的一等块持有。
|
||||
// 这是删除前的一次活查询(不是持久化账本):同一份字节可能同时被多个块共享。
|
||||
func (a *Agent) payloadHeld(digest string) bool {
|
||||
if digest == "" {
|
||||
return false
|
||||
}
|
||||
if a.context != nil {
|
||||
for _, b := range a.context.Blocks() {
|
||||
if b.PayloadDigest == digest {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
if a.docStore != nil {
|
||||
for _, b := range a.docStore.Blocks() {
|
||||
if b.PayloadDigest == digest {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
if a.memory != nil {
|
||||
if blocks, err := a.memory.MemoryBlocks(); err == nil {
|
||||
for _, b := range blocks {
|
||||
if b.PayloadDigest == digest {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// forgetPayloads 在记忆块被永久删除后删除它们的内容。
|
||||
// 此前这里有 heldMediaDigests / payloadHeld / forgetPayloads 三个函数,
|
||||
// 它们存在的**唯一理由**是补 CAS 的漏:CAS 是全库字节存储,它的检索结果
|
||||
// 不等于「记忆里的媒体」,所以要靠一套 digest 记账把已无处可归的内容滤掉。
|
||||
// 块自带负载后这一整类问题从根上消失——字节就在块里,块没了字节就没了,
|
||||
// 不需要账本、不需要 GC、不需要「删前先问谁还持有」。
|
||||
//
|
||||
// 与文本块一致:删除块即删除内容。只有确认没有任何存活块仍共享该 digest
|
||||
// 时才删字节(同一张图可能被多个块引用)。
|
||||
func (a *Agent) forgetPayloads(digests []string) {
|
||||
if a.mediaStore == nil {
|
||||
return
|
||||
}
|
||||
for _, d := range digests {
|
||||
if d == "" || a.payloadHeld(d) {
|
||||
continue
|
||||
}
|
||||
if err := a.mediaStore.Delete(d); err != nil {
|
||||
log.Printf("[media] 删除内容失败 %s: %v", shortDigest(d), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 因此那三个函数被整体删除,只保留向量迁移(它仍需要,因为换模型/换维度
|
||||
// 会让历史块的向量落在另一个空间里)。
|
||||
|
||||
// reembedStaleMedia 在启动时批量迁移历史媒体向量到当前向量空间。
|
||||
// reembedStaleMedia 在启动时批量把历史媒体块的向量迁移到当前向量空间。
|
||||
//
|
||||
// 触发场景(任一变化都会导致旧向量无法参与查询):
|
||||
// - 切换模型(模型 A→模型 B,fp 变了)
|
||||
// - 切换向量维度(ONNX→HTTP dim 512→1024)
|
||||
// - 首次部署嵌入服务(历史无向量的媒体补算)
|
||||
// - 嵌入服务离线后重新上线(失败条目 vec_model 仍为空)
|
||||
// - 嵌入服务离线后重新上线(失败条目 fingerprint 仍为空)
|
||||
//
|
||||
// 并发策略:启动时用 worker pool 并行迁移,避免上千张图片串行耗时过长。
|
||||
// 并发数在 ONNX 内嵌路径下不超 CPU 核心数(避免 ONNX 并发限流),
|
||||
// 外部 API 路径下不超 8(避免打爆外部服务)。
|
||||
func (a *Agent) reembedStaleMedia() {
|
||||
if a.multimodalSpace == nil || a.mediaStore == nil {
|
||||
if a.multimodalSpace == nil || !a.multimodalSpace.Loaded() || a.memory == nil {
|
||||
return
|
||||
}
|
||||
fp := a.multimodalSpace.Fingerprint()
|
||||
digests, err := a.mediaStore.StaleVecDigestsAll(fp)
|
||||
stale, err := a.memory.StaleBlockVectors(fp)
|
||||
if err != nil {
|
||||
log.Printf("[media] 查询需重算向量的媒体失败: %v", err)
|
||||
log.Printf("[media] 查询需重算向量的块失败: %v", err)
|
||||
return
|
||||
}
|
||||
if len(digests) == 0 {
|
||||
log.Printf("[media] 无需迁移向量(所有媒体已与当前空间对齐 fp=%s)", shortFP(fp))
|
||||
// 只迁图像:多模态空间当前覆盖 text+image,音频/视频不在其内
|
||||
// (不拿别的模态的向量顶替——那是静默错配)。
|
||||
var targets []memory.MemoryBlock
|
||||
for _, b := range stale {
|
||||
if b.Modality == memory.BlockImage && len(b.Data) > 0 {
|
||||
targets = append(targets, b)
|
||||
}
|
||||
}
|
||||
if len(targets) == 0 {
|
||||
log.Printf("[media] 无需迁移向量(所有图像块已与当前空间对齐 fp=%s)", shortFP(fp))
|
||||
return
|
||||
}
|
||||
|
||||
// 并发度:ONNX 内嵌不超过 4,外部 API 不超过 8(由配置或实际环境动态定)
|
||||
workers := 4
|
||||
if fp[:min(4, len(fp))] == "http:" {
|
||||
if len(fp) >= 5 && fp[:5] == "http:" {
|
||||
workers = 8
|
||||
}
|
||||
log.Printf("[media] 启动向量迁移: %d 条 → 新空间 fp=%s dim=%d workers=%d",
|
||||
len(digests), shortFP(fp), a.multimodalSpace.Dim(), workers)
|
||||
log.Printf("[media] 启动向量迁移: %d 个图像块 → fp=%s dim=%d workers=%d",
|
||||
len(targets), shortFP(fp), a.multimodalSpace.Dim(), workers)
|
||||
|
||||
jobs := make(chan string, workers*2)
|
||||
var done, failed, unsupported int64
|
||||
var failedMu sync.Mutex
|
||||
jobs := make(chan memory.MemoryBlock, workers*2)
|
||||
var done, failed int64
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for d := range jobs {
|
||||
switch err := a.reembedOne(d, fp); {
|
||||
case err == nil:
|
||||
atomic.AddInt64(&done, 1)
|
||||
case errors.Is(err, vector.ErrModalityUnsupported):
|
||||
// 该模态不在本空间内(如音频):不重试、不计失败,
|
||||
// 也不拿另一个模型的向量顶替。
|
||||
atomic.AddInt64(&unsupported, 1)
|
||||
default:
|
||||
failedMu.Lock()
|
||||
failed++
|
||||
failedMu.Unlock()
|
||||
for b := range jobs {
|
||||
if err := a.reembedOneBlock(b, fp); err != nil {
|
||||
atomic.AddInt64(&failed, 1)
|
||||
continue
|
||||
}
|
||||
atomic.AddInt64(&done, 1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
for i, d := range digests {
|
||||
jobs <- d
|
||||
// 每迁移 20 条输出进度日志,让用户看到迁移在推进
|
||||
for i, b := range targets {
|
||||
jobs <- b
|
||||
if (i+1)%20 == 0 {
|
||||
log.Printf("[media] 向量迁移进度: %d/%d (done=%d failed=%d)", i+1, len(digests), atomic.LoadInt64(&done), failed)
|
||||
log.Printf("[media] 向量迁移进度: %d/%d (done=%d failed=%d)",
|
||||
i+1, len(targets), atomic.LoadInt64(&done), failed)
|
||||
}
|
||||
}
|
||||
close(jobs)
|
||||
wg.Wait()
|
||||
log.Printf("[media] 向量迁移完成: 成功=%d 失败=%d 不在本空间=%d 总计=%d fp=%s",
|
||||
done, failed, unsupported, len(digests), shortFP(fp))
|
||||
log.Printf("[media] 向量迁移完成: 成功=%d 失败=%d 总计=%d fp=%s",
|
||||
done, failed, len(targets), shortFP(fp))
|
||||
}
|
||||
|
||||
// reembedOne 为单条媒体重新计算向量并写入(stat/get 失败时跳过该条目)。
|
||||
// reembedOneBlock 为一个图像块重算向量并写回。负载就在块里,无需外部取字节。
|
||||
//
|
||||
// 模态不在本空间覆盖范围时返回 ErrModalityUnsupported,调用方据此区分
|
||||
// 「永久无向量」与「本次失败重试」。
|
||||
func (a *Agent) reembedOne(digest, fp string) error {
|
||||
it, err := a.mediaStore.Stat(digest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := a.mediaStore.Get(digest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mime := it.MIME
|
||||
func (a *Agent) reembedOneBlock(b memory.MemoryBlock, fp string) error {
|
||||
mime := b.MIME
|
||||
if mime == "" {
|
||||
mime = "image/png"
|
||||
}
|
||||
vec, err := a.multimodalSpace.EmbedImageDense(data, mime)
|
||||
vec, err := a.multimodalSpace.EmbedImageDense(b.Data, mime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.mediaStore.SetVec(digest, vec, fp)
|
||||
if err := a.memory.SetBlockVector(b.ID, vec, fp); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// shortFP 截断 fingerprint 为可读日志格式。
|
||||
|
||||
@ -1,85 +1,59 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
agentAPI "github.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||||
)
|
||||
|
||||
// blockSeq 保证块 ID 全局唯一(Graph memory_blocks 以 id 为主键)。
|
||||
var blockSeq int64
|
||||
// 媒体记忆接线:把对话里出现的图片/音频变成一等记忆块。
|
||||
//
|
||||
// ★ 2026-10-11:块自带负载(CAS 已淘汰)。
|
||||
//
|
||||
// 此前这里把字节存进独立的内容寻址存储(CAS),块只留一个 digest 指向它。
|
||||
// 那是把「媒体的内容」从块里拆出去另建一层——现在媒体字节直接进块的 Data,
|
||||
// 与文本块的 Text 对称:一个块要么带文本、要么带字节。
|
||||
//
|
||||
// 媒体进入对话有两条路:用户直接发图(ContentBlock data URL)、插件注入
|
||||
// (SetToolBlocks)。两条都在这里收口成块,挂到当轮 ContextEvent;
|
||||
// 事件被 Prune 时块随之迁移到 L2 文档(块本身不变,只换持有层)。
|
||||
//
|
||||
// 不再生成任何描述文本,也不再往正文写 media marker:图片只按自己的
|
||||
// 统一空间向量被检索,描述式索引是将就方案。
|
||||
|
||||
func newBlockID() string {
|
||||
return fmt.Sprintf("blk_%d_%d", time.Now().UnixNano(), atomic.AddInt64(&blockSeq, 1))
|
||||
}
|
||||
|
||||
// blockModalityOf 把 CAS 媒体大类映射为一等记忆块模态。
|
||||
func blockModalityOf(k media.Kind) memory.BlockModality {
|
||||
switch k {
|
||||
case media.KindImage:
|
||||
// blockModalityFromMIME 按 MIME 大类推断块的模态。
|
||||
func blockModalityFromMIME(mime string) memory.BlockModality {
|
||||
switch {
|
||||
case strings.HasPrefix(mime, "image/"):
|
||||
return memory.BlockImage
|
||||
case media.KindVideo:
|
||||
case strings.HasPrefix(mime, "video/"):
|
||||
return memory.BlockVideo
|
||||
case media.KindAudio:
|
||||
case strings.HasPrefix(mime, "audio/"):
|
||||
return memory.BlockAudio
|
||||
default:
|
||||
return memory.BlockText
|
||||
}
|
||||
}
|
||||
|
||||
// blockFromDigest 把一份已入库媒体变成一等记忆块。
|
||||
// 块携带 digest/向量/fingerprint;CAS 只提供字节与元数据,不参与生命周期。
|
||||
func (a *Agent) blockFromDigest(digest string) (memory.MemoryBlock, bool) {
|
||||
if a.mediaStore == nil || digest == "" {
|
||||
return memory.MemoryBlock{}, false
|
||||
}
|
||||
it, err := a.mediaStore.Stat(digest)
|
||||
if err != nil || it == nil {
|
||||
return memory.MemoryBlock{}, false
|
||||
}
|
||||
return memory.MemoryBlock{
|
||||
ID: newBlockID(),
|
||||
Modality: blockModalityOf(it.Kind),
|
||||
PayloadDigest: it.Digest,
|
||||
MIME: it.MIME,
|
||||
Size: it.Size,
|
||||
Width: it.Width,
|
||||
Height: it.Height,
|
||||
Vector: it.Vec,
|
||||
Fingerprint: it.VecModel,
|
||||
Tool: it.Tool,
|
||||
CreatedAt: it.FirstSeen,
|
||||
}, true
|
||||
}
|
||||
|
||||
// 媒体记忆接线:把对话里出现的图片/音频落进内容寻址存储(CAS),
|
||||
// 并让 L0 的 ContextEvent 直接持有一等记忆块。
|
||||
// captureBlockMedia 把 blocks 里的 data URL 媒体变成一等记忆块,返回块列表。
|
||||
//
|
||||
// 媒体进入对话有两条路:用户直接发图(ContentBlock data URL)、插件注入
|
||||
// (SetToolBlocks)。两条都在这里收口:从 data URL 取出字节存进 CAS,
|
||||
// 用其向量构造一等记忆块挂到当轮 ContextEvent 上;事件被 Prune 时
|
||||
// 块随之迁移到 L2 文档。
|
||||
// 只处理 data URL:http(s) URL 拿不到字节,而"下载它再存"会把一次对话变成
|
||||
// 一次网络请求(超时、鉴权、SSRF 全来了),不在本层解决。
|
||||
//
|
||||
// 不再生成任何描述文本,也不再往正文写 media marker:图片只按自己的
|
||||
// 统一空间向量被检索,描述式索引是将就方案。
|
||||
|
||||
// captureBlockMedia 把 blocks 里的 data URL 媒体落进 CAS,返回 digest 列表。
|
||||
//
|
||||
// 只处理 data URL:http(s) URL 拿不到字节就无法做内容寻址,
|
||||
// 而"下载它再存"会把一次对话变成一次网络请求(超时、鉴权、SSRF 全来了),
|
||||
// 不在本层解决。
|
||||
func (a *Agent) captureBlockMedia(blocks []agentAPI.ContentBlock, tool string) []string {
|
||||
if a.mediaStore == nil || len(blocks) == 0 {
|
||||
// 块 ID 由内容派生(memory.MediaBlockID):同内容 ⇒ 同 ID ⇒ 落库时
|
||||
// ON CONFLICT 天然去重(这正是内容寻址当年的好处,但现在由块 ID 直接提供,
|
||||
// 不需要一层独立的寻址存储)。
|
||||
func (a *Agent) captureBlockMedia(blocks []agentAPI.ContentBlock, tool string) []memory.MemoryBlock {
|
||||
if len(blocks) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var digests []string
|
||||
var out []memory.MemoryBlock
|
||||
for _, b := range blocks {
|
||||
var url string
|
||||
switch {
|
||||
@ -91,106 +65,124 @@ func (a *Agent) captureBlockMedia(blocks []agentAPI.ContentBlock, tool string) [
|
||||
continue
|
||||
}
|
||||
|
||||
mime, data, ok := media.ParseDataURL(url)
|
||||
mime, data, ok := parseDataURL(url)
|
||||
if !ok {
|
||||
continue // http(s) URL 或格式不认,跳过
|
||||
}
|
||||
|
||||
d, err := a.mediaStore.Put(data, media.Item{
|
||||
MIME: mime,
|
||||
Tool: tool,
|
||||
})
|
||||
if err != nil {
|
||||
// 媒体存不进去不该让对话失败——它是记忆增强,不是对话必需品
|
||||
log.Printf("[media] 落盘失败 (tool=%s mime=%s): %v", tool, mime, err)
|
||||
continue
|
||||
block := memory.MemoryBlock{
|
||||
ID: memory.MediaBlockID(mime, data),
|
||||
Modality: blockModalityFromMIME(mime),
|
||||
Data: data,
|
||||
MIME: mime,
|
||||
Size: int64(len(data)),
|
||||
Tool: tool,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
// 入库即算一次多模态坐标并缓存(多模态空间可用时)。
|
||||
// 之后 doc_query / memory_recall / 内部召回直接复用 SetVec 的缓存坐标,
|
||||
// 不重复跑 ONNX;模型切换由启动时的 reembedStaleMedia 补算。
|
||||
a.embedMediaOnIngest(d, mime, data)
|
||||
digests = append(digests, d)
|
||||
// 入库即算一次多模态坐标(多模态空间可用且为图像时)。
|
||||
// 之后块召回直接用它;换模型由启动时的 reembedStaleMedia 补算。
|
||||
a.embedBlockOnIngest(&block)
|
||||
out = append(out, block)
|
||||
}
|
||||
return digests
|
||||
}
|
||||
|
||||
// embedMediaOnIngest 给刚入库的图片立即计算多模态坐标并缓存。
|
||||
// 只在 多模态空间可用且为图像时执行;音频/未配置时静默跳过(保持既有行为)。
|
||||
func (a *Agent) embedMediaOnIngest(digest, mime string, data []byte) {
|
||||
if a.multimodalSpace == nil || !a.multimodalSpace.Loaded() {
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(mime, "image/") {
|
||||
return
|
||||
}
|
||||
vec, err := a.multimodalSpace.EmbedImageDense(data, mime)
|
||||
if err != nil {
|
||||
log.Printf("[media] 入库嵌入失败 %s: %v", shortDigest(digest), err)
|
||||
return
|
||||
}
|
||||
if err := a.mediaStore.SetVec(digest, vec, a.multimodalSpace.Fingerprint()); err != nil {
|
||||
log.Printf("[media] 入库写向量失败 %s: %v", shortDigest(digest), err)
|
||||
}
|
||||
}
|
||||
|
||||
// stageMediaDigests 累积本轮捕获的 digest,等 ContextEvent 建好后一起挂上。
|
||||
//
|
||||
// 为何要缓存而不是当场建块:媒体在 process() 执行期间被捕获,而承载它的
|
||||
// ContextEvent 要等 process() 返回后才 Append——此刻还没有 owner_id。
|
||||
// 与既有的 a.pendingMedia 同一手法(均由 schedulerLoop goroutine 独占读写)。
|
||||
func (a *Agent) stageMediaDigests(digests ...string) {
|
||||
if len(digests) == 0 {
|
||||
return
|
||||
}
|
||||
a.pendingMediaDigests = append(a.pendingMediaDigests, digests...)
|
||||
}
|
||||
|
||||
// drainMediaDigests 取出并清空本轮累积的 digest。
|
||||
func (a *Agent) drainMediaDigests() []string {
|
||||
if len(a.pendingMediaDigests) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := a.pendingMediaDigests
|
||||
a.pendingMediaDigests = nil
|
||||
return out
|
||||
}
|
||||
|
||||
// bindEventMedia 把本轮捕获的媒体变成一等记忆块,直接挂到 ContextEvent 上。
|
||||
// embedBlockOnIngest 给刚捕获的块立即计算多模态坐标。
|
||||
// 只在多模态空间可用且为图像时执行;音频/未配置时静默跳过(保持既有行为)。
|
||||
func (a *Agent) embedBlockOnIngest(b *memory.MemoryBlock) {
|
||||
if b == nil || a.multimodalSpace == nil || !a.multimodalSpace.Loaded() {
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(b.MIME, "image/") {
|
||||
return
|
||||
}
|
||||
vec, err := a.multimodalSpace.EmbedImageDense(b.Data, b.MIME)
|
||||
if err != nil {
|
||||
log.Printf("[media] 入库嵌入失败 %s: %v", shortID(b.ID), err)
|
||||
return
|
||||
}
|
||||
b.Vector = vec
|
||||
b.Fingerprint = a.multimodalSpace.Fingerprint()
|
||||
}
|
||||
|
||||
// stageMediaBlocks 累积本轮捕获的块,等 ContextEvent 建好后一起挂上。
|
||||
//
|
||||
// 块存储在事件自身(随 context.json 持久化),不再写 media_refs:
|
||||
// 存活与否由“三层记忆块是否持有这个 digest”决定,不维护引用账本。
|
||||
func (a *Agent) bindEventMedia(evt *ContextEvent, digests []string) {
|
||||
if a.mediaStore == nil || evt == nil || len(digests) == 0 {
|
||||
// 为何要缓存而不是当场挂:块在 process() 执行期间被捕获,而承载它的
|
||||
// ContextEvent 要等 process() 返回后才 Append——此刻还没有 owner。
|
||||
// 与既有的 a.pendingMedia 同一手法(均由 schedulerLoop goroutine 独占读写)。
|
||||
func (a *Agent) stageMediaBlocks(blocks ...memory.MemoryBlock) {
|
||||
if len(blocks) == 0 {
|
||||
return
|
||||
}
|
||||
a.pendingMediaBlocks = append(a.pendingMediaBlocks, blocks...)
|
||||
}
|
||||
|
||||
// drainMediaBlocks 取出并清空本轮累积的块。
|
||||
func (a *Agent) drainMediaBlocks() []memory.MemoryBlock {
|
||||
if len(a.pendingMediaBlocks) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := a.pendingMediaBlocks
|
||||
a.pendingMediaBlocks = nil
|
||||
return out
|
||||
}
|
||||
|
||||
// bindEventMedia 把本轮捕获的块挂到 ContextEvent 上。
|
||||
//
|
||||
// 块存储在事件自身(随 context.json 持久化),不维护引用账本:
|
||||
// 存活与否由持有它的层决定。
|
||||
func (a *Agent) bindEventMedia(evt *ContextEvent, blocks []memory.MemoryBlock) {
|
||||
if evt == nil || len(blocks) == 0 {
|
||||
return
|
||||
}
|
||||
if evt.ID == "" {
|
||||
evt.ID = newEventID()
|
||||
}
|
||||
for _, d := range digests {
|
||||
b, ok := a.blockFromDigest(d)
|
||||
if !ok {
|
||||
log.Printf("[media] 块构造失败 (%s)", shortDigest(d))
|
||||
continue
|
||||
}
|
||||
evt.Blocks = append(evt.Blocks, b)
|
||||
}
|
||||
evt.Blocks = append(evt.Blocks, blocks...)
|
||||
}
|
||||
|
||||
// mediaLabel 渲染一行媒体标签,供提示词告知"这条记忆带着哪份媒体"。
|
||||
// blockLabel 渲染一行媒体块标签,供提示词告知"这条记忆带着哪份媒体"。
|
||||
//
|
||||
// 不再包含任何生成的描述文本:图片只按自己的向量被检索,标签仅提供
|
||||
// MIME 与短 digest,让模型知道有这份媒体、可据 digest 取回字节。
|
||||
// 查不到返回空串:内容可能已被删除,不该造出一条指向虚无的标签。
|
||||
func mediaLabel(it *media.Item) string {
|
||||
if it == nil {
|
||||
// 不含任何生成的描述文本:图片按自己的向量被检索,标签仅提供 MIME 与
|
||||
// 短块 ID,让模型知道有这份媒体、并可据此取回(块 ID 是它的唯一身份)。
|
||||
func blockLabel(b *memory.MemoryBlock) string {
|
||||
if b == nil {
|
||||
return ""
|
||||
}
|
||||
label := string(it.Kind)
|
||||
if it.MIME != "" {
|
||||
label = it.MIME
|
||||
label := string(b.Modality)
|
||||
if b.MIME != "" {
|
||||
label = b.MIME
|
||||
}
|
||||
return fmt.Sprintf("[%s %s]", label, shortDigest(it.Digest))
|
||||
return fmt.Sprintf("[%s %s]", label, shortID(b.ID))
|
||||
}
|
||||
|
||||
// parseDataURL 从 data:<mime>;base64,<data> 解出 MIME 与原始字节。
|
||||
//
|
||||
// 自 CAS 淘汰后,这段解析归内核媒体接线自己持有(原在 media 包)。
|
||||
func parseDataURL(url string) (mime string, data []byte, ok bool) {
|
||||
const prefix = "data:"
|
||||
if !strings.HasPrefix(url, prefix) {
|
||||
return "", nil, false
|
||||
}
|
||||
rest := url[len(prefix):]
|
||||
comma := strings.IndexByte(rest, ',')
|
||||
if comma < 0 {
|
||||
return "", nil, false
|
||||
}
|
||||
head := rest[:comma]
|
||||
payload := rest[comma+1:]
|
||||
if !strings.HasSuffix(strings.ToLower(head), ";base64") {
|
||||
return "", nil, false
|
||||
}
|
||||
mime = head[:len(head)-len(";base64")]
|
||||
if mime == "" || payload == "" {
|
||||
return "", nil, false
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(payload)
|
||||
if err != nil {
|
||||
return "", nil, false
|
||||
}
|
||||
return mime, decoded, true
|
||||
}
|
||||
|
||||
// newEventID 生成 ContextEvent 的稳定标识。
|
||||
@ -202,9 +194,10 @@ func newEventID() string {
|
||||
return fmt.Sprintf("evt_%d", time.Now().UnixNano())
|
||||
}
|
||||
|
||||
func shortDigest(d string) string {
|
||||
if len(d) > 12 {
|
||||
return d[:12]
|
||||
// shortID 把块 ID 截短供日志/提示词使用。
|
||||
func shortID(id string) string {
|
||||
if len(id) > 20 {
|
||||
return id[:20]
|
||||
}
|
||||
return d
|
||||
return id
|
||||
}
|
||||
|
||||
@ -391,10 +391,10 @@ func (a *Agent) prepareInputTask(evt *agentIO.InputEvent) (*TaskFrame, taskTermi
|
||||
a.pendingMedia = evt.Payload
|
||||
}
|
||||
|
||||
// 媒体先落进 CAS。不存的后果是 ContextEvent.Input 只剩一句 alt 文本,
|
||||
// base64 随 message 数组发给模型后就丢了。
|
||||
// 媒体先变成一等块(字节进块的 Data)。不存的后果是 ContextEvent.Input
|
||||
// 只剩一句 alt 文本,base64 随 message 数组发给模型后就丢了。
|
||||
if len(in.blocks) > 0 {
|
||||
a.stageMediaDigests(a.captureBlockMedia(in.blocks, in.captureTool)...)
|
||||
a.stageMediaBlocks(a.captureBlockMedia(in.blocks, in.captureTool)...)
|
||||
}
|
||||
|
||||
noMemory := false
|
||||
@ -516,7 +516,7 @@ func (a *Agent) finishInputTask(f *TaskFrame, out stepOutcome) {
|
||||
ToolsUsed: f.ToolsUsed,
|
||||
ToolResults: f.ToolResults,
|
||||
}
|
||||
a.bindEventMedia(&turnEvt, a.drainMediaDigests())
|
||||
a.bindEventMedia(&turnEvt, a.drainMediaBlocks())
|
||||
a.context.Append(turnEvt)
|
||||
|
||||
a.emitResponse(evt, f.Response, f.turnUsage)
|
||||
@ -1098,8 +1098,8 @@ func (a *Agent) stepToolAfter(f *TaskFrame) stepOutcome {
|
||||
}
|
||||
}
|
||||
if len(blocks) > 0 {
|
||||
// 先落进 CAS:无论下面走直视还是回退转写,媒体本体都该进记忆。
|
||||
a.stageMediaDigests(a.captureBlockMedia(blocks, tc.Name)...)
|
||||
// 先落成块:无论下面走直视还是回退转写,媒体本体都该进记忆。
|
||||
a.stageMediaBlocks(a.captureBlockMedia(blocks, tc.Name)...)
|
||||
|
||||
if native, fallbackText := a.prepareToolBlocks(blocks); len(native) > 0 {
|
||||
// 能直视:另起一条 user message 承载媒体,并补一句来源说明。
|
||||
|
||||
@ -339,9 +339,9 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str
|
||||
}
|
||||
// 模型显式关联的媒体:结构化字段随三元组一起提交,
|
||||
// 由 commitTriplesWithMedia 变成 L3 一等块并与句子建边——
|
||||
// 不再把 marker 写进句子文本。
|
||||
if digests := getStringSlice(m, "media_digests"); len(digests) > 0 {
|
||||
t.MediaDigests = a.resolveMediaDigests(digests)
|
||||
// 不再把 marker 写进句子文本。值是块 ID(或其唯一前缀)。
|
||||
if refs := getStringSlice(m, "media_digests"); len(refs) > 0 {
|
||||
t.MediaDigests = a.resolveMediaRefs(refs)
|
||||
// 块边需要句子作端点。模型没给原句时用三元组本身拼一句
|
||||
// 自然语言——不能造一段 marker 文本,那正是被废弃的东西。
|
||||
if t.SentenceText == "" && len(t.MediaDigests) > 0 {
|
||||
@ -697,25 +697,27 @@ func (a *Agent) executeSocialTool(tc agentAPI.ToolCall) string {
|
||||
}
|
||||
}
|
||||
|
||||
// knowledgeMediaRefs 把模型给的 digest 列表解析成知识条目的媒体引用。
|
||||
// knowledgeMediaRefs 把模型给的块引用(ID 或其唯一前缀)解析成知识条目的媒体引用。
|
||||
//
|
||||
// 复用 doc_commit 的既有约定:digest 可传前缀(ResolvePrefix),解析不了
|
||||
// 的跳过而不是报错——模型偶尔会把 digest 记错,不该让整次写入失败。
|
||||
// MIME 从媒体存储回读,嵌入时需要(EmbedImageDense 靠它判定模态)。
|
||||
func (a *Agent) knowledgeMediaRefs(digests []string) []knowledge.KnowledgeMediaRef {
|
||||
if a.mediaStore == nil || len(digests) == 0 {
|
||||
// 复用 doc_commit 的既有约定:引用可传前缀,解析不了的跳过而不是报错——
|
||||
// 模型偶尔会把 ID 记错,不该让整次写入失败。MIME 从块回读,嵌入时需要
|
||||
// (EmbedImageDense 靠它判定模态)。字节随条目落盘(writeMediaFiles)。
|
||||
func (a *Agent) knowledgeMediaRefs(refs []string) []knowledge.KnowledgeMediaRef {
|
||||
if a.memory == nil || len(refs) == 0 {
|
||||
return nil
|
||||
}
|
||||
var out []knowledge.KnowledgeMediaRef
|
||||
for _, d := range a.resolveMediaDigests(digests) {
|
||||
it, err := a.mediaStore.Stat(d)
|
||||
if err != nil {
|
||||
for _, r := range refs {
|
||||
b, ok, err := a.memory.BlockByIDPrefix(r)
|
||||
if err != nil || !ok {
|
||||
log.Printf("[knowledge] 媒体块引用 %s 无法唯一解析,跳过", r)
|
||||
continue
|
||||
}
|
||||
out = append(out, knowledge.KnowledgeMediaRef{
|
||||
Digest: it.Digest,
|
||||
MIME: it.MIME,
|
||||
Kind: string(it.Kind),
|
||||
Digest: b.ID,
|
||||
MIME: b.MIME,
|
||||
Kind: string(b.Modality),
|
||||
Data: b.Data,
|
||||
})
|
||||
}
|
||||
return out
|
||||
@ -922,9 +924,9 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string {
|
||||
|
||||
// 模型显式关联的媒体:直接变成文档持有的一等块。
|
||||
// 不再往正文写 marker——文档向量会融合这些块的媒体向量,
|
||||
// 图片按自己的向量被检索。
|
||||
for _, d := range a.resolveMediaDigests(getStringSlice(tc.Arguments, "media_digests")) {
|
||||
if b, ok := a.blockFromDigest(d); ok {
|
||||
// 图片按自己的向量被检索。值是块 ID(或其唯一前缀)。
|
||||
for _, ref := range a.resolveMediaRefs(getStringSlice(tc.Arguments, "media_digests")) {
|
||||
if b, ok, err := a.memory.BlockByIDPrefix(ref); err == nil && ok {
|
||||
doc.Blocks = append(doc.Blocks, b)
|
||||
}
|
||||
}
|
||||
|
||||
@ -85,10 +85,9 @@ func TestDenseCrossModalRecall(t *testing.T) {
|
||||
img: map[string][]float64{"__default": eby, "d-cat": eax},
|
||||
}
|
||||
s.SetDenseSpace(mm)
|
||||
s.SetMediaGetter(fakeMedia{byDigest: map[string][]byte{"d-cat": []byte("d-cat")}})
|
||||
|
||||
if err := s.AddWithMedia("cat-photo", "猫 图片 说明", []KnowledgeMediaRef{
|
||||
{Digest: "d-cat", MIME: "image/png", Kind: "image"},
|
||||
{File: "cat.png", MIME: "image/png", Kind: "image"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@ -224,9 +223,8 @@ func TestDenseUnsupportedModalityStillTextEmbedded(t *testing.T) {
|
||||
imgErr: map[string]error{"d-audio": vector.ErrModalityUnsupported},
|
||||
}
|
||||
s.SetDenseSpace(mm)
|
||||
s.SetMediaGetter(fakeMedia{byDigest: map[string][]byte{"d-audio": []byte("d-audio")}})
|
||||
|
||||
if err := s.AddWithMedia("song", "一首歌", []KnowledgeMediaRef{{Digest: "d-audio", MIME: "audio/mpeg"}}); err != nil {
|
||||
if err := s.AddWithMedia("song", "一首歌", []KnowledgeMediaRef{{File: "a.mp3", MIME: "audio/mpeg"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
k := s.items["song"]
|
||||
@ -289,13 +287,12 @@ func TestAttachMediaRecomputesDense(t *testing.T) {
|
||||
img: map[string][]float64{"__default": {0, 1}, "d-x": eax},
|
||||
}
|
||||
s.SetDenseSpace(mm)
|
||||
s.SetMediaGetter(fakeMedia{byDigest: map[string][]byte{"d-x": []byte("d-x")}})
|
||||
|
||||
if err := s.Add("k", "正文"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := append([]float64{}, s.items["k"].Dense...)
|
||||
if err := s.AttachMedia("k", KnowledgeMediaRef{Digest: "d-x", MIME: "image/png"}); err != nil {
|
||||
if err := s.AttachMedia("k", KnowledgeMediaRef{File: "x.png", MIME: "image/png"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after := s.items["k"].Dense
|
||||
|
||||
@ -8,7 +8,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
)
|
||||
|
||||
// ===== 从目录导入知识 =====
|
||||
@ -370,37 +370,74 @@ func (s *Store) has(name string) bool {
|
||||
return ok
|
||||
}
|
||||
|
||||
// MediaPutter 是把字节写进媒体库的能力(由 media.Store 实现)。
|
||||
// putMediaFile 读一个媒体文件进条目引用(字节随 Data 瞬态携带,
|
||||
// 由 Write 落盘到条目目录)。
|
||||
//
|
||||
// 为何用接口而不是直接依赖 *media.Store:Store 已经有 SetMediaGetter
|
||||
// 这类注入点,导入沿用同一形状,测试才能用假实现,不必拉起真媒体库。
|
||||
type MediaPutter interface {
|
||||
Put(data []byte, meta media.Item) (string, error)
|
||||
}
|
||||
|
||||
// putMediaFile 把一个媒体文件复制进媒体库,返回条目用的引用。
|
||||
//
|
||||
// 媒体库未接入时返回错误而不是静默跳过 —— 但调用方对错误只记警告:
|
||||
// 媒体是可选增强,正文不该因为图片入不了库而整个条目失败。
|
||||
// ★ 2026-10-11 CAS 淘汰:不再写入全局媒体库 —— 字节是条目自带数据,
|
||||
// 与 content.md 同目录同生灭。读取失败返回错误,调用方只记警告跳过该媒体
|
||||
// (正文不该因为图片入不了库而整个条目失败)。
|
||||
func (s *Store) putMediaFile(path, kind string) (KnowledgeMediaRef, error) {
|
||||
if s.mediaPut == nil {
|
||||
return KnowledgeMediaRef{}, fmt.Errorf("媒体库不可用")
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return KnowledgeMediaRef{}, err
|
||||
}
|
||||
mime := mimeByExt(filepath.Ext(path))
|
||||
digest, err := s.mediaPut.Put(data, media.Item{
|
||||
Kind: media.Kind(kind),
|
||||
MIME: mime,
|
||||
Size: int64(len(data)),
|
||||
OriginPath: path,
|
||||
})
|
||||
if err != nil {
|
||||
return KnowledgeMediaRef{}, err
|
||||
// File 名取源文件名:同目录内天然唯一(同名正文+同名媒体本就是一对)。
|
||||
return KnowledgeMediaRef{
|
||||
Digest: memory.MediaBlockID(mime, data), // 内容身份:与媒体块 ID 同构
|
||||
MIME: mime,
|
||||
Kind: kind,
|
||||
File: filepath.Base(path),
|
||||
Data: data,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// writeMediaFiles 把引用携带的瞬态字节落进条目目录。
|
||||
//
|
||||
// 只写引用里带 Data 的条目(目录导入路径);字节已在盘上的(如模型引用
|
||||
// 已有媒体)直接跳过。File 为空时补足。
|
||||
func (s *Store) writeMediaFiles(dir string, refs []KnowledgeMediaRef) error {
|
||||
for i := range refs {
|
||||
r := &refs[i]
|
||||
if len(r.Data) == 0 && r.File != "" {
|
||||
if _, err := os.Stat(filepath.Join(dir, r.File)); err == nil {
|
||||
continue // 字节已在盘上
|
||||
}
|
||||
}
|
||||
if len(r.Data) == 0 {
|
||||
continue // 没字节也没文件:引用悬空,留给 denseFor 静默降级
|
||||
}
|
||||
if r.File == "" {
|
||||
ext := extByMIME(r.MIME)
|
||||
r.File = fmt.Sprintf("media_%d%s", i, ext)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, r.File), r.Data, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// extByMIME 按常见 MIME 给扩展名(writeMediaFiles 补 File 名用)。
|
||||
func extByMIME(mime string) string {
|
||||
switch mime {
|
||||
case "image/png":
|
||||
return ".png"
|
||||
case "image/jpeg":
|
||||
return ".jpg"
|
||||
case "image/gif":
|
||||
return ".gif"
|
||||
case "image/webp":
|
||||
return ".webp"
|
||||
case "audio/mpeg":
|
||||
return ".mp3"
|
||||
case "audio/wav":
|
||||
return ".wav"
|
||||
case "video/mp4":
|
||||
return ".mp4"
|
||||
default:
|
||||
return ".bin"
|
||||
}
|
||||
return KnowledgeMediaRef{Digest: digest, MIME: mime, Kind: kind}, nil
|
||||
}
|
||||
|
||||
// mimeByExt 按扩展名给 MIME。够用即可:媒体库靠 MIME 决定能否解码
|
||||
|
||||
@ -2,14 +2,11 @@ package knowledge
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||||
)
|
||||
|
||||
// ===== 从目录导入知识 =====
|
||||
@ -400,27 +397,9 @@ func TestImportDirDoesNotFlushPerItem(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 媒体导入:图片按字节复制进媒体库,条目挂 digest 引用。
|
||||
//
|
||||
// 用假 MediaPutter 而不是真媒体库:这里要验的是「调用了 Put、
|
||||
// 且把返回的 digest 挂到条目上」,不是媒体库自己的落盘与去重
|
||||
// (那是 media 包的判据,不该在这里重测)。
|
||||
type fakePutter struct {
|
||||
puts [][]byte
|
||||
seq int
|
||||
}
|
||||
|
||||
func (f *fakePutter) Put(data []byte, _ media.Item) (string, error) {
|
||||
f.puts = append(f.puts, data)
|
||||
f.seq++
|
||||
return fmt.Sprintf("digest%02d", f.seq), nil
|
||||
}
|
||||
|
||||
func TestImportDirMediaAttachedToEntry(t *testing.T) {
|
||||
src := mkSrc(t, map[string]string{"note.md": "看图", "note.png": "PNGDATA"})
|
||||
s, root := newImportStore(t)
|
||||
fp := &fakePutter{}
|
||||
s.SetMediaPutter(fp)
|
||||
|
||||
st, err := s.ImportDir(ImportOptions{Dir: src, IncludeMedia: true})
|
||||
if err != nil {
|
||||
@ -429,16 +408,21 @@ func TestImportDirMediaAttachedToEntry(t *testing.T) {
|
||||
if st.Media != 1 {
|
||||
t.Errorf("应导入 1 个媒体,实际 %d(%+v)", st.Media, st)
|
||||
}
|
||||
if len(fp.puts) != 1 || string(fp.puts[0]) != "PNGDATA" {
|
||||
t.Errorf("媒体字节未原样复制进媒体库:%v", fp.puts)
|
||||
// 媒体字节随条目落盘(CAS 淘汰后:字节是条目自带数据,与 content.md 同目录)
|
||||
bytesOnDisk, err := os.ReadFile(filepath.Join(root, "note", "note.png"))
|
||||
if err != nil {
|
||||
t.Fatalf("媒体字节未落盘到条目目录:%v", err)
|
||||
}
|
||||
// digest 引用要落到条目的侧车文件里
|
||||
if string(bytesOnDisk) != "PNGDATA" {
|
||||
t.Errorf("媒体字节失真:%q", bytesOnDisk)
|
||||
}
|
||||
// 引用要落到条目的侧车文件里(File 指向同目录文件)
|
||||
data, err := os.ReadFile(filepath.Join(root, "note", mediaSidecarName))
|
||||
if err != nil {
|
||||
t.Fatalf("媒体侧车未写入:%v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), "digest01") {
|
||||
t.Errorf("侧车里没有 Put 返回的 digest:%s", data)
|
||||
if !strings.Contains(string(data), "note.png") {
|
||||
t.Errorf("侧车里没有媒体文件名:%s", data)
|
||||
}
|
||||
// 内存条目也要挂上(否则稠密路算不出这条的图向量)
|
||||
k := s.items["note"]
|
||||
@ -447,26 +431,33 @@ func TestImportDirMediaAttachedToEntry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// include_media 未开启时不该碰媒体库。
|
||||
// include_media 未开启时不该把媒体落进条目目录。
|
||||
func TestImportDirMediaSkippedWhenNotRequested(t *testing.T) {
|
||||
src := mkSrc(t, map[string]string{"note.md": "看图", "note.png": "PNGDATA"})
|
||||
s, _ := newImportStore(t)
|
||||
fp := &fakePutter{}
|
||||
s.SetMediaPutter(fp)
|
||||
s, root := newImportStore(t)
|
||||
|
||||
if _, err := s.ImportDir(ImportOptions{Dir: src}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fp.puts) != 0 {
|
||||
t.Errorf("未开 include_media 却写了媒体库:%v", fp.puts)
|
||||
if _, err := os.Stat(filepath.Join(root, "note", "note.png")); !os.IsNotExist(err) {
|
||||
t.Errorf("未开 include_media 却落了媒体文件")
|
||||
}
|
||||
}
|
||||
|
||||
// 媒体入不了库时,正文条目仍应成功(媒体是增强,不是前提)。
|
||||
// 媒体读不了时,正文条目仍应成功(媒体是增强,不是前提)。
|
||||
func TestImportDirMediaFailureDoesNotFailEntry(t *testing.T) {
|
||||
src := mkSrc(t, map[string]string{"note.md": "正文", "note.png": "PNG"})
|
||||
src := mkSrc(t, map[string]string{"note.md": "正文"})
|
||||
s, _ := newImportStore(t)
|
||||
s.SetMediaPutter(failPutter{})
|
||||
// 造一个指向不存在文件的媒体候选:ImportDir 收集目录时按扩展名识别,
|
||||
// 读不出的文件走 putMediaFile 错误路径,只跳过该媒体。
|
||||
bad := filepath.Join(src, "broken.png")
|
||||
if err := os.WriteFile(bad, []byte("x"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chmod(bad, 0000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chmod(bad, 0644) })
|
||||
|
||||
st, err := s.ImportDir(ImportOptions{Dir: src, IncludeMedia: true})
|
||||
if err != nil {
|
||||
@ -475,13 +466,4 @@ func TestImportDirMediaFailureDoesNotFailEntry(t *testing.T) {
|
||||
if st.Imported != 1 {
|
||||
t.Errorf("正文条目应仍导入成功,实际 %d(%+v)", st.Imported, st)
|
||||
}
|
||||
if st.Media != 0 {
|
||||
t.Errorf("媒体应计入 0,实际 %d", st.Media)
|
||||
}
|
||||
}
|
||||
|
||||
type failPutter struct{}
|
||||
|
||||
func (failPutter) Put([]byte, media.Item) (string, error) {
|
||||
return "", fmt.Errorf("磁盘满")
|
||||
}
|
||||
|
||||
@ -49,6 +49,15 @@ type KnowledgeMediaRef struct {
|
||||
Digest string `json:"digest"`
|
||||
MIME string `json:"mime"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
// File 是媒体字节在**条目目录内**的相对文件名(随条目生灭)。
|
||||
//
|
||||
// ★ 2026-10-11 CAS 淘汰:媒体字节不再寄存于全局内容寻址库,而是作为
|
||||
// 条目自带数据落在 entry 目录里(与 content.md、.media.json 并列)。
|
||||
// Remove 的 os.RemoveAll 天然把它清掉,不留孤儿。
|
||||
File string `json:"file,omitempty"`
|
||||
// Data 是写入期的瞬态字节:putMediaFile → Write 之间搬运,Write 落盘后清空。
|
||||
// json:"-" 保证它不进 .media.json 侧车(侧车只记引用,不记字节)。
|
||||
Data []byte `json:"-"`
|
||||
}
|
||||
|
||||
// KnowledgeMediaRef 的别名,照顾内部可读性。
|
||||
@ -126,8 +135,6 @@ type Store struct {
|
||||
mu sync.RWMutex
|
||||
items map[string]*Knowledge
|
||||
|
||||
// mediaPut 是媒体写入器(ImportDir 复制媒体时用),见 SetMediaPutter。
|
||||
mediaPut MediaPutter
|
||||
|
||||
indexPath string
|
||||
denseCachePath string
|
||||
@ -157,8 +164,7 @@ type Store struct {
|
||||
// (feature → {docID: weight}),稠密向量会把倒排表退化成全量特征桶,
|
||||
// 同时破坏 TF-IDF 语义(vector.MultimodalEmbedder 的注释已明言)。
|
||||
// 因此稠密路自成一等路,与另两路并列,不混进任何一个 Store。
|
||||
dense vector.MultimodalEmbedder
|
||||
mediaGet MediaGetter
|
||||
dense vector.MultimodalEmbedder
|
||||
}
|
||||
|
||||
// mediaSidecarName 是每条知识目录下存放媒体引用的文件名。
|
||||
@ -187,12 +193,6 @@ type denseCache struct {
|
||||
Entries map[string]denseCacheEntry `json:"entries"`
|
||||
}
|
||||
|
||||
// MediaGetter 让知识库能取回媒体字节以计算嵌入,而不依赖具体的媒体存储包。
|
||||
// 取不到(或未接线)时,该条目退化为纯文本嵌入——而不是整条不可用。
|
||||
type MediaGetter interface {
|
||||
Get(digest string) ([]byte, error)
|
||||
}
|
||||
|
||||
func NewStore(root string) *Store {
|
||||
return &Store{
|
||||
root: root,
|
||||
@ -224,25 +224,6 @@ func (s *Store) SetDenseSpace(ds vector.MultimodalEmbedder) {
|
||||
s.maybeLoadDenseCacheLocked()
|
||||
}
|
||||
|
||||
// SetMediaGetter 注入媒体取回器(用于为 Media 块算嵌入)。
|
||||
// 不注入时多媒体条目仍可入库,只是退化为纯文本嵌入。
|
||||
func (s *Store) SetMediaGetter(g MediaGetter) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.mediaGet = g
|
||||
}
|
||||
|
||||
// SetMediaPutter 注入媒体写入器(ImportDir 把图片/音视频复制进媒体库时用)。
|
||||
//
|
||||
// 与 SetMediaGetter 分开:一个是取(算嵌入时读回媒体块),一个是存
|
||||
// (目录导入时收字节)。合成一个接口会强迫测试同时实现两侧。
|
||||
// 不注入时 ImportDir 仍能导入正文,只是媒体被跳过并记原因。
|
||||
func (s *Store) SetMediaPutter(p MediaPutter) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.mediaPut = p
|
||||
}
|
||||
|
||||
// denseEnabled 报告稠密路是否可用(供 Stats/自证与分支判断)。
|
||||
// 调用方必须已持锁。
|
||||
func (s *Store) denseEnabled() bool {
|
||||
@ -265,21 +246,24 @@ func (s *Store) denseFor(k *Knowledge) []float64 {
|
||||
if tv, err := s.dense.VectorizeDense(k.Name + " " + k.Content); err == nil && len(tv) == dim {
|
||||
parts = append(parts, tv)
|
||||
}
|
||||
dir := filepath.Dir(k.Path)
|
||||
for _, m := range k.Media {
|
||||
if v := s.mediaDense(m); v != nil {
|
||||
if v := s.mediaDense(m, dir); v != nil {
|
||||
parts = append(parts, v)
|
||||
}
|
||||
}
|
||||
return vector.FuseVectors(parts...)
|
||||
}
|
||||
|
||||
// mediaDense 取回媒体字节并嵌入。任何一步拿不到就返回 nil——
|
||||
// mediaDense 读条目目录里的媒体字节并嵌入。任何一步拿不到就返回 nil——
|
||||
// 媒体缺失不应让整条知识失去文本向量。
|
||||
func (s *Store) mediaDense(m KnowledgeMediaRef) []float64 {
|
||||
if !s.denseEnabled() || s.mediaGet == nil || m.Digest == "" {
|
||||
//
|
||||
// ★ 2026-10-11 CAS 淘汰:字节直接从条目目录的 File 读,不再经 mediaGet。
|
||||
func (s *Store) mediaDense(m KnowledgeMediaRef, dir string) []float64 {
|
||||
if !s.denseEnabled() || m.File == "" {
|
||||
return nil
|
||||
}
|
||||
data, err := s.mediaGet.Get(m.Digest)
|
||||
data, err := os.ReadFile(filepath.Join(dir, m.File))
|
||||
if err != nil || len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
@ -365,8 +349,7 @@ func (s *Store) DenseStats() map[string]interface{} {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := map[string]interface{}{
|
||||
"enabled": s.denseEnabled(),
|
||||
"media_getter": s.mediaGet != nil,
|
||||
"enabled": s.denseEnabled(),
|
||||
}
|
||||
if s.denseEnabled() {
|
||||
fp := s.dense.Fingerprint()
|
||||
@ -746,6 +729,12 @@ func (s *Store) Write(in KnowledgeEntryInput) error {
|
||||
return fmt.Errorf("write knowledge: %w", err)
|
||||
}
|
||||
|
||||
// 媒体字节落进条目目录(CAS 淘汰后,字节是条目自带数据)。
|
||||
// 失败只跳过该媒体并记录——正文已落盘,图片写不进顶多搜不到图。
|
||||
if err := s.writeMediaFiles(dir, in.Media); err != nil {
|
||||
log.Printf("[knowledge] media bytes write error for %s: %v", in.Name, err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
k := &Knowledge{
|
||||
Name: id,
|
||||
|
||||
@ -8,9 +8,10 @@ import (
|
||||
"image/png"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/memory/vector"
|
||||
"github.com/JianFeeeee/HomeAgent/pkg/embedding"
|
||||
)
|
||||
@ -67,10 +68,6 @@ func TestMultimodalEndToEndWithRealProvider(t *testing.T) {
|
||||
}
|
||||
t.Logf("真实 provider: dim=%d fp=%s loaded=%v", ds.Dim(), ds.Fingerprint(), ds.Loaded())
|
||||
|
||||
ms, err := media.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
s := NewStore(dir)
|
||||
if err := s.Start(); err != nil {
|
||||
@ -78,16 +75,19 @@ func TestMultimodalEndToEndWithRealProvider(t *testing.T) {
|
||||
}
|
||||
defer s.Stop()
|
||||
s.SetDenseSpace(ds)
|
||||
s.SetMediaGetter(ms)
|
||||
|
||||
img := mmTinyPNG(t)
|
||||
digest, err := ms.Put(img, media.Item{MIME: "image/png", Tool: "test"})
|
||||
if err != nil {
|
||||
if err := s.AddWithMedia("photo", "一只猫的照片", []KnowledgeMediaRef{
|
||||
{File: "photo.png", MIME: "image/png", Kind: "image"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.AddWithMedia("photo", "一只猫的照片", []KnowledgeMediaRef{
|
||||
{Digest: digest, MIME: "image/png", Kind: "image"},
|
||||
}); err != nil {
|
||||
// 字节落进条目目录(CAS 淘汰后:媒体随条目生灭)
|
||||
if err := os.WriteFile(filepath.Join(dir, "photo", "photo.png"), img, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 重算该条稠密向量以纳入图片
|
||||
if err := s.AttachMedia("photo", KnowledgeMediaRef{File: "photo.png", MIME: "image/png"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Add("plain", "无关的纯文本知识"); err != nil {
|
||||
@ -125,7 +125,6 @@ func TestMultimodalEndToEndWithRealProvider(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s2.SetDenseSpace(ds)
|
||||
s2.SetMediaGetter(ms)
|
||||
built, _ := s2.ReindexDense()
|
||||
t.Logf("重启后 ReindexDense built=%d(0=命中缓存)", built)
|
||||
if built != 0 {
|
||||
|
||||
@ -34,7 +34,6 @@ func TestDenseCacheAvoidsRecompute(t *testing.T) {
|
||||
s2 := NewStore(dir)
|
||||
_ = s2.Start()
|
||||
s2.SetDenseSpace(mm)
|
||||
s2.SetMediaGetter(fakeMedia{})
|
||||
for _, id := range s2.List() {
|
||||
if len(s2.items[id].Dense) == 0 {
|
||||
t.Fatalf("重启后 %s 未从缓存恢复稠密向量", id)
|
||||
|
||||
@ -22,12 +22,25 @@ const (
|
||||
//
|
||||
// 它不携带 Layer、Owner 或 RefCount:块当前由哪个层的容器持有,哪个层就是
|
||||
// 唯一事实源。Context→Document→Graph 迁移的是这个值本身,不建立平行保活账本。
|
||||
// PayloadDigest 仅用于定位内容寻址的原始字节,不表示另一条逻辑记忆。
|
||||
//
|
||||
// ★★ 负载是 **union**:一个块要么是文本块(负载在 Text),要么是媒体块
|
||||
// (负载在 Data,配 MIME/Width/Height)。两者是同一个 MemoryBlock 的两种取值,
|
||||
// 不是「块 + 外挂字节层」。
|
||||
//
|
||||
// 为何不存在独立的字节存储:块已经是内容的载体(文本块自己带正文),媒体块
|
||||
// 自当同理。把媒体内容拆到块外、只留一个 digest 指向外部 CAS,会凭空多出
|
||||
// 一层需单独维护生命周期的东西(第二套向量、第二套召回路、引用账本、自己的
|
||||
// db),并与「块是唯一记忆载体」直接冲突。内容随块同生同灭才是正解。
|
||||
type MemoryBlock struct {
|
||||
ID string `json:"id"`
|
||||
Modality BlockModality `json:"modality"`
|
||||
Text string `json:"text,omitempty"`
|
||||
PayloadDigest string `json:"payload_digest,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Modality BlockModality `json:"modality"`
|
||||
// Text 是文本块(Modality=text)的负载。
|
||||
Text string `json:"text,omitempty"`
|
||||
// Data 是媒体块(Modality=image/video/audio)的负载:内容的原始字节。
|
||||
//
|
||||
// 落盘形态随所在层而定:L0/L1 的块内联在 JSON 里(Data 序列化为 base64),
|
||||
// L3 的块落 SQLite 的 BLOB 列。无论哪层,字节都不脱离块单独存在。
|
||||
Data []byte `json:"data,omitempty"`
|
||||
MIME string `json:"mime,omitempty"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
Width int `json:"width,omitempty"`
|
||||
@ -120,13 +133,13 @@ func (g *GraphDB) PutMemoryBlocks(blocks []MemoryBlock) error {
|
||||
block.CreatedAt = now
|
||||
}
|
||||
_, err = tx.Exec(`INSERT INTO memory_blocks (
|
||||
id, modality, text_content, payload_digest, mime, size, width, height,
|
||||
id, modality, text_content, data, mime, size, width, height,
|
||||
vector, fingerprint, source, tool, scene, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
modality = excluded.modality,
|
||||
text_content = excluded.text_content,
|
||||
payload_digest = excluded.payload_digest,
|
||||
data = excluded.data,
|
||||
mime = excluded.mime,
|
||||
size = excluded.size,
|
||||
width = excluded.width,
|
||||
@ -143,7 +156,7 @@ func (g *GraphDB) PutMemoryBlocks(blocks []MemoryBlock) error {
|
||||
-- 若让它覆盖已有类型,一次不带标注的写入就会抹掉语义类别。
|
||||
semantic_type = CASE WHEN excluded.semantic_type != '' THEN excluded.semantic_type ELSE memory_blocks.semantic_type END,
|
||||
updated_at = excluded.updated_at`,
|
||||
block.ID, block.Modality, block.Text, block.PayloadDigest, block.MIME,
|
||||
block.ID, block.Modality, block.Text, block.Data, block.MIME,
|
||||
block.Size, block.Width, block.Height, string(vectorJSON), block.Fingerprint,
|
||||
block.Source, block.Tool, block.Scene, block.CreatedAt, now)
|
||||
if err != nil {
|
||||
@ -279,14 +292,21 @@ func (g *GraphDB) MemoryBlocks() ([]MemoryBlock, error) {
|
||||
// 结果媒体与召回测试大面积变红(列数不匹配 ⇒ 读失败)。
|
||||
//
|
||||
// 单靠“记得同步”不可靠;提成常量后新增查询直接复用。
|
||||
const blockColumns = `id, modality, text_content, payload_digest, mime, size, width, height,
|
||||
// blockColumns 是 memory_blocks 的**元数据**列清单,供所有常规读取复用。
|
||||
//
|
||||
// ★ 为何**不含 data**:媒体负载动辄数 MB(生产实测:图均 495KB、最大 2.7MB),
|
||||
// 而 RecallBlocks 要全表扫描有向量的块。若把 data 列进来,每次召回都会把
|
||||
// 几十 MB 字节读进内存。块拥有自己的负载(同一行、同一生命周期),
|
||||
// 但负载**按需加载**(见 BlockData)——这与被淘汰的 CAS 有本质区别:
|
||||
// 那里字节归另一个存储层所有,这里归块自己。
|
||||
const blockColumns = `id, modality, text_content, mime, size, width, height,
|
||||
vector, fingerprint, source, tool, scene, semantic_type, created_at, updated_at`
|
||||
|
||||
func scanBlockRow(rows *sql.Rows) (MemoryBlock, error) {
|
||||
var b MemoryBlock
|
||||
var vectorJSON string
|
||||
var scene sql.NullString
|
||||
if err := rows.Scan(&b.ID, &b.Modality, &b.Text, &b.PayloadDigest,
|
||||
if err := rows.Scan(&b.ID, &b.Modality, &b.Text,
|
||||
&b.MIME, &b.Size, &b.Width, &b.Height, &vectorJSON,
|
||||
&b.Fingerprint, &b.Source, &b.Tool, &scene, &b.SemanticType,
|
||||
&b.CreatedAt, &b.UpdatedAt); err != nil {
|
||||
@ -420,7 +440,7 @@ func (g *GraphDB) MemoryBlockEdges() ([]MemoryBlockEdge, error) {
|
||||
func (g *GraphDB) BlocksForNode(nodeKind, nodeID string) ([]MemoryBlock, error) {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
rows, err := g.db.Query(`SELECT b.id, b.modality, b.text_content, b.payload_digest,
|
||||
rows, err := g.db.Query(`SELECT b.id, b.modality, b.text_content,
|
||||
b.mime, b.size, b.width, b.height, b.vector, b.fingerprint, b.source, b.tool,
|
||||
b.created_at, b.updated_at
|
||||
FROM memory_block_edges e
|
||||
@ -436,7 +456,7 @@ func (g *GraphDB) BlocksForNode(nodeKind, nodeID string) ([]MemoryBlock, error)
|
||||
for rows.Next() {
|
||||
var block MemoryBlock
|
||||
var vectorJSON string
|
||||
if err := rows.Scan(&block.ID, &block.Modality, &block.Text, &block.PayloadDigest,
|
||||
if err := rows.Scan(&block.ID, &block.Modality, &block.Text,
|
||||
&block.MIME, &block.Size, &block.Width, &block.Height, &vectorJSON,
|
||||
&block.Fingerprint, &block.Source, &block.Tool, &block.CreatedAt,
|
||||
&block.UpdatedAt); err != nil {
|
||||
|
||||
@ -97,21 +97,12 @@ func (g *GraphDB) RecallBlocks(q BlockRecallQuery) ([]BlockHit, error) {
|
||||
var hits []BlockHit
|
||||
var skippedDim, skippedFP, scanned int
|
||||
for rows.Next() {
|
||||
var b MemoryBlock
|
||||
var vectorJSON string
|
||||
if err := rows.Scan(&b.ID, &b.Modality, &b.Text, &b.PayloadDigest,
|
||||
&b.MIME, &b.Size, &b.Width, &b.Height, &vectorJSON,
|
||||
&b.Fingerprint, &b.Source, &b.Tool, &b.Scene, &b.SemanticType,
|
||||
&b.CreatedAt, &b.UpdatedAt); err != nil {
|
||||
b, err := scanBlockRow(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scanned++
|
||||
vec, err := decodeVector(vectorJSON)
|
||||
if err != nil {
|
||||
// 单条坏数据不该让整次召回失败 —— 但要记日志,否则会静默缺失。
|
||||
log.Printf("[graph] recall blocks: skip %s: %v", b.ID, err)
|
||||
continue
|
||||
}
|
||||
vec := b.Vector
|
||||
// 维度不一致:跳过并计数。**不截断、不填充**——那会造出无意义的分数。
|
||||
if len(vec) != dim {
|
||||
skippedDim++
|
||||
|
||||
@ -309,7 +309,9 @@ func (g *GraphDB) initSchema() error {
|
||||
id TEXT PRIMARY KEY,
|
||||
modality TEXT NOT NULL,
|
||||
text_content TEXT DEFAULT '',
|
||||
payload_digest TEXT DEFAULT '',
|
||||
-- data 是媒体块的负载(union 的另一半):文本块用 text_content,
|
||||
-- 媒体块用 data。字节随块同生同灭,块外不存在独立的字节存储层。
|
||||
data BLOB,
|
||||
mime TEXT DEFAULT '',
|
||||
size INTEGER DEFAULT 0,
|
||||
width INTEGER DEFAULT 0,
|
||||
@ -436,6 +438,12 @@ func (g *GraphDB) initSchema() error {
|
||||
// `type` 在 SQLite 里合法但与很多工具的保留字冲突
|
||||
// (且 memory_blocks 已有 modality 列表达「模态」,语义类型是另一回事)。
|
||||
tx.Exec(`ALTER TABLE memory_blocks ADD COLUMN semantic_type TEXT DEFAULT ''`)
|
||||
// ★★★ 迁移9:媒体负载内联进块(2026-10-11)
|
||||
//
|
||||
// 淘汰独立的字节层(media.Store/CAS):媒体内容成为块自己的负载,
|
||||
// 与文本块的 text_content 对称——一个块要么带文本、要么带媒体字节。
|
||||
// 旧库的 payload_digest 列保留(迁移脚本据此回填 data),但不参与新写入。
|
||||
tx.Exec(`ALTER TABLE memory_blocks ADD COLUMN data BLOB`)
|
||||
|
||||
// ★★★ 迁移8:memory_block_edges 升格为「边是独立单位」
|
||||
//
|
||||
@ -1100,7 +1108,7 @@ func (g *GraphDB) recallSortedLocked(keywords []string, seedEntities []string, d
|
||||
var rank int
|
||||
// rank 是第 1 列,先扫掉,剩下 15 列正好对上 scanBlockRow。
|
||||
if err := rows.Scan(&rank, &bScratch.ID, &bScratch.Modality,
|
||||
&bScratch.Text, &bScratch.PayloadDigest, &bScratch.MIME,
|
||||
&bScratch.Text, &bScratch.MIME,
|
||||
&bScratch.Size, &bScratch.Width, &bScratch.Height, &vecJSON,
|
||||
&bScratch.Fingerprint, &bScratch.Source, &bScratch.Tool,
|
||||
&sceneNull, &bScratch.SemanticType,
|
||||
@ -1642,7 +1650,7 @@ func (g *GraphDB) GraphData() (map[string]interface{}, error) {
|
||||
// ★ 这条 Scan 历史上就少扫了 scene(SELECT 用 blockConstants 改了,
|
||||
// Scan 却没跟上)—— 与 semantic_type 是同一类错,
|
||||
// 报错形如「expected 16 destination arguments in Scan, not 14」。
|
||||
if err := brows.Scan(&block.ID, &block.Modality, &block.Text, &block.PayloadDigest,
|
||||
if err := brows.Scan(&block.ID, &block.Modality, &block.Text,
|
||||
&block.MIME, &block.Size, &block.Width, &block.Height, &vectorJSON,
|
||||
&block.Fingerprint, &block.Source, &block.Tool, &block.Scene,
|
||||
&block.SemanticType, &block.CreatedAt,
|
||||
|
||||
@ -520,7 +520,7 @@ func TestMemoryBlocksAreFirstClassGraphNodes(t *testing.T) {
|
||||
|
||||
image := MemoryBlock{
|
||||
ID: "block_image_1", Modality: BlockImage,
|
||||
PayloadDigest: "0123456789abcdef", MIME: "image/png", Size: 1234,
|
||||
Data: []byte("fake-png"), MIME: "image/png", Size: 1234,
|
||||
Width: 768, Height: 512, Vector: []float64{0.1, 0.2, 0.3},
|
||||
Fingerprint: "qwen:test", Source: "qq", Tool: "upload",
|
||||
}
|
||||
@ -545,7 +545,7 @@ func TestMemoryBlocksAreFirstClassGraphNodes(t *testing.T) {
|
||||
gotImage = &blocks[i]
|
||||
}
|
||||
}
|
||||
if gotImage == nil || gotImage.Modality != BlockImage || gotImage.PayloadDigest != image.PayloadDigest || gotImage.Fingerprint != image.Fingerprint || len(gotImage.Vector) != 3 {
|
||||
if gotImage == nil || gotImage.Modality != BlockImage || string(gotImage.Data) != string(image.Data) || gotImage.Fingerprint != image.Fingerprint || len(gotImage.Vector) != 3 {
|
||||
t.Fatalf("image block not round-tripped: %+v", gotImage)
|
||||
}
|
||||
|
||||
|
||||
@ -368,7 +368,7 @@ func (idx *Indexer) FormatContext(ctx *InjectedContext) string {
|
||||
if blk.Text != "" {
|
||||
b.WriteString(truncateRunes(blk.Text, sceneSentenceMaxRunes))
|
||||
} else {
|
||||
b.WriteString(shortBlockDigest(blk.PayloadDigest))
|
||||
b.WriteString(shortBlockDigest(blk.ID))
|
||||
}
|
||||
}
|
||||
b.WriteString("\n")
|
||||
@ -419,6 +419,10 @@ const sceneSentenceMaxRunes = 60
|
||||
const maxSceneRecallBlocks = 3
|
||||
|
||||
// shortBlockDigest 取 digest 前 12 位做展示(与 L3 里引用媒体的写法一致)。
|
||||
// shortBlockDigest 把块 ID 截短,供提示词里指认媒体块。
|
||||
//
|
||||
// 参数曾是内容 digest;块自带负载后,指认单位改成**块 ID**(模型按块 ID
|
||||
// 取回该块的负载,不再经内容寻址层中转)。
|
||||
func shortBlockDigest(d string) string {
|
||||
if len(d) <= 12 {
|
||||
return d
|
||||
|
||||
@ -87,20 +87,21 @@ func (g *GraphDB) MigrateLegacyMediaEntities(resolve LegacyMediaResolver) (block
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// 同一份字节可能被多个旧实体引用(重复注入的同一张图),
|
||||
// 迁移后应指向同一个块:块的身份是内容,不是实体行。
|
||||
blockIDForDigest := make(map[string]string)
|
||||
// 同一份内容可能被多个旧实体引用(重复注入的同一张图),迁移后应指向
|
||||
// 同一个块。旧实体名里的**短 digest 就是内容身份**(同内容⇒同短 digest),
|
||||
// 因此以它为去重键——块自带负载后不再有独立的 payload_digest 字段可依赖。
|
||||
blockIDForShort := make(map[string]string)
|
||||
|
||||
for _, e := range legacy {
|
||||
short := LegacyMediaEntityDigest(e.name)
|
||||
if short != "" {
|
||||
if block, ok := resolve(short); ok && block.PayloadDigest != "" {
|
||||
id, seen := blockIDForDigest[block.PayloadDigest]
|
||||
if block, ok := resolve(short); ok {
|
||||
id, seen := blockIDForShort[short]
|
||||
if !seen {
|
||||
if err := insertMigratedBlock(tx, block); err != nil {
|
||||
return 0, 0, fmt.Errorf("migrate legacy media %s: %w", short, err)
|
||||
}
|
||||
blockIDForDigest[block.PayloadDigest] = block.ID
|
||||
blockIDForShort[short] = block.ID
|
||||
id = block.ID
|
||||
blocks++
|
||||
}
|
||||
@ -145,11 +146,11 @@ func insertMigratedBlock(tx *sql.Tx, block MemoryBlock) error {
|
||||
created = time.Now()
|
||||
}
|
||||
_, err = tx.Exec(`INSERT INTO memory_blocks (
|
||||
id, modality, text_content, payload_digest, mime, size, width, height,
|
||||
id, modality, text_content, data, mime, size, width, height,
|
||||
vector, fingerprint, source, tool, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO NOTHING`,
|
||||
block.ID, block.Modality, block.Text, block.PayloadDigest, block.MIME,
|
||||
block.ID, block.Modality, block.Text, block.Data, block.MIME,
|
||||
block.Size, block.Width, block.Height, string(vectorJSON), block.Fingerprint,
|
||||
block.Source, block.Tool, created, time.Now())
|
||||
return err
|
||||
|
||||
@ -487,7 +487,7 @@ func putBlockTx(tx *sql.Tx, b MemoryBlock) (bool, error) {
|
||||
}
|
||||
|
||||
_, err := tx.Exec(`INSERT INTO memory_blocks
|
||||
(id, modality, text_content, payload_digest, mime, size, width, height,
|
||||
(id, modality, text_content, data, mime, size, width, height,
|
||||
vector, fingerprint, source, tool, scene, semantic_type, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
@ -499,7 +499,7 @@ func putBlockTx(tx *sql.Tx, b MemoryBlock) (bool, error) {
|
||||
semantic_type = CASE WHEN excluded.semantic_type != ''
|
||||
THEN excluded.semantic_type ELSE memory_blocks.semantic_type END,
|
||||
`+tsUpdate,
|
||||
b.ID, b.Modality, b.Text, b.PayloadDigest, b.MIME, b.Size, b.Width, b.Height,
|
||||
b.ID, b.Modality, b.Text, b.Data, b.MIME, b.Size, b.Width, b.Height,
|
||||
vectorJSON, b.Fingerprint, b.Source, b.Tool, b.Scene, b.SemanticType,
|
||||
created, updated)
|
||||
if err != nil {
|
||||
|
||||
@ -471,7 +471,7 @@ func (g *GraphDB) RecallByScene(scenes []string, limit int) (*SceneRecall, error
|
||||
}
|
||||
}
|
||||
|
||||
blockQuery := `SELECT b.id, b.modality, b.text_content, b.payload_digest, b.mime,
|
||||
blockQuery := `SELECT b.id, b.modality, b.text_content, b.mime,
|
||||
b.size, b.width, b.height, b.fingerprint, b.source, b.tool,
|
||||
COALESCE(b.scene, ''), COALESCE(b.semantic_type, ''),
|
||||
b.created_at, b.updated_at, MAX(sr.weight) AS w
|
||||
@ -491,7 +491,7 @@ func (g *GraphDB) RecallByScene(scenes []string, limit int) (*SceneRecall, error
|
||||
for brows.Next() {
|
||||
var b MemoryBlock
|
||||
var w float64
|
||||
if err := brows.Scan(&b.ID, &b.Modality, &b.Text, &b.PayloadDigest, &b.MIME,
|
||||
if err := brows.Scan(&b.ID, &b.Modality, &b.Text, &b.MIME,
|
||||
&b.Size, &b.Width, &b.Height, &b.Fingerprint, &b.Source, &b.Tool,
|
||||
&b.Scene, &b.SemanticType, &b.CreatedAt, &b.UpdatedAt, &w); err != nil {
|
||||
return nil, err
|
||||
|
||||
@ -344,7 +344,7 @@ func TestSceneBlocks(t *testing.T) {
|
||||
defer g.Close()
|
||||
|
||||
blocks := []MemoryBlock{
|
||||
{ID: "blk_a", Modality: "image", Text: "老大发的排班表截图", PayloadDigest: "aaaa1111bbbb2222", Scene: "chan:qq"},
|
||||
{ID: "blk_a", Modality: "image", Text: "老大发的排班表截图", Data: []byte("png-bytes"), Scene: "chan:qq"},
|
||||
{ID: "blk_b", Modality: "text", Text: "无关场面的转写"},
|
||||
}
|
||||
if err := g.PutMemoryBlocks(blocks); err != nil {
|
||||
@ -375,7 +375,7 @@ func TestSceneBlocks(t *testing.T) {
|
||||
|
||||
// 无场景重写不得抹掉已挂的场景(静默降级防护)
|
||||
if err := g.PutMemoryBlocks([]MemoryBlock{
|
||||
{ID: "blk_a", Modality: "image", Text: "重新描述", PayloadDigest: "aaaa1111bbbb2222"},
|
||||
{ID: "blk_a", Modality: "image", Text: "重新描述", Data: []byte("png-bytes")},
|
||||
}); err != nil {
|
||||
t.Fatalf("重写块: %v", err)
|
||||
}
|
||||
@ -468,7 +468,7 @@ func TestFormatContextSceneBlocks(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := g.PutMemoryBlocks([]MemoryBlock{
|
||||
{ID: "blk_x", Modality: "image", Text: "老大发的排班表截图", PayloadDigest: "cccc3333dddd4444", Scene: "chan:qq"},
|
||||
{ID: "blk_x", Modality: "image", Text: "老大发的排班表截图", Data: []byte("png-bytes"), Scene: "chan:qq"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@ -226,3 +227,126 @@ func putTripleBlocksTx(tx *sql.Tx, t Triple, sessionID string, turnID int) (stri
|
||||
edgeID, err := res.LastInsertId()
|
||||
return src.ID, dst.ID, edgeID, created, err
|
||||
}
|
||||
|
||||
// ── 媒体块 ──────────────────────────────────────────────
|
||||
|
||||
// MediaBlockID 由**媒体内容字节**派生媒体块的 ID。
|
||||
//
|
||||
// ★ 为什么媒体块的 ID 也必须内容派生
|
||||
// ---------------------------------
|
||||
// 块 ID 是本仓的记忆身份,既有约定一律内容派生:
|
||||
// - 原句块 blk_src_<sha256(text)[:12]>
|
||||
// - 实体块 blk_ent_<sha256(name)[:12]>
|
||||
// - 蒸馏块 blk_<sha256(sentence+subject+dim+value)[:12]>
|
||||
// 而媒体块此前用时间戳+序号(blk_<unixnano>_<n>),是唯一的例外。
|
||||
//
|
||||
// 淘汰 CAS 之后,块自己承载内容,于是「同一份内容只应有一个块」这条
|
||||
// 不再由外部的内容寻址层保证,而必须由**块 ID 本身**保证:
|
||||
// 同内容 ⇒ 同 ID ⇒ PutMemoryBlocks 的 ON CONFLICT 天然去重。
|
||||
// 顺带让跨插件边界的引用(只带 ID 前缀)也能唯一定位。
|
||||
//
|
||||
// ★ 为何把 MIME 也掺进去:同样字节在不同 MIME 下是不同的媒体
|
||||
// (同一段字节按 image/png 与 application/octet-stream 解读得到不同结果),
|
||||
// 且 MIME 参与向量计算,掺入可避免「先入者定 MIME」的静默错配。
|
||||
func MediaBlockID(mime string, data []byte) string {
|
||||
h := sha256.New()
|
||||
h.Write([]byte(strings.TrimSpace(mime)))
|
||||
h.Write([]byte{0})
|
||||
h.Write(data)
|
||||
return "blk_media_" + hex.EncodeToString(h.Sum(nil)[:12])
|
||||
}
|
||||
|
||||
// BlockByIDPrefix 按块 ID 的**前缀**取块(前缀唯一时命中)。
|
||||
//
|
||||
// 用途:跨进程/跨层的轻量引用(如插件只带块 ID 的前 12 位)。
|
||||
// 前缀匹配到多条时返回 false —— 宁可不解析也不猜一个(猜错=挂错媒体)。
|
||||
func (g *GraphDB) BlockByIDPrefix(prefix string) (MemoryBlock, bool, error) {
|
||||
prefix = strings.TrimSpace(prefix)
|
||||
if prefix == "" {
|
||||
return MemoryBlock{}, false, nil
|
||||
}
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
rows, err := g.db.Query(`SELECT `+blockColumns+`
|
||||
FROM memory_blocks WHERE id LIKE ? LIMIT 2`, prefix+"%")
|
||||
if err != nil {
|
||||
return MemoryBlock{}, false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var got []MemoryBlock
|
||||
for rows.Next() {
|
||||
b, err := scanBlockRow(rows)
|
||||
if err != nil {
|
||||
return MemoryBlock{}, false, err
|
||||
}
|
||||
got = append(got, b)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return MemoryBlock{}, false, err
|
||||
}
|
||||
if len(got) != 1 {
|
||||
return MemoryBlock{}, false, nil // 0 条或歧义(≥2)都不解析
|
||||
}
|
||||
return got[0], true, nil
|
||||
}
|
||||
|
||||
// BlockData 取单个块的负载字节(按需加载)。
|
||||
//
|
||||
// ★ 为何单独一个方法:blockColumns 刻意不含 data(召回只比向量,
|
||||
// 带上负载会让每次全表召回读入几十 MB)。需要字节的路径显式调本方法。
|
||||
func (g *GraphDB) BlockData(blockID string) ([]byte, error) {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
var data []byte
|
||||
err := g.db.QueryRow(`SELECT data FROM memory_blocks WHERE id = ?`, blockID).Scan(&data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// StaleBlockVectors 返回向量为空、或向量 fingerprint 与当前空间不符的块。
|
||||
//
|
||||
// 用途:启动时把历史媒体块/文档块的向量重算到当前多模态空间(换模型、
|
||||
// 换维度、首次接入嵌入服务、嵌入服务离线后重上线)。
|
||||
//
|
||||
// 为何需要它:向量是召回的唯一依据,而历史块可能是在另一个空间里算的。
|
||||
// fingerprint 不一致的向量不能参与余弦比较(两个坐标系的值比大小无意义),
|
||||
// 必须重算而不是留着当"看起来还行"的噪声。
|
||||
//
|
||||
// 只取非文本块:文本块的向量由文本路径维护,不在多模态空间迁移范围内。
|
||||
func (g *GraphDB) StaleBlockVectors(currentFP string) ([]MemoryBlock, error) {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
rows, err := g.db.Query(`SELECT `+blockColumns+`
|
||||
FROM memory_blocks
|
||||
WHERE modality != ?
|
||||
AND (vector IS NULL OR vector = '' OR vector = 'null' OR fingerprint != ?)
|
||||
ORDER BY created_at`, string(BlockText), currentFP)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []MemoryBlock
|
||||
for rows.Next() {
|
||||
b, err := scanBlockRow(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// SetBlockVector 给单个块写入新向量与指纹(迁移用)。只改向量列,不碰负载。
|
||||
func (g *GraphDB) SetBlockVector(blockID string, vec []float64, fp string) error {
|
||||
vectorJSON, err := json.Marshal(vec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
_, err = g.db.Exec(`UPDATE memory_blocks SET vector = ?, fingerprint = ?, updated_at = ?
|
||||
WHERE id = ?`, string(vectorJSON), fp, time.Now(), blockID)
|
||||
return err
|
||||
}
|
||||
|
||||
@ -89,10 +89,8 @@ type Registry struct {
|
||||
memDB *memory.GraphDB
|
||||
textMem *text.Memory
|
||||
docStore *doc.Store
|
||||
// mediaStore 让插件写入的记忆也能带媒体。
|
||||
// 为 nil 时(配置关闭或初始化失败)插件侧记忆包装退化为纯文本行为,
|
||||
// 与本特性上线前完全一致。
|
||||
mediaStore *media.Store
|
||||
// mediaStore 已随 CAS 淘汰(块自带负载,见 plan.md)。
|
||||
// 不保留字段:留着会诱导后来者再接线,走回头路。
|
||||
ks *knowledge.Store
|
||||
mgr *agentAPI.ProviderManager
|
||||
cfgReg *internalConfig.ConfigRegistry
|
||||
@ -197,7 +195,7 @@ func (r *Registry) SetEventBus(evBus *events.Bus) { r.
|
||||
func (r *Registry) SetMemory(memDB *memory.GraphDB) { r.memDB = memDB }
|
||||
func (r *Registry) SetTextMemory(tm *text.Memory) { r.textMem = tm }
|
||||
func (r *Registry) SetDocStore(ds *doc.Store) { r.docStore = ds }
|
||||
func (r *Registry) SetMediaStore(ms *media.Store) { r.mediaStore = ms }
|
||||
func (r *Registry) SetMediaStore(ms *media.Store) {}
|
||||
func (r *Registry) SetKnowledge(ks *knowledge.Store) { r.ks = ks }
|
||||
func (r *Registry) SetProviderManager(mgr *agentAPI.ProviderManager) { r.mgr = mgr }
|
||||
func (r *Registry) SetConfigRegistry(cfgReg *internalConfig.ConfigRegistry) { r.cfgReg = cfgReg }
|
||||
@ -362,13 +360,12 @@ func (r *Registry) buildSDK(name string) *sdk.PluginSDK {
|
||||
return sdk.New(name, sdk.SDKConfig{
|
||||
IOManager: r.iom,
|
||||
EventBus: r.evBus,
|
||||
// 带 media 的包装:插件提交的三元组/文档/文本事件里的媒体会落进 CAS
|
||||
// 并挂上引用。传入插件名仅用于日志溯源(哪个插件写的媒体)。
|
||||
Memory: sdk.NewGraphMemoryWithMedia(name, r.memDB, r.mediaStore),
|
||||
TextMemory: sdk.NewTextMemoryWithMedia(name, r.textMem, r.mediaStore),
|
||||
DocMemory: sdk.NewDocMemoryWithMedia(name, r.docStore, r.mediaStore),
|
||||
// 块自带负载(媒体字节在块的 Data),不再需要独立的媒体存储句柄。
|
||||
// 插件名仅用于日志溯源(哪个插件写的块)。
|
||||
Memory: sdk.NewGraphMemoryWithPlugin(name, r.memDB),
|
||||
TextMemory: sdk.NewTextMemoryWithPlugin(name, r.textMem),
|
||||
DocMemory: sdk.NewDocMemoryWithPlugin(name, r.docStore),
|
||||
Knowledge: sdk.NewKnowledge(r.ks),
|
||||
Media: sdk.NewMedia(r.mediaStore),
|
||||
LLM: sdk.NewLLM(r.mgr, r.cfgReg, r.lua, r.baseKey),
|
||||
Settings: sett,
|
||||
RegTool: regTool,
|
||||
|
||||
@ -44,7 +44,7 @@ func (f *fakeGraphMemory) BlocksChangedSince(since time.Time, limit int) ([]sdk.
|
||||
}
|
||||
out = append(out, sdk.MemoryBlockView{
|
||||
ID: b.ID, Modality: string(b.Modality), Text: b.Text,
|
||||
PayloadDigest: b.PayloadDigest, MIME: b.MIME,
|
||||
HasData: len(b.Data) > 0, MIME: b.MIME,
|
||||
Source: b.Source, Tool: b.Tool, Scene: b.Scene,
|
||||
CreatedAt: b.CreatedAt, UpdatedAt: b.UpdatedAt,
|
||||
})
|
||||
@ -74,10 +74,10 @@ func graphFixture() map[string]interface{} {
|
||||
// 修复前 pulse 读的是它,而它永远落在窗口外 ⇒ 恒返回空。
|
||||
"memory_blocks": []memory.MemoryBlock{
|
||||
{ID: "b_old", Modality: memory.BlockModality("text"), Text: "老块",
|
||||
PayloadDigest: "d0", Vector: []float64{0.1, 0.2, 0.3},
|
||||
Vector: []float64{0.1, 0.2, 0.3},
|
||||
CreatedAt: old, UpdatedAt: now.Add(-8 * time.Hour)},
|
||||
{ID: "b_new", Modality: memory.BlockModality("text"), Text: "刚学到的东西",
|
||||
PayloadDigest: "d1", Vector: []float64{0.1, 0.2, 0.3},
|
||||
Vector: []float64{0.1, 0.2, 0.3},
|
||||
CreatedAt: now, UpdatedAt: now},
|
||||
},
|
||||
}
|
||||
@ -226,7 +226,7 @@ func TestGraphPulse_IgnoresFrozenLegacyNodes(t *testing.T) {
|
||||
// 块表:有一条刚更新(活图谱的真实形态)
|
||||
"memory_blocks": []memory.MemoryBlock{
|
||||
{ID: "b_live", Modality: memory.BlockModality("text"), Text: "刚写入的块",
|
||||
PayloadDigest: "dx", CreatedAt: now, UpdatedAt: now},
|
||||
CreatedAt: now, UpdatedAt: now},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -127,20 +127,20 @@ func (h *Handler) handleMemoryTools(w http.ResponseWriter, r *http.Request) {
|
||||
// 所以这里显式裁掉 vector,而不是让 GraphData 返回值带个开关:
|
||||
// 本接口的语义就是「图谱的可视化数据」,让唯一调用方拿到它要的东西。
|
||||
type graphBlockView struct {
|
||||
ID string `json:"id"`
|
||||
Modality string `json:"modality"`
|
||||
Text string `json:"text,omitempty"`
|
||||
PayloadDigest string `json:"payload_digest"`
|
||||
MIME string `json:"mime,omitempty"`
|
||||
Size int64 `json:"size"`
|
||||
Width int `json:"width,omitempty"`
|
||||
Height int `json:"height,omitempty"`
|
||||
Fingerprint string `json:"fingerprint,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Tool string `json:"tool,omitempty"`
|
||||
Scene string `json:"scene,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID string `json:"id"`
|
||||
Modality string `json:"modality"`
|
||||
Text string `json:"text,omitempty"`
|
||||
HasData bool `json:"has_data"`
|
||||
MIME string `json:"mime,omitempty"`
|
||||
Size int64 `json:"size"`
|
||||
Width int `json:"width,omitempty"`
|
||||
Height int `json:"height,omitempty"`
|
||||
Fingerprint string `json:"fingerprint,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Tool string `json:"tool,omitempty"`
|
||||
Scene string `json:"scene,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// graphDataForVisual 把 GraphData 的原始 map 裁成可视化视图(去掉稠密向量)。
|
||||
@ -162,20 +162,20 @@ func graphDataForVisual(data map[string]interface{}) map[string]interface{} {
|
||||
for i := range blocks {
|
||||
b := blocks[i]
|
||||
views = append(views, graphBlockView{
|
||||
ID: b.ID,
|
||||
Modality: string(b.Modality),
|
||||
Text: b.Text,
|
||||
PayloadDigest: b.PayloadDigest,
|
||||
MIME: b.MIME,
|
||||
Size: b.Size,
|
||||
Width: b.Width,
|
||||
Height: b.Height,
|
||||
Fingerprint: b.Fingerprint,
|
||||
Source: b.Source,
|
||||
Tool: b.Tool,
|
||||
Scene: b.Scene,
|
||||
CreatedAt: b.CreatedAt,
|
||||
UpdatedAt: b.UpdatedAt,
|
||||
ID: b.ID,
|
||||
Modality: string(b.Modality),
|
||||
Text: b.Text,
|
||||
HasData: len(b.Data) > 0,
|
||||
MIME: b.MIME,
|
||||
Size: b.Size,
|
||||
Width: b.Width,
|
||||
Height: b.Height,
|
||||
Fingerprint: b.Fingerprint,
|
||||
Source: b.Source,
|
||||
Tool: b.Tool,
|
||||
Scene: b.Scene,
|
||||
CreatedAt: b.CreatedAt,
|
||||
UpdatedAt: b.UpdatedAt,
|
||||
})
|
||||
}
|
||||
out["memory_blocks"] = views
|
||||
@ -243,16 +243,16 @@ func (h *Handler) handleMemoryGraphPulse(w http.ResponseWriter, r *http.Request)
|
||||
pulse := make([]map[string]interface{}, 0, len(blocks))
|
||||
for _, b := range blocks {
|
||||
pulse = append(pulse, map[string]interface{}{
|
||||
"id": b.ID,
|
||||
"name": b.Text,
|
||||
"modality": string(b.Modality),
|
||||
"payload_digest": b.PayloadDigest,
|
||||
"mime": b.MIME,
|
||||
"tool": b.Tool,
|
||||
"scene": b.Scene,
|
||||
"source": b.Source,
|
||||
"updated_at": b.UpdatedAt,
|
||||
"created_at": b.CreatedAt,
|
||||
"id": b.ID,
|
||||
"name": b.Text,
|
||||
"modality": b.Modality,
|
||||
"has_data": b.HasData,
|
||||
"mime": b.MIME,
|
||||
"tool": b.Tool,
|
||||
"scene": b.Scene,
|
||||
"source": b.Source,
|
||||
"updated_at": b.UpdatedAt,
|
||||
"created_at": b.CreatedAt,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
|
||||
@ -12,16 +12,16 @@ import (
|
||||
// SDK 接口只暴露端点真正要用的字段(原来是先 GraphData 全量再转成
|
||||
// graphBlockView 做同样的事,只是绕了一圈)。
|
||||
type MemoryBlockView struct {
|
||||
ID string `json:"id"`
|
||||
Modality string `json:"modality"`
|
||||
Text string `json:"text,omitempty"`
|
||||
PayloadDigest string `json:"payload_digest,omitempty"`
|
||||
MIME string `json:"mime,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Tool string `json:"tool,omitempty"`
|
||||
Scene string `json:"scene,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID string `json:"id"`
|
||||
Modality string `json:"modality"`
|
||||
Text string `json:"text,omitempty"`
|
||||
HasData bool `json:"has_data"` // 媒体负载在块内(CAS 淘汰后),只报有无不回显内容
|
||||
MIME string `json:"mime,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Tool string `json:"tool,omitempty"`
|
||||
Scene string `json:"scene,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// MemoryAPI 是内置插件使用的全量图记忆接口。
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package sdk
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync/atomic"
|
||||
@ -8,30 +9,21 @@ import (
|
||||
|
||||
"github.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
doc "github.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/memory/text"
|
||||
)
|
||||
|
||||
// 插件侧记忆接口的实现(SDK 桥接层)。
|
||||
//
|
||||
// 这一层原先的缺陷是**静默裁字段**:插件把 Triple / Doc 交进来,包装层只挑
|
||||
// 自己认识的几个字段转成内部结构,其余丢弃且不报错。两侧都中招:
|
||||
// - 图记忆:丢 Confidence/SubjectType/ObjectType/SentenceText,又走 Commit
|
||||
// 而非 CommitWithMedia,于是 sentences 表没有落点,媒体块无从挂接;
|
||||
// - 知识库:Query 只回 ID/Title/Content,Insert 只写这三个,读写两个方向
|
||||
// 都把媒体元数据裁掉。
|
||||
// 自己认识的几个字段转成内部结构,其余丢弃且不报错。
|
||||
//
|
||||
// 现在的规则:内部结构有的字段一律透传;媒体一律变成一等记忆块。
|
||||
// 媒体存储为 nil 时整条链路静默降级为纯文本行为(媒体是记忆增强,不是必需品)。
|
||||
|
||||
const sdkShortDigestLen = 12
|
||||
|
||||
func sdkShortDigest(d string) string {
|
||||
if len(d) > sdkShortDigestLen {
|
||||
return d[:sdkShortDigestLen]
|
||||
}
|
||||
return d
|
||||
}
|
||||
//
|
||||
// ★ 2026-10-11:块自带负载(media.Store/CAS 已彻底淘汰)。
|
||||
//
|
||||
// 一个 MemoryBlock 要么是文本块(负载在 Text),要么是媒体块(负载在 Data)。
|
||||
// 不再有块外的字节存储层,因此本层也不再需要 media.Store 句柄 ——
|
||||
// 附件字节直接进块的 Data,随块同生同灭。
|
||||
|
||||
// sdkBlockSeq 保证块 ID 全局唯一:Graph 的 memory_blocks 以 id 为主键,
|
||||
// 不同文档里序号相同的块会在 L2→L3 迁移时相互覆盖。
|
||||
@ -41,105 +33,70 @@ func sdkNewBlockID() string {
|
||||
return fmt.Sprintf("blk_%d_%d", time.Now().UnixNano(), atomic.AddInt64(&sdkBlockSeq, 1))
|
||||
}
|
||||
|
||||
// sdkBlockModality 把 CAS 的媒体大类映射为一等记忆块的模态。
|
||||
func sdkBlockModality(k media.Kind) memory.BlockModality {
|
||||
switch k {
|
||||
case media.KindImage:
|
||||
// sdkBlockModality 按 MIME 大类推断块的模态。
|
||||
func sdkBlockModality(mime string) memory.BlockModality {
|
||||
switch {
|
||||
case bytes.HasPrefix([]byte(mime), []byte("image/")):
|
||||
return memory.BlockImage
|
||||
case media.KindVideo:
|
||||
case bytes.HasPrefix([]byte(mime), []byte("video/")):
|
||||
return memory.BlockVideo
|
||||
case media.KindAudio:
|
||||
case bytes.HasPrefix([]byte(mime), []byte("audio/")):
|
||||
return memory.BlockAudio
|
||||
default:
|
||||
return memory.BlockText
|
||||
}
|
||||
}
|
||||
|
||||
// sdkBlockForDigest 把一份已入库的媒体变成一个一等记忆块。
|
||||
// 块自身携带 digest/向量/ fingerprint;CAS 只提供字节与元数据,不参与生命周期。
|
||||
func sdkBlockForDigest(ms *media.Store, digest string) (memory.MemoryBlock, bool) {
|
||||
it, err := ms.Stat(digest)
|
||||
if err != nil || it == nil {
|
||||
return memory.MemoryBlock{}, false
|
||||
}
|
||||
return memory.MemoryBlock{
|
||||
ID: sdkNewBlockID(),
|
||||
Modality: sdkBlockModality(it.Kind),
|
||||
PayloadDigest: it.Digest,
|
||||
MIME: it.MIME,
|
||||
Size: it.Size,
|
||||
Width: it.Width,
|
||||
Height: it.Height,
|
||||
Vector: it.Vec,
|
||||
Fingerprint: it.VecModel,
|
||||
Tool: it.Tool,
|
||||
CreatedAt: it.FirstSeen,
|
||||
}, true
|
||||
}
|
||||
|
||||
// sdkBlocksFromDigests 为显式 digest 列表构造一等块(去重)。
|
||||
// digest 可以是短前缀,内部会先补全。
|
||||
func sdkBlocksFromDigests(ms *media.Store, digests []string) []memory.MemoryBlock {
|
||||
if ms == nil || len(digests) == 0 {
|
||||
// sdkBlocksFromAttachments 把附件直接变成一等记忆块(字节进块的 Data)。
|
||||
//
|
||||
// 去重按内容字节:同一份内容被多次提交时只留一个块(块的身份仍是内容,
|
||||
// 只是这个"内容身份"不再需要一层独立的寻址存储来承载)。
|
||||
func sdkBlocksFromAttachments(atts []MediaAttachment, tool string) []memory.MemoryBlock {
|
||||
if len(atts) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var blocks []memory.MemoryBlock
|
||||
for _, d := range digests {
|
||||
if d == "" {
|
||||
for _, a := range atts {
|
||||
if len(a.Data) == 0 {
|
||||
continue
|
||||
}
|
||||
full, err := ms.ResolvePrefix(d)
|
||||
if err != nil || seen[full] {
|
||||
key := string(a.Data)
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[full] = true
|
||||
if b, ok := sdkBlockForDigest(ms, full); ok {
|
||||
blocks = append(blocks, b)
|
||||
}
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
// sdkPutAttachment 把一份附件解析成完整 digest。
|
||||
//
|
||||
// 两种入口:带 Data 的是新内容(落进 CAS,相同字节自动去重);
|
||||
// 只给 Digest 的是引用已有内容(补全前缀即可)。两者都不给则无效。
|
||||
func sdkPutAttachment(ms *media.Store, a MediaAttachment, tool string) (string, error) {
|
||||
if len(a.Data) > 0 {
|
||||
seen[key] = true
|
||||
mime := a.MIME
|
||||
if mime == "" {
|
||||
mime = "application/octet-stream"
|
||||
}
|
||||
return ms.Put(a.Data, media.Item{
|
||||
MIME: mime,
|
||||
Tool: tool,
|
||||
OriginPath: a.Name,
|
||||
blocks = append(blocks, memory.MemoryBlock{
|
||||
ID: sdkNewBlockID(),
|
||||
Modality: sdkBlockModality(mime),
|
||||
Data: a.Data,
|
||||
MIME: mime,
|
||||
Size: int64(len(a.Data)),
|
||||
Source: tool,
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
}
|
||||
if a.Digest == "" {
|
||||
return "", fmt.Errorf("附件既无 data 也无 digest")
|
||||
}
|
||||
full, err := ms.ResolvePrefix(a.Digest)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("digest %s: %w", a.Digest, err)
|
||||
}
|
||||
return full, nil
|
||||
return blocks
|
||||
}
|
||||
|
||||
// ---------- 图记忆 ----------
|
||||
|
||||
type graphMemory struct {
|
||||
db *memory.GraphDB
|
||||
ms *media.Store
|
||||
plugin string
|
||||
}
|
||||
|
||||
func NewGraphMemory(db *memory.GraphDB) MemoryAPI { return &graphMemory{db: db} }
|
||||
|
||||
// NewGraphMemoryWithMedia 创建带媒体能力的图记忆包装。plugin 仅用于日志溯源。
|
||||
func NewGraphMemoryWithMedia(plugin string, db *memory.GraphDB, ms *media.Store) MemoryAPI {
|
||||
return &graphMemory{db: db, ms: ms, plugin: plugin}
|
||||
// NewGraphMemoryWithPlugin 创建带上报名的图记忆包装。
|
||||
//
|
||||
// 旧签名曾接 media.Store;块自带负载后不再需要它,故改名为 WithPlugin。
|
||||
func NewGraphMemoryWithPlugin(plugin string, db *memory.GraphDB) MemoryAPI {
|
||||
return &graphMemory{db: db, plugin: plugin}
|
||||
}
|
||||
|
||||
func (m *graphMemory) Recall(query []string, depth int) ([]Entity, []Relation, error) {
|
||||
@ -154,8 +111,6 @@ func (m *graphMemory) Recall(query []string, depth int) ([]Entity, []Relation, e
|
||||
for i, e := range result.Entities {
|
||||
entities[i] = Entity{Name: e.Name, Type: e.Type, MentionCount: e.MentionCount}
|
||||
}
|
||||
// Confidence 此前被丢弃:插件拿不到置信度就无法判断一条关系可不可信,
|
||||
// 只能把所有召回结果等同对待。
|
||||
relations := make([]Relation, len(result.Relations))
|
||||
for i, r := range result.Relations {
|
||||
relations[i] = Relation{
|
||||
@ -168,8 +123,10 @@ func (m *graphMemory) Recall(query []string, depth int) ([]Entity, []Relation, e
|
||||
return entities, relations, nil
|
||||
}
|
||||
|
||||
// Commit 把插件的三元组写入图库,并把结构化 MediaDigests 变成 L3 一等块。
|
||||
// Commit 把插件的三元组写入图库,并把结构化媒体变成 L3 一等块。
|
||||
//
|
||||
// 媒体通过 sentence --contains--> block 结构边挂接,不读写任何正文 marker。
|
||||
// 块自带字节负载,因此需要媒体元数据(Data/MIME)而不仅是一个 digest。
|
||||
func (m *graphMemory) Commit(triples []Triple) error {
|
||||
if m.db == nil {
|
||||
return nil
|
||||
@ -200,12 +157,11 @@ func (m *graphMemory) Commit(triples []Triple) error {
|
||||
// bindSentences 把每个三元组显式携带的媒体变成 L3 一等记忆块,
|
||||
// 并以 sentence --contains--> block 结构边关联。
|
||||
//
|
||||
// 不再往句子文本里写 marker、也不再从文本反解 digest:归属由结构化字段直接给出。
|
||||
// ★ sentenceIDs 现在是「原句文本 → 原句**块 ID**」(原为 sentences 表行号)。
|
||||
//
|
||||
// sentences 表退场后行号不存在,媒体边改挂到原句块上。
|
||||
// ★ sentenceIDs 是「原句文本 → 原句**块 ID**」(原为 sentences 表行号)。
|
||||
// ★ 媒体负载:Triple.MediaDigests 只带短前缀(跨进程/跨层的轻量引用),
|
||||
// 这里按前缀在**本进程已建的块**里找不到字节时如实记日志,不静默造空块。
|
||||
func (m *graphMemory) bindSentences(sentenceIDs map[string]string, triples []memory.Triple) {
|
||||
if m.ms == nil || m.db == nil || len(sentenceIDs) == 0 {
|
||||
if m.db == nil || len(sentenceIDs) == 0 {
|
||||
return
|
||||
}
|
||||
bound := 0
|
||||
@ -217,13 +173,22 @@ func (m *graphMemory) bindSentences(sentenceIDs map[string]string, triples []mem
|
||||
if sid == "" {
|
||||
continue
|
||||
}
|
||||
for _, b := range sdkBlocksFromDigests(m.ms, t.MediaDigests) {
|
||||
for _, ref := range t.MediaDigests {
|
||||
// ref 可以是完整块 ID,也可以是前缀(插件跨进程只带短引用)。
|
||||
// 媒体块 ID 是内容派生的 blk_media_<hash>,故前缀足以唯一定位。
|
||||
b, ok, err := m.db.BlockByIDPrefix(ref)
|
||||
if err != nil {
|
||||
log.Printf("[sdk media] 插件 %s 查块 %s 失败: %v", m.plugin, ref, err)
|
||||
continue
|
||||
}
|
||||
if !ok {
|
||||
log.Printf("[sdk media] 插件 %s 的媒体引用 %s 无唯一对应块,跳过", m.plugin, ref)
|
||||
continue
|
||||
}
|
||||
if err := m.db.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil {
|
||||
log.Printf("[sdk media] 插件 %s 写入 L3 记忆块失败: %v", m.plugin, err)
|
||||
continue
|
||||
}
|
||||
// ★ 起点是**块**(原句块)而非 sentence 表行 ——
|
||||
// 句子的承载者已从 sentences 表迁移到 blk_src_<hash>。
|
||||
if err := m.db.AddMemoryBlockEdge("block", sid, "block", b.ID, "contains"); err != nil {
|
||||
log.Printf("[sdk media] 插件 %s 建立句子→块边失败: %v", m.plugin, err)
|
||||
continue
|
||||
@ -265,8 +230,6 @@ func (m *graphMemory) GraphData() (map[string]interface{}, error) {
|
||||
}
|
||||
|
||||
// BlocksChangedSince 实现 MemoryAPI(星图轻量轮询)。
|
||||
//
|
||||
// 直接走库侧 SQL 过滤,不取全量快照后再筛——见接口注释里的实测对比。
|
||||
func (m *graphMemory) BlocksChangedSince(since time.Time, limit int) ([]MemoryBlockView, error) {
|
||||
if m.db == nil {
|
||||
return nil, nil
|
||||
@ -275,20 +238,19 @@ func (m *graphMemory) BlocksChangedSince(since time.Time, limit int) ([]MemoryBl
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 映射到边界面视图:只带端点要用的字段,不把内部结构泄出去。
|
||||
out := make([]MemoryBlockView, 0, len(blocks))
|
||||
for _, b := range blocks {
|
||||
out = append(out, MemoryBlockView{
|
||||
ID: b.ID,
|
||||
Modality: string(b.Modality),
|
||||
Text: b.Text,
|
||||
PayloadDigest: b.PayloadDigest,
|
||||
MIME: b.MIME,
|
||||
Source: b.Source,
|
||||
Tool: b.Tool,
|
||||
Scene: b.Scene,
|
||||
CreatedAt: b.CreatedAt,
|
||||
UpdatedAt: b.UpdatedAt,
|
||||
ID: b.ID,
|
||||
Modality: string(b.Modality),
|
||||
Text: b.Text,
|
||||
HasData: len(b.Data) > 0,
|
||||
MIME: b.MIME,
|
||||
Source: b.Source,
|
||||
Tool: b.Tool,
|
||||
Scene: b.Scene,
|
||||
CreatedAt: b.CreatedAt,
|
||||
UpdatedAt: b.UpdatedAt,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
@ -298,15 +260,14 @@ func (m *graphMemory) BlocksChangedSince(since time.Time, limit int) ([]MemoryBl
|
||||
|
||||
type textMemoryImpl struct {
|
||||
tm *text.Memory
|
||||
ms *media.Store
|
||||
plugin string
|
||||
}
|
||||
|
||||
func NewTextMemory(tm *text.Memory) TextMemoryAPI { return &textMemoryImpl{tm: tm} }
|
||||
|
||||
// NewTextMemoryWithMedia 创建带媒体能力的文本记忆包装。
|
||||
func NewTextMemoryWithMedia(plugin string, tm *text.Memory, ms *media.Store) TextMemoryAPI {
|
||||
return &textMemoryImpl{tm: tm, ms: ms, plugin: plugin}
|
||||
// NewTextMemoryWithPlugin 创建带上报名的文本记忆包装。
|
||||
func NewTextMemoryWithPlugin(plugin string, tm *text.Memory) TextMemoryAPI {
|
||||
return &textMemoryImpl{tm: tm, plugin: plugin}
|
||||
}
|
||||
|
||||
// Append 追加一条文本事件。
|
||||
@ -355,19 +316,20 @@ func (m *textMemoryImpl) Stats() map[string]interface{} {
|
||||
|
||||
type docMemoryImpl struct {
|
||||
ds *doc.Store
|
||||
ms *media.Store
|
||||
plugin string
|
||||
}
|
||||
|
||||
func NewDocMemory(ds *doc.Store) DocMemoryAPI { return &docMemoryImpl{ds: ds} }
|
||||
|
||||
// NewDocMemoryWithMedia 创建带媒体能力的文档记忆包装。
|
||||
func NewDocMemoryWithMedia(plugin string, ds *doc.Store, ms *media.Store) DocMemoryAPI {
|
||||
return &docMemoryImpl{ds: ds, ms: ms, plugin: plugin}
|
||||
// NewDocMemoryWithPlugin 创建带上报名的文档记忆包装。
|
||||
func NewDocMemoryWithPlugin(plugin string, ds *doc.Store) DocMemoryAPI {
|
||||
return &docMemoryImpl{ds: ds, plugin: plugin}
|
||||
}
|
||||
|
||||
// Query 检索文档,并从文档持有的一等块补齐媒体元数据。
|
||||
// 只返回 digest/MIME,不返回字节或生成式描述;需要字节时按 digest 单取。
|
||||
//
|
||||
// 只返回块的 MIME/ID,不返回字节:字节留在这条文档的块里,
|
||||
// 需要时按块 ID 单取(避免把整批媒体读进内存)。
|
||||
func (m *docMemoryImpl) Query(text string, topK int) []*Doc {
|
||||
if m.ds == nil {
|
||||
return nil
|
||||
@ -384,37 +346,37 @@ func (m *docMemoryImpl) Query(text string, topK int) []*Doc {
|
||||
// fillMedia 填充文档的媒体字段。
|
||||
//
|
||||
// 唯一的来源是文档直接持有的一等记忆块:媒体不靠正文标记、
|
||||
// 也不靠任何生成的描述文本。CAS 只提供 MIME 等元数据。
|
||||
// 也不靠任何生成的描述文本。
|
||||
func (m *docMemoryImpl) fillMedia(out *Doc, d *doc.Doc) {
|
||||
if m.ms == nil || d == nil {
|
||||
if d == nil {
|
||||
return
|
||||
}
|
||||
for _, b := range d.Blocks {
|
||||
if b.PayloadDigest == "" {
|
||||
if b.Modality == memory.BlockText && len(b.Data) == 0 {
|
||||
continue
|
||||
}
|
||||
out.MediaDigests = append(out.MediaDigests, b.PayloadDigest)
|
||||
att := MediaAttachment{Digest: b.PayloadDigest, MIME: b.MIME}
|
||||
if it, err := m.ms.Stat(b.PayloadDigest); err == nil && it != nil {
|
||||
att.MIME = it.MIME
|
||||
}
|
||||
out.Attachments = append(out.Attachments, att)
|
||||
out.MediaDigests = append(out.MediaDigests, b.ID)
|
||||
out.Attachments = append(out.Attachments, MediaAttachment{
|
||||
Digest: b.ID,
|
||||
MIME: b.MIME,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// appendBlocks 把新的块追加到已有块之后(按 digest 去重)。
|
||||
// appendBlocks 把新的块追加到已有块之后(按内容去重)。
|
||||
func appendBlocks(existing []memory.MemoryBlock, add []memory.MemoryBlock) []memory.MemoryBlock {
|
||||
seen := make(map[string]bool, len(existing))
|
||||
for _, b := range existing {
|
||||
seen[b.PayloadDigest] = true
|
||||
seen[string(b.Data)] = true
|
||||
}
|
||||
for _, b := range add {
|
||||
if b.PayloadDigest != "" && seen[b.PayloadDigest] {
|
||||
k := string(b.Data)
|
||||
if k != "" && seen[k] {
|
||||
continue
|
||||
}
|
||||
existing = append(existing, b)
|
||||
if b.PayloadDigest != "" {
|
||||
seen[b.PayloadDigest] = true
|
||||
if k != "" {
|
||||
seen[k] = true
|
||||
}
|
||||
}
|
||||
return existing
|
||||
@ -425,9 +387,9 @@ func (m *docMemoryImpl) Insert(d *Doc) error { return m.InsertWithMedia(d, nil)
|
||||
|
||||
// InsertWithMedia 写入文档并关联媒体。
|
||||
//
|
||||
// 媒体直接成为文档持有的一等记忆块:落进 CAS 拿到 digest,
|
||||
// 再变成块挂到文档上。不往正文写 marker——文档向量会融合这些块的
|
||||
// 媒体向量(同一统一空间),图片按自己的向量被召回。
|
||||
// 媒体直接成为文档持有的一等记忆块(字节在块的 Data)。
|
||||
// 不往正文写 marker——文档向量会融合这些块的媒体向量(同一统一空间),
|
||||
// 图片按自己的向量被召回。
|
||||
func (m *docMemoryImpl) InsertWithMedia(d *Doc, attachments []MediaAttachment) error {
|
||||
if m.ds == nil || d == nil {
|
||||
return nil
|
||||
@ -437,75 +399,25 @@ func (m *docMemoryImpl) InsertWithMedia(d *Doc, attachments []MediaAttachment) e
|
||||
target.Source = "plugin:" + m.plugin
|
||||
}
|
||||
|
||||
digests := m.storeAttachments(attachments)
|
||||
|
||||
// 一等记忆块:文档直接持有块本身,CAS 只提供字节与向量。
|
||||
target.Blocks = appendBlocks(target.Blocks, sdkBlocksFromDigests(m.ms, digests))
|
||||
target.Blocks = appendBlocks(target.Blocks,
|
||||
sdkBlocksFromAttachments(attachments, "plugin_doc:"+m.plugin))
|
||||
|
||||
if err := m.ds.Insert(target); err != nil {
|
||||
return err
|
||||
}
|
||||
// 回填给调用方:ID 是新建时内核生成的。
|
||||
d.ID = target.ID
|
||||
return nil
|
||||
}
|
||||
|
||||
// storeAttachments 把附件落库,返回全部完整 digest。
|
||||
func (m *docMemoryImpl) storeAttachments(atts []MediaAttachment) []string {
|
||||
if m.ms == nil || len(atts) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var digests []string
|
||||
for _, a := range atts {
|
||||
full, err := sdkPutAttachment(m.ms, a, "plugin_doc:"+m.plugin)
|
||||
if err != nil {
|
||||
// 媒体存不进去不该让文档写入失败——它是记忆增强,不是文档必需品
|
||||
log.Printf("[sdk media] 插件 %s 文档附件入库失败: %v", m.plugin, err)
|
||||
continue
|
||||
}
|
||||
if seen[full] {
|
||||
continue
|
||||
}
|
||||
seen[full] = true
|
||||
digests = append(digests, full)
|
||||
}
|
||||
return digests
|
||||
}
|
||||
|
||||
// Remove 删除文档,并删除它持有的一等块所对应的内容(无其他块共享时)。
|
||||
// Remove 删除文档。
|
||||
//
|
||||
// 与文本块一致:删除块即删除内容。媒体字节是块的内容存储,
|
||||
// 不单独做引用计数或 GC。
|
||||
// 块随文档一起消失,块里的字节也随之消失——无需引用计数或外部 GC:
|
||||
// 字节就在块里,块没了字节就没了。
|
||||
func (m *docMemoryImpl) Remove(id string) {
|
||||
if m.ds == nil {
|
||||
return
|
||||
}
|
||||
var digests []string
|
||||
if d := m.ds.Get(id); d != nil {
|
||||
for _, b := range d.Blocks {
|
||||
if b.PayloadDigest != "" {
|
||||
digests = append(digests, b.PayloadDigest)
|
||||
}
|
||||
}
|
||||
}
|
||||
m.ds.Remove(id)
|
||||
if m.ms == nil {
|
||||
return
|
||||
}
|
||||
// 仍被其它文档持有的 digest 不能删(同一份字节可能被多个块共享)。
|
||||
stillHeld := map[string]bool{}
|
||||
for _, b := range m.ds.Blocks() {
|
||||
stillHeld[b.PayloadDigest] = true
|
||||
}
|
||||
for _, d := range digests {
|
||||
if stillHeld[d] {
|
||||
continue
|
||||
}
|
||||
if err := m.ms.Delete(d); err != nil {
|
||||
log.Printf("[sdk media] 删除文档 %s 的内容失败 %s: %v", id, sdkShortDigest(d), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *docMemoryImpl) Stats() map[string]interface{} {
|
||||
|
||||
@ -65,8 +65,8 @@ func putMedia(t *testing.T, ms *media.Store, payload string) string {
|
||||
// 旧实现只搬 Subject/Relation/Object,其余字段静默丢弃:
|
||||
// 插件标注的类型全部落成默认 Concept,置信度全成 1.0,SentenceText 直接消失。
|
||||
func TestGraphCommit_CarriesAllFields(t *testing.T) {
|
||||
g, _, _, ms := newTestStores(t)
|
||||
m := NewGraphMemoryWithMedia("tester", g, ms)
|
||||
g, _, _, _ := newTestStores(t)
|
||||
m := NewGraphMemory("tester", g)
|
||||
|
||||
err := m.Commit([]Triple{{
|
||||
Subject: "张三",
|
||||
|
||||
Reference in New Issue
Block a user