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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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