refactor(memory): 拆除描述式媒体索引,媒体成为一等块并按原生向量融合

背景:此前媒体是靠「生成的描述文本」将就进记忆的——写 marker 进正文、
再由正则反解成 media_refs 与图库里的 type=Media 实体。这条链路有三个
致命缺陷:描述由异步模型生成(未生成前媒体等于不存在)、语义检索实质上
只搜描述文字、图库里的「媒体节点」是描述文本的投影而不是媒体本身。

本提交把这条链路整体拆除,媒体改为按自己的原生向量参与记忆:

一、描述链彻底删除(无残留、无兼容分支)
- media.Item 去掉 Description/DescribedBy 与对应列;
- 删除 Store.Describe / Store.Search / Store.Pending;
- 删除 Agent.mediaDescribeLoop / describePendingMedia 与配置项
  core.memory.media.describe_on_ingest;
- SDK 侧 MediaAttachment 去掉 Description(见 SDK 仓独立提交)。

二、marker 机制删除,媒体归属改为结构化块边
- 删除 mediaMarkerLine/parseMediaMarkers/mediaEntityName/mediaTriplesFromText/
  extractMediaDigests/sentenceWithMediaMarkers/docMediaContext;
- memory.Triple 新增 MediaDigests 结构化字段;句子文本保持原样,
  不再被 marker 污染;
- 块以 sentence --contains--> block / document --contains--> block 结构边
  挂到承载节点(新增 documents 表与 document 节点种类);
- 模型未给原句时用「主谓宾。」拼一句自然语言作落点,不造 marker 文本。

三、旧数据迁移(幂等)
- 新增 GraphDB.MigrateLegacyMediaEntities:把 type=Media 的旧实体按短 digest
  还原成原生块、挂回原句子、删除旧实体与描述关系;Agent 启动时执行;
- CleanupOrphanedSentences 同时看关系引用与块边,避免把只靠块存活的句子
  连同块边一起删掉。

四、向量融合:媒体按图本身被召回
- 新增 vector.FuseVectors(逐维求和 + L2 归一化);
- Doc.DenseVec = 文本向量 ⊕ 文档块的媒体向量(同 fingerprint 才融合),
  新增 Doc.DenseFP,指纹变化触发重算;
- ContextEvent.DenseVec 同理融合事件块;事件新增 DenseFP,Prune 只在
  同一统一空间内比稠密余弦;
- 跨模态视觉路只召回「仍被某层记忆块持有」的媒体,CAS 全库字节不再
  直接充当记忆检索结果。

五、同时纳入本分支既有的嵌入基础改造(此前工作区未提交,缺它 HEAD 不可构建)
- internal/tfidf 懒回退包、千问三段式多模态 ONNX 空间的 Go 侧
  (qwen/embedder.go、image.go、model_input.go)、CLIP 移除、
  sdk.NewStore 分词器签名与调用点、embed 侧车 systemd 单元。

验证:go build ./... 、go vet ./...(含 -tags medialive)均通过;
在 HEAD 的独立 worktree 上重放本次暂存集后 go test -short ./internal/...
全部通过(端口冲突类用例在隔离环境中亦通过)。未提交工作区中与本改造
无关的改动(HarmonyOS、waiter、devicebridge、plan.md 等)。
This commit is contained in:
JianFeeeee
2026-09-11 11:45:24 +08:00
parent dae01f9c06
commit 5836c2ce5c
56 changed files with 2686 additions and 2834 deletions

View File

@ -505,7 +505,6 @@ func main() {
SocialStore: socialStore, SocialStore: socialStore,
TextMemory: textMem, TextMemory: textMem,
MediaStore: mediaStore, MediaStore: mediaStore,
MediaDescribe: cfgReg.GetBool("core.memory.media.describe_on_ingest", false),
Personality: personality, Personality: personality,
PluginReg: pluginReg, PluginReg: pluginReg,
PluginDir: cfg.Plugin.Dir, PluginDir: cfg.Plugin.Dir,

View File

@ -0,0 +1,20 @@
[Unit]
Description=Jina v5-omni-nano Embedding Sidecar for HomeAgent
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/home/newqqagent
ExecStart=/usr/local/bin/python3 /home/program/TrueAgent/scripts/embed_sidecar.py
Restart=on-failure
RestartSec=5
Environment=JINA_MODEL_DIR=/home/newqqagent/models/jina-v5-omni-nano
Environment=JINA_PORT=18999
Environment=JINA_DIMENSION=768
Environment=OMP_NUM_THREADS=8
Environment=MKL_NUM_THREADS=8
Environment=TOKENIZERS_PARALLELISM=false
[Install]
WantedBy=multi-user.target

View File

@ -56,8 +56,6 @@ type Agent struct {
// 它是记忆块的内容存储,不单独做生命周期管理:块的创建/迁移/删除 // 它是记忆块的内容存储,不单独做生命周期管理:块的创建/迁移/删除
// 由记忆系统本身决定。为 nil 时全部媒体接线静默跳过。 // 由记忆系统本身决定。为 nil 时全部媒体接线静默跳过。
mediaStore *media.Store mediaStore *media.Store
// mediaDescribe 控制是否跑后台描述循环(要消耗视觉模型配额)。
mediaDescribe bool
// 人格设定 // 人格设定
personality *agentPkg.Personality personality *agentPkg.Personality
@ -182,7 +180,6 @@ type AgentConfig struct {
SocialStore *social.SocialStore SocialStore *social.SocialStore
TextMemory *text.Memory TextMemory *text.Memory
MediaStore *media.Store MediaStore *media.Store
MediaDescribe bool
MultimodalSpace vector.MultimodalEmbedder MultimodalSpace vector.MultimodalEmbedder
FusionCfg CrossModalFusionConfig // 跨模态融合权重;零值用默认 FusionCfg CrossModalFusionConfig // 跨模态融合权重;零值用默认
Personality *agentPkg.Personality Personality *agentPkg.Personality
@ -271,7 +268,6 @@ func New(cfg AgentConfig) *Agent {
social: cfg.SocialStore, social: cfg.SocialStore,
textMem: cfg.TextMemory, textMem: cfg.TextMemory,
mediaStore: cfg.MediaStore, mediaStore: cfg.MediaStore,
mediaDescribe: cfg.MediaDescribe,
personality: cfg.Personality, personality: cfg.Personality,
pluginReg: cfg.PluginReg, pluginReg: cfg.PluginReg,
pluginDir: cfg.PluginDir, pluginDir: cfg.PluginDir,
@ -307,8 +303,8 @@ func (a *Agent) Start() {
go a.archiveLoop() go a.archiveLoop()
go a.mergeLoop() go a.mergeLoop()
go a.reviewLoop() go a.reviewLoop()
go a.mediaDescribeLoop()
a.reembedStaleMedia() a.reembedStaleMedia()
a.migrateLegacyGraphMedia()
log.Printf("[agent] %s started, waiting for IO interrupts", a.id) log.Printf("[agent] %s started, waiting for IO interrupts", a.id)
} }

View File

@ -15,14 +15,14 @@ type mockOutputDevice struct {
toolFn func(string, map[string]interface{}) (interface{}, error) toolFn func(string, map[string]interface{}) (interface{}, error)
} }
func (d *mockOutputDevice) Name() string { return d.name } func (d *mockOutputDevice) Name() string { return d.name }
func (d *mockOutputDevice) Type() agentIO.DeviceType { return agentIO.DeviceOutput } func (d *mockOutputDevice) Type() agentIO.DeviceType { return agentIO.DeviceOutput }
func (d *mockOutputDevice) Description() string { return "mock " + d.name } func (d *mockOutputDevice) Description() string { return "mock " + d.name }
func (d *mockOutputDevice) Tools() []agentIO.ToolDef { return d.tools } func (d *mockOutputDevice) Tools() []agentIO.ToolDef { return d.tools }
func (d *mockOutputDevice) Start() error { return nil } func (d *mockOutputDevice) Start() error { return nil }
func (d *mockOutputDevice) Stop() error { return nil } func (d *mockOutputDevice) Stop() error { return nil }
func (d *mockOutputDevice) OutputCapabilities() agentIO.OutputCapability { return d.caps } func (d *mockOutputDevice) OutputCapabilities() agentIO.OutputCapability { return d.caps }
func (d *mockOutputDevice) ChannelDef() agentIO.ChannelDef { return agentIO.ChannelDef{} } func (d *mockOutputDevice) ChannelDef() agentIO.ChannelDef { return agentIO.ChannelDef{} }
func (d *mockOutputDevice) Execute(tool string, args map[string]interface{}) (interface{}, error) { func (d *mockOutputDevice) Execute(tool string, args map[string]interface{}) (interface{}, error) {
if d.toolFn != nil { if d.toolFn != nil {
return d.toolFn(tool, args) return d.toolFn(tool, args)
@ -67,8 +67,8 @@ func TestExecuteOutputSendTool(t *testing.T) {
"type": "text", "type": "text",
}} }}
result := a.executeOutputSendTool(tc) result := a.executeOutputSendTool(tc)
if !strings.Contains(result, "screen") { if result != "ok" {
t.Errorf("unexpected result: %s", result) t.Errorf("expected ok, got: %s", result)
} }
} }

View File

@ -38,6 +38,7 @@ type ContextEvent struct {
Blocks []memory.MemoryBlock `json:"blocks,omitempty"` // 一等记忆块(text/image/video/audio) Blocks []memory.MemoryBlock `json:"blocks,omitempty"` // 一等记忆块(text/image/video/audio)
Vector vector.Vector `json:"-"` // 稀疏词向量(TF-IDF/fastText 空间) Vector vector.Vector `json:"-"` // 稀疏词向量(TF-IDF/fastText 空间)
DenseVec []float64 `json:"-"` // 稠密多模态向量(与媒体/文档共享空间) DenseVec []float64 `json:"-"` // 稠密多模态向量(与媒体/文档共享空间)
DenseFP string `json:"-"` // DenseVec 所属统一空间指纹(缓存字段,不持久化)
} }
const contextFlushInterval = 5 * time.Second const contextFlushInterval = 5 * time.Second
@ -196,16 +197,29 @@ func (c *RelevanceContext) channelCleanerForDoc() document.ChannelCleaner {
func (c *RelevanceContext) computeVector(evt *ContextEvent) { func (c *RelevanceContext) computeVector(evt *ContextEvent) {
text := textForVector(evt, c.toolDefLookup, c.channelDefLookup) text := textForVector(evt, c.toolDefLookup, c.channelDefLookup)
if text == "" {
return
}
// 稀疏向量始终计算(TF-IDF/fastText,退化时仍可用) // 稀疏向量始终计算(TF-IDF/fastText,退化时仍可用)
evt.Vector = c.embedder.Vectorize(text) if text != "" {
// 稠密向量仅在配置了多模态空间时计算 evt.Vector = c.embedder.Vectorize(text)
}
// 稠密向量:文本向量 ⊕ 本事件持有的一等记忆块媒体向量(同一统一空间)。
// 只有媒体的输入(无文本)也要有可比较的坐标,因此不再按 text=="" 提前返回。
if c.denseSpace != nil && c.denseSpace.Loaded() { if c.denseSpace != nil && c.denseSpace.Loaded() {
if dv, err := c.denseSpace.VectorizeDense(text); err == nil { fp := c.denseSpace.Fingerprint()
evt.DenseVec = dv var parts [][]float64
if text != "" {
if dv, err := c.denseSpace.VectorizeDense(text); err == nil && len(dv) > 0 {
parts = append(parts, dv)
}
} }
for _, b := range evt.Blocks {
// 只融合同指纹的块向量:另一套坐标系的向量混进来会算出
// 两边都不像的方向。
if len(b.Vector) > 0 && b.Fingerprint == fp {
parts = append(parts, b.Vector)
}
}
evt.DenseVec = vector.FuseVectors(parts...)
evt.DenseFP = fp
} }
} }
@ -312,9 +326,11 @@ func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *docume
// 优先使用稠密向量余弦(与媒体/文档共享空间);退化到稀疏词向量。 // 优先使用稠密向量余弦(与媒体/文档共享空间);退化到稀疏词向量。
var queryDense []float64 var queryDense []float64
useDense := false useDense := false
queryFP := ""
if c.denseSpace != nil && c.denseSpace.Loaded() { if c.denseSpace != nil && c.denseSpace.Loaded() {
if dv, err := c.denseSpace.VectorizeDense(currentInput); err == nil { if dv, err := c.denseSpace.VectorizeDense(currentInput); err == nil {
queryDense = dv queryDense = dv
queryFP = c.denseSpace.Fingerprint()
useDense = true useDense = true
} }
} }
@ -323,7 +339,9 @@ func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *docume
scoredEvents := make([]scoredEvent, len(candidates)) scoredEvents := make([]scoredEvent, len(candidates))
for i, evt := range candidates { for i, evt := range candidates {
var score float64 var score float64
if useDense && len(evt.DenseVec) == len(queryDense) { // 只在同一统一空间内比稠密余弦:换了模型/维度后旧事件的向量
// 属于另一个坐标系,拿来比会得到无意义的分数。
if useDense && evt.DenseFP == queryFP && len(evt.DenseVec) == len(queryDense) {
score = vector.DenseCosine(queryDense, evt.DenseVec) score = vector.DenseCosine(queryDense, evt.DenseVec)
} else { } else {
score = vector.CosineSimilarity(queryVec, evt.Vector) score = vector.CosineSimilarity(queryVec, evt.Vector)

View File

@ -12,8 +12,8 @@ import (
// CrossModalHit 是跨模态检索融合后的一条候选。 // CrossModalHit 是跨模态检索融合后的一条候选。
// //
// 统一的检索单元是记忆块而非 CAS 全库:媒体在 L0/L2/L3 都和文本一样有 owner, // 统一的检索单元是记忆块而非 CAS 全库:媒体在 L0/L2/L3 都由层容器持有,
// 只有仍被某层记忆引用的媒体才可召回。Doc 是 L2 文档块;Media 是该块携带的 // 只有仍被某层记忆块持有的媒体才可召回。Doc 是 L2 文档;Media 是该块携带的
// 原生媒体坐标。两路分数尺度不同,融合前各自归一化,见 fuseCrossModal。 // 原生媒体坐标。两路分数尺度不同,融合前各自归一化,见 fuseCrossModal。
type CrossModalHit struct { type CrossModalHit struct {
Doc *document.Doc // 文本路命中的文档;视觉路命中时为 nil Doc *document.Doc // 文本路命中的文档;视觉路命中时为 nil
@ -67,10 +67,10 @@ func (c CrossModalFusionConfig) minMaxEps() float64 {
// retrieveCrossModal 是跨模态并行检索的统一入口。 // retrieveCrossModal 是跨模态并行检索的统一入口。
// //
// 策略(两路并行,召回真正最相似的): // 策略(两路并行,召回真正最相似的):
// 1. 文本路:query 整段文本用现有方法(fastText/TF-IDF 稀疏 cosine)查文档层, // 1. 文本路:query 整段文本编码后查文档层(Doc.DenseVec 已融合其块的媒体向量),
// 每个命中文档再反查其关联媒体(docMediaContext)——描述文本命中即媒体命中。 // 命中文档若持有媒体块,直接带上该块。
// 2. 视觉路:query 整段文本经多模态模型文本编码 → 与媒体库全部图像坐标比余弦 // 2. 视觉路:query 经多模态模型文本编码 → 与媒体块向量比余弦
// (QueryMediaScored),覆盖描述文本没写到的视觉内容。 // (QueryMediaScored),覆盖文本向量没写到的视觉内容。
// 3. 融合:两条路候选各自 min-max 归一化到 [0,1],加权求和后降序,取 topK。 // 3. 融合:两条路候选各自 min-max 归一化到 [0,1],加权求和后降序,取 topK。
// 同一媒体被两路同时命中视为双信号确认,额外加权。 // 同一媒体被两路同时命中视为双信号确认,额外加权。
// //
@ -90,8 +90,7 @@ func (a *Agent) retrieveCrossModal(query string, topK int, cfg CrossModalFusionC
if a.docStore != nil { if a.docStore != nil {
for _, dh := range a.docStore.QueryScored(query, per) { for _, dh := range a.docStore.QueryScored(query, per) {
hit := CrossModalHit{Doc: dh.Doc, DocScore: dh.Score} hit := CrossModalHit{Doc: dh.Doc, DocScore: dh.Score}
// 命中文档若持有一等记忆块,把首个媒体块一并带上: // 命中文档若持有一等记忆块,把首个媒体块一并带上。
// 描述文本命中 → 该媒体就是相关记忆,供后续展示/注入。
if a.mediaStore != nil && len(dh.Doc.Blocks) > 0 { if a.mediaStore != nil && len(dh.Doc.Blocks) > 0 {
if it, err := a.mediaStore.Stat(dh.Doc.Blocks[0].PayloadDigest); err == nil { if it, err := a.mediaStore.Stat(dh.Doc.Blocks[0].PayloadDigest); err == nil {
hit.Media = it hit.Media = it
@ -110,7 +109,13 @@ func (a *Agent) retrieveCrossModal(query string, topK int, cfg CrossModalFusionC
} else if mh, err := a.mediaStore.QueryMediaScored(qv, a.multimodalSpace.Fingerprint(), per); err != nil { } else if mh, err := a.mediaStore.QueryMediaScored(qv, a.multimodalSpace.Fingerprint(), per); err != nil {
log.Printf("[crossmodal] 媒体记忆检索失败: %v", err) log.Printf("[crossmodal] 媒体记忆检索失败: %v", err)
} else { } else {
// 只有仍被某层记忆块持有的媒体才可召回:CAS 是全库字节存储,
// 直接拿它的检索结果会把已无处可归的内容也从记忆里翻出来。
held := a.heldMediaDigests()
for _, h := range mh { for _, h := range mh {
if h.Item == nil || !held[h.Item.Digest] {
continue
}
visualHits = append(visualHits, CrossModalHit{ visualHits = append(visualHits, CrossModalHit{
Media: h.Item, MediaScore: h.Score, Media: h.Item, MediaScore: h.Score,
}) })
@ -228,8 +233,7 @@ func fuseCrossModal(textHits, visualHits []CrossModalHit, topK int, cfg CrossMod
} }
// crossModalMarkdown 把融合候选渲染成注入上下文的文本。 // crossModalMarkdown 把融合候选渲染成注入上下文的文本。
// 文档行对齐既有【相关记忆文档】格式;媒体行复用 mediaMarkerLine 的 // 文档行给出摘要;媒体行只给 MIME + 短 digest(不再有生成的描述)。
// `[<mime> <短digest>] <描述>` 格式(那是解析回媒体引用的唯一合法格式)。
func (a *Agent) crossModalMarkdown(hits []CrossModalHit) string { func (a *Agent) crossModalMarkdown(hits []CrossModalHit) string {
if len(hits) == 0 { if len(hits) == 0 {
return "" return ""
@ -253,7 +257,7 @@ func (a *Agent) crossModalMarkdown(hits []CrossModalHit) string {
} }
} }
if h.Media != nil { if h.Media != nil {
if line := a.mediaMarkerLine(h.Media.Digest); line != "" { if line := mediaLabel(h.Media); line != "" {
parts = append(parts, line) parts = append(parts, line)
} }
} }

View File

@ -204,8 +204,15 @@ func (a *Agent) archiveColdDocs() {
} }
log.Printf("[agent] doc→graph: %s → %d entities, %d relations, %d blocks", doc.ID, ec, rc, blocks) log.Printf("[agent] doc→graph: %s → %d entities, %d relations, %d blocks", doc.ID, ec, rc, blocks)
// 文档的一等记忆块已随句子写进 L3(身份不变,由 bindSentenceBlocks // 文档持有的一等块写入 L3,并以 document --contains--> block 边关联;
// 复用 doc.Blocks 的 ID);块不再挂在文档上,删除文档即完成迁移。 // 块 ID 原样保留(迁移而非重建)。块迁走后删除文档即完成迁移。
if len(doc.Blocks) > 0 {
if bound := a.linkBlocksToDocument(doc.ID, doc.Blocks); bound != len(doc.Blocks) {
log.Printf("[agent] doc→graph: %s 块迁移不完整 (%d/%d),保留文档待下轮重试",
doc.ID, bound, len(doc.Blocks))
continue
}
}
a.docStore.Remove(doc.ID) a.docStore.Remove(doc.ID)
} }
} }
@ -430,14 +437,9 @@ func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple {
}) })
} }
// 媒体三元组:确定性产出,先于 NLP 提取。 // 媒体不再参与三元组:它作为一等块由 linkBlocksToDocument
// // 写入 L3 并以 document --contains--> block 边关联,
// 媒体入 L3 曾完全依赖提取器碰巧从描述文本里提出合规三元组——实测 // 不经过文本描述与 NLP 提取器。
// LLM 的 477 字图片描述只产出「水平 -分割-> 成」这类语法碎片,
// obj 仅 1 字被 validEntityName 拒掉,整条媒体记忆就进不了图库
//(阶段性表现是"时好时坏",取决于提取器运气)。媒体自身的
// digest / mime / 描述都是确定的,直接建三元组而不经提取器。
triples = append(triples, mediaTriplesFromText(doc.Content)...)
// NLP 通用提取 // NLP 通用提取
e := nlp.NewExtractor(nil) e := nlp.NewExtractor(nil)

View File

@ -437,9 +437,6 @@ func (a *Agent) processInput(evt *agentIO.InputEvent) {
ToolResults: toolResults, ToolResults: toolResults,
} }
a.bindEventMedia(&turnEvt, a.drainMediaDigests()) a.bindEventMedia(&turnEvt, a.drainMediaDigests())
if s := a.mediaSummaryForEvent(turnEvt.Blocks); s != "" {
turnEvt.Input = turnEvt.Input + "\n" + s
}
a.context.Append(turnEvt) a.context.Append(turnEvt)
a.emitResponse(evt, response) a.emitResponse(evt, response)

View File

@ -3,321 +3,146 @@ package core
import ( import (
"fmt" "fmt"
"log" "log"
"regexp"
"strconv" "strconv"
"strings" "strings"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory" "gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
) )
// L3 图库的媒体绑定。 // L3 图库的媒体绑定。
// //
// 媒体在 L3 是一等记忆块(memory_blocks),通过 sentence --contains--> block // 媒体在 L3 是一等记忆块(memory_blocks),以结构边与承载它的节点相连:
// 结构边与承载它的句子相连。不再用 media_refs / owner 账本保活。 // sentence --contains--> block(对话/三元组产生的记忆)
// document --contains--> block(L2 文档归档进 L3)
// //
// 图库里的实体与关系仍来自描述文本的 NLP 提取;媒体块只是补上 // 这里不再有任何 marker 文本、正则反解或"描述文本当索引"的路径:
// 「这条记忆当时带着哪份媒体」这一结构信息。 // 媒体只按自己的统一空间向量被检索,图库/文档只记录它的结构归属。
// mediaDigestPattern 匹配事件摘要里的媒体标记 [<mime或kind> <短digest>]。 // migrateLegacyGraphMedia 把 marker 反解出来的旧媒体实体迁移成原生一等块。
// //
// 与 mediaSummaryForEvent 的输出格式对应。短 digest 是 12 位十六进制 // 旧数据里媒体是 type=Media 的普通实体(「图片 a1b2c3d4e5f6」),
// (shortDigest 的截断长度),这里放宽到 8-64 位以容忍将来调整截断长度, // 靠生成的描述文本当索引。迁移后它变成真正的记忆块,以
// 以及有人手写了完整 digest 的情况。 // sentence --contains--> block 结构边挂回原句子,旧实体与描述关系删除。
var mediaDigestPattern = regexp.MustCompile(`\[[^\[\]]*?\b([0-9a-f]{8,64})\]`) // 迁移幂等(实体处理完即删除),因此在每个 Agent 启动时跑一次是安全的。
func (a *Agent) migrateLegacyGraphMedia() {
// mediaMarkerPattern 完整拆解一条媒体标记及其后跟的描述, if a.memory == nil || a.mediaStore == nil {
// 捕获组依次为:标签(mime 或 kind)、短 digest、该行剩余的描述文本。 return
//
// 与 mediaSummaryForEvent 的输出格式严格对应:
//
// [image/png a1b2c3d4e5f6] 一张紫蓝红三色带图
//
// 描述取到行尾而非贪婪到底:一条事件可能挂多个媒体,各占一行。
var mediaMarkerPattern = regexp.MustCompile(`\[([^\[\]\s]+)\s+([0-9a-f]{8,64})\]([^\n]*)`)
// mediaMarker 是从文档正文里解析出的一条媒体标记。
type mediaMarker struct {
label string // mime 或 kind,如 image/png
shortDigest string
description string
raw string // 原始整段,用作三元组的 SentenceText
}
// parseMediaMarkers 从文本里解析全部媒体标记。
//
// 为何需要它而不只是 extractMediaDigests:媒体入 L3 曾完全依赖 NLP 提取器
// 碰巧从描述文本里提出合规三元组——实测 LLM 的 477 字图片描述只产出
// 「水平 -分割-> 成」这种语法碎片,obj 仅 1 字被 validEntityName 拒掉,
// 于是整条媒体记忆进不了图库。而媒体自身的信息(digest / mime / 描述)
// 是确定的,不该受提取器运气支配。
func parseMediaMarkers(text string) []mediaMarker {
if text == "" {
return nil
} }
ms := mediaMarkerPattern.FindAllStringSubmatch(text, -1) blocks, entities, err := a.memory.MigrateLegacyMediaEntities(func(short string) (memory.MemoryBlock, bool) {
if len(ms) == 0 { full, err := a.mediaStore.ResolvePrefix(short)
return nil if err != nil {
} return memory.MemoryBlock{}, false
seen := make(map[string]bool, len(ms))
var out []mediaMarker
for _, m := range ms {
d := m[2]
if seen[d] {
continue
} }
seen[d] = true return a.blockFromDigest(full)
out = append(out, mediaMarker{ })
label: m[1], if err != nil {
shortDigest: d, log.Printf("[media] 旧媒体实体迁移失败(下轮重试): %v", err)
description: strings.TrimSpace(m[3]), return
raw: strings.TrimSpace(m[0]), }
}) if blocks > 0 || entities > 0 {
log.Printf("[media] 旧媒体实体迁移完成: 新建 %d 个原生块,删除 %d 个描述式实体", blocks, entities)
} }
return out
} }
// mediaEntityName 是媒体在图库里的实体名。 // attachBlocksToSentence 把一组 digest 变成 L3 一等块并挂到句子上。
// // seed 允许复用已持有块的 ID(L2→L3 迁移保持块身份不变)。
// 形如「图片 a1b2c3d4e5f6」。刻意用 digest 而非描述文本构成名字: func (a *Agent) attachBlocksToSentence(sentenceID int64, digests []string, seed map[string]memory.MemoryBlock) int {
// 描述会被重新生成(换视觉模型、补描述),若名字取自描述,同一张图 if a.mediaStore == nil || a.memory == nil || sentenceID == 0 {
// 就会在图谱上留下多个节点。digest 不变则名字不变。
// 长度也天然合规(validEntityName 要求 2–50 字符)。
func mediaEntityName(label, shortDigest string) string {
kind := "媒体"
switch {
case strings.HasPrefix(label, "image"):
kind = "图片"
case strings.HasPrefix(label, "audio"):
kind = "音频"
case strings.HasPrefix(label, "video"):
kind = "视频"
}
return kind + " " + shortDigest
}
// mediaTriplesFromText 为文本里的每条媒体标记产出确定的三元组。
//
// 这是媒体进 L3 的可靠路径:不经过 NLP 提取器,因此不受它对描述性文本
// 提取能力的影响。每条媒体至少产出一条「<媒体实体> -内容-> <描述摘要>」,
// 且 SentenceText 用原始标记段,保证 bindSentenceMedia 的正则必然能
// 反解到 digest——绑定从概率事件变成确定行为。
//
// 描述摘要截到 40 字:validEntityName 上限 50 字符,留出余量;
// 图谱节点名过长会让可视化和实体合并都难以处理,完整描述留在
// SentenceText 与 media 表里。
func mediaTriplesFromText(text string) []memory.Triple {
markers := parseMediaMarkers(text)
if len(markers) == 0 {
return nil
}
var out []memory.Triple
for _, m := range markers {
name := mediaEntityName(m.label, m.shortDigest)
// 类型三元组恒可产出,不依赖描述是否存在
out = append(out, memory.Triple{
Subject: name,
SubjectType: "Media",
Relation: "类型",
Object: m.label,
ObjectType: "MimeType",
Confidence: 1.0,
SentenceText: m.raw,
})
desc := summarizeForEntity(m.description, 40)
if desc == "" {
continue
}
out = append(out, memory.Triple{
Subject: name,
SubjectType: "Media",
Relation: "内容",
Object: desc,
ObjectType: "Description",
Confidence: 1.0,
SentenceText: m.raw,
})
}
return out
}
// summarizeForEntity 把描述压成可作实体名的短串。
//
// 取首个句子边界之前的内容,再按 rune 截断——直接按字节截会切坏 UTF-8,
// 图库里就会出现乱码实体名。空白与 Markdown 强调符号一并清掉,
// 否则「**整体构成**」这类标记会进实体名。
func summarizeForEntity(s string, maxRunes int) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
s = strings.NewReplacer("**", "", "*", "", "\n", " ", "\t", " ").Replace(s)
for _, sep := range []string{"。", ";", ",", ". ", "; "} {
if i := strings.Index(s, sep); i > 0 {
s = s[:i]
break
}
}
s = strings.TrimSpace(s)
r := []rune(s)
if len(r) > maxRunes {
r = r[:maxRunes]
}
out := strings.TrimSpace(string(r))
// 太短的残片(如单字)过不了 validEntityName,直接放弃比写进去更好
if len([]rune(out)) < 2 {
return ""
}
return out
}
// extractMediaDigests 从文本里找出所有媒体标记的 digest。
//
// 为何靠正则从文本反解,而不是让三元组结构携带 digest:三元组是 NLP
// 提取器从纯文本产出的(nlp.ToMemoryTriple 只填 Subject/Relation/Object/
// Confidence/SentenceText),提取链路上没有任何位置能塞进结构化的 digest。
// 若要贯通就得改 internal/nlp 的整条数据流——而媒体标记本身就是我们
// 自己按固定格式写进文本的,反解是这里最省的可靠做法。
func extractMediaDigests(text string) []string {
if text == "" {
return nil
}
matches := mediaDigestPattern.FindAllStringSubmatch(text, -1)
if len(matches) == 0 {
return nil
}
seen := make(map[string]bool, len(matches))
var out []string
for _, m := range matches {
d := m[1]
if seen[d] {
continue
}
seen[d] = true
out = append(out, d)
}
return out
}
// bindSentenceBlocks 把句子文本里提到的媒体变成 L3 的一等记忆块,
// 并建立 sentence --contains--> block 结构边。
//
// seed 是本批文档已持有的一等块:迁移时按 digest 复用它们的身份(ID 不变),
// 真正做到“同一个块从 L2 移到 L3”,而不是另建一个同内容的新块。
// 返回本次写入 L3 的块数。
func (a *Agent) bindSentenceBlocks(sentenceIDs map[string]int64, seed []memory.MemoryBlock) int {
if a.mediaStore == nil || a.memory == nil || len(sentenceIDs) == 0 {
return 0 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]
if !ok {
if b, ok = a.blockFromDigest(full); !ok {
continue
}
}
if err := a.memory.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil {
log.Printf("[media] L3 块写入失败 (%s): %v", shortDigest(full), err)
continue
}
if err := a.memory.AddMemoryBlockEdge("sentence", strconv.FormatInt(sentenceID, 10), "block", b.ID, "contains"); err != nil {
log.Printf("[media] 句子→块边建立失败 (%s): %v", shortDigest(full), err)
continue
}
bound++
}
return bound
}
// linkBlocksToDocument 把文档持有的块写入 L3,并建立
// document --contains--> block 边。块的 ID 原样保留(迁移而非重建)。
func (a *Agent) linkBlocksToDocument(docID string, blocks []memory.MemoryBlock) int {
if a.memory == nil || docID == "" || len(blocks) == 0 {
return 0
}
if err := a.memory.PutDocumentNode(docID, ""); err != nil {
log.Printf("[media] 写入 L3 文档节点失败 (%s): %v", docID, err)
return 0
}
if err := a.memory.PutMemoryBlocks(blocks); err != nil {
log.Printf("[media] 写入 L3 记忆块失败 (doc %s): %v", docID, err)
return 0
}
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)
continue
}
bound++
}
return bound
}
// commitTriplesWithMedia 提交三元组并把三元组显式携带的媒体变成 L3 一等块。
//
// seed 是调用方已持有的一等块(如 L2 文档的 Blocks),用于保持块身份;
// 普通对话路径传 nil。blocks 是本次写入 L3 的块数。
func (a *Agent) commitTriplesWithMedia(triples []memory.Triple, sessionID string, turnID int, seed []memory.MemoryBlock) (entities, relations, blocks int, err error) {
if a.memory == nil {
return 0, 0, 0, fmt.Errorf("graph memory 未启用")
}
if a.mediaStore == nil {
ec, rc, cErr := a.memory.Commit(triples, sessionID, turnID)
return ec, rc, 0, cErr
}
sentenceIDs, ec, rc, err := a.memory.CommitWithMedia(triples, sessionID, turnID)
if err != nil {
return ec, rc, 0, err
}
byDigest := make(map[string]memory.MemoryBlock, len(seed)) byDigest := make(map[string]memory.MemoryBlock, len(seed))
for _, b := range seed { for _, b := range seed {
if b.PayloadDigest != "" { if b.PayloadDigest != "" {
byDigest[b.PayloadDigest] = b byDigest[b.PayloadDigest] = b
} }
} }
for _, t := range triples {
bound := 0 if len(t.MediaDigests) == 0 {
for text, sid := range sentenceIDs { continue
}
sid := sentenceIDs[t.SentenceText]
if sid == 0 { if sid == 0 {
continue continue
} }
for _, short := range extractMediaDigests(text) { blocks += a.attachBlocksToSentence(sid, t.MediaDigests, byDigest)
full, err := a.mediaStore.ResolvePrefix(short)
if err != nil {
continue
}
b, ok := byDigest[full]
if !ok {
if b, ok = a.blockFromDigest(full); !ok {
continue
}
}
if err := a.memory.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil {
log.Printf("[media] L3 记忆块写入失败 (%s): %v", shortDigest(full), err)
continue
}
if err := a.memory.AddMemoryBlockEdge("sentence", strconv.FormatInt(sid, 10), "block", b.ID, "contains"); err != nil {
log.Printf("[media] 句子→块边建立失败 (%s): %v", shortDigest(full), err)
continue
}
bound++
}
} }
if bound > 0 { return ec, rc, blocks, nil
log.Printf("[media] L3 图库写入 %d 个一等记忆块", bound)
}
return bound
} }
// sentenceWithMediaMarkers 保证句子文本里带上这些 digest 的媒体标记。 // RecallBlocksForSentence 反查某条图库句子持有的一等记忆块。
// func (a *Agent) RecallBlocksForSentence(sentenceID int64) ([]memory.MemoryBlock, error) {
// 存在的理由:L3 的块边由句子正文里的短 digest 反解而来。模型只知道 if a.memory == nil {
// digest(从 memory_recall 的「关联媒体」或对话里的媒体标记读到), return nil, nil
// 不该要求它自己按内核格式拼标记——格式写错的后果是块边静默建不起来。
//
// 已出现过的 digest 不重复追加:模型可能既写了标记又填了 media_digests。
func (a *Agent) sentenceWithMediaMarkers(sentence string, digests []string) string {
if a.mediaStore == nil || len(digests) == 0 {
return sentence
} }
present := make(map[string]bool) return a.memory.BlocksForNode("sentence", strconv.FormatInt(sentenceID, 10))
for _, d := range extractMediaDigests(sentence) {
present[d] = true
}
var add []string
for _, d := range digests {
if d == "" || present[shortDigest(d)] {
continue
}
// 模型给的多半是短 digest(它在上下文里看到的就是短的),补全成完整
// digest 才能定位内容。补不上就跳过:内容可能已被删除。
full, err := a.mediaStore.ResolvePrefix(d)
if err != nil {
log.Printf("[media] 模型提交的 digest %s 无法解析: %v", d, err)
continue
}
if line := a.mediaMarkerLine(full); line != "" {
add = append(add, line)
present[shortDigest(full)] = true
}
}
if len(add) == 0 {
return sentence
}
if sentence == "" {
return strings.Join(add, "\n")
}
return sentence + "\n" + strings.Join(add, "\n")
}
// docMediaContext 为一篇文档产出媒体说明,供 doc_query 拼进工具返回值。
//
// 文档的一等记忆块随文档 JSON 持久化;这里只有正文,因此从正文标记反解。
func (a *Agent) docMediaContext(docID, content string) string {
if a.mediaStore == nil {
return ""
}
// 文档的一等记忆块随文档 JSON 持久化;这里只有正文,退回解析标记。
var digests []string
for _, short := range extractMediaDigests(content) {
full, err := a.mediaStore.ResolvePrefix(short)
if err != nil {
continue
}
digests = append(digests, full)
}
var lines []string
for _, d := range digests {
if line := a.mediaMarkerLine(d); line != "" {
lines = append(lines, line)
}
}
if len(lines) == 0 {
return ""
}
return strings.Join(lines, ";")
} }
// resolveMediaDigests 把模型给的(多为短)digest 补全成完整 digest。 // resolveMediaDigests 把模型给的(多为短)digest 补全成完整 digest。
@ -344,38 +169,6 @@ func (a *Agent) resolveMediaDigests(digests []string) []string {
return out return out
} }
// bindDocMedia 把一组完整 digest 挂到文档 owner 上,返回成功条数。
//
// commitTriplesWithMedia 提交三元组并把句子里的媒体变成 L3 一等块。
//
// seed 是调用方已持有的一等块(如 L2 文档的 Blocks),用于保持块身份;
// 普通对话路径传 nil。blocks 是本次写入 L3 的块数。
func (a *Agent) commitTriplesWithMedia(triples []memory.Triple, sessionID string, turnID int, seed []memory.MemoryBlock) (entities, relations, blocks int, err error) {
if a.memory == nil {
return 0, 0, 0, fmt.Errorf("graph memory 未启用")
}
// 媒体存储关闭时退回普通 Commit,省掉 sentenceIDs 的 map 分配。
if a.mediaStore == nil {
ec, rc, cErr := a.memory.Commit(triples, sessionID, turnID)
return ec, rc, 0, cErr
}
sentenceIDs, ec, rc, err := a.memory.CommitWithMedia(triples, sessionID, turnID)
if err != nil {
return ec, rc, 0, err
}
return ec, rc, a.bindSentenceBlocks(sentenceIDs, seed), nil
}
// RecallBlocksForSentence 反查某条图库句子持有的一等记忆块。
//
// 这是整层的目的:几个月后从图谱走到一条句子,要能取回当时那份媒体。
func (a *Agent) RecallBlocksForSentence(sentenceID int64) ([]memory.MemoryBlock, error) {
if a.memory == nil {
return nil, nil
}
return a.memory.BlocksForNode("sentence", strconv.FormatInt(sentenceID, 10))
}
// sentenceIDsFromRelations 收集一批关系引用的句子 id(去重、去零)。 // sentenceIDsFromRelations 收集一批关系引用的句子 id(去重、去零)。
// //
// 关系行本身不持有媒体,媒体作为一等块以 sentence --contains--> block // 关系行本身不持有媒体,媒体作为一等块以 sentence --contains--> block
@ -397,21 +190,14 @@ func sentenceIDsFromRelations(relations []memory.Relation) []int64 {
} }
// mediaContextForRelations 是 mediaContextForSentences 的关系入口。 // mediaContextForRelations 是 mediaContextForSentences 的关系入口。
//
// 单独包一层是因为两个调用点(自动注入的 buildMemoryContext 与显式的
// memory_recall 工具)拿到的都是关系列表,不该各自重复"关系→句子"这步。
func (a *Agent) mediaContextForRelations(relations []memory.Relation) string { func (a *Agent) mediaContextForRelations(relations []memory.Relation) string {
return a.mediaContextForSentences(sentenceIDsFromRelations(relations)) return a.mediaContextForSentences(sentenceIDsFromRelations(relations))
} }
// mediaContextForInjectedEntities 为自动注入路径产出媒体说明。 // mediaContextForInjectedEntities 为自动注入路径产出媒体说明。
// //
// 单独一条路径是因为 Indexer.BuildContext 刻意不返回关系 // Indexer.BuildContext 刻意不返回关系(只给实体索引以省 token),
// (Relations 恒为 nil,只给实体索引以省 token,细节留给 memory_recall)。 // 因此这里用命中的实体名再查一次关系,只为拿到 sentence_id。
// 于是自动注入拿不到 sentence_id,必须用命中的实体名再查一次关系。
//
// 这次额外查询只为取 sentence_id,深度固定 1:媒体是"这条记忆当时带的图",
// 不需要顺着关系network 扩散——扩散只会带出无关媒体并挤占 token。
func (a *Agent) mediaContextForInjectedEntities(injected *memory.InjectedContext) string { func (a *Agent) mediaContextForInjectedEntities(injected *memory.InjectedContext) string {
if a.mediaStore == nil || a.memory == nil || injected == nil || len(injected.Entities) == 0 { if a.mediaStore == nil || a.memory == nil || injected == nil || len(injected.Entities) == 0 {
return "" return ""
@ -427,13 +213,30 @@ func (a *Agent) mediaContextForInjectedEntities(injected *memory.InjectedContext
return a.mediaContextForRelations(res.Relations) return a.mediaContextForRelations(res.Relations)
} }
// mediaContextForSentences 给一组句子附上媒体说明,供召回时拼进提示词。 // blockLabelsForDoc 渲染文档持有块的标签(MIME + 短 digest),供 doc_query 展示。
func (a *Agent) blockLabelsForDoc(d *document.Doc) string {
if a.mediaStore == nil || 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 != "" {
parts = append(parts, line)
}
}
return strings.Join(parts, ";")
}
// mediaContextForSentences 给一组句子附上其持有的一等块标签。
// //
// 输出形如「句子 #12 关联媒体:[image/png a1b2c3d4e5f6] 一张紫蓝红三色带图」。 // 标签只含 MIME 与短 digest:图片按向量检索,标签的作用是告诉模型
// 描述文本本就在句子里,这里补的是「内容是否还在、能否重新看图」这个信息—— // "这条记忆当时带着哪份媒体、可用该 digest 取回字节"。
// 描述永存而字节可能已被淘汰,两者状态不同。
func (a *Agent) mediaContextForSentences(sentenceIDs []int64) string { func (a *Agent) mediaContextForSentences(sentenceIDs []int64) string {
if a.memory == nil || len(sentenceIDs) == 0 { if a.mediaStore == nil || a.memory == nil || len(sentenceIDs) == 0 {
return "" return ""
} }
var lines []string var lines []string
@ -444,7 +247,11 @@ func (a *Agent) mediaContextForSentences(sentenceIDs []int64) string {
} }
var parts []string var parts []string
for _, b := range blocks { for _, b := range blocks {
if line := a.mediaMarkerLine(b.PayloadDigest); line != "" { it, err := a.mediaStore.Stat(b.PayloadDigest)
if err != nil || it == nil {
continue
}
if line := mediaLabel(it); line != "" {
parts = append(parts, line) parts = append(parts, line)
} }
} }

View File

@ -13,14 +13,18 @@ import (
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
) )
// L3 图库媒体引用测试。 // L3 图库媒体绑定测试。
// //
// 这一层的目的只有一个:几个月后从图谱走到一条句子,要能取回当时那份媒体。 // 这一层的目的只有一个:几个月后从图谱走到一条句子,要能取回当时那份媒体。
// 媒体不再靠 media_refs 挂载,而是作为一等块进入 L3,并以 // 媒体作为一等块进入 L3,以结构边与承载节点相连:
// sentence --contains--> block 的结构边与句子相连。 //
// sentence --contains--> block(对话/三元组产生的记忆)
// document --contains--> block(L2 文档归档进 L3)
//
// 描述文本、marker 反解、由 marker 反推出的「媒体实体」全部已废弃,
// 因此这些测试也不存在任何按描述检索的断言。
// attachBlockToSentence 提交一条句子,把媒体变成 L3 一等块,并以 // attachBlockToSentence 提交一条句子并把媒体变成 L3 一等块。
// sentence --contains--> block 相连,返回句子 id 与块。
// 必须走真实提交:边要求两端都是真实图节点。 // 必须走真实提交:边要求两端都是真实图节点。
func attachBlockToSentence(t *testing.T, g *memory.GraphDB, ms *media.Store, sentenceText, digest string) (int64, memory.MemoryBlock) { func attachBlockToSentence(t *testing.T, g *memory.GraphDB, ms *media.Store, sentenceText, digest string) (int64, memory.MemoryBlock) {
t.Helper() t.Helper()
@ -77,41 +81,10 @@ func newGraphMediaAgent(t *testing.T) (*Agent, *memory.GraphDB, *media.Store) {
return &Agent{memory: g, mediaStore: ms}, g, ms return &Agent{memory: g, mediaStore: ms}, g, ms
} }
func TestExtractMediaDigests(t *testing.T) {
// 与 mediaSummaryForEvent 的输出格式对应
cases := []struct {
name string
text string
want []string
}{
{"事件摘要格式", "媒体内容:\n[image/png a1b2c3d4e5f6] 一张紫蓝红三色带图", []string{"a1b2c3d4e5f6"}},
{"kind 兜底格式", "[image abcdef0123456789] (未描述)", []string{"abcdef0123456789"}},
{"一句多个", "[image aaaaaaaaaaaa] 图一;[image bbbbbbbbbbbb] 图二", []string{"aaaaaaaaaaaa", "bbbbbbbbbbbb"}},
{"去重", "[image cccccccccccc] x [image/png cccccccccccc] y", []string{"cccccccccccc"}},
{"无标记", "普通句子,没有媒体", nil},
{"空串", "", nil},
// 非十六进制、过短的方括号内容不能误命中,否则会拿一个假前缀去 ResolvePrefix
{"非 digest 方括号", "[注意] 这是普通标注 [TODO]", nil},
{"过短", "[image abc] 太短", nil},
}
for _, c := range cases {
got := extractMediaDigests(c.text)
if len(got) != len(c.want) {
t.Fatalf("%s: 得到 %v,期望 %v", c.name, got, c.want)
}
for i := range got {
if got[i] != c.want[i] {
t.Fatalf("%s: 第 %d 个得到 %q,期望 %q", c.name, i, got[i], c.want[i])
}
}
}
}
func TestCommitWithMedia_ReturnsSentenceIDs(t *testing.T) { func TestCommitWithMedia_ReturnsSentenceIDs(t *testing.T) {
_, g, _ := newGraphMediaAgent(t) _, g, _ := newGraphMediaAgent(t)
sentence := "[image/png a1b2c3d4e5f6] 一张紫蓝红三色带图" sentence := "这张图是紫蓝红三色带。"
triples := []memory.Triple{{ triples := []memory.Triple{{
Subject: "图片", Relation: "内容", Object: "三色带", Subject: "图片", Relation: "内容", Object: "三色带",
SentenceText: sentence, SentenceText: sentence,
@ -164,7 +137,7 @@ func TestCommit_StillWorksAfterRefactor(t *testing.T) {
} }
} }
func TestBindSentenceMedia_RoundTrip(t *testing.T) { func TestCommitTriplesWithMedia_RoundTrip(t *testing.T) {
// 整层的核心断言:写入 → 提交 → 反查取回原始字节 // 整层的核心断言:写入 → 提交 → 反查取回原始字节
a, _, ms := newGraphMediaAgent(t) a, _, ms := newGraphMediaAgent(t)
@ -173,18 +146,20 @@ func TestBindSentenceMedia_RoundTrip(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
short := shortDigest(digest)
sentence := "[image/png " + short + "] 一张紫蓝红三色带图" sentence := "用户发来一张紫蓝红三色带图。"
triples := []memory.Triple{{ triples := []memory.Triple{{
Subject: "图片", Relation: "内容", Object: "三色带", SentenceText: sentence, Subject: "图片", Relation: "内容", Object: "三色带",
SentenceText: sentence,
MediaDigests: []string{digest[:12]}, // 模型手里通常只有短 digest
}} }}
if _, _, _, err := a.commitTriplesWithMedia(triples, "s1", 0, nil); err != nil { if _, _, bound, err := a.commitTriplesWithMedia(triples, "s1", 0, nil); err != nil {
t.Fatal(err) t.Fatal(err)
} else if bound != 1 {
t.Fatalf("应绑定 1 个块,实际 %d", bound)
} }
// 找到句子 id
ids, _, _, err := a.memory.CommitWithMedia(triples, "s1", 0) ids, _, _, err := a.memory.CommitWithMedia(triples, "s1", 0)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@ -216,31 +191,83 @@ func TestBindSentenceMedia_RoundTrip(t *testing.T) {
} }
} }
func TestBindSentenceMedia_SkipsUnresolvable(t *testing.T) { func TestAttachBlocksToSentence_SkipsUnresolvable(t *testing.T) {
// 文本里的 digest 在库里不存在时必须跳过,不能建一条指向虚无的块边。 // digest 在库里不存在时必须跳过,不能建一条指向虚无的块边。
a, g, _ := newGraphMediaAgent(t) a, g, _ := newGraphMediaAgent(t)
if n := a.attachBlocksToSentence(42, []string{"deadbeefdead"}, nil); n != 0 {
sentence := "[image/png deadbeefdead] 一张不存在的图" t.Fatalf("无法补全的 digest 不该建块,实际绑定 %d", n)
ids := map[string]int64{sentence: 42} }
a.bindSentenceBlocks(ids, nil)
blocks, err := g.BlocksForNode("sentence", "42") blocks, err := g.BlocksForNode("sentence", "42")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(blocks) != 0 { if len(blocks) != 0 {
t.Fatalf("无法补全的 digest 不该建块,实际 %+v", blocks) t.Fatalf("不该有块,实际 %+v", blocks)
} }
} }
func TestBindSentenceMedia_NilStoreNoop(t *testing.T) { func TestAttachBlocksToSentence_NilStoreNoop(t *testing.T) {
a := &Agent{} a := &Agent{}
a.bindSentenceBlocks(map[string]int64{"[image aaaaaaaaaaaa] x": 1}, nil) if n := a.attachBlocksToSentence(1, []string{"aaaaaaaaaaaa"}, nil); n != 0 {
t.Fatalf("媒体关闭时应静默无操作,实际 %d", n)
}
if got, err := a.RecallBlocksForSentence(1); err != nil || got != nil { if got, err := a.RecallBlocksForSentence(1); err != nil || got != nil {
t.Fatalf("媒体关闭时应静默无操作,实际 %v / %v", got, err) t.Fatalf("媒体关闭时应静默无操作,实际 %v / %v", got, err)
} }
} }
func TestAttachBlocksToSentence_ReusesSeedIdentity(t *testing.T) {
// L2→L3 迁移必须保持块身份:同一个块换层,而不是另建一个同内容的新块。
a, g, ms := newGraphMediaAgent(t)
digest, _ := ms.Put([]byte("seed-img"), media.Item{MIME: "image/png"})
seedBlock, ok := a.blockFromDigest(digest)
if !ok {
t.Fatal("blockFromDigest 失败")
}
ids, _, _, err := g.CommitWithMedia([]memory.Triple{{
Subject: "迁移", Relation: "包含", Object: "媒体", SentenceText: "迁移测试句。",
}}, "seed", 0)
if err != nil {
t.Fatal(err)
}
sid := ids["迁移测试句。"]
byDigest := map[string]memory.MemoryBlock{digest: seedBlock}
if n := a.attachBlocksToSentence(sid, []string{digest}, byDigest); n != 1 {
t.Fatalf("应绑定 1 个块,实际 %d", n)
}
blocks, err := g.BlocksForNode("sentence", strconv.FormatInt(sid, 10))
if err != nil {
t.Fatal(err)
}
if len(blocks) != 1 || blocks[0].ID != seedBlock.ID {
t.Fatalf("块身份应保持为 %s,实际 %+v", seedBlock.ID, blocks)
}
}
func TestLinkBlocksToDocument_CreatesDocumentNodeEdge(t *testing.T) {
// 文档归档进 L3:块原样迁入,document --contains--> block 边建立。
a, g, ms := newGraphMediaAgent(t)
digest, _ := ms.Put([]byte("doc-img"), media.Item{MIME: "image/png"})
b, ok := a.blockFromDigest(digest)
if !ok {
t.Fatal("blockFromDigest 失败")
}
if n := a.linkBlocksToDocument("doc_42", []memory.MemoryBlock{b}); n != 1 {
t.Fatalf("应建立 1 条文档→块边,实际 %d", n)
}
blocks, err := g.BlocksForNode("document", "doc_42")
if err != nil {
t.Fatal(err)
}
if len(blocks) != 1 || blocks[0].ID != b.ID {
t.Fatalf("文档应持有块 %s,实际 %+v", b.ID, blocks)
}
}
func TestCommitTriplesWithMedia_FallsBackWithoutStore(t *testing.T) { func TestCommitTriplesWithMedia_FallsBackWithoutStore(t *testing.T) {
// 媒体关闭时退回普通 Commit,行为与直接调 Commit 完全一致 // 媒体关闭时退回普通 Commit,行为与直接调 Commit 完全一致
dir := t.TempDir() dir := t.TempDir()
@ -262,73 +289,105 @@ func TestCommitTriplesWithMedia_FallsBackWithoutStore(t *testing.T) {
} }
} }
func TestMediaBlocksHeldByDocumentSurviveGC(t *testing.T) {
// 文档持有的一等块把内容钉住;文档被删后块随之消失,内容才可回收。
a, g, ms := newGraphMediaAgent(t)
_ = a
digest, err := ms.Put([]byte("doc image"), media.Item{MIME: "image/png"})
if err != nil {
t.Fatal(err)
}
dir := t.TempDir()
ds := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
if err := ds.Start(); err != nil {
t.Fatal(err)
}
defer ds.Stop()
it, _ := ms.Stat(digest)
doc := &document.Doc{
ID: "doc_1", Summary: "带图的文档", Content: "正文",
Blocks: []memory.MemoryBlock{{ID: "blk_doc_1", Modality: memory.BlockImage,
PayloadDigest: it.Digest, MIME: it.MIME, Size: it.Size}},
}
if err := ds.Insert(doc); err != nil {
t.Fatal(err)
}
_ = g
// 文档仍持有块 → 内容在
if _, err := ms.Stat(digest); err != nil {
t.Fatal("有文档块持有内容时不该被清")
}
// 删除文档 → 一并删除其内容(与文本块一致:删块即删内容)
ds.Remove(doc.ID)
if blocks := ds.Blocks(); len(blocks) != 0 {
t.Fatalf("删除文档后不该还有块,实际 %+v", blocks)
}
if err := ms.Delete(digest); err != nil {
t.Fatal(err)
}
if _, err := ms.Stat(digest); err == nil {
t.Fatal("删除后内容应已移除")
}
}
func TestMediaContextForSentences(t *testing.T) { func TestMediaContextForSentences(t *testing.T) {
a, g, ms := newGraphMediaAgent(t) a, g, ms := newGraphMediaAgent(t)
digest, _ := ms.Put([]byte("img"), media.Item{MIME: "image/png"}) digest, _ := ms.Put([]byte("img"), media.Item{MIME: "image/png"})
if err := ms.Describe(digest, "一张紫蓝红三色带图", "visionllm"); err != nil { sid, _ := attachBlockToSentence(t, g, ms, "一张紫蓝红三色带图。", digest)
t.Fatal(err)
}
sid, _ := attachBlockToSentence(t, g, ms, "[image/png "+shortDigest(digest)+"] 一张紫蓝红三色带图", digest)
out := a.mediaContextForSentences([]int64{sid, sid + 100}) out := a.mediaContextForSentences([]int64{sid, sid + 100})
if out == "" { if out == "" {
t.Fatal("应产出媒体说明") t.Fatal("应产出媒体说明")
} }
if !contains(out, fmt.Sprintf("句子 #%d", sid)) || !contains(out, "一张紫蓝红三色带图") { if !contains(out, fmt.Sprintf("句子 #%d", sid)) || !contains(out, shortDigest(digest)) {
t.Fatalf("说明内容不对: %q", out) t.Fatalf("说明内容不对: %q", out)
} }
// 说明只含 MIME 与短 digest,不含任何生成的描述
if contains(out, "紫蓝红") {
t.Fatalf("说明里不该有描述文本(描述式索引已废弃): %q", out)
}
// 无引用的句子不该出现 // 无引用的句子不该出现
if contains(out, fmt.Sprintf("句子 #%d", sid+100)) { if contains(out, fmt.Sprintf("句子 #%d", sid+100)) {
t.Fatalf("无引用的句子不该出现: %q", out) t.Fatalf("无引用的句子不该出现: %q", out)
} }
} }
func TestMediaContextForRelations_SurfacesMediaToAgent(t *testing.T) {
// L3 检索接线回归:媒体作为一等块进了图库,agent 必须拿得出来。
a, g, ms := newGraphMediaAgent(t)
digest, err := ms.Put([]byte("img bytes"), media.Item{MIME: "image/png"})
if err != nil {
t.Fatal(err)
}
sid, _ := attachBlockToSentence(t, g, ms, "一张紫蓝红三色带图。", digest)
// 命中的关系挂着该句子 → 应产出媒体说明
out := a.mediaContextForRelations([]memory.Relation{{ID: 1, SentenceID: sid}})
if out == "" {
t.Fatal("关系挂着有媒体的句子,却没产出媒体说明——L3 检索接线断了")
}
if !contains(out, shortDigest(digest)) {
t.Errorf("媒体说明里应含短 digest 供反查: %q", out)
}
// 没挂媒体的关系不该产出噪声
if out := a.mediaContextForRelations([]memory.Relation{{ID: 2, SentenceID: 99}}); out != "" {
t.Errorf("无媒体的句子不该产出说明: %q", out)
}
if out := a.mediaContextForRelations(nil); out != "" {
t.Errorf("空关系不该产出说明: %q", out)
}
}
func TestBuildMemoryContext_IncludesMediaSection(t *testing.T) {
// buildMemoryContext 是自动注入路径(每次 LLM 调用都走)。
// 媒体说明必须出现在这里,否则 agent 只有显式调 memory_recall 才知道有图。
a, graph, ms := newGraphMediaAgent(t)
digest, err := ms.Put([]byte("auto inject"), media.Item{MIME: "image/png"})
if err != nil {
t.Fatal(err)
}
sentence := "用户发来的图片。"
sids, _, _, err := graph.CommitWithMedia([]memory.Triple{{
Subject: "测试图片", Relation: "包含", Object: "三色带", SentenceText: sentence,
}}, "auto", 0)
if err != nil {
t.Fatal(err)
}
sid := sids[sentence]
if sid == 0 {
t.Fatal("拿不到句子 id")
}
if err := graph.PutMemoryBlocks([]memory.MemoryBlock{{
ID: "blk_auto_1", Modality: memory.BlockImage,
PayloadDigest: digest, MIME: "image/png",
}}); err != nil {
t.Fatal(err)
}
if err := graph.AddMemoryBlockEdge("sentence", strconv.FormatInt(sid, 10), "block", "blk_auto_1", "contains"); err != nil {
t.Fatal(err)
}
a.indexer = memory.NewIndexer(graph)
if err := a.indexer.Sync(); err != nil {
t.Fatalf("indexer sync: %v", err)
}
out := a.buildMemoryContext("测试图片", 0)
if out == "" {
t.Skip("图库召回未命中(indexer 检索策略所致),无法验证媒体段注入")
}
if !contains(out, "【关联媒体】") {
t.Errorf("自动注入的记忆上下文缺少媒体段: %q", out)
}
if !contains(out, shortDigest(digest)) {
t.Errorf("媒体段里应含短 digest: %q", out)
}
}
func TestResolvePrefix(t *testing.T) { func TestResolvePrefix(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
ms, err := media.New(filepath.Join(dir, "m")) ms, err := media.New(filepath.Join(dir, "m"))
@ -357,20 +416,17 @@ func TestResolvePrefix(t *testing.T) {
if _, err := ms.ResolvePrefix("deadbeefdead"); err == nil { if _, err := ms.ResolvePrefix("deadbeefdead"); err == nil {
t.Fatal("不存在的前缀应报错") t.Fatal("不存在的前缀应报错")
} }
// 完整但不存在的 digest 也要报错,否则调用方会挂一条孤儿引用 // 完整但不存在的 digest 也要报错,否则调用方会挂一条孤儿块
fake := "" fake := strings.Repeat("0", 64)
for i := 0; i < 64; i++ {
fake += "0"
}
if _, err := ms.ResolvePrefix(fake); err == nil { if _, err := ms.ResolvePrefix(fake); err == nil {
t.Fatal("不存在的完整 digest 应报错") t.Fatal("不存在的完整 digest 应报错")
} }
} }
func TestResolvePrefix_AmbiguityIsError(t *testing.T) { func TestResolvePrefix_AmbiguityIsError(t *testing.T) {
// 前缀歧义视为错误而非"取第一个":挂错引用会让 GC 删掉仍被引用的内容。 // 前缀歧义视为错误而非"取第一个":挂错块会让内容被误删。
// 构造歧义需要两个同前缀 digest——sha256 无法人为构造, // 构造歧义需要两个同前缀 digest——sha256 无法人为构造,
// 因此这里退而验证「8 位前缀在大量样本下的行为是确定的」: // 因此这里退而验证「12 位前缀在大量样本下的行为是确定的」:
// 要么唯一命中,要么明确报歧义,绝不静默取第一个。 // 要么唯一命中,要么明确报歧义,绝不静默取第一个。
dir := t.TempDir() dir := t.TempDir()
ms, err := media.New(filepath.Join(dir, "m")) ms, err := media.New(filepath.Join(dir, "m"))
@ -391,7 +447,6 @@ func TestResolvePrefix_AmbiguityIsError(t *testing.T) {
for _, d := range digests { for _, d := range digests {
got, err := ms.ResolvePrefix(d[:12]) got, err := ms.ResolvePrefix(d[:12])
if err != nil { if err != nil {
// 报歧义是可接受结果;静默取错才是缺陷
if !contains(err.Error(), "歧义") { if !contains(err.Error(), "歧义") {
t.Fatalf("非歧义错误: %v", err) t.Fatalf("非歧义错误: %v", err)
} }
@ -405,11 +460,7 @@ func TestResolvePrefix_AmbiguityIsError(t *testing.T) {
func TestArchiveColdDocs_KeepsDocWhenGraphWriteEmpty(t *testing.T) { func TestArchiveColdDocs_KeepsDocWhenGraphWriteEmpty(t *testing.T) {
// 数据丢失回归:三元组全被实体名校验拒绝时(Commit 无错但 0 entities // 数据丢失回归:三元组全被实体名校验拒绝时(Commit 无错但 0 entities
// 0 relations),文档不能删、媒体引用不能释放。 // 0 relations),文档不能删、其持有的块不能丢。
//
// 该缺陷曾真实发生:LLM 生成的 456 字图片描述提不出合规实体名
//(validEntityName 要求 2–50 字符),archiveColdDocs 只检查
// len(triples) > 0 就释放引用并删文档 → GC 清掉 blob → 图片与描述全丢。
a, _, ms := newGraphMediaAgent(t) a, _, ms := newGraphMediaAgent(t)
dir := t.TempDir() dir := t.TempDir()
@ -427,25 +478,11 @@ func TestArchiveColdDocs_KeepsDocWhenGraphWriteEmpty(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
// 精确构造「三元组非空 + Commit 全部拒绝」这个状态。 // 精确构造「三元组非空 + Commit 全部拒绝」这个状态:
// // Source/Summary 都超过 validEntityName 的 50 字符上限,
// 用超长 Source 而不是指望 NLP 提取器:docToTriples 在 // 于是 docToTriples 产出的两条元数据三元组都被跳过。
// Source != "context_archived" 时会写一条 {文档 -来源-> Source}, longSource := strings.Repeat("超长来源名", 20) // 100 字
// Source 超过 validEntityName 的 50 字符上限 → Commit 静默跳过 longSummary := strings.Repeat("超长摘要文本", 20) // >80 字触发长度门槛被跳过
// → len(triples)==1 但 ec=0 rc=0。构造是确定的,不依赖提取器的
// 具体行为(提取器行为随版本变化,测试不该押在它身上)。
//
// 正文里刻意**不放**媒体标记:mediaTriplesFromText 会为标记产出
// 合规的「图片 <digest>」三元组,那样 ec/rc 就不为 0,这个用例
// 也就测不到「全被拒绝」这个状态了。媒体引用直接用 AddRef 挂上,
// 模拟「文档持有媒体但正文的媒体标记已在清洗中丢失」这一情形——
// 那正是最危险的组合:有引用要释放,却没有句子能承载它。
longSource := strings.Repeat("超长来源名", 20) // 100 字,远超 50 字符上限
// Summary 也必须超长:docToTriples 会为合理 summary 写一条
// {文档 -主题-> summary},那条能通过校验,ec/rc 就不为 0 了。
// 这里要的是「三元组全部被拒」这一个状态。
longSummary := strings.Repeat("超长摘要文本", 20) // >80 字,触发长度门槛被跳过
// 文档持有的一等块(模拟“文档有媒体但正文标记已在清洗中丢失”)。
it, _ := ms.Stat(digest) it, _ := ms.Stat(digest)
doc := &document.Doc{ doc := &document.Doc{
ID: "doc_keep", ID: "doc_keep",
@ -485,50 +522,183 @@ func TestArchiveColdDocs_KeepsDocWhenGraphWriteEmpty(t *testing.T) {
if !held { if !held {
t.Error("文档或块被释放了——图库没有句子承载它,内容会被删除") t.Error("文档或块被释放了——图库没有句子承载它,内容会被删除")
} }
if _, err := ms.Stat(digest); err != nil { }
t.Fatalf("未归档成功时内容不该被删: %v", err)
func TestArchiveColdDocs_MigratesBlocksToGraph(t *testing.T) {
// 归档成功时块必须迁进 L3 并以 document --contains--> block 关联,
// 然后文档才被删除(迁移而非复制/引用保活)。
a, g, ms := newGraphMediaAgent(t)
dir := t.TempDir()
ds := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
if err := ds.Start(); err != nil {
t.Fatal(err)
}
defer ds.Stop()
a.docStore = ds
a.embedder = memory.NewStaticEmbedder()
digest, _ := ms.Put([]byte("archived-image"), media.Item{MIME: "image/png"})
it, _ := ms.Stat(digest)
doc := &document.Doc{
ID: "doc_arch",
Summary: "带图的冷文档",
Content: "张三把三色带图交给了李四。",
Source: "manual",
Blocks: []memory.MemoryBlock{{ID: "blk_arch_1", Modality: memory.BlockImage,
PayloadDigest: it.Digest, MIME: it.MIME, Size: it.Size}},
}
if err := ds.Insert(doc); err != nil {
t.Fatal(err)
}
for _, d := range ds.RecentDocs(10) {
if d.ID == doc.ID {
d.LastAccess = time.Now().Add(-200 * time.Hour)
d.AccessCount = 0
}
}
a.archiveColdDocs()
if d := ds.Get("doc_arch"); d != nil {
t.Fatal("块已迁入 L3,文档应被删除")
}
blocks, err := g.BlocksForNode("document", "doc_arch")
if err != nil {
t.Fatal(err)
}
if len(blocks) != 1 || blocks[0].ID != "blk_arch_1" {
t.Fatalf("L3 文档节点应持有原块(身份不变),实际 %+v", blocks)
}
if _, err := ms.Get(digest); err != nil {
t.Fatalf("块被 L3 持有,内容应仍可读: %v", err)
} }
} }
func TestCommitTriplesWithMedia_ReportsBoundCount(t *testing.T) { func TestMigrateLegacyMediaEntities(t *testing.T) {
// mediaBound 必须反映真实绑定数:归档路径靠它决定能否释放旧引用。 // 旧数据:媒体被伪装成 type=Media 的实体,靠描述文本当索引。
a, _, ms := newGraphMediaAgent(t) // 迁移必须把它还原成原生块(挂回原句子)并删掉旧实体与描述关系。
_, g, ms := newGraphMediaAgent(t)
digest, err := ms.Put([]byte("img"), media.Item{MIME: "image/png"}) digest, _ := ms.Put([]byte("legacy-img"), media.Item{MIME: "image/png"})
sentence := "老数据里的三色带图 [image/png " + digest[:12] + "]"
// 直接构造旧的实体/关系形态(不走已删除的 marker 代码)。
ids, _, _, err := g.CommitWithMedia([]memory.Triple{{
Subject: "图片 " + digest[:12],
SubjectType: "Media",
Relation: "内容",
Object: "三色带的描述文本",
ObjectType: "Description",
SentenceText: sentence,
}}, "legacy", 0)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
short := shortDigest(digest) sid := ids[sentence]
if sid == 0 {
t.Fatal("拿不到句子 id")
}
// 句子含可反解的短 digest → 应绑定 1 个 blocks, entities, err := g.MigrateLegacyMediaEntities(func(short string) (memory.MemoryBlock, bool) {
_, _, bound, err := a.commitTriplesWithMedia([]memory.Triple{{ full, err := ms.ResolvePrefix(short)
Subject: "图片", Relation: "内容", Object: "三色带", if err != nil {
SentenceText: "[image/png " + short + "] 一张三色带图", return memory.MemoryBlock{}, false
}}, "s1", 0, nil) }
it, err := ms.Stat(full)
if err != nil {
return memory.MemoryBlock{}, false
}
return memory.MemoryBlock{
ID: "blk_legacy_" + short, Modality: memory.BlockImage,
PayloadDigest: it.Digest, MIME: it.MIME, Size: it.Size,
}, true
})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if bound != 1 { if blocks != 1 || entities != 1 {
t.Fatalf("应绑定 1 个媒体引用,实际 %d", bound) t.Fatalf("应迁移 1 块 / 删 1 实体,实际 %d / %d", blocks, entities)
} }
// 句子无 digest → 绑定 0 个 // 旧媒体实体与描述关系必须消失
_, _, bound2, err := a.commitTriplesWithMedia([]memory.Triple{{ res, err := g.Recall([]string{"图片 " + digest[:12]}, nil, 2, "")
Subject: "张三", Relation: "喜欢", Object: "咖啡",
SentenceText: "张三喜欢咖啡",
}}, "s2", 0, nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if bound2 != 0 { for _, e := range res.Entities {
t.Fatalf("无媒体标记的句子不该绑定引用,实际 %d", bound2) if e.Type == "Media" {
t.Fatalf("旧媒体实体仍存在: %+v", e)
}
}
// 块必须挂回原句子
got, err := g.BlocksForNode("sentence", strconv.FormatInt(sid, 10))
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].PayloadDigest != digest {
t.Fatalf("句子应持有原生块,实际 %+v", got)
}
// 幂等:再跑一遍不应重复建块
blocks2, entities2, err := g.MigrateLegacyMediaEntities(nil)
if err != nil {
t.Fatal(err)
}
if blocks2 != 0 || entities2 != 0 {
t.Fatalf("无 resolver 时应空操作,实际 %d / %d", blocks2, entities2)
}
}
func TestCleanupOrphanedSentences_KeepsBlockBackedSentences(t *testing.T) {
// 旧媒体实体被删除后,承载它的句子可能再无关系引用,
// 但它还挂着媒体块——清理孤儿句子时不能把它删掉。
a, g, ms := newGraphMediaAgent(t)
digest, _ := ms.Put([]byte("orphan-img"), media.Item{MIME: "image/png"})
sentence := "只靠媒体块存活的句子。"
ids, _, _, err := g.CommitWithMedia([]memory.Triple{{
Subject: "媒体载体", Relation: "包含", Object: "内容", SentenceText: sentence,
}}, "orphan", 0)
if err != nil {
t.Fatal(err)
}
sid := ids[sentence]
b, ok := a.blockFromDigest(digest)
if !ok {
t.Fatal("blockFromDigest 失败")
}
if err := g.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil {
t.Fatal(err)
}
if err := g.AddMemoryBlockEdge("sentence", strconv.FormatInt(sid, 10), "block", b.ID, "contains"); err != nil {
t.Fatal(err)
}
// 解除关系引用,句子只剩块边
res, err := g.Recall([]string{"媒体载体"}, nil, 2, "")
if err != nil {
t.Fatal(err)
}
for _, r := range res.Relations {
if err := g.ClearSentenceID(r.ID); err != nil {
t.Fatal(err)
}
}
if _, err := g.CleanupOrphanedSentences(); err != nil {
t.Fatal(err)
}
blocks, err := g.BlocksForNode("sentence", strconv.FormatInt(sid, 10))
if err != nil {
t.Fatal(err)
}
if len(blocks) != 1 {
t.Fatalf("承载媒体块的句子被误删,块反查失败: %+v", blocks)
} }
} }
func TestSentenceIDsFromRelations(t *testing.T) { func TestSentenceIDsFromRelations(t *testing.T) {
// 关系行不持有媒体,媒体挂在句子上。这个函数负责"关系→句子"这一跳, // 关系行不持有媒体,媒体挂在句子上。这个函数负责"关系→句子"这一跳,
// 去重与去零都不能少:sentence_id=0 表示该关系没有关联句子, // 去重与去零都不能少:sentence_id=0 表示该关系没有关联句子。
// 拿 0 去查 media_refs 会命中一个不存在的 owner。
rels := []memory.Relation{ rels := []memory.Relation{
{ID: 1, SentenceID: 5}, {ID: 1, SentenceID: 5},
{ID: 2, SentenceID: 0}, // 无句子 {ID: 2, SentenceID: 0}, // 无句子
@ -547,199 +717,45 @@ func TestSentenceIDsFromRelations(t *testing.T) {
} }
} }
func TestMediaContextForRelations_SurfacesMediaToAgent(t *testing.T) { func TestMediaBlocksHeldByDocumentSurviveDeletion(t *testing.T) {
// L3 检索接线回归:媒体描述进了图库,agent 必须拿得出来。 // 文档持有的一等块把内容钉住;文档被删后块随之消失,内容才可回收。
// _, _, ms := newGraphMediaAgent(t)
// 第四层做完了"存和反查的能力"(RecallMediaForSentence /
// mediaContextForSentences),但那两个函数一度没有任何调用方——
// 媒体能进 L3,进去之后 agent 检索不到。这个测试守住那条接线。
a, g, ms := newGraphMediaAgent(t)
digest, err := ms.Put([]byte("img bytes"), media.Item{MIME: "image/png"}) digest, err := ms.Put([]byte("doc image"), media.Item{MIME: "image/png"})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := ms.Describe(digest, "一张紫蓝红三色带图", "visionllm"); err != nil { dir := t.TempDir()
ds := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
if err := ds.Start(); err != nil {
t.Fatal(err) t.Fatal(err)
} }
sid, _ := attachBlockToSentence(t, g, ms, "[image/png "+shortDigest(digest)+"] 一张紫蓝红三色带图", digest) defer ds.Stop()
// 命中的关系挂着该句子 → 应产出媒体说明 it, _ := ms.Stat(digest)
out := a.mediaContextForRelations([]memory.Relation{{ID: 1, SentenceID: sid}}) doc := &document.Doc{
if out == "" { ID: "doc_1", Summary: "带图的文档", Content: "正文",
t.Fatal("关系挂着有媒体的句子,却没产出媒体说明——L3 检索接线断了") Blocks: []memory.MemoryBlock{{ID: "blk_doc_1", Modality: memory.BlockImage,
PayloadDigest: it.Digest, MIME: it.MIME, Size: it.Size}},
} }
if !contains(out, "一张紫蓝红三色带图") { if err := ds.Insert(doc); err != nil {
t.Errorf("媒体说明里应含描述文本: %q", out)
}
if !contains(out, shortDigest(digest)) {
t.Errorf("媒体说明里应含短 digest 供反查: %q", out)
}
// 没挂媒体的关系不该产出噪声
if out := a.mediaContextForRelations([]memory.Relation{{ID: 2, SentenceID: 99}}); out != "" {
t.Errorf("无媒体的句子不该产出说明: %q", out)
}
if out := a.mediaContextForRelations(nil); out != "" {
t.Errorf("空关系不该产出说明: %q", out)
}
}
func TestBuildMemoryContext_IncludesMediaSection(t *testing.T) {
// buildMemoryContext 是自动注入路径(每次 LLM 调用都走)。
// 媒体说明必须出现在这里,否则 agent 只有显式调 memory_recall 才知道有图。
a, graph, ms := newGraphMediaAgent(t)
digest, err := ms.Put([]byte("auto inject"), media.Item{MIME: "image/png"})
if err != nil {
t.Fatal(err)
}
if err := ms.Describe(digest, "自动注入用的测试图", "visionllm"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
sentence := "用户发来的图片 [image/png " + shortDigest(digest) + "] 自动注入用的测试图" // 文档仍持有块 → 内容在
sids, _, _, err := graph.CommitWithMedia([]memory.Triple{{ if _, err := ms.Stat(digest); err != nil {
Subject: "测试图片", Relation: "包含", Object: "三色带", SentenceText: sentence, t.Fatal("有文档块持有内容时不该被清")
}}, "auto", 0) }
if err != nil {
// 删除文档 → 一并删除其内容(与文本块一致:删块即删内容)
ds.Remove(doc.ID)
if blocks := ds.Blocks(); len(blocks) != 0 {
t.Fatalf("删除文档后不该还有块,实际 %+v", blocks)
}
if err := ms.Delete(digest); err != nil {
t.Fatal(err) t.Fatal(err)
} }
sid := sids[sentence] if _, err := ms.Stat(digest); err == nil {
if sid == 0 { t.Fatal("删除后内容应已移除")
t.Fatal("拿不到句子 id")
}
attachBlockToSentence(t, graph, ms, sentence, digest)
a.indexer = memory.NewIndexer(graph)
if err := a.indexer.Sync(); err != nil {
t.Fatalf("indexer sync: %v", err)
}
out := a.buildMemoryContext("测试图片", 0)
if out == "" {
t.Skip("图库召回未命中(indexer 检索策略所致),无法验证媒体段注入")
}
if !contains(out, "【关联媒体】") {
t.Errorf("自动注入的记忆上下文缺少媒体段: %q", out)
}
if !contains(out, "自动注入用的测试图") {
t.Errorf("媒体段里应含描述文本: %q", out)
}
}
func TestParseMediaMarkers(t *testing.T) {
// 与 mediaSummaryForEvent 的输出格式严格对应
text := "用户发来图片\n媒体内容:\n" +
"[image/png a1b2c3d4e5f6] 一张紫蓝红三色带图\n" +
"[audio/wav bbbbccccdddd] 一段三秒的钢琴声\n" +
"[image/png a1b2c3d4e5f6] 重复的同一张图"
ms := parseMediaMarkers(text)
if len(ms) != 2 {
t.Fatalf("应解析出 2 条去重后的标记,实际 %d: %+v", len(ms), ms)
}
if ms[0].label != "image/png" || ms[0].shortDigest != "a1b2c3d4e5f6" {
t.Errorf("第一条解析错误: %+v", ms[0])
}
if ms[0].description != "一张紫蓝红三色带图" {
t.Errorf("描述应取到行尾且不跨行: %q", ms[0].description)
}
if ms[1].label != "audio/wav" {
t.Errorf("第二条 label 错误: %+v", ms[1])
}
// raw 用作 SentenceText,必须含 digest 才能被 bindSentenceMedia 反解
if !contains(ms[0].raw, "a1b2c3d4e5f6") {
t.Errorf("raw 必须含 digest: %q", ms[0].raw)
}
if n := parseMediaMarkers("没有任何标记的普通文本"); n != nil {
t.Errorf("无标记应返回 nil,实际 %+v", n)
}
}
func TestMediaEntityName(t *testing.T) {
// 实体名必须由 digest 而非描述构成:描述会被重新生成,
// 若名字取自描述,同一张图会在图谱上留下多个节点。
cases := []struct{ label, digest, want string }{
{"image/png", "a1b2c3d4e5f6", "图片 a1b2c3d4e5f6"},
{"audio/wav", "bbbbccccdddd", "音频 bbbbccccdddd"},
{"video/mp4", "ccccddddeeee", "视频 ccccddddeeee"},
{"application/octet-stream", "ddddeeeeffff", "媒体 ddddeeeeffff"},
}
for _, c := range cases {
got := mediaEntityName(c.label, c.digest)
if got != c.want {
t.Errorf("mediaEntityName(%q,%q) = %q,期望 %q", c.label, c.digest, got, c.want)
}
// 必须过 validEntityName 的 2–50 字符门槛,否则 Commit 会静默跳过
if n := len([]rune(got)); n < 2 || n > 50 {
t.Errorf("实体名长度 %d 不在 2–50 之间: %q", n, got)
}
}
}
func TestSummarizeForEntity(t *testing.T) {
cases := []struct{ in, want string }{
{"一张紫蓝红三色带图。还有更多内容。", "一张紫蓝红三色带图"},
{"**整体构成**:正方形画布", "整体构成:正方形画布"}, // Markdown 强调符被清掉
{"", ""},
{"短", ""}, // 单字过不了 validEntityName,宁可不写
// 无句子边界时按 rune 截到 40(不是按字节,否则切坏 UTF-8 会在图库里留乱码)
{"没有句子边界的一长串文字需要按 rune 截断以免切坏 UTF-8 编码导致图库里出现乱码实体名字符",
"没有句子边界的一长串文字需要按 rune 截断以免切坏 UTF-8 编码导致图库"},
}
for _, c := range cases {
got := summarizeForEntity(c.in, 40)
if got != c.want {
t.Errorf("summarizeForEntity(%q) = %q,期望 %q", c.in, got, c.want)
}
}
}
func TestMediaTriplesFromText_DeterministicRegardlessOfNLP(t *testing.T) {
// 核心回归:媒体入 L3 不再依赖 NLP 提取器的运气。
//
// 实测 LLM 的 477 字图片描述经提取器只产出「水平 -分割-> 成」,
// obj 仅 1 字被 validEntityName 拒掉 → ec=0 rc=0 → 媒体记忆进不了图库,
// 且时好时坏取决于描述文本。这里验证确定性路径。
longDesc := "这张图片是一张纯色块构成的抽象图像,不包含任何文字、人物、物体或可识别的场景。" +
"整体构成:一个小尺寸的正方形图像,被水平分割成三条颜色条带。"
text := "媒体内容:\n[image/png 89e293b42546] " + longDesc
triples := mediaTriplesFromText(text)
if len(triples) < 2 {
t.Fatalf("应至少产出类型+内容两条三元组,实际 %d", len(triples))
}
// 每条都必须能通过 validEntityName(经 Commit 实证)
g, err := memory.NewGraphDB(filepath.Join(t.TempDir(), "g.db"))
if err != nil {
t.Fatal(err)
}
defer g.Close()
sids, ec, rc, err := g.CommitWithMedia(triples, "det", 0)
if err != nil {
t.Fatal(err)
}
if ec == 0 || rc == 0 {
t.Fatalf("确定性三元组应能写入图库,实际 ec=%d rc=%d", ec, rc)
}
if len(sids) == 0 {
t.Fatal("应返回句子 id 供 bindSentenceMedia 绑定")
}
// SentenceText 必须含 digest,否则绑定还是断的
for st := range sids {
if !contains(st, "89e293b42546") {
t.Errorf("句子必须含短 digest 供反解: %q", st)
}
}
// 描述为空时仍应产出类型三元组——媒体节点不能因为没描述就不存在
bare := mediaTriplesFromText("[image/png 89e293b42546]")
if len(bare) != 1 {
t.Fatalf("无描述时应只有类型三元组,实际 %d 条", len(bare))
}
if bare[0].Relation != "类型" {
t.Errorf("无描述时那条应是类型三元组: %+v", bare[0])
} }
} }

View File

@ -192,63 +192,84 @@ func TestResolveInput_UnifiesAllModalities(t *testing.T) {
}) })
} }
// ---------- 模型工具侧:sentenceWithMediaMarkers ---------- // ---------- 模型工具侧:memory_digests 结构化传递 ----------
// 模型只知道 digest(从对话或 memory_recall 的「关联媒体」读到), // 模型只知道 digest(从对话或 memory_recall 的「关联媒体」读到)。
// 不该要求它自己按内核格式拼标记——格式写错的后果是引用静默挂不上。 // 它不再需要自己拼任何标记:digest 作为结构化字段随三元组提交。
func TestSentenceWithMediaMarkers(t *testing.T) { func TestResolveMediaDigestsAndNoMarkerText(t *testing.T) {
a, ms := newInputTestAgent(t) a, ms := newInputTestAgent(t)
digest, err := ms.Put([]byte("marker-bytes"), media.Item{ digest, err := ms.Put([]byte("marker-bytes"), media.Item{MIME: "image/png"})
MIME: "image/png", Description: "一张紫蓝红三色带图",
})
if err != nil { if err != nil {
t.Fatalf("Put: %v", err) t.Fatalf("Put: %v", err)
} }
t.Run("短digest补全并生成标记", func(t *testing.T) { t.Run("短digest补全", func(t *testing.T) {
got := a.sentenceWithMediaMarkers("用户发来一张图。", []string{digest[:12]}) got := a.resolveMediaDigests([]string{digest[:12]})
if !strings.Contains(got, "三色带图") { if len(got) != 1 || got[0] != digest {
t.Errorf("描述未并入句子: %q", got) t.Fatalf("短 digest 应补全为完整 digest,得到 %v", got)
}
if !strings.Contains(got, digest[:12]) {
t.Errorf("digest 未并入句子(反查会失效): %q", got)
}
// 反解必须成功,否则 bindSentenceMedia 挂不上引用
if got := extractMediaDigests(got); len(got) != 1 {
t.Errorf("生成的标记无法被 extractMediaDigests 反解: %v", got)
} }
}) })
t.Run("模型已写标记时不重复追加", func(t *testing.T) { t.Run("无法解析的digest被丢弃", func(t *testing.T) {
sentence := "看这个 [image/png " + digest[:12] + "] 三色带图" if got := a.resolveMediaDigests([]string{"ffffffffffff"}); len(got) != 0 {
got := a.sentenceWithMediaMarkers(sentence, []string{digest[:12]}) t.Errorf("不存在的 digest 不该保留: %v", got)
if n := strings.Count(got, digest[:12]); n != 1 {
t.Errorf("digest 出现 %d 次,期望 1 次: %q", n, got)
} }
}) })
t.Run("空句子时标记本身充当句子", func(t *testing.T) { t.Run("无媒体存储时返回nil", func(t *testing.T) {
got := a.sentenceWithMediaMarkers("", []string{digest})
if got == "" {
t.Error("媒体必须有句子落点,否则 media_refs 无从挂起")
}
})
t.Run("无法解析的digest被跳过", func(t *testing.T) {
got := a.sentenceWithMediaMarkers("原句。", []string{"ffffffffffff"})
if got != "原句。" {
t.Errorf("不存在的 digest 不该造出标记: %q", got)
}
})
t.Run("无媒体存储时原样返回", func(t *testing.T) {
bare := &Agent{} bare := &Agent{}
if got := bare.sentenceWithMediaMarkers("原句。", []string{digest}); got != "原句。" { if got := bare.resolveMediaDigests([]string{digest}); got != nil {
t.Errorf("无媒体存储时应原样返回: %q", got) t.Errorf("无媒体存储时应返回 nil: %v", got)
} }
}) })
} }
// 句子文本必须保持原样:媒体归属走结构化块边,不往文本里贴 marker。
func TestMemoryCommit_DoesNotPolluteSentenceText(t *testing.T) {
dir := t.TempDir()
g, err := memory.NewGraphDB(filepath.Join(dir, "graph.db"))
if err != nil {
t.Fatal(err)
}
defer g.Close()
ms, err := media.New(filepath.Join(dir, "media"))
if err != nil {
t.Fatal(err)
}
defer ms.Close()
a := &Agent{memory: g, mediaStore: ms}
digest, _ := ms.Put([]byte("clean-sentence"), media.Item{MIME: "image/png"})
sentence := "用户发来一张图。"
triples := []memory.Triple{{
Subject: "用户", Relation: "发来", Object: "图片",
SentenceText: sentence,
MediaDigests: a.resolveMediaDigests([]string{digest[:12]}),
}}
if _, _, _, err := a.commitTriplesWithMedia(triples, "s1", 0, nil); err != nil {
t.Fatal(err)
}
res, err := a.memory.Recall([]string{"用户"}, nil, 2, "")
if err != nil {
t.Fatal(err)
}
if len(res.Relations) == 0 {
t.Fatal("召回为空")
}
if res.Relations[0].SentenceText != sentence {
t.Errorf("句子文本被污染: %q", res.Relations[0].SentenceText)
}
blocks, err := a.memory.BlocksForNode("sentence", strconv.FormatInt(res.Relations[0].SentenceID, 10))
if err != nil {
t.Fatal(err)
}
if len(blocks) != 1 || blocks[0].PayloadDigest != digest {
t.Errorf("块应挂到句子,实际 %+v", blocks)
}
}
// ---------- resolveMediaDigests ---------- // ---------- resolveMediaDigests ----------
func TestResolveMediaDigests(t *testing.T) { func TestResolveMediaDigests(t *testing.T) {
@ -319,63 +340,61 @@ func TestDocCommit_StoresBlocks(t *testing.T) {
} }
} }
// ---------- docMediaContext ---------- // ---------- 文档持有块标签(doc_query 展示用) ----------
func TestDocMediaContext(t *testing.T) { func TestBlockLabelsForDoc(t *testing.T) {
a, ms := newInputTestAgent(t) a, ms := newInputTestAgent(t)
digest, _ := ms.Put([]byte("ctx-bytes"), media.Item{ digest, _ := ms.Put([]byte("ctx-bytes"), media.Item{MIME: "image/png"})
MIME: "image/png", Description: "文档里的配图", b, ok := a.blockFromDigest(digest)
}) if !ok {
t.Fatal("blockFromDigest 失败")
}
t.Run("无块时解析正文标记", func(t *testing.T) { t.Run("从文档持有的一等块渲染", func(t *testing.T) {
content := "旧正文 [image/png " + digest[:12] + "] 文档里的配图" got := a.blockLabelsForDoc(&document.Doc{ID: "doc_1", Blocks: []memory.MemoryBlock{b}})
got := a.docMediaContext("doc_legacy", content) if !strings.Contains(got, shortDigest(digest)) {
if !strings.Contains(got, "文档里的配图") { t.Errorf("标签应含短 digest: %q", got)
t.Errorf("历史文档只有标记时应回退解析: %q", got) }
if !strings.Contains(got, "image/png") {
t.Errorf("标签应含 MIME: %q", got)
} }
}) })
t.Run("既无引用也无标记", func(t *testing.T) { t.Run("无块时为空", func(t *testing.T) {
if got := a.docMediaContext("doc_empty", "普通正文"); got != "" { if got := a.blockLabelsForDoc(&document.Doc{ID: "doc_x", Content: "普通正文"}); got != "" {
t.Errorf("应返回空串,实际 %q", got) t.Errorf("应返回空串,实际 %q", got)
} }
}) })
t.Run("无媒体存储", func(t *testing.T) { t.Run("无媒体存储", func(t *testing.T) {
bare := &Agent{} bare := &Agent{}
if got := bare.docMediaContext("doc_x", "任意"); got != "" { if got := bare.blockLabelsForDoc(&document.Doc{ID: "doc_x"}); got != "" {
t.Errorf("无媒体存储时应返回空串,实际 %q", got) t.Errorf("无媒体存储时应返回空串,实际 %q", got)
} }
}) })
} }
// ---------- mediaMarkerLine ---------- // ---------- mediaLabel ----------
// 标记格式的唯一生成处。此前 mediaSummaryForEvent 与 mediaContextForSentences // 媒体标签的唯一生成处:只含 MIME 与短 digest,不含任何生成的描述。
// 各拼一份,改动截断长度或分隔符时只改一处,另一处写出的标记就再也解析不回来。 func TestMediaLabel(t *testing.T) {
func TestMediaMarkerLine(t *testing.T) {
a, ms := newInputTestAgent(t) a, ms := newInputTestAgent(t)
_ = a
described, _ := ms.Put([]byte("with-desc"), media.Item{ digest, _ := ms.Put([]byte("labelled"), media.Item{MIME: "image/png"})
MIME: "image/png", Description: "已描述的图", it, err := ms.Stat(digest)
}) if err != nil {
if got := a.mediaMarkerLine(described); !strings.Contains(got, "已描述的图") { t.Fatal(err)
t.Errorf("有描述时应带描述: %q", got)
} }
got := mediaLabel(it)
// 「已入库但还没描述」与「压根没有媒体」必须可区分 if !strings.Contains(got, "image/png") {
bare, _ := ms.Put([]byte("no-desc"), media.Item{MIME: "image/png"}) t.Errorf("标签应含 MIME: %q", got)
got := a.mediaMarkerLine(bare)
if !strings.Contains(got, "(未描述)") {
t.Errorf("无描述时应有占位符: %q", got)
} }
if !strings.Contains(got, shortDigest(bare)) { if !strings.Contains(got, shortDigest(digest)) {
t.Errorf("必须带短 digest 供反查: %q", got) t.Errorf("必须带短 digest 供反查: %q", got)
} }
if got := mediaLabel(nil); got != "" {
// 查不到返回空串:媒体可能已被容量 GC 淘汰,此时不该造出指向虚无的标记 t.Errorf("nil 应返回空串,实际 %q", got)
if got := a.mediaMarkerLine("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"); got != "" {
t.Errorf("查不到的 digest 应返回空串,实际 %q", got)
} }
} }
@ -417,9 +436,7 @@ func newToolTestAgent(t *testing.T) (*Agent, *media.Store) {
// memory_commit 带 media_digests:三元组入库后必须能从句子反查回那份字节。 // memory_commit 带 media_digests:三元组入库后必须能从句子反查回那份字节。
func TestToolMemoryCommit_BindsMedia(t *testing.T) { func TestToolMemoryCommit_BindsMedia(t *testing.T) {
a, ms := newToolTestAgent(t) a, ms := newToolTestAgent(t)
digest, _ := ms.Put([]byte("commit-bytes"), media.Item{ digest, _ := ms.Put([]byte("commit-bytes"), media.Item{MIME: "image/png"})
MIME: "image/png", Description: "提交时关联的图",
})
out := a.executeMemoryTool(agentAPI.ToolCall{ out := a.executeMemoryTool(agentAPI.ToolCall{
Name: "memory_commit", Name: "memory_commit",
@ -498,12 +515,10 @@ func TestToolMemoryCommit_CarriesSentenceText(t *testing.T) {
} }
} }
// doc_commit 带 media_digests:标记进正文(否则检索不到)+ 引用挂文档 owner(否则 GC 会清)。 // doc_commit 带 media_digests:媒体成为文档直接持有的一等块;正文保持原样。
func TestToolDocCommit_BindsMedia(t *testing.T) { func TestToolDocCommit_BindsMedia(t *testing.T) {
a, ms := newToolTestAgent(t) a, ms := newToolTestAgent(t)
digest, _ := ms.Put([]byte("doc-commit-bytes"), media.Item{ digest, _ := ms.Put([]byte("doc-commit-bytes"), media.Item{MIME: "image/png"})
MIME: "image/png", Description: "笔记里的插图",
})
out := a.executeDocTool(agentAPI.ToolCall{ out := a.executeDocTool(agentAPI.ToolCall{
Name: "doc_commit", Name: "doc_commit",
@ -522,8 +537,8 @@ func TestToolDocCommit_BindsMedia(t *testing.T) {
t.Fatal("文档未写入") t.Fatal("文档未写入")
} }
d := docs[0] d := docs[0]
if !strings.Contains(d.Content, "笔记里的插图") { if strings.Contains(d.Content, "image/png") {
t.Errorf("标记未进正文(向量索引看不到这份媒体): %q", d.Content) t.Errorf("正文不该被媒体标记污染: %q", d.Content)
} }
var held bool var held bool
for _, b := range d.Blocks { for _, b := range d.Blocks {
@ -539,9 +554,7 @@ func TestToolDocCommit_BindsMedia(t *testing.T) {
// doc_query 必须把媒体说明附在返回值里,否则模型检索到带图文档也不知道有图。 // doc_query 必须把媒体说明附在返回值里,否则模型检索到带图文档也不知道有图。
func TestToolDocQuery_ShowsMedia(t *testing.T) { func TestToolDocQuery_ShowsMedia(t *testing.T) {
a, ms := newToolTestAgent(t) a, ms := newToolTestAgent(t)
digest, _ := ms.Put([]byte("query-bytes"), media.Item{ digest, _ := ms.Put([]byte("query-bytes"), media.Item{MIME: "image/png"})
MIME: "image/png", Description: "检索命中的配图",
})
a.executeDocTool(agentAPI.ToolCall{ a.executeDocTool(agentAPI.ToolCall{
Name: "doc_commit", Name: "doc_commit",
@ -560,7 +573,7 @@ func TestToolDocQuery_ShowsMedia(t *testing.T) {
// 正文进的是 cold_storage 事件(工具返回值只给引用编号),媒体说明也在那里。 // 正文进的是 cold_storage 事件(工具返回值只给引用编号),媒体说明也在那里。
var found bool var found bool
for _, e := range a.context.Recent(10) { for _, e := range a.context.Recent(10) {
if strings.Contains(e.Response, "检索命中的配图") { if strings.Contains(e.Response, shortDigest(digest)) {
found = true found = true
} }
} }

View File

@ -200,7 +200,6 @@ func newLiveEnv(t *testing.T, c liveCfg) *liveEnv {
Memory: graph, Memory: graph,
DocStore: docStore, DocStore: docStore,
MediaStore: ms, MediaStore: ms,
MediaDescribe: true, // 描述循环由测试直接调 describePendingMedia
StageHost: NewStageHost(), StageHost: NewStageHost(),
MaxContextSize: 3, // 故意压低:第二轮就能触发 Prune 归档 MaxContextSize: 3, // 故意压低:第二轮就能触发 Prune 归档
InputProcessing: types.InputProcessingConfig{}, InputProcessing: types.InputProcessingConfig{},
@ -250,19 +249,28 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
a.handleInput(evt) a.handleInput(evt)
t.Logf("第一轮(含真实 LLM 往返)耗时 %.1fs", time.Since(t0).Seconds()) t.Logf("第一轮(含真实 LLM 往返)耗时 %.1fs", time.Since(t0).Seconds())
// 用 Pending 而非 Search 查刚落盘的项:Search 的 WHERE 里带 // 媒体不再有文字描述:CAS 里只有字节、元数据与向量。
// `COALESCE(description,'') != ''`,只返回**已描述**的媒体, // 这里直接按 digest 定位刚落的图(不再有 Pending 队列)。
// 此刻描述还没生成(阶段3 才做),Search 必然返回 0 条。 st := env.mediaSt.Stats()
items, err := env.mediaSt.Pending(10) if st["count"].(int) != 1 {
t.Fatalf("CAS 应自动收到 1 张图,实际 %v 张(captureBlockMedia 未被触发?)", st["count"])
}
var digest string
var found bool
for _, e := range a.context.Recent(0) {
for _, b := range e.Blocks {
digest, found = b.PayloadDigest, true
}
}
if !found {
t.Fatal("无法从上下文块定位刚落盘的图")
}
it0, err := env.mediaSt.Stat(digest)
if err != nil { if err != nil {
t.Fatalf("pending: %v", err) t.Fatal(err)
} }
if len(items) != 1 {
t.Fatalf("CAS 应自动收到 1 张图,实际 %d 张(captureBlockMedia 未被触发?)", len(items))
}
digest := items[0].Digest
t.Logf("✓ 阶段1 CAS 自动落盘: digest=%s size=%d tool=%s", t.Logf("✓ 阶段1 CAS 自动落盘: digest=%s size=%d tool=%s",
digest[:12], items[0].Size, items[0].Tool) digest[:12], it0.Size, it0.Tool)
stored, err := env.mediaSt.Get(digest) stored, err := env.mediaSt.Get(digest)
if err != nil || !bytes.Equal(stored, img) { if err != nil || !bytes.Equal(stored, img) {
@ -271,13 +279,15 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
// ── 阶段 2:一等记忆块自动挂到 ContextEvent 上 ── // ── 阶段 2:一等记忆块自动挂到 ContextEvent 上 ──
// //
// 这一步验证 bindEventMedia:事件必须拿到 ID 并直接持有块。 // 这一步验证 bindEventMedia:事件必须拿到 ID 并直接持有块;
// 事件文本必须保持原样(不再往正文里贴媒体标记)。
var evtID string var evtID string
var summaryOK bool
for _, e := range a.context.Recent(0) { for _, e := range a.context.Recent(0) {
if len(e.Blocks) > 0 { if len(e.Blocks) > 0 {
evtID = e.ID evtID = e.ID
summaryOK = strings.Contains(e.Input, digest[:12]) if strings.Contains(e.Input, digest[:12]) {
t.Error("事件 Input 里被写入了媒体标记——描述式索引链应该已经拆除")
}
if e.Blocks[0].PayloadDigest != digest { if e.Blocks[0].PayloadDigest != digest {
t.Fatalf("事件持有的块 digest 不对: %+v", e.Blocks) t.Fatalf("事件持有的块 digest 不对: %+v", e.Blocks)
} }
@ -287,51 +297,17 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
if evtID == "" { if evtID == "" {
t.Fatal("没有任何 ContextEvent 挂上媒体(bindEventMedia 未被触发)") t.Fatal("没有任何 ContextEvent 挂上媒体(bindEventMedia 未被触发)")
} }
if !summaryOK { t.Logf("✓ 阶段2 块自动绑定: event=%s", evtID)
t.Error("事件 Input 里没有媒体摘要标记(mediaSummaryForEvent 未生效)——" +
"L2/L3 靠正文里的短 digest 反查,缺了它整条召回链断掉")
}
t.Logf("✓ 阶段2 块自动绑定: event=%s 摘要内嵌=%v", evtID, summaryOK)
// ── 阶段 3:描述由后台循环自动生成(真实视觉模型)── // ── 阶段 3:媒体只按自己的向量被索引,不再生成任何描述 ──
pending, err := env.mediaSt.Pending(5) if it, err := env.mediaSt.Stat(digest); err != nil {
if err != nil {
t.Fatal(err) t.Fatal(err)
} } else if len(it.Vec) == 0 {
if len(pending) != 1 { // 未配置多模态空间时就没有向量——这是合法的降级状态,
t.Fatalf("应有 1 条待描述,实际 %d 条", len(pending)) // 但要明确报出来,而不是靠描述文本假装能检索。
} t.Log("未配置多模态空间:本图无向量,之后只能靠块结构召回 digest")
t1 := time.Now()
a.describePendingMedia()
t.Logf("描述生成耗时 %.1fs", time.Since(t1).Seconds())
it, err := env.mediaSt.Stat(digest)
if err != nil {
t.Fatal(err)
}
if it.Description == "" {
t.Fatal("描述为空——describePendingMedia 未能通过视觉源生成描述")
}
sawColors := strings.Contains(it.Description, "紫") &&
strings.Contains(it.Description, "蓝") &&
strings.Contains(it.Description, "红")
t.Logf("✓ 阶段3 描述自动生成 (%d 字, 源=%s): %s",
len([]rune(it.Description)), it.DescribedBy, truncRunes(it.Description, 90))
if !sawColors {
t.Errorf("描述未含紫/蓝/红三色,视觉模型可能没真正看到图片: %s",
truncRunes(it.Description, 200))
}
if left, _ := env.mediaSt.Pending(5); len(left) != 0 {
t.Errorf("描述完成后仍在待描述队列(%d 条)——会被反复重描述", len(left))
}
// 有描述之后 Search 才应能命中(它按 description 做 LIKE)
if found, err := env.mediaSt.Search("紫", media.KindImage, 5); err != nil {
t.Errorf("search: %v", err)
} else if len(found) == 0 {
t.Error("描述已生成但 Search(\"紫\") 命中 0 条——媒体库关键词入口失效")
} else { } else {
t.Logf("✓ 阶段3 Search(\"紫\") 命中 %d 条", len(found)) t.Logf("✓ 阶段3 已写入原生向量: dim=%d", len(it.Vec))
} }
// ── 阶段 4:Prune 自动把块从 L0 迁移到 L2 ── // ── 阶段 4:Prune 自动把块从 L0 迁移到 L2 ──
@ -385,11 +361,11 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
t.Fatalf("迁移后内容不可读: %v", err) t.Fatalf("迁移后内容不可读: %v", err)
} }
// ── 阶段 5:archiveColdDocs 自动把媒体带进 L3 图库 ── // ── 阶段 5:archiveColdDocs 自动把块连到 L3 文档节点 ──
// //
// FindColdDocs(72h, 2) 要求文档足够"冷",测试里新建的文档不满足, // FindColdDocs(72h, 2) 要求文档足够"冷",测试里新建的文档不满足,
// 因此把 LastAccess 往前推——这是为了触发生产代码路径, // 因此把 LastAccess 往前推——这是为了触发生产代码路径,
// 而不是替代它(Commit/bindSentenceBlocks 全部由它自己调)。 // 而不是替代它(commitTriplesWithMedia/linkBlocksToDocument 全由它自己调)。
for _, d := range env.docStore.RecentDocs(20) { for _, d := range env.docStore.RecentDocs(20) {
if d.ID == docRefsFound { if d.ID == docRefsFound {
d.LastAccess = time.Now().Add(-100 * time.Hour) d.LastAccess = time.Now().Add(-100 * time.Hour)
@ -398,13 +374,21 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
} }
a.archiveColdDocs() a.archiveColdDocs()
// 块可能以 document --contains--> block(文档归档)或
// sentence --contains--> block(对话三元组)两种边存在。
sentRefs := 0 sentRefs := 0
var boundSentence int64 var boundSentence int64
docBound := 0
rows, err := env.graph.Recall(nil, nil, 1, "") rows, err := env.graph.Recall(nil, nil, 1, "")
if err != nil { if err != nil {
t.Fatalf("graph recall: %v", err) t.Fatalf("graph recall: %v", err)
} }
t.Logf("图库实体数 %d", len(rows.Entities)) t.Logf("图库实体数 %d", len(rows.Entities))
docBlocks, err := env.graph.BlocksForNode("document", docRefsFound)
if err != nil {
t.Fatal(err)
}
docBound = len(docBlocks)
// 句子 id 是自增整数,扫前若干个足够覆盖本测试写入的量 // 句子 id 是自增整数,扫前若干个足够覆盖本测试写入的量
for sid := int64(1); sid <= 40; sid++ { for sid := int64(1); sid <= 40; sid++ {
blocks, err := env.graph.BlocksForNode("sentence", strconv.FormatInt(sid, 10)) blocks, err := env.graph.BlocksForNode("sentence", strconv.FormatInt(sid, 10))
@ -415,10 +399,18 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
} }
} }
} }
if sentRefs == 0 { if sentRefs == 0 && docBound == 0 {
t.Error("L2→L3 未写入任何句子→块边——" + t.Error("L2→L3 未写入任何块边——linkBlocksToDocument 未被 archiveColdDocs 触发")
"bindSentenceBlocks 未被 commitTriplesWithMedia 触发," + } else if docBound > 0 {
"或句子正文里没有可反解的短 digest") t.Logf("✓ 阶段5 L3 自动写入: 文档 %s 持有 %d 个块", docRefsFound, docBound)
got := docBlocks
if got[0].PayloadDigest != digest {
t.Errorf("文档节点持有的块 digest 不对: %+v", got)
} else if raw, err := env.mediaSt.Get(got[0].PayloadDigest); err != nil || !bytes.Equal(raw, img) {
t.Errorf("从文档块取回的字节与原图不一致 (err=%v)", err)
} else {
t.Logf("✓ 阶段5 反查取回 %d 字节,与原图逐字节一致", len(raw))
}
} else { } else {
t.Logf("✓ 阶段5 L3 自动写入: %d 个句子块,首个 sentences.id=%d", sentRefs, boundSentence) t.Logf("✓ 阶段5 L3 自动写入: %d 个句子块,首个 sentences.id=%d", sentRefs, boundSentence)
@ -448,15 +440,10 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
if err := a.indexer.Sync(); err != nil { if err := a.indexer.Sync(); err != nil {
t.Fatalf("indexer sync: %v", err) t.Fatalf("indexer sync: %v", err)
} }
if mc := a.buildMemoryContext("图片 颜色", 0); mc != "" { if mc := a.buildMemoryContext("测试图片", 0); mc != "" {
t.Logf("注入的记忆上下文: %s", truncRunes(mc, 200)) t.Logf("注入的记忆上下文: %s", truncRunes(mc, 200))
if strings.Contains(mc, "【关联媒体】") {
t.Logf("✓ 记忆上下文含媒体段")
} else {
t.Error("记忆上下文缺少媒体段——L3 媒体检索接线未生效")
}
} else { } else {
t.Error("图库召回为空,agent 无从得知历史媒体") t.Log("图库召回为空(本测试不再依赖文本描述,仅记录现状)")
} }
ask := &agentIO.InputEvent{ ask := &agentIO.InputEvent{
@ -475,6 +462,9 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
a.handleInput(ask) a.handleInput(ask)
t.Logf("第二轮耗时 %.1fs", time.Since(t2).Seconds()) t.Logf("第二轮耗时 %.1fs", time.Since(t2).Seconds())
// 第二轮仍走真实 LLM:这里只验证链路不报错、有回复。
// 不再断言"答出紫/蓝/红":图片的颜色信息只在原生向量里,
// 未配置多模态空间时模型本来就无从得知——那不属于记忆接线缺陷。
var answer string var answer string
select { select {
case out := <-respCh: case out := <-respCh:
@ -483,27 +473,20 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
t.Fatal("第二轮没有收到回复") t.Fatal("第二轮没有收到回复")
} }
t.Logf("agent 回答: %s", truncRunes(answer, 220)) t.Logf("agent 回答: %s", truncRunes(answer, 220))
if strings.HasPrefix(answer, "处理错误:") {
recalled := strings.Contains(answer, "紫") && t.Skipf("上游 LLM 调用失败,端到端召回无法判定: %s", truncRunes(answer, 160))
strings.Contains(answer, "蓝") &&
strings.Contains(answer, "红")
if !recalled {
t.Errorf("agent 未能召回三色。这可能是记忆注入链路问题,"+
"也可能是本轮上下文里已无相关记忆(描述在 L2/L3 但未被检索命中)。回答: %s",
truncRunes(answer, 300))
} else {
t.Logf("✓ 阶段7 E2E 召回成功:不给图,agent 答出紫/蓝/红")
} }
t.Logf("✓ 阶段7 E2E 链路贯通(召回能力取决于是否配置多模态向量空间)")
st := env.mediaSt.Stats() st = env.mediaSt.Stats()
t.Logf("收尾: %v 条 / %v 字节 / 已描述 %v", t.Logf("收尾: %v 条 / %v 字节 / 类型 %v",
st["count"], st["total_bytes"], st["described"]) st["count"], st["total_bytes"], st["by_kind"])
} }
// TestMediaLive_NegativeControl 阴性对照:没有媒体记忆时不该"记得"。 // TestMediaLive_NegativeControl 阴性对照:没有媒体记忆时不该"记得"。
// //
// 没有这条对照,阶段7 的"答出紫蓝红"可能只是模型在猜常见配色, // 没有这条对照,任何"答出了具体内容"的结果都可能只是模型先验,
// 无法区分真召回与先验偏好。 // 无法区分真召回与猜测。
func TestMediaLive_NegativeControl(t *testing.T) { func TestMediaLive_NegativeControl(t *testing.T) {
c := requireLiveCfg(t) c := requireLiveCfg(t)
env := newLiveEnv(t, c) env := newLiveEnv(t, c)

View File

@ -2,26 +2,44 @@ package core
import ( import (
"log" "log"
"runtime/debug"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media" "gitcode.com/JianFeeeee/HomeAgent/internal/memory"
) )
// 媒体记忆的后台循环。 // 媒体与记忆块的生命周期辅助。
//
// mediaDescribeLoop 给未描述的媒体生成文字描述。
// //
// 媒体不单独做生命周期管理(没有 GC、没有引用计数):blob 是记忆块的内容, // 媒体不单独做生命周期管理(没有 GC、没有引用计数):blob 是记忆块的内容,
// 块的创建/迁移/删除由记忆系统本身决定,块被永久删除时内容随之删除 // 块的创建/迁移/删除由记忆系统本身决定,块被永久删除时内容随之删除。
// (见 forgetPayloads)。 // 图片不靠文本描述索引——它只按自己的统一空间向量被检索。
// heldMediaDigests 汇总三层记忆当前持有的媒体 digest 集合。
// //
// 为何描述要走后台而不是入库时同步做:视觉模型一次调用在生产实测 9.6s // CAS 是全库字节存储,它的检索结果不等于「记忆里的媒体」——
// (see_video 6 帧批量 23s)。放在对话路径上会让每张图都给回复加十几秒, // 召回前用它把已无处可归的内容过滤掉。
// 而描述的价值是**几个月后还能检索到这张图**,不是这一轮对话—— 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 是否仍被三层记忆中的一等块持有。 // payloadHeld 报告某个 digest 是否仍被三层记忆中的一等块持有。
// 这是删除前的一次活查询(不是持久化账本):同一份字节可能同时被多个块共享。 // 这是删除前的一次活查询(不是持久化账本):同一份字节可能同时被多个块共享。
@ -73,135 +91,6 @@ func (a *Agent) forgetPayloads(digests []string) {
} }
} }
const (
// mediaDescribeBatch 是单轮描述的媒体条数上限。
//
// 取 4:既有回退链的 modalFallbackMaxBlocks 是 6(一次请求最多带 6 个媒体),
// 这里留出余量,且每条单独请求以便逐条落库——批量描述拿回来一整段文字
// 无法可靠切分回各自的 digest。
mediaDescribeBatch = 4
// mediaDescribeMinInterval 是两轮描述之间的最小间隔。
//
// 描述是纯后台的锦上添花,不该跟对话抢视觉模型配额。取 30s 让它
// 慢慢消化积压,而不是一上线就把几百条历史媒体全打过去。
mediaDescribeMinInterval = 30 * time.Second
)
// mediaDescribeLoop 给未描述的媒体补文字描述。
//
// 描述文本才是持久语义记忆:它留在 media 表里,并经 mediaSummaryForEvent
// 写进 L0 事件、随归档进 L2 文档、经蒸馏进 L3 图库。
// 于是「那张紫蓝红三色带图」仍然可被检索到。
func (a *Agent) mediaDescribeLoop() {
defer func() {
if r := recover(); r != nil {
log.Printf("[agent] mediaDescribeLoop panic recovered: %v\n%s", r, debug.Stack())
time.Sleep(time.Second)
go a.mediaDescribeLoop()
}
}()
if a.mediaStore == nil || !a.mediaDescribe {
return
}
ticker := time.NewTicker(mediaDescribeMinInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
a.describePendingMedia()
case <-a.ctx.Done():
return
}
}
}
// describePendingMedia 取一批未描述的媒体逐条描述。
//
// 逐条而非批量:批量拿回来是一整段文字,无法可靠切分回各自的 digest
// (模型未必按序号输出,也可能把两张图合并成一句)。宁可多几次往返
// 也要保证「描述 ↔ digest」的对应关系是确定的。
func (a *Agent) describePendingMedia() {
pending, err := a.mediaStore.Pending(mediaDescribeBatch)
if err != nil {
log.Printf("[media] 取待描述项失败: %v", err)
return
}
if len(pending) == 0 {
return
}
for _, it := range pending {
select {
case <-a.ctx.Done():
return
default:
}
kind := "image"
if it.Kind == media.KindAudio {
kind = "audio"
} else if it.Kind != media.KindImage {
// 视频帧以 image 入库;其余大类没有可用的描述通道,
// 标记成"不可描述"以免每轮都被 Pending 取出来重试。
if err := a.mediaStore.Describe(it.Digest, "", "unsupported"); err != nil {
log.Printf("[media] 标记不可描述失败 %s: %v", shortDigest(it.Digest), err)
}
continue
}
p, srcName := a.resolveModalFallback(kind)
if p == nil {
// 没有声明该模态能力的源——这一轮整体跳过,不逐条重试。
// 配置好之后自然会被下一轮捡起来。
log.Printf("[media] 无可用的 %s 描述源,跳过本轮(%d 条待描述)", kind, len(pending))
return
}
data, err := a.mediaStore.Get(it.Digest)
if err != nil {
// blob 已被 GC 清掉但元数据还在(GC 会同删,此处属异常路径):
// 标记一下避免死循环。
log.Printf("[media] 读内容失败 %s: %v", shortDigest(it.Digest), err)
if e := a.mediaStore.Describe(it.Digest, "", "content-missing"); e != nil {
log.Printf("[media] 标记内容缺失失败 %s: %v", shortDigest(it.Digest), e)
}
continue
}
mime := it.MIME
if mime == "" {
mime = "image/png"
}
url := media.DataURL(mime, data)
desc, err := a.chatModalFallbackBatch(p, kind, []string{url}, []string{"high"})
if err != nil {
// 失败不标记:可能是网络抖动或配额,下一轮该重试。
log.Printf("[media] 描述失败 %s (源=%s): %v", shortDigest(it.Digest), srcName, err)
continue
}
if desc == "" {
// 空回复通常意味着上游把媒体剥离了——与 modalfallback 里的判断
// 同一个道理,视作失败而非"没什么可说的"。
log.Printf("[media] 描述为空 %s (源=%s),视作失败", shortDigest(it.Digest), srcName)
continue
}
if err := a.mediaStore.Describe(it.Digest, desc, srcName); err != nil {
log.Printf("[media] 写描述失败 %s: %v", shortDigest(it.Digest), err)
continue
}
log.Printf("[media] 已描述 %s (%s, %d 字, 源=%s)", shortDigest(it.Digest), kind, len([]rune(desc)), srcName)
// 描述成功后无需再次做视觉嵌入:图片在进入 L0 记忆块时已由
// embedMediaOnIngest 计算并写入 CAS,L0→L2→L3 只转移引用并复用坐标。
// 历史已有图片或模型切换由启动时 reembedStaleMedia 一次性补算。
}
}
// reembedStaleMedia 在启动时批量迁移历史媒体向量到当前向量空间。 // reembedStaleMedia 在启动时批量迁移历史媒体向量到当前向量空间。
// //
// 触发场景(任一变化都会导致旧向量无法参与查询): // 触发场景(任一变化都会导致旧向量无法参与查询):
@ -270,8 +159,7 @@ func (a *Agent) reembedStaleMedia() {
done, failed, len(digests), shortFP(fp)) done, failed, len(digests), shortFP(fp))
} }
// reembedOne 为单条媒体重新计算向量并写入。stat 错误时跳过(可能已被 GC 清除)。 // reembedOne 为单条媒体重新计算向量并写入(stat/get 失败时跳过该条目)。
// Get 错误或 Embed 错误时静默跳过该条目(不影响迁移其他条目)。
func (a *Agent) reembedOne(digest, fp string) error { func (a *Agent) reembedOne(digest, fp string) error {
it, err := a.mediaStore.Stat(digest) it, err := a.mediaStore.Stat(digest)
if err != nil { if err != nil {

View File

@ -4,18 +4,18 @@ import (
"context" "context"
"path/filepath" "path/filepath"
"testing" "testing"
"time"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory" "gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
) )
// 媒体后台循环测试。 // 媒体与记忆块的生命周期测试。
// //
// 媒体没有独立生命周期管理(没有 GC、没有引用计数):blob 是记忆块的内容, // 媒体没有独立生命周期管理(没有 GC、没有引用计数):blob 是记忆块的内容,
// 块的创建/迁移/删除由记忆系统决定。这里只测描述循环与删除语义。 // 块的创建/迁移/删除由记忆系统决定。图片也不靠文本描述索引。
func newMediaLoopAgent(t *testing.T, describe bool) (*Agent, *media.Store) { func newMediaLoopAgent(t *testing.T) (*Agent, *media.Store) {
t.Helper() t.Helper()
dir := t.TempDir() dir := t.TempDir()
ms, err := media.New(filepath.Join(dir, "media")) ms, err := media.New(filepath.Join(dir, "media"))
@ -24,73 +24,81 @@ func newMediaLoopAgent(t *testing.T, describe bool) (*Agent, *media.Store) {
} }
t.Cleanup(func() { ms.Close() }) t.Cleanup(func() { ms.Close() })
a := &Agent{ a := &Agent{mediaStore: ms}
mediaStore: ms,
mediaDescribe: describe,
}
a.ctx, a.cancel = context.WithCancel(context.Background()) a.ctx, a.cancel = context.WithCancel(context.Background())
t.Cleanup(a.cancel) t.Cleanup(a.cancel)
return a, ms return a, ms
} }
func TestMediaDescribeLoop_ExitsWhenDisabled(t *testing.T) { // heldMediaDigests 汇总三层记忆持有的媒体:只有这些才可被召回。
// describe 关闭时必须立即返回(默认就是关闭,绝大多数部署走这条路) func TestHeldMediaDigests_CollectsAcrossLayers(t *testing.T) {
a, _ := newMediaLoopAgent(t, false) a, ms := newMediaLoopAgent(t)
done := make(chan struct{}) d1, _ := ms.Put([]byte("ctx-layer"), media.Item{MIME: "image/png"})
go func() { a.mediaDescribeLoop(); close(done) }() d2, _ := ms.Put([]byte("doc-layer"), media.Item{MIME: "image/png"})
select { d3, _ := ms.Put([]byte("graph-layer"), media.Item{MIME: "image/png"})
case <-done: d4, _ := ms.Put([]byte("orphan"), media.Item{MIME: "image/png"})
case <-time.After(2 * time.Second):
t.Fatal("describe 关闭时 mediaDescribeLoop 未立即返回") a.context = NewRelevanceContext("", memory.NewStaticEmbedder(""))
a.context.Append(ContextEvent{Input: "带图的一轮", Blocks: []memory.MemoryBlock{
{ID: "blk_ctx", Modality: memory.BlockImage, PayloadDigest: d1},
}})
dir := t.TempDir()
bo, ok := a.blockFromDigest(d2)
if !ok {
t.Fatal("blockFromDigest 失败")
} }
} ds := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
if err := ds.Start(); err != nil {
t.Fatal(err)
}
defer ds.Stop()
if err := ds.Insert(&document.Doc{ID: "doc_1", Summary: "s", Blocks: []memory.MemoryBlock{bo}}); err != nil {
t.Fatal(err)
}
a.docStore = ds
func TestDescribePendingMedia_NoProviderLeavesUndescribed(t *testing.T) { g, err := memory.NewGraphDB(filepath.Join(dir, "graph.db"))
// 没有声明视觉能力的源时整轮跳过,且**不能**把项标记成已处理——
// 配置好之后必须还能被捡起来。
a, ms := newMediaLoopAgent(t, true)
d, _ := ms.Put([]byte("img"), media.Item{MIME: "image/png"})
// providerManager 为 nil → resolveModalFallback 返回 nil
a.describePendingMedia()
it, err := ms.Stat(d)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if it.Description != "" || it.DescribedBy != "" { defer g.Close()
t.Fatalf("无可用源时不该写描述: %+v", it) if err := g.PutMemoryBlocks([]memory.MemoryBlock{
} {ID: "blk_g", Modality: memory.BlockImage, PayloadDigest: d3},
pending, _ := ms.Pending(10) }); err != nil {
if len(pending) != 1 {
t.Fatalf("项应仍在待描述队列里,实际 %d 条", len(pending))
}
}
func TestDescribePendingMedia_MarksUnsupportedKind(t *testing.T) {
// video/other 大类没有可用的描述通道,必须标记掉,
// 否则每轮 Pending 都把它取出来重试,永远卡住队列头部。
a, ms := newMediaLoopAgent(t, true)
other, _ := ms.Put([]byte("blob"), media.Item{MIME: "application/octet-stream"})
a.describePendingMedia()
it, err := ms.Stat(other)
if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if it.DescribedBy != "unsupported" { a.memory = g
t.Fatalf("不可描述的大类应被标记,实际 DescribedBy=%q", it.DescribedBy)
held := a.heldMediaDigests()
for _, want := range []string{d1, d2, d3} {
if !held[want] {
t.Errorf("层次持有 %s 却不在结果里: %v", shortDigest(want), held)
}
} }
pending, _ := ms.Pending(10) if held[d4] {
if len(pending) != 0 { t.Errorf("无人持有的 %s 不该出现在结果里", shortDigest(d4))
t.Fatalf("标记 unsupported 后应退出待描述队列,仍有 %d 条", len(pending))
} }
} }
func TestDescribePendingMedia_EmptyQueueIsNoop(t *testing.T) { // payloadHeld 是删除前的活查询。
a, _ := newMediaLoopAgent(t, true) func TestPayloadHeld(t *testing.T) {
a.describePendingMedia() // 不该 panic a, ms := newMediaLoopAgent(t)
d, _ := ms.Put([]byte("held"), media.Item{MIME: "image/png"})
if a.payloadHeld(d) {
t.Fatal("尚无块持有时不该报已持有")
}
a.context = NewRelevanceContext("", memory.NewStaticEmbedder(""))
a.context.Append(ContextEvent{Input: "x", Blocks: []memory.MemoryBlock{
{ID: "blk_1", Modality: memory.BlockImage, PayloadDigest: d},
}})
if !a.payloadHeld(d) {
t.Fatal("L0 持有却报未持有")
}
if a.payloadHeld("") {
t.Fatal("空 digest 应为 false")
}
} }
// TestForgetPayloads_DeletesOnlyUnheldContent 验证删除语义: // TestForgetPayloads_DeletesOnlyUnheldContent 验证删除语义:

View File

@ -59,21 +59,15 @@ func (a *Agent) blockFromDigest(digest string) (memory.MemoryBlock, bool) {
} }
// 媒体记忆接线:把对话里出现的图片/音频落进内容寻址存储(CAS), // 媒体记忆接线:把对话里出现的图片/音频落进内容寻址存储(CAS),
// 并让 L0 的 ContextEvent 记住它们的 digest。 // 并让 L0 的 ContextEvent 直接持有一等记忆块。
// //
// 为何需要这一层:媒体进入对话有两条路,两条都只把**文字**留给记忆—— // 媒体进入对话有两条路:用户直接发图(ContentBlock data URL)、插件注入
// (SetToolBlocks)。两条都在这里收口:从 data URL 取出字节存进 CAS,
// 用其向量构造一等记忆块挂到当轮 ContextEvent 上;事件被 Prune 时
// 块随之迁移到 L2 文档。
// //
// 1. 用户直接发图 → processInput/resolveInput → mediaToBlocks // 不再生成任何描述文本,也不再往正文写 media marker:图片只按自己的
// ContextEvent.Input 只存 alt 文本("[从 qq 收到了 image]"), // 统一空间向量被检索,描述式索引是将就方案。
// base64 随 message 数组发给模型后就丢了。
// 2. 插件注入 → SetToolBlocks → process.go 的 mediaMsg
// ToolResultItem.Output 只存那句 "[已将图片注入后续对话] /tmp/x.png"。
//
// 于是下一轮对话起,模型能看到的只有一句路径或一句 alt。那个文件被删、
// 被覆盖,或者本来就是 /tmp 下的临时产物,连线索都断了。
//
// 现在两条路都在同一处收口:从 ContentBlock 的 data URL 取出字节存进 CAS,
// digest 挂到当轮 ContextEvent 上;事件被 Prune 归档进 L2 时引用随之转移。
// captureBlockMedia 把 blocks 里的 data URL 媒体落进 CAS,返回 digest 列表。 // captureBlockMedia 把 blocks 里的 data URL 媒体落进 CAS,返回 digest 列表。
// //
@ -183,53 +177,20 @@ func (a *Agent) bindEventMedia(evt *ContextEvent, digests []string) {
} }
} }
// mediaSummaryForEvent 给已有描述的媒体生成一行文字,供写进 ContextEvent.Input。 // mediaLabel 渲染一行媒体标签,供提示词告知"这条记忆带着哪份媒体"。
// //
// 这是方案 C 的落点:**描述文本才是持久语义记忆,blob 只是缓存**。 // 不再包含任何生成的描述文本:图片只按自己的向量被检索,标签仅提供
// blob 可能已被删除,但描述会一直留在 L0/L2/L3 的文本里, // MIME 与短 digest,让模型知道有这份媒体、可据 digest 取回字节。
// 让"那张紫蓝红三色带图"在几个月后仍然可被检索到。 // 查不到返回空串:内容可能已被删除,不该造出一条指向虚无的标签。
func (a *Agent) mediaSummaryForEvent(blocks []memory.MemoryBlock) string { func mediaLabel(it *media.Item) string {
if a.mediaStore == nil || len(blocks) == 0 { if it == nil {
return ""
}
var lines []string
for _, b := range blocks {
if line := a.mediaMarkerLine(b.PayloadDigest); line != "" {
lines = append(lines, line)
}
}
if len(lines) == 0 {
return ""
}
return "媒体内容:\n" + strings.Join(lines, "\n")
}
// mediaMarkerLine 为一份媒体生成一行标记文本 `[<mime> <短digest>] <描述>`。
//
// 这是媒体标记格式的唯一生成处。此前 mediaSummaryForEvent 与
// mediaContextForSentences 各拼一份,改动截断长度或分隔符时只改一处,
// 另一处写出的标记就再也解析不回来——而解析失败是静默的(引用挂不上)。
//
// 查不到返回空串:媒体可能已被删除,此时不该造出一条指向虚无的标记。
func (a *Agent) mediaMarkerLine(digest string) string {
if a.mediaStore == nil {
return ""
}
it, err := a.mediaStore.Stat(digest)
if err != nil || it == nil {
return "" return ""
} }
label := string(it.Kind) label := string(it.Kind)
if it.MIME != "" { if it.MIME != "" {
label = it.MIME label = it.MIME
} }
desc := it.Description return fmt.Sprintf("[%s %s]", label, shortDigest(it.Digest))
if desc == "" {
// 「已入库但还没描述」与「压根没有媒体」必须可区分:描述由后台循环
// 异步补齐,占位符保证补齐前这份媒体也不会从文本里消失。
desc = "(未描述)"
}
return fmt.Sprintf("[%s %s] %s", label, shortDigest(digest), desc)
} }
// newEventID 生成 ContextEvent 的稳定标识。 // newEventID 生成 ContextEvent 的稳定标识。

View File

@ -106,8 +106,8 @@ func TestCaptureBlockMedia_NilStoreIsNoop(t *testing.T) {
if len(evt.Blocks) != 0 || evt.ID != "" { if len(evt.Blocks) != 0 || evt.ID != "" {
t.Fatalf("nil store 时不该改动事件: %+v", evt) t.Fatalf("nil store 时不该改动事件: %+v", evt)
} }
if s := a.mediaSummaryForEvent(nil); s != "" { if s := mediaLabel(nil); s != "" {
t.Fatalf("nil store 时摘要应为空,得到 %q", s) t.Fatalf("nil 媒体应产出空标签,得到 %q", s)
} }
} }
@ -183,31 +183,27 @@ func TestBindEventMedia_LazyIDOnlyWhenNeeded(t *testing.T) {
} }
} }
func TestMediaSummary_DescriptionIsThePersistentMemory(t *testing.T) { func TestMediaLabel_NoGeneratedDescription(t *testing.T) {
// 方案 C 的核心:描述文本才是持久语义记忆,blob 只是缓存。 // 标签只用来告诉模型「这条记忆带着哪份媒体、可用该 digest 取回字节」。
// blob 被容量 GC 淘汰后,描述仍留在 L0/L2/L3 的文本里可被检索。 // 它不包含任何生成的描述:描述式索引是把就机制,已彻底废弃。
a, ms := newTestAgentWithMedia(t) a, ms := newTestAgentWithMedia(t)
d, _ := ms.Put([]byte("img"), media.Item{MIME: "image/png"}) d, _ := ms.Put([]byte("img"), media.Item{MIME: "image/png"})
b, ok := a.blockFromDigest(d) it, err := ms.Stat(d)
if !ok { if err != nil {
t.Fatal("blockFromDigest 失败") t.Fatal(err)
} }
if s := a.mediaSummaryForEvent([]memory.MemoryBlock{b}); s == "" { s := mediaLabel(it)
t.Fatal("未描述项也应产出一行(标注未描述)")
}
ms.Describe(d, "一张紫蓝红三色带图", "visionllm")
s := a.mediaSummaryForEvent([]memory.MemoryBlock{b})
if s == "" { if s == "" {
t.Fatal("应产出摘要") t.Fatal("应产出标签")
}
if !strings.Contains(s, "紫蓝红三色带图") {
t.Fatalf("摘要应含描述文本: %q", s)
} }
if !strings.Contains(s, "image/png") { if !strings.Contains(s, "image/png") {
t.Fatalf("摘要应含 MIME 标注: %q", s) t.Fatalf("标签应含 MIME 标注: %q", s)
} }
if !strings.Contains(s, shortDigest(d)) {
t.Fatalf("标签应含短 digest 供反查: %q", s)
}
_ = a
} }
func TestPrune_NilMediaStoreStillArchives(t *testing.T) { func TestPrune_NilMediaStoreStillArchives(t *testing.T) {

View File

@ -190,11 +190,16 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
Object: getString(m, "object"), Object: getString(m, "object"),
SentenceText: getString(m, "sentence_text"), SentenceText: getString(m, "sentence_text"),
} }
// 模型显式关联的媒体:标记由内核补进句子文本,模型不必知道格式。 // 模型显式关联的媒体:结构化字段随三元组一起提交,
// 没有 sentence_text 时 sentenceWithMediaMarkers 会用标记本身 // 由 commitTriplesWithMedia 变成 L3 一等块并与句子建边——
// 充当句子——媒体必须有句子落点,否则块边无法建立。 // 不再把 marker 写进句子文本。
if digests := getStringSlice(m, "media_digests"); len(digests) > 0 { if digests := getStringSlice(m, "media_digests"); len(digests) > 0 {
t.SentenceText = a.sentenceWithMediaMarkers(t.SentenceText, digests) t.MediaDigests = a.resolveMediaDigests(digests)
// 块边需要句子作端点。模型没给原句时用三元组本身拼一句
// 自然语言——不能造一段 marker 文本,那正是被废弃的东西。
if t.SentenceText == "" && len(t.MediaDigests) > 0 {
t.SentenceText = fmt.Sprintf("%s%s%s。", t.Subject, t.Relation, t.Object)
}
} }
if t.Subject != "" && t.Relation != "" && t.Object != "" { if t.Subject != "" && t.Relation != "" && t.Object != "" {
triples = append(triples, t) triples = append(triples, t)
@ -531,10 +536,10 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string {
if len(content) > 2000 { if len(content) > 2000 {
content = content[:2000] + "..." content = content[:2000] + "..."
} }
// 媒体说明单独一行进冷存事件:正文可能被上面的 2000 字截断, // 媒体块标签单独一行进冷存事件:正文可能被上面的 2000 字截断,
// 而媒体标记往往在文档末尾——截掉之后模型就不知道这篇文档带过图。 // 截掉之后模型就不知道这篇文档带过图。
if mc := a.docMediaContext(d.ID, d.Content); mc != "" { if labels := a.blockLabelsForDoc(d); labels != "" {
content = content + "\n关联媒体: " + mc content = content + "\n关联媒体: " + labels
} }
a.context.InsertByTimestamp(ContextEvent{ a.context.InsertByTimestamp(ContextEvent{
Timestamp: d.CreatedAt, Timestamp: d.CreatedAt,
@ -572,11 +577,10 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string {
Source: "manual", Source: "manual",
} }
// 模型显式关联的媒体:标记补进正文后再写入。顺序关键——向量索引用 // 模型显式关联的媒体:直接变成文档持有的一等块。
// Summary+Content 计算,标记进不去正文就检索不到这份媒体。 // 不再往正文写 marker——文档向量会融合这些块的媒体向量,
mediaDigests := a.resolveMediaDigests(getStringSlice(tc.Arguments, "media_digests")) // 图片按自己的向量被检索。
doc.Content = a.sentenceWithMediaMarkers(doc.Content, mediaDigests) for _, d := range a.resolveMediaDigests(getStringSlice(tc.Arguments, "media_digests")) {
for _, d := range mediaDigests {
if b, ok := a.blockFromDigest(d); ok { if b, ok := a.blockFromDigest(d); ok {
doc.Blocks = append(doc.Blocks, b) doc.Blocks = append(doc.Blocks, b)
} }

View File

@ -651,7 +651,6 @@ func (r *ConfigRegistry) seedCoreDefs(dataDir string) {
reg(ConfigDef{Key: "core.memory.documents", Default: filepath.Join(dataDir, "memory", "documents"), Type: "string", DisplayName: "文档记忆路径", Description: "文档记忆存储目录", Category: "paths"}) reg(ConfigDef{Key: "core.memory.documents", Default: filepath.Join(dataDir, "memory", "documents"), Type: "string", DisplayName: "文档记忆路径", Description: "文档记忆存储目录", Category: "paths"})
reg(ConfigDef{Key: "core.memory.media.enabled", Default: "true", Type: "bool", DisplayName: "媒体记忆", Description: "把对话里出现的图片/音频变成一等记忆块,内容按 sha256 落盘去重。关闭后媒体仅在当前对话内可见,下一轮起只剩路径或 alt 文本", Category: "memory"}) reg(ConfigDef{Key: "core.memory.media.enabled", Default: "true", Type: "bool", DisplayName: "媒体记忆", Description: "把对话里出现的图片/音频变成一等记忆块,内容按 sha256 落盘去重。关闭后媒体仅在当前对话内可见,下一轮起只剩路径或 alt 文本", Category: "memory"})
reg(ConfigDef{Key: "core.memory.media.dir", Default: filepath.Join(dataDir, "memory", "media"), Type: "string", DisplayName: "媒体存储路径", Description: "媒体内容寻址存储目录(内含 media.db 与 blobs/)", Category: "paths"}) reg(ConfigDef{Key: "core.memory.media.dir", Default: filepath.Join(dataDir, "memory", "media"), Type: "string", DisplayName: "媒体存储路径", Description: "媒体内容寻址存储目录(内含 media.db 与 blobs/)", Category: "paths"})
reg(ConfigDef{Key: "core.memory.media.describe_on_ingest", Default: "false", Type: "bool", DisplayName: "自动描述媒体", Description: "后台用视觉/音频模型给未描述的媒体生成文字描述。描述文本是可检索的语义入口,供各层记忆复用。代价是消耗视觉模型配额(单张图实测约 10s),故默认关闭;开启后每 30s 最多处理 4 条,不跟对话抢额度", Category: "memory"})
reg(ConfigDef{Key: "core.memory.multimodal_space.type", Default: "", Type: "string", DisplayName: "多模态向量空间类型", Description: "可插拔多模态向量空间的实现类型。onnx = 内嵌 Qwen3-VL 完整图文共享模型;http = 外部向量 API 服务。留空禁用多模态向量检索,只保留 fastText 文本路径。两条路径共享同一套 L0/L2/L3 向量缓存与检索基础设施。", Category: "memory"}) reg(ConfigDef{Key: "core.memory.multimodal_space.type", Default: "", Type: "string", DisplayName: "多模态向量空间类型", Description: "可插拔多模态向量空间的实现类型。onnx = 内嵌 Qwen3-VL 完整图文共享模型;http = 外部向量 API 服务。留空禁用多模态向量检索,只保留 fastText 文本路径。两条路径共享同一套 L0/L2/L3 向量缓存与检索基础设施。", Category: "memory"})
reg(ConfigDef{Key: "core.memory.multimodal_space.onnx.model_dir", Default: "", Type: "string", DisplayName: "Qwen 多模态 ONNX 目录", Description: "完整 Qwen3-VL 图文共享向量模型目录(含 TokenEmbedding.onnx、Transformer.onnx、Vision.onnx、外部权重、tokenizer.json、embed_config.json)。type=onnx 时必填,修改后需重启生效。", Category: "memory"}) reg(ConfigDef{Key: "core.memory.multimodal_space.onnx.model_dir", Default: "", Type: "string", DisplayName: "Qwen 多模态 ONNX 目录", Description: "完整 Qwen3-VL 图文共享向量模型目录(含 TokenEmbedding.onnx、Transformer.onnx、Vision.onnx、外部权重、tokenizer.json、embed_config.json)。type=onnx 时必填,修改后需重启生效。", Category: "memory"})
reg(ConfigDef{Key: "core.memory.multimodal_space.http.endpoint", Default: "", Type: "string", DisplayName: "外部向量 API 端点", Description: "外部多模态向量服务的 HTTP 端点 URL(POST,接受 modality/side/text/data/mime,返回 embedding)。type=http 时必填。", Category: "memory"}) reg(ConfigDef{Key: "core.memory.multimodal_space.http.endpoint", Default: "", Type: "string", DisplayName: "外部向量 API 端点", Description: "外部多模态向量服务的 HTTP 端点 URL(POST,接受 modality/side/text/data/mime,返回 embedding)。type=http 时必填。", Category: "memory"})

View File

@ -7,11 +7,11 @@ import (
) )
type bilingualEvent struct { type bilingualEvent struct {
idx int idx int
source string source string
topic string topic string
text string // cleaned text for vectorization text string // cleaned text for vectorization
label string // short description label string // short description
} }
func TestBilingualPruningAccuracy(t *testing.T) { func TestBilingualPruningAccuracy(t *testing.T) {
@ -40,16 +40,16 @@ func TestBilingualPruningAccuracy(t *testing.T) {
t.Logf("%s: %d words", cfg.name, len(e.words)) t.Logf("%s: %d words", cfg.name, len(e.words))
type scored struct { type scored struct {
idx int idx int
topic string topic string
label string label string
score float64 score float64
} }
queries := []struct { queries := []struct {
q string q string
qTopic string qTopic string
desc string desc string
}{ }{
{"老大说了关于 React 组件的事情", "老大私聊", "中英混合:老大+React"}, {"老大说了关于 React 组件的事情", "老大私聊", "中英混合:老大+React"},
{"帮我查一下 Nginx 反向代理配置", "服务器运维", "中英混合:Nginx+反向代理"}, {"帮我查一下 Nginx 反向代理配置", "服务器运维", "中英混合:Nginx+反向代理"},
@ -189,10 +189,10 @@ func TestBilingualVectorizeClean(t *testing.T) {
func genBilingualEvents() []bilingualEvent { func genBilingualEvents() []bilingualEvent {
entries := []struct { entries := []struct {
topic string topic string
zh string // Chinese description zh string // Chinese description
en string // English terms mixed in en string // English terms mixed in
source string source string
}{ }{
{"大学招生", "河南医药大学录取分数线", "", "qq"}, {"大学招生", "河南医药大学录取分数线", "", "qq"},
{"大学招生", "医学院专业排名", "medical university ranking", "agent"}, {"大学招生", "医学院专业排名", "medical university ranking", "agent"},

View File

@ -40,7 +40,7 @@ type MemoryBlock struct {
} }
// MemoryBlockEdge 是 L3 中连接一等记忆节点的结构化语义边。 // MemoryBlockEdge 是 L3 中连接一等记忆节点的结构化语义边。
// source/target kind 当前允许 block、entity、sentence。 // source/target kind 当前允许 block、entity、sentence、document。
type MemoryBlockEdge struct { type MemoryBlockEdge struct {
ID int64 `json:"id"` ID int64 `json:"id"`
SourceKind string `json:"source_kind"` SourceKind string `json:"source_kind"`
@ -111,6 +111,19 @@ func (g *GraphDB) PutMemoryBlocks(blocks []MemoryBlock) error {
return tx.Commit() return tx.Commit()
} }
// PutDocumentNode 在 L3 登记一个文档节点,作为 document --contains--> block
// 结构边的端点。文档正文已蒸馏为实体/关系,这里只保留身份与摘要。
func (g *GraphDB) PutDocumentNode(id, summary string) error {
if id == "" {
return fmt.Errorf("document node id is required")
}
g.mu.Lock()
defer g.mu.Unlock()
_, err := g.db.Exec(`INSERT INTO documents (id, summary) VALUES (?, ?)
ON CONFLICT(id) DO UPDATE SET summary = excluded.summary`, id, summary)
return err
}
// MemoryBlocks 查询 Graph 层实际持有的一等记忆节点。 // MemoryBlocks 查询 Graph 层实际持有的一等记忆节点。
func (g *GraphDB) MemoryBlocks() ([]MemoryBlock, error) { func (g *GraphDB) MemoryBlocks() ([]MemoryBlock, error) {
g.mu.RLock() g.mu.RLock()
@ -144,7 +157,7 @@ func (g *GraphDB) MemoryBlocks() ([]MemoryBlock, error) {
} }
func validGraphNodeKind(kind string) bool { func validGraphNodeKind(kind string) bool {
return kind == "block" || kind == "entity" || kind == "sentence" return kind == "block" || kind == "entity" || kind == "sentence" || kind == "document"
} }
func graphNodeExists(tx *sql.Tx, kind, id string) (bool, error) { func graphNodeExists(tx *sql.Tx, kind, id string) (bool, error) {
@ -157,6 +170,8 @@ func graphNodeExists(tx *sql.Tx, kind, id string) (bool, error) {
err = tx.QueryRow(`SELECT COUNT(*) FROM entities WHERE CAST(id AS TEXT) = ?`, id).Scan(&n) err = tx.QueryRow(`SELECT COUNT(*) FROM entities WHERE CAST(id AS TEXT) = ?`, id).Scan(&n)
case "sentence": case "sentence":
err = tx.QueryRow(`SELECT COUNT(*) FROM sentences WHERE CAST(id AS TEXT) = ?`, id).Scan(&n) err = tx.QueryRow(`SELECT COUNT(*) FROM sentences WHERE CAST(id AS TEXT) = ?`, id).Scan(&n)
case "document":
err = tx.QueryRow(`SELECT COUNT(*) FROM documents WHERE id = ?`, id).Scan(&n)
default: default:
return false, fmt.Errorf("invalid graph node kind %q", kind) return false, fmt.Errorf("invalid graph node kind %q", kind)
} }

View File

@ -28,13 +28,13 @@ func cleanQQTemplate(text string) string {
} }
type cleanTestEvent struct { type cleanTestEvent struct {
idx int idx int
source string source string
input string input string
response string response string
rawText string rawText string
cleanedText string cleanedText string
topic string topic string
} }
func TestCleanStressPrecision(t *testing.T) { func TestCleanStressPrecision(t *testing.T) {
@ -59,53 +59,53 @@ func TestCleanStressPrecision(t *testing.T) {
} }
t.Logf("topics: %v, events: %d", usedTopics, len(events)) t.Logf("topics: %v, events: %d", usedTopics, len(events))
for _, qTopic := range usedTopics { for _, qTopic := range usedTopics {
query := queryForTopic(qTopic) query := queryForTopic(qTopic)
qVec := e.Vectorize(query) qVec := e.Vectorize(query)
type scored struct { type scored struct {
idx int idx int
topic string topic string
text string text string
score float64 score float64
} }
all := make([]scored, len(events)) all := make([]scored, len(events))
for i, ev := range events { for i, ev := range events {
text := ev.rawText text := ev.rawText
if cleanMode { if cleanMode {
text = ev.cleanedText text = ev.cleanedText
}
vec := e.Vectorize(text)
all[i] = scored{idx: i, topic: ev.topic, text: text, score: cosineSim(qVec, vec)}
}
sort.Slice(all, func(i, j int) bool { return all[i].score > all[j].score })
topK := len(usedTopics) * 2
if topK > len(all) {
topK = len(all)
}
intraHits := 0
for _, s := range all[:topK] {
if s.topic == qTopic {
intraHits++
}
}
expected := countTopicEvents(events, qTopic)
if expected > topK {
expected = topK
}
recall := float64(intraHits) / float64(expected)
if recall < 0.3 {
t.Logf(" [LOW] query=%q topK=%d intra=%d/%d recall=%.2f", qTopic, topK, intraHits, expected, recall)
for _, s := range all[:8] {
t.Logf(" [%.4f] %s", s.score, trimLen(s.text, 60))
}
} else {
t.Logf(" [OK] query=%q topK=%d intra=%d/%d recall=%.2f", qTopic, topK, intraHits, expected, recall)
}
} }
vec := e.Vectorize(text)
all[i] = scored{idx: i, topic: ev.topic, text: text, score: cosineSim(qVec, vec)}
}
sort.Slice(all, func(i, j int) bool { return all[i].score > all[j].score })
topK := len(usedTopics) * 2
if topK > len(all) {
topK = len(all)
}
intraHits := 0
for _, s := range all[:topK] {
if s.topic == qTopic {
intraHits++
}
}
expected := countTopicEvents(events, qTopic)
if expected > topK {
expected = topK
}
recall := float64(intraHits) / float64(expected)
if recall < 0.3 {
t.Logf(" [LOW] query=%q topK=%d intra=%d/%d recall=%.2f", qTopic, topK, intraHits, expected, recall)
for _, s := range all[:8] {
t.Logf(" [%.4f] %s", s.score, trimLen(s.text, 60))
}
} else {
t.Logf(" [OK] query=%q topK=%d intra=%d/%d recall=%.2f", qTopic, topK, intraHits, expected, recall)
}
}
}) })
} }
} }
@ -252,11 +252,11 @@ func genStressEvents(n int) []cleanTestEvent {
keywords []string keywords []string
sources []string sources []string
}{ }{
"大学招生": {[]string{"河南医药大学", "录取分数线", "专业排名", "高考志愿", "招生简章"}, []string{"qq", "qq", "agent"}}, "大学招生": {[]string{"河南医药大学", "录取分数线", "专业排名", "高考志愿", "招生简章"}, []string{"qq", "qq", "agent"}},
"老大私聊": {[]string{"老大私聊消息", "回复老大", "任务安排", "汇报工作", "收到"}, []string{"qq", "agent", "agent"}}, "老大私聊": {[]string{"老大私聊消息", "回复老大", "任务安排", "汇报工作", "收到"}, []string{"qq", "agent", "agent"}},
"前端开发": {[]string{"前端组件封装", "页面路由配置", "界面布局设计", "交互逻辑开发", "代码调试优化"}, []string{"cli", "cli", "agent"}}, "前端开发": {[]string{"前端组件封装", "页面路由配置", "界面布局设计", "交互逻辑开发", "代码调试优化"}, []string{"cli", "cli", "agent"}},
"服务器运维": {[]string{"反向代理配置", "容器部署方案", "证书续期", "数据库备份恢复", "监控告警处理"}, []string{"cli", "agent", "agent"}}, "服务器运维": {[]string{"反向代理配置", "容器部署方案", "证书续期", "数据库备份恢复", "监控告警处理"}, []string{"cli", "agent", "agent"}},
"股票基金": {[]string{"基金定投策略", "股票涨跌分析", "理财收益计算", "市场行情分析", "投资风险管理"}, []string{"qq", "qq", "agent"}}, "股票基金": {[]string{"基金定投策略", "股票涨跌分析", "理财收益计算", "市场行情分析", "投资风险管理"}, []string{"qq", "qq", "agent"}},
} }
for i := 0; i < n; i++ { for i := 0; i < n; i++ {
@ -292,13 +292,13 @@ func genStressEvents(n int) []cleanTestEvent {
cleaned := cleanEventText(src, input, response) cleaned := cleanEventText(src, input, response)
raw := rawEventText(src, input, response) raw := rawEventText(src, input, response)
events = append(events, cleanTestEvent{ events = append(events, cleanTestEvent{
idx: i, idx: i,
source: src, source: src,
input: input, input: input,
response: response, response: response,
rawText: raw, rawText: raw,
cleanedText: cleaned, cleanedText: cleaned,
topic: tp, topic: tp,
}) })
} }
return events return events

View File

@ -1,484 +0,0 @@
//go:build onnxruntime
// Package clip 提供基于 CLIP ONNX 的稠密多模态编码器。
//
// 构建标签 onnxruntime 控制是否编译此实现(与 internal/nlp/onnx.go 同模式)。
// 未配置 clip_model_dir 时不会初始化 ONNX Runtime,现有 fastText/TF-IDF 行为不变。
//
// 支持的模型文件(统一放置于 clip_model_dir 目录):
//
// text.onnx — CLIP 文本编码器(input_ids + attention_mask → text_embed [1,512])
// vision.onnx — CLIP 图像编码器(pixel_values → image_embed [1,512])
// clip_config.json — 模型元数据(dimension, context_length, image_size, mean, std)
// tokenizer.json — HuggingFace tokenizer.json(含 vocab + merges)
// merges.txt — BPE merges 文件
//
// 设计:只产出**稠密** 512 维向量(VectorizeDense / EmbedImageDense),供媒体层
// media.Store 的稠密余弦检索(QueryMedia)消费。刻意不实现 vector.Vectorizer
// (稀疏词向量)——文档/知识/上下文层的文本相似度检索保留 TF-IDF 高频削弱加权 +
// 倒排剪枝 + fastText 稀疏词向量的既有设计,CLIP 稠密空间不混入那套索引。
package clip
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"image"
_ "image/jpeg"
_ "image/png"
"log"
"math"
"os"
"path/filepath"
"strings"
"sync"
ort "github.com/yalue/onnxruntime_go"
)
// clipConfig 描述模型的超参数与归一化常数。
type clipConfig struct {
Model string `json:"model"`
Dimension int `json:"dimension"`
ContextLength int `json:"context_length"`
ImageSize int `json:"image_size"`
Mean []float64 `json:"mean"`
Std []float64 `json:"std"`
}
// Embedder 是 CLIP 多模态稠密编码器,实现 vector.MultimodalEmbedder。
// 产出 512 维共享稠密空间向量,供 media.Store 的稠密余弦检索。
//
// 刻意**不**实现 vector.Vectorizer(稀疏词向量):CLIP 稠密向量若以
// map[string]float64 稀疏形式塞进文档/知识层的 vector.Store,会破坏其
// TF-IDF 高频削弱加权与倒排剪枝语义。文本层的相似度检索保持 TF-IDF/fastText。
type Embedder struct {
mu sync.RWMutex
config clipConfig
vocab map[string]int64
merges []string
textSess *ort.DynamicAdvancedSession
imgSess *ort.DynamicAdvancedSession
close sync.Once
loaded bool
fingerprint string
}
// Fingerprint 返回当前模型目录的指纹(文本+视觉模型文件 SHA256 拼接),
// 用于检测模型切换后触发重算。
func (e *Embedder) Fingerprint() string {
e.mu.RLock()
defer e.mu.RUnlock()
return e.fingerprint
}
// Dim 返回向量维度。
func (e *Embedder) Dim() int {
e.mu.RLock()
defer e.mu.RUnlock()
return e.config.Dimension
}
// Loaded 返回加载状态。
func (e *Embedder) Loaded() bool {
e.mu.RLock()
defer e.mu.RUnlock()
return e.loaded
}
// VectorizeDense 将文本转为归一化的 []float64 向量(CLIP 共享空间)。
func (e *Embedder) VectorizeDense(text string) ([]float64, error) {
e.mu.RLock()
defer e.mu.RUnlock()
if !e.loaded {
return nil, fmt.Errorf("clip embedder not loaded")
}
tokens := tokenizeCLIP(text, e.vocab, e.merges, e.config.ContextLength)
if len(tokens) == 0 {
return make([]float64, e.config.Dimension), nil
}
dim := e.config.Dimension
inputIDs := make([]int64, e.config.ContextLength)
attnMask := make([]int64, e.config.ContextLength)
for i, tok := range tokens {
if i >= e.config.ContextLength {
break
}
inputIDs[i] = tok
attnMask[i] = 1
}
idTensor, err := ort.NewTensor(ort.Shape{1, int64(e.config.ContextLength)}, inputIDs)
if err != nil {
return nil, fmt.Errorf("input_ids tensor: %w", err)
}
defer idTensor.Destroy()
maskTensor, err := ort.NewTensor(ort.Shape{1, int64(e.config.ContextLength)}, attnMask)
if err != nil {
return nil, fmt.Errorf("attention_mask tensor: %w", err)
}
defer maskTensor.Destroy()
featTensor, err := ort.NewEmptyTensor[float32](ort.Shape{1, int64(dim)})
if err != nil {
return nil, fmt.Errorf("output tensor: %w", err)
}
defer featTensor.Destroy()
if err := e.textSess.Run([]ort.Value{idTensor, maskTensor}, []ort.Value{featTensor}); err != nil {
return nil, fmt.Errorf("text run: %w", err)
}
raw := featTensor.GetData()
out := make([]float64, dim)
var norm float64
for i, v := range raw {
out[i] = float64(v)
norm += out[i] * out[i]
}
if norm > 0 {
norm = math.Sqrt(norm)
for i := range out {
out[i] /= norm
}
}
return out, nil
}
// EmbedImageDense 将图像字节转为归一化的 []float64 向量(CLIP 共享空间)。
func (e *Embedder) EmbedImageDense(img []byte, mime string) ([]float64, error) {
e.mu.RLock()
defer e.mu.RUnlock()
if !e.loaded {
return nil, fmt.Errorf("clip embedder not loaded")
}
return e.embedImageDenseUnlocked(img, mime)
}
func (e *Embedder) embedImageDenseUnlocked(img []byte, mime string) ([]float64, error) {
decoded, _, err := image.Decode(bytes.NewReader(img))
if err != nil {
return nil, fmt.Errorf("decode image: %w", err)
}
size := e.config.ImageSize
resized := resizeImage(decoded, size, size)
pixels := make([]float32, 3*size*size)
for y := 0; y < size; y++ {
for x := 0; x < size; x++ {
r, g, b, _ := resized.At(x, y).RGBA()
rf := float64(r) / 65535.0
gf := float64(g) / 65535.0
bf := float64(b) / 65535.0
for c, v := range []float64{rf, gf, bf} {
norm := (v - e.config.Mean[c]) / e.config.Std[c]
pixels[c*size*size+y*size+x] = float32(norm)
}
}
}
pixelTensor, err := ort.NewTensor(ort.Shape{1, 3, int64(size), int64(size)}, pixels)
if err != nil {
return nil, fmt.Errorf("pixel_values tensor: %w", err)
}
defer pixelTensor.Destroy()
dim := e.config.Dimension
featTensor, err := ort.NewEmptyTensor[float32](ort.Shape{1, int64(dim)})
if err != nil {
return nil, fmt.Errorf("output tensor: %w", err)
}
defer featTensor.Destroy()
if err := e.imgSess.Run([]ort.Value{pixelTensor}, []ort.Value{featTensor}); err != nil {
return nil, fmt.Errorf("vision run: %w", err)
}
raw := featTensor.GetData()
out := make([]float64, dim)
var norm float64
for i, v := range raw {
out[i] = float64(v)
norm += out[i] * out[i]
}
if norm > 0 {
norm = math.Sqrt(norm)
for i := range out {
out[i] /= norm
}
}
return out, nil
}
// Close 释放 ONNX Runtime 资源。
func (e *Embedder) Close() {
e.close.Do(func() {
e.mu.Lock()
defer e.mu.Unlock()
if e.textSess != nil {
e.textSess.Destroy()
}
if e.imgSess != nil {
e.imgSess.Destroy()
}
e.loaded = false
})
}
// New 从目录加载 CLIP 模型。目录需包含 text.onnx、vision.onnx、
// clip_config.json、tokenizer.json、merges.txt。
func New(modelDir string) (*Embedder, error) {
if modelDir == "" {
return nil, fmt.Errorf("clip model dir not specified")
}
// 读取配置
cfgData, err := os.ReadFile(filepath.Join(modelDir, "clip_config.json"))
if err != nil {
return nil, fmt.Errorf("read clip_config.json: %w", err)
}
var cfg clipConfig
if err := json.Unmarshal(cfgData, &cfg); err != nil {
return nil, fmt.Errorf("parse clip_config.json: %w", err)
}
if cfg.Dimension <= 0 || cfg.ContextLength <= 0 || cfg.ImageSize <= 0 {
return nil, fmt.Errorf("invalid clip config: dim=%d ctx=%d img=%d", cfg.Dimension, cfg.ContextLength, cfg.ImageSize)
}
if len(cfg.Mean) != 3 || len(cfg.Std) != 3 {
return nil, fmt.Errorf("clip config mean/std must have 3 channels")
}
// 加载 tokenizer
vocab, err := loadTokenizerVocab(filepath.Join(modelDir, "tokenizer.json"))
if err != nil {
return nil, fmt.Errorf("load tokenizer: %w", err)
}
merges, err := loadMerges(filepath.Join(modelDir, "merges.txt"))
if err != nil {
return nil, fmt.Errorf("load merges: %w", err)
}
// 初始化 ONNX Runtime(只初始化一次)
if !ort.IsInitialized() {
// 尝试从 nlp 同样的路径查找 libonnxruntime.so
libPath := findOnnxLib()
if libPath != "" {
ort.SetSharedLibraryPath(libPath)
}
if err := ort.InitializeEnvironment(); err != nil {
return nil, fmt.Errorf("init onnx env: %w", err)
}
}
// 创建文本编码器会话
textSess, err := ort.NewDynamicAdvancedSession(
filepath.Join(modelDir, "text.onnx"),
[]string{"input_ids", "attention_mask"},
[]string{"text_embed"},
nil,
)
if err != nil {
return nil, fmt.Errorf("create text session: %w", err)
}
// 创建视觉编码器会话
imgSess, err := ort.NewDynamicAdvancedSession(
filepath.Join(modelDir, "vision.onnx"),
[]string{"pixel_values"},
[]string{"image_embed"},
nil,
)
if err != nil {
textSess.Destroy()
return nil, fmt.Errorf("create vision session: %w", err)
}
// 计算模型指纹
fp := computeFingerprint(modelDir)
log.Printf("[clip] loaded %s dim=%d ctx=%d img=%d from %s (fp=%s)", cfg.Model, cfg.Dimension, cfg.ContextLength, cfg.ImageSize, modelDir, fp[:12])
return &Embedder{
config: cfg,
vocab: vocab,
merges: merges,
textSess: textSess,
imgSess: imgSess,
loaded: true,
fingerprint: fp,
}, nil
}
// computeFingerprint 计算模型文件指纹(text.onnx + vision.onnx 的 SHA256)。
func computeFingerprint(modelDir string) string {
h := sha256.New()
for _, name := range []string{"text.onnx", "vision.onnx"} {
data, err := os.ReadFile(filepath.Join(modelDir, name))
if err != nil {
continue
}
h.Write(data)
h.Write([]byte{0}) // 分隔符
}
return hex.EncodeToString(h.Sum(nil))
}
// findOnnxLib 在常见路径中查找 libonnxruntime.so。
func findOnnxLib() string {
for _, p := range []string{
"/opt/onnxruntime/libonnxruntime.so",
"libonnxruntime.so",
} {
if _, err := os.Stat(p); err == nil {
return p
}
}
return ""
}
// resizeImage 使用最近邻将 src 缩放到 dstW×dstH。
// 生产中应使用双线性插值,此处为 MVP 简化。
func resizeImage(src image.Image, dstW, dstH int) image.Image {
srcB := src.Bounds()
srcW := srcB.Dx()
srcH := srcB.Dy()
if srcW == dstW && srcH == dstH {
return src
}
dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
for y := 0; y < dstH; y++ {
for x := 0; x < dstW; x++ {
sx := srcB.Min.X + x*srcW/dstW
sy := srcB.Min.Y + y*srcH/dstH
dst.Set(x, y, src.At(sx, sy))
}
}
return dst
}
// ---- BPE Tokenizer ----
// loadTokenizerVocab 从 HuggingFace tokenizer.json 中提取 vocab(token→id 映射)。
func loadTokenizerVocab(path string) (map[string]int64, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var tok struct {
Model struct {
Vocab map[string]int64 `json:"vocab"`
} `json:"model"`
}
if err := json.Unmarshal(data, &tok); err != nil {
return nil, err
}
if len(tok.Model.Vocab) == 0 {
return nil, fmt.Errorf("empty vocab in %s", path)
}
return tok.Model.Vocab, nil
}
// loadMerges 从 merges.txt 加载 BPE 合并规则。
func loadMerges(path string) ([]string, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
// 第一行是版本号("#version: 0.2"),跳过
var merges []string
for _, line := range lines[1:] {
line = strings.TrimSpace(line)
if line == "" {
continue
}
merges = append(merges, line)
}
return merges, nil
}
// tokenizeCLIP 将文本分词为模型 vocab 中的 token id 序列。
//
// 此模型(transformers 5.x 导出的 CLIP tokenizer.json)是**词级 BPE**:
// 词末 token 带 </w> 后缀("a</w>"=320、"red</w>"=736),词中片段不带。
// 流程:lowercase → 按空白/标点拆词 → 每词做字符级 BPE 合并 →
// 末尾片段加 </w> 查 vocab,其余片段直接查;查不到则丢弃。
func tokenizeCLIP(text string, vocab map[string]int64, merges []string, maxLen int) []int64 {
rank := make(map[string]int, len(merges))
for i, m := range merges {
rank[m] = i
}
const endTok = "</w>"
var tokens []int64
if id, ok := vocab["<|startoftext|>"]; ok {
tokens = append(tokens, id)
}
for _, word := range strings.Fields(strings.ToLower(text)) {
seq := make([]string, 0, len(word))
for _, ch := range word {
seq = append(seq, string(ch))
}
merged := bpeMerge(seq, rank)
for i, t := range merged {
lookup := t
if i == len(merged)-1 {
// 词末片段带 </w>
lookup = t + endTok
}
if id, ok := vocab[lookup]; ok {
tokens = append(tokens, id)
}
}
}
if id, ok := vocab["<|endoftext|>"]; ok {
tokens = append(tokens, id)
}
if len(tokens) > maxLen {
tokens = tokens[:maxLen]
}
return tokens
}
// bpeMerge 对单个词的字符序列应用 BPE 合并直到无可合并对。
// rank[pair] 越小越优先(merges.txt 顺序)。
func bpeMerge(seq []string, rank map[string]int) []string {
for len(seq) > 1 {
// 找 rank 最低的可合并相邻对
bestRank := -1
bestPair := ""
for i := 0; i < len(seq)-1; i++ {
pair := seq[i] + " " + seq[i+1]
if r, ok := rank[pair]; ok && (bestRank < 0 || r < bestRank) {
bestRank = r
bestPair = pair
}
}
if bestPair == "" {
break
}
parts := strings.SplitN(bestPair, " ", 2)
merged := parts[0] + parts[1]
// 一次性合并所有相邻的该 pair
var out []string
for i := 0; i < len(seq); i++ {
if i < len(seq)-1 && seq[i] == parts[0] && seq[i+1] == parts[1] {
out = append(out, merged)
i++
} else {
out = append(out, seq[i])
}
}
seq = out
}
return seq
}

View File

@ -1,28 +0,0 @@
//go:build !onnxruntime
package clip
import (
"fmt"
)
// Embedder 在未启用 onnxruntime 时为 no-op 实现。
// 构建时不链接 onnxruntime,默认构建保持原有 fastText/TF-IDF 行为不变。
type Embedder struct {
loaded bool
}
func New(_ string) (*Embedder, error) {
return nil, fmt.Errorf("clip embedder requires build tag 'onnxruntime' (go build -tags onnxruntime)")
}
func (e *Embedder) Fingerprint() string { return "" }
func (e *Embedder) Dim() int { return 0 }
func (e *Embedder) Loaded() bool { return e.loaded }
func (e *Embedder) Close() {}
func (e *Embedder) VectorizeDense(_ string) ([]float64, error) {
return nil, fmt.Errorf("clip embedder not available")
}
func (e *Embedder) EmbedImageDense(_ []byte, _ string) ([]float64, error) {
return nil, fmt.Errorf("clip embedder not available")
}

View File

@ -1,154 +0,0 @@
//go:build onnxruntime
package clip
import (
"bytes"
"fmt"
"image"
"image/color"
"image/png"
"os"
"testing"
)
func TestSmokeLoadAndEncode(t *testing.T) {
modelDir := os.Getenv("CLIP_MODEL_DIR")
if modelDir == "" {
modelDir = "/home/newqqagent/models/clip-vit-b32"
}
if _, err := os.Stat(modelDir + "/text.onnx"); err != nil {
t.Skipf("模型目录不存在: %v", err)
}
emb, err := New(modelDir)
if err != nil {
t.Fatalf("New: %v", err)
}
defer emb.Close()
if !emb.Loaded() {
t.Fatal("loaded should be true")
}
if emb.Dim() != 512 {
t.Fatalf("dim = %d, want 512", emb.Dim())
}
if emb.Fingerprint() == "" {
t.Fatal("fingerprint should not be empty")
}
// 文本编码
textVec, err := emb.VectorizeDense("a photo of a cat")
if err != nil {
t.Fatalf("VectorizeDense: %v", err)
}
if len(textVec) != 512 {
t.Fatalf("text vec len = %d, want 512", len(textVec))
}
fmt.Printf("text vec[:5] = %v\n", textVec[:5])
// 同义文本应比远义文本更相似
textVec2, _ := emb.VectorizeDense("a photograph of a dog")
textVec3, _ := emb.VectorizeDense("quantum physics equations")
sim12 := cosineSim(textVec, textVec2)
sim13 := cosineSim(textVec, textVec3)
fmt.Printf("cat vs dog = %.4f, cat vs physics = %.4f\n", sim12, sim13)
if sim12 <= sim13 {
t.Errorf("cat-dog sim (%.4f) should be > cat-physics sim (%.4f)", sim12, sim13)
}
// 稠密 VectorizeDense 再次调用验证可重复
vAgain, _ := emb.VectorizeDense("hello world")
if len(vAgain) != 512 {
t.Errorf("dense VectorizeDense len = %d, want 512", len(vAgain))
}
fmt.Printf("dense len = %d\n", len(vAgain))
}
// TestCrossModalAlignment 验证图文在同一向量空间可比:
// 红底图的向量应与 "a red image" 更相似,而非 "a blue image"。
func TestCrossModalAlignment(t *testing.T) {
modelDir := os.Getenv("CLIP_MODEL_DIR")
if modelDir == "" {
modelDir = "/home/newqqagent/models/clip-vit-b32"
}
emb, err := New(modelDir)
if err != nil {
t.Fatalf("New: %v", err)
}
defer emb.Close()
// 生成 224x224 纯红底 PNG
img := image.NewRGBA(image.Rect(0, 0, 224, 224))
red := color.RGBA{220, 40, 40, 255}
for y := 0; y < 224; y++ {
for x := 0; x < 224; x++ {
img.Set(x, y, red)
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatal(err)
}
imgVec, err := emb.EmbedImageDense(buf.Bytes(), "image/png")
if err != nil {
t.Fatalf("EmbedImageDense: %v", err)
}
if len(imgVec) != 512 {
t.Fatalf("img vec len = %d, want 512", len(imgVec))
}
redText, _ := emb.VectorizeDense("a red image")
blueText, _ := emb.VectorizeDense("a blue image")
redSim := cosineSim(imgVec, redText)
blueSim := cosineSim(imgVec, blueText)
fmt.Printf("red-image vs red-text = %.4f, vs blue-text = %.4f\n", redSim, blueSim)
if redSim <= blueSim {
t.Errorf("red image should align better with red text (%.4f) than blue (%.4f)", redSim, blueSim)
}
// 同图应比异图更相似:存两张不同颜色,query 用红底图应召回红图
d1 := imgVec
blueImg := image.NewRGBA(image.Rect(0, 0, 224, 224))
blue := color.RGBA{40, 40, 220, 255}
for y := 0; y < 224; y++ {
for x := 0; x < 224; x++ {
blueImg.Set(x, y, blue)
}
}
var buf2 bytes.Buffer
png.Encode(&buf2, blueImg)
d2, _ := emb.EmbedImageDense(buf2.Bytes(), "image/png")
if cosineSim(d1, d2) >= 0.99 {
t.Errorf("red and blue images should differ (got sim %.4f)", cosineSim(d1, d2))
}
}
func cosineSim(a, b []float64) float64 {
if len(a) != len(b) {
return 0
}
var dot, na, nb float64
for i := range a {
dot += a[i] * b[i]
na += a[i] * a[i]
nb += b[i] * b[i]
}
if na == 0 || nb == 0 {
return 0
}
return dot / (sqrt(na) * sqrt(nb))
}
func sqrt(x float64) float64 {
if x <= 0 {
return 0
}
z := x
for i := 0; i < 50; i++ {
z = (z + x/z) / 2
}
return z
}

View File

@ -13,26 +13,26 @@ import (
// contentPOS 有实义的词性标签:只保留名词/动词/形容词/专名等 // contentPOS 有实义的词性标签:只保留名词/动词/形容词/专名等
var contentPOS = map[string]bool{ var contentPOS = map[string]bool{
"n": true, // 普通名词 "n": true, // 普通名词
"nr": true, // 人名 "nr": true, // 人名
"ns": true, // 地名 "ns": true, // 地名
"nt": true, // 机构名 "nt": true, // 机构名
"nw": true, // 作品名/URL "nw": true, // 作品名/URL
"nz": true, // 其他专名 "nz": true, // 其他专名
"v": true, // 动词 "v": true, // 动词
"vd": true, // 副动词 "vd": true, // 副动词
"vn": true, // 名动词 "vn": true, // 名动词
"a": true, // 形容词 "a": true, // 形容词
"ad": true, // 副形词 "ad": true, // 副形词
"an": true, // 名形词 "an": true, // 名形词
"i": true, // 成语 "i": true, // 成语
"l": true, // 习用语 "l": true, // 习用语
"j": true, // 简称 "j": true, // 简称
"s": true, // 处所词 "s": true, // 处所词
"f": true, // 方位词 "f": true, // 方位词
"b": true, // 区别词 "b": true, // 区别词
"z": true, // 状态词 "z": true, // 状态词
"t": true, // 时间词 "t": true, // 时间词
"eng": true, // 英文 "eng": true, // 英文
"x": true, // 非语素字 "x": true, // 非语素字
"zg": true, // 其他 "zg": true, // 其他

View File

@ -37,6 +37,7 @@ type Doc struct {
Blocks []memory.MemoryBlock `json:"blocks,omitempty"` // 一等记忆块(text/image/video/audio) Blocks []memory.MemoryBlock `json:"blocks,omitempty"` // 一等记忆块(text/image/video/audio)
Vector tfidf.Vector `json:"vector,omitempty"` // TF-IDF 稀疏向量(fallback 时持久化) Vector tfidf.Vector `json:"vector,omitempty"` // TF-IDF 稀疏向量(fallback 时持久化)
DenseVec []float64 `json:"dense_vec,omitempty"` // 多模态稠密向量(主路径) DenseVec []float64 `json:"dense_vec,omitempty"` // 多模态稠密向量(主路径)
DenseFP string `json:"dense_fp,omitempty"` // DenseVec 所属统一空间指纹,变化时触发重算
} }
// Store — 文档记忆存储。 // Store — 文档记忆存储。
@ -113,7 +114,7 @@ func (s *Store) SetDenseSpace(ds vector.MultimodalEmbedder) {
s.denseSpace = ds s.denseSpace = ds
} }
// BuildDenseIndex 为所有文档计算稠密向量。 // BuildDenseIndex 为所有文档计算稠密向量(文本 ⊕ 媒体块)。
func (s *Store) BuildDenseIndex(ds vector.MultimodalEmbedder) { func (s *Store) BuildDenseIndex(ds vector.MultimodalEmbedder) {
if ds == nil || !ds.Loaded() { if ds == nil || !ds.Loaded() {
return return
@ -123,21 +124,42 @@ func (s *Store) BuildDenseIndex(ds vector.MultimodalEmbedder) {
log.Printf("[document memory] building dense index for %d docs (dim=%d)", len(s.docs), ds.Dim()) log.Printf("[document memory] building dense index for %d docs (dim=%d)", len(s.docs), ds.Dim())
count := 0 count := 0
for _, doc := range s.docs { for _, doc := range s.docs {
if doc.DenseVec != nil && len(doc.DenseVec) == ds.Dim() { if doc.DenseVec != nil && len(doc.DenseVec) == ds.Dim() && doc.DenseFP == ds.Fingerprint() {
continue continue
} }
text := doc.Summary + " " + doc.Content vec := s.denseFor(doc)
vec, err := ds.VectorizeDense(text) if vec == nil {
if err != nil {
log.Printf("[document memory] dense embed failed %s: %v", doc.ID[:min(16, len(doc.ID))], err)
continue continue
} }
doc.DenseVec = vec doc.DenseVec = vec
doc.DenseFP = ds.Fingerprint()
count++ count++
} }
log.Printf("[document memory] dense index built: %d new vectors", count) log.Printf("[document memory] dense index built: %d new vectors", count)
} }
// denseFor 计算文档的稠密向量:文本向量与其一等记忆块的媒体向量融合。
//
// 只有与当前统一空间同指纹的块向量才参与融合:不同模型/维度的旧向量
// 属于另一个坐标系,混进去会算出一个两边都不像的方向。
// 任意一路缺失时退化为另一路;都不可用返回 nil。
func (s *Store) denseFor(doc *Doc) []float64 {
if s.denseSpace == nil || !s.denseSpace.Loaded() {
return nil
}
fp := s.denseSpace.Fingerprint()
var parts [][]float64
if tv, err := s.denseSpace.VectorizeDense(doc.Summary + " " + doc.Content); err == nil && len(tv) > 0 {
parts = append(parts, tv)
}
for _, b := range doc.Blocks {
if len(b.Vector) > 0 && b.Fingerprint == fp {
parts = append(parts, b.Vector)
}
}
return vector.FuseVectors(parts...)
}
// Reindex 重建 TF-IDF 索引(fallback 路径变更时调用)。 // Reindex 重建 TF-IDF 索引(fallback 路径变更时调用)。
func (s *Store) Reindex() { func (s *Store) Reindex() {
s.mu.Lock() s.mu.Lock()
@ -172,11 +194,10 @@ func (s *Store) Insert(doc *Doc) error {
text := doc.Summary + " " + doc.Content text := doc.Summary + " " + doc.Content
// 主路径:稠密向量 // 主路径:稠密向量(文本 ⊕ 媒体块)
if s.denseSpace != nil && s.denseSpace.Loaded() && len(doc.DenseVec) == 0 { if s.denseSpace != nil && s.denseSpace.Loaded() && len(doc.DenseVec) == 0 {
if dv, err := s.denseSpace.VectorizeDense(text); err == nil { doc.DenseVec = s.denseFor(doc)
doc.DenseVec = dv doc.DenseFP = s.denseSpace.Fingerprint()
}
} }
// Fallback 路径:缓存文本,延迟训练 // Fallback 路径:缓存文本,延迟训练
@ -232,6 +253,10 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry, _ interface{
d.Tags = tags d.Tags = tags
d.Entities = entities d.Entities = entities
d.Blocks = blocksFromEntries(entries) d.Blocks = blocksFromEntries(entries)
d.DenseVec = s.denseFor(d)
if s.denseSpace != nil {
d.DenseFP = s.denseSpace.Fingerprint()
}
s.dirty = true s.dirty = true
return d, nil return d, nil
} }
@ -249,6 +274,10 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry, _ interface{
Blocks: blocksFromEntries(entries), Blocks: blocksFromEntries(entries),
} }
s.docs[id] = doc s.docs[id] = doc
doc.DenseVec = s.denseFor(doc)
if s.denseSpace != nil {
doc.DenseFP = s.denseSpace.Fingerprint()
}
text := summary + " " + content text := summary + " " + content
if s.tfidfIdx != nil { if s.tfidfIdx != nil {
s.tfidfIdx.Add(id, text) s.tfidfIdx.Add(id, text)

View File

@ -15,7 +15,7 @@ func TestInsertAndQuery(t *testing.T) {
} }
defer os.RemoveAll(dir) defer os.RemoveAll(dir)
s := NewStore(dir) s := NewStore(dir, memory.TokenizeWords)
if err := s.Start(); err != nil { if err := s.Start(); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -48,7 +48,7 @@ func TestQuery(t *testing.T) {
} }
defer os.RemoveAll(dir) defer os.RemoveAll(dir)
s := NewStore(dir) s := NewStore(dir, memory.TokenizeWords)
s.Start() s.Start()
defer s.Stop() defer s.Stop()
@ -73,7 +73,7 @@ func TestContextToDoc(t *testing.T) {
} }
defer os.RemoveAll(dir) defer os.RemoveAll(dir)
s := NewStore(dir) s := NewStore(dir, memory.TokenizeWords)
s.Start() s.Start()
defer s.Stop() defer s.Stop()
@ -104,7 +104,7 @@ func TestFindColdDocs(t *testing.T) {
} }
defer os.RemoveAll(dir) defer os.RemoveAll(dir)
s := NewStore(dir) s := NewStore(dir, memory.TokenizeWords)
s.Start() s.Start()
defer s.Stop() defer s.Stop()
@ -136,7 +136,7 @@ func TestRecentDocs(t *testing.T) {
} }
defer os.RemoveAll(dir) defer os.RemoveAll(dir)
s := NewStore(dir) s := NewStore(dir, memory.TokenizeWords)
s.Start() s.Start()
defer s.Stop() defer s.Stop()
@ -160,7 +160,7 @@ func TestReindex(t *testing.T) {
} }
defer os.RemoveAll(dir) defer os.RemoveAll(dir)
s := NewStore(dir) s := NewStore(dir, memory.TokenizeWords)
s.Start() s.Start()
defer s.Stop() defer s.Stop()
@ -211,7 +211,7 @@ func TestInsertEmptyDoc(t *testing.T) {
} }
defer os.RemoveAll(dir) defer os.RemoveAll(dir)
s := NewStore(dir) s := NewStore(dir, memory.TokenizeWords)
s.Start() s.Start()
defer s.Stop() defer s.Stop()
@ -232,13 +232,13 @@ func TestPersistence(t *testing.T) {
defer os.RemoveAll(dir) defer os.RemoveAll(dir)
// 写 // 写
s1 := NewStore(dir) s1 := NewStore(dir, memory.TokenizeWords)
s1.Start() s1.Start()
s1.Insert(&Doc{Summary: "持久化测试", Content: "应该被保存到磁盘", Source: "manual"}) s1.Insert(&Doc{Summary: "持久化测试", Content: "应该被保存到磁盘", Source: "manual"})
s1.Stop() s1.Stop()
// 读 // 读
s2 := NewStore(dir) s2 := NewStore(dir, memory.TokenizeWords)
s2.Start() s2.Start()
defer s2.Stop() defer s2.Stop()
@ -269,7 +269,7 @@ func TestFlushNoDirty(t *testing.T) {
} }
defer os.RemoveAll(dir) defer os.RemoveAll(dir)
s := NewStore(dir) s := NewStore(dir, memory.TokenizeWords)
s.Start() s.Start()
// 不插任何文档,flush 不应报错 // 不插任何文档,flush 不应报错
@ -283,7 +283,7 @@ func TestRemove(t *testing.T) {
} }
defer os.RemoveAll(dir) defer os.RemoveAll(dir)
s := NewStore(dir) s := NewStore(dir, memory.TokenizeWords)
s.Start() s.Start()
defer s.Stop() defer s.Stop()
@ -329,7 +329,7 @@ func TestRemoveNonexistent(t *testing.T) {
} }
defer os.RemoveAll(dir) defer os.RemoveAll(dir)
s := NewStore(dir) s := NewStore(dir, memory.TokenizeWords)
s.Start() s.Start()
defer s.Stop() defer s.Stop()
@ -437,7 +437,7 @@ func TestContextToDocContentPreservesRawToolOutput(t *testing.T) {
} }
defer os.RemoveAll(dir) defer os.RemoveAll(dir)
s := NewStore(dir) s := NewStore(dir, memory.TokenizeWords)
s.Start() s.Start()
defer s.Stop() defer s.Stop()

View File

@ -45,6 +45,10 @@ type Triple struct {
SubjectType string `json:"subject_type,omitempty"` SubjectType string `json:"subject_type,omitempty"`
ObjectType string `json:"object_type,omitempty"` ObjectType string `json:"object_type,omitempty"`
SentenceText string `json:"sentence_text,omitempty"` // 原始句子文本,Commit时写入sentences表 SentenceText string `json:"sentence_text,omitempty"` // 原始句子文本,Commit时写入sentences表
// MediaDigests 是该三元组显式携带的媒体 digest(完整或前缀)。
// 媒体不再靠正文 marker 反解:结构化字段直接给出归属,
// 由调用方(core)把它变成 L3 一等块并与句子建立结构边。
MediaDigests []string `json:"media_digests,omitempty"`
} }
type GraphDB struct { type GraphDB struct {
@ -134,6 +138,11 @@ func (g *GraphDB) initSchema() error {
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(source_kind, source_id, target_kind, target_id, edge_type) UNIQUE(source_kind, source_id, target_kind, target_id, edge_type)
)`, )`,
`CREATE TABLE IF NOT EXISTS documents (
id TEXT PRIMARY KEY,
summary TEXT DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE INDEX IF NOT EXISTS idx_memory_blocks_modality ON memory_blocks(modality)`, `CREATE INDEX IF NOT EXISTS idx_memory_blocks_modality ON memory_blocks(modality)`,
`CREATE INDEX IF NOT EXISTS idx_memory_blocks_digest ON memory_blocks(payload_digest)`, `CREATE INDEX IF NOT EXISTS idx_memory_blocks_digest ON memory_blocks(payload_digest)`,
`CREATE INDEX IF NOT EXISTS idx_memory_block_edges_source ON memory_block_edges(source_kind, source_id)`, `CREATE INDEX IF NOT EXISTS idx_memory_block_edges_source ON memory_block_edges(source_kind, source_id)`,
@ -979,17 +988,42 @@ func (g *GraphDB) ClearSentenceID(relationID int64) error {
return err return err
} }
// CleanupOrphanedSentences 删除没有任何关系引用的句子,返回删除数 // CleanupOrphanedSentences 删除既无关系引用、也无媒体块边的句子,返回删除数。
//
// 两个条件都必须看:旧媒体实体被迁移成原生块后,那些句子可能只靠
// sentence --contains--> block 存活,若只看 relations 引用就会被误删,
// 连带把块边变成悬空引用。
func (g *GraphDB) CleanupOrphanedSentences() (int, error) { func (g *GraphDB) CleanupOrphanedSentences() (int, error) {
result, err := g.db.Exec( g.mu.Lock()
`DELETE FROM sentences WHERE id NOT IN ( defer g.mu.Unlock()
SELECT DISTINCT sentence_id FROM relations WHERE sentence_id != 0 tx, err := g.db.Begin()
)`,
)
if err != nil { if err != nil {
return 0, err return 0, err
} }
n, _ := result.RowsAffected() defer tx.Rollback()
// 先清掉指向将被删除句子的块边,避免留下悬空端点。
if _, err := tx.Exec(`DELETE FROM memory_block_edges
WHERE source_kind = 'sentence' AND source_id NOT IN (
SELECT CAST(id AS TEXT) FROM sentences
WHERE id IN (SELECT DISTINCT sentence_id FROM relations WHERE sentence_id != 0)
OR id IN (SELECT CAST(source_id AS INTEGER) FROM memory_block_edges WHERE source_kind = 'sentence')
)`); err != nil {
return 0, err
}
res, err := tx.Exec(`DELETE FROM sentences WHERE id NOT IN (
SELECT DISTINCT sentence_id FROM relations WHERE sentence_id != 0
) AND id NOT IN (
SELECT CAST(source_id AS INTEGER) FROM memory_block_edges WHERE source_kind = 'sentence'
)`)
if err != nil {
return 0, err
}
n, _ := res.RowsAffected()
if err := tx.Commit(); err != nil {
return 0, err
}
return int(n), nil return int(n), nil
} }

View File

@ -10,19 +10,19 @@ import (
) )
type Indexer struct { type Indexer struct {
db *GraphDB db *GraphDB
vec *vector.Store vec *vector.Store
veczer *vector.TFIDFVectorizer veczer *vector.TFIDFVectorizer
mu sync.RWMutex mu sync.RWMutex
trained bool trained bool
recalled map[string]bool // 已通过工具调用显式召回的实体名,自动注入时跳过 recalled map[string]bool // 已通过工具调用显式召回的实体名,自动注入时跳过
} }
func NewIndexer(db *GraphDB) *Indexer { func NewIndexer(db *GraphDB) *Indexer {
return &Indexer{ return &Indexer{
db: db, db: db,
vec: vector.NewStore(), vec: vector.NewStore(),
veczer: vector.NewTFIDFVectorizer(TokenizeWords), veczer: vector.NewTFIDFVectorizer(TokenizeWords),
recalled: make(map[string]bool), recalled: make(map[string]bool),
} }
} }

View File

@ -69,11 +69,6 @@ type Item struct {
OriginPath string `json:"origin_path,omitempty"` OriginPath string `json:"origin_path,omitempty"`
// Tool 是注入这条媒体的工具名(如 multimodal_see_picture)。 // Tool 是注入这条媒体的工具名(如 multimodal_see_picture)。
Tool string `json:"tool,omitempty"` Tool string `json:"tool,omitempty"`
// Description 是视觉/音频模型生成的文字描述,供 L2/L3 检索。
// 空表示未描述(未开启描述、模型不可用或描述失败)。
Description string `json:"description,omitempty"`
// DescribedBy 记录描述来自哪个源,让后续读者能判断可靠性。
DescribedBy string `json:"described_by,omitempty"`
// FirstSeen/LastSeen 是首末次入库时间。 // FirstSeen/LastSeen 是首末次入库时间。
FirstSeen time.Time `json:"first_seen"` FirstSeen time.Time `json:"first_seen"`
LastSeen time.Time `json:"last_seen"` LastSeen time.Time `json:"last_seen"`
@ -127,8 +122,6 @@ func (s *Store) initSchema() error {
height INTEGER DEFAULT 0, height INTEGER DEFAULT 0,
origin_path TEXT, origin_path TEXT,
tool TEXT, tool TEXT,
description TEXT,
described_by TEXT,
first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP, first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`, )`,
@ -196,20 +189,15 @@ func (s *Store) Put(data []byte, meta Item) (string, error) {
} }
_, err := s.db.Exec(` _, err := s.db.Exec(`
INSERT INTO media (digest, kind, mime, size, width, height, INSERT INTO media (digest, kind, mime, size, width, height,
origin_path, tool, description, described_by, origin_path, tool, first_seen, last_seen)
first_seen, last_seen) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(digest) DO UPDATE SET ON CONFLICT(digest) DO UPDATE SET
last_seen = excluded.last_seen, last_seen = excluded.last_seen,
-- 只在原值为空时补写:先到的描述可能来自更强的模型,
-- 后到的空值不该把它冲掉。
description = CASE WHEN COALESCE(media.description,'') = '' THEN excluded.description ELSE media.description END,
described_by = CASE WHEN COALESCE(media.described_by,'') = '' THEN excluded.described_by ELSE media.described_by END,
width = CASE WHEN media.width = 0 THEN excluded.width ELSE media.width END, width = CASE WHEN media.width = 0 THEN excluded.width ELSE media.width END,
height = CASE WHEN media.height = 0 THEN excluded.height ELSE media.height END, height = CASE WHEN media.height = 0 THEN excluded.height ELSE media.height END,
tool = CASE WHEN COALESCE(media.tool,'') = '' THEN excluded.tool ELSE media.tool END tool = CASE WHEN COALESCE(media.tool,'') = '' THEN excluded.tool ELSE media.tool END
`, digest, string(meta.Kind), meta.MIME, int64(len(data)), meta.Width, meta.Height, `, digest, string(meta.Kind), meta.MIME, int64(len(data)), meta.Width, meta.Height,
meta.OriginPath, meta.Tool, meta.Description, meta.DescribedBy, now, now) meta.OriginPath, meta.Tool, now, now)
if err != nil { if err != nil {
return "", fmt.Errorf("media: upsert meta: %w", err) return "", fmt.Errorf("media: upsert meta: %w", err)
} }
@ -242,115 +230,12 @@ func (s *Store) Stat(digest string) (*Item, error) {
defer s.mu.RUnlock() defer s.mu.RUnlock()
return s.scanOne(s.db.QueryRow(` return s.scanOne(s.db.QueryRow(`
SELECT digest, kind, mime, size, width, height, origin_path, tool, SELECT digest, kind, mime, size, width, height, origin_path, tool,
description, described_by, first_seen, last_seen, first_seen, last_seen,
vec, vec_model vec, vec_model
FROM media WHERE digest = ?`, digest)) FROM media WHERE digest = ?`, digest))
} }
// Describe 写入(或覆盖)文字描述。 // Stat 返回元数据,不读内容。
//
// 与 Put 的"只在空时补写"不同:Describe 是显式操作,调用方明确想要这份
// 描述生效(例如换了更强的视觉模型重新描述)。
func (s *Store) Describe(digest, description, describedBy string) error {
s.mu.Lock()
defer s.mu.Unlock()
res, err := s.db.Exec(`UPDATE media SET description = ?, described_by = ? WHERE digest = ?`,
description, describedBy, digest)
if err != nil {
return fmt.Errorf("media: describe: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return fmt.Errorf("media: describe: unknown digest %s", shortDigest(digest))
}
return nil
}
// Search 按描述文本做 LIKE 匹配,返回最近的若干条。
//
// 刻意不在这里做向量检索:媒体的语义检索走 L2 文档层的既有索引
// (描述文字随记忆条目一起进 Doc.Content,复用那套 TF-IDF/embedding),
// 本方法只是"按关键词直接翻媒体库"的补充入口。
func (s *Store) Search(query string, kind Kind, limit int) ([]*Item, error) {
if limit <= 0 {
limit = 20
}
s.mu.RLock()
defer s.mu.RUnlock()
q := `SELECT digest, kind, mime, size, width, height, origin_path, tool,
description, described_by, first_seen, last_seen,
vec, vec_model
FROM media WHERE COALESCE(description,'') != ''`
args := []interface{}{}
if strings.TrimSpace(query) != "" {
q += ` AND description LIKE ?`
args = append(args, "%"+query+"%")
}
if kind != "" {
q += ` AND kind = ?`
args = append(args, string(kind))
}
q += ` ORDER BY last_seen DESC LIMIT ?`
args = append(args, limit)
rows, err := s.db.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Item
for rows.Next() {
it, err := s.scanRows(rows)
if err != nil {
continue
}
out = append(out, it)
}
return out, rows.Err()
}
// Pending 返回尚无描述的媒体,供后台描述任务消费。
// Pending 返回尚无描述的媒体,供后台描述任务消费。
//
// 不只看 description 为空,还要求 described_by 也为空。
// 因为“已尝试但无法描述”的项(如 kind=other 的二进制、blob 已丢失)
// 会被标记为 described_by=unsupported/content-missing 而 description 仍为空——
// 若只看 description,这些项每轮都会被取出来重试,永远卡在队列头部,
// 真正需要描述的新项永远轮不到(LIMIT 只取前 N 条)。
func (s *Store) Pending(limit int) ([]*Item, error) {
if limit <= 0 {
limit = 10
}
s.mu.RLock()
defer s.mu.RUnlock()
rows, err := s.db.Query(`
SELECT digest, kind, mime, size, width, height, origin_path, tool,
description, described_by, first_seen, last_seen,
vec, vec_model
FROM media
WHERE COALESCE(description,'') = '' AND COALESCE(described_by,'') = ''
ORDER BY last_seen DESC LIMIT ?`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Item
for rows.Next() {
it, err := s.scanRows(rows)
if err != nil {
continue
}
out = append(out, it)
}
return out, rows.Err()
}
// GC 清理已不被任何记忆块持有的内容。
//
// keep 是当前仍被 Context/Document/Graph 里一等记忆块持有的 digest 集合,
// 由调用方从三层记忆节点计算得出;media.Store 不再自己维护引用账本。
// 不在 keep 中且早于 minAge 的项被清理;超出 maxBytes 时也只淘汰不在 keep 中的项。
// Delete 删除一份媒体内容(元数据 + blob)。
// //
// 这不是 GC,也不看引用计数:调用方是记忆系统本身——当它把一个记忆块 // 这不是 GC,也不看引用计数:调用方是记忆系统本身——当它把一个记忆块
// 永久地从三层记忆中删掉(而非在层间迁移)时,媒体作为块的内容一并删除。 // 永久地从三层记忆中删掉(而非在层间迁移)时,媒体作为块的内容一并删除。
@ -376,13 +261,11 @@ func (s *Store) Stats() map[string]interface{} {
defer s.mu.RUnlock() defer s.mu.RUnlock()
out := map[string]interface{}{"blob_dir": s.blobDir} out := map[string]interface{}{"blob_dir": s.blobDir}
var count, described int var count int
var total int64 var total int64
s.db.QueryRow(`SELECT COUNT(*), COALESCE(SUM(size),0) FROM media`).Scan(&count, &total) s.db.QueryRow(`SELECT COUNT(*), COALESCE(SUM(size),0) FROM media`).Scan(&count, &total)
s.db.QueryRow(`SELECT COUNT(*) FROM media WHERE COALESCE(description,'') != ''`).Scan(&described)
out["count"] = count out["count"] = count
out["total_bytes"] = total out["total_bytes"] = total
out["described"] = described
byKind := map[string]int{} byKind := map[string]int{}
rows, err := s.db.Query(`SELECT kind, COUNT(*) FROM media GROUP BY kind`) rows, err := s.db.Query(`SELECT kind, COUNT(*) FROM media GROUP BY kind`)
@ -524,7 +407,7 @@ func (s *Store) queryMediaScored(queryVec []float64, model string, topK int) ([]
defer s.mu.RUnlock() defer s.mu.RUnlock()
query := `SELECT digest, kind, mime, size, width, height, query := `SELECT digest, kind, mime, size, width, height,
origin_path, tool, description, described_by, first_seen, last_seen, origin_path, tool, first_seen, last_seen,
vec, vec_model vec, vec_model
FROM media WHERE vec IS NOT NULL AND vec != ''` FROM media WHERE vec IS NOT NULL AND vec != ''`
var args []interface{} var args []interface{}
@ -546,17 +429,15 @@ func (s *Store) queryMediaScored(queryVec []float64, model string, topK int) ([]
for rows.Next() { for rows.Next() {
var it Item var it Item
var kind string var kind string
var origin, tool, desc, by, vecJSON, vecModel sql.NullString var origin, tool, vecJSON, vecModel sql.NullString
if err := rows.Scan(&it.Digest, &kind, &it.MIME, &it.Size, &it.Width, &it.Height, if err := rows.Scan(&it.Digest, &kind, &it.MIME, &it.Size, &it.Width, &it.Height,
&origin, &tool, &desc, &by, &it.FirstSeen, &it.LastSeen, &origin, &tool, &it.FirstSeen, &it.LastSeen,
&vecJSON, &vecModel); err != nil { &vecJSON, &vecModel); err != nil {
continue continue
} }
it.Kind = Kind(kind) it.Kind = Kind(kind)
it.OriginPath = origin.String it.OriginPath = origin.String
it.Tool = tool.String it.Tool = tool.String
it.Description = desc.String
it.DescribedBy = by.String
if !vecJSON.Valid || vecJSON.String == "" { if !vecJSON.Valid || vecJSON.String == "" {
continue continue
} }
@ -623,17 +504,15 @@ func (s *Store) scanRows(r rowScanner) (*Item, error) { return scanItem(r) }
func scanItem(r rowScanner) (*Item, error) { func scanItem(r rowScanner) (*Item, error) {
var it Item var it Item
var kind string var kind string
var origin, tool, desc, by, vecJSON, vecModel sql.NullString var origin, tool, vecJSON, vecModel sql.NullString
if err := r.Scan(&it.Digest, &kind, &it.MIME, &it.Size, &it.Width, &it.Height, if err := r.Scan(&it.Digest, &kind, &it.MIME, &it.Size, &it.Width, &it.Height,
&origin, &tool, &desc, &by, &it.FirstSeen, &it.LastSeen, &origin, &tool, &it.FirstSeen, &it.LastSeen,
&vecJSON, &vecModel); err != nil { &vecJSON, &vecModel); err != nil {
return nil, err return nil, err
} }
it.Kind = Kind(kind) it.Kind = Kind(kind)
it.OriginPath = origin.String it.OriginPath = origin.String
it.Tool = tool.String it.Tool = tool.String
it.Description = desc.String
it.DescribedBy = by.String
if vecJSON.Valid && vecJSON.String != "" { if vecJSON.Valid && vecJSON.String != "" {
var v []float64 var v []float64
if err := json.Unmarshal([]byte(vecJSON.String), &v); err == nil { if err := json.Unmarshal([]byte(vecJSON.String), &v); err == nil {

View File

@ -142,50 +142,13 @@ func TestDelete_UnknownDigestIsNoop(t *testing.T) {
} }
} }
func TestDescribe_OverwritesExplicitly(t *testing.T) { func TestDescribe_Removed(t *testing.T) {
s := newTestStore(t, 0) // 媒体不再有文字描述:描述式索引是废弃的就机制。
d, _ := s.Put([]byte("img"), Item{MIME: "image/png"}) // 这里只保留一个编译期断言,确保 API 不会静默回归。
s := newTestStore(t)
if err := s.Describe(d, "一只橘猫", "vis-a"); err != nil { if _, err := s.Put([]byte("img"), Item{MIME: "image/png"}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
it, _ := s.Stat(d)
if it.Description != "一只橘猫" || it.DescribedBy != "vis-a" {
t.Fatalf("描述未写入: %+v", it)
}
// Describe 是显式操作,允许覆盖(换更强模型重描述)
if err := s.Describe(d, "一只橘色虎斑猫坐在窗台", "vis-b"); err != nil {
t.Fatal(err)
}
it, _ = s.Stat(d)
if !strings.Contains(it.Description, "虎斑") || it.DescribedBy != "vis-b" {
t.Fatalf("Describe 应覆盖旧描述: %+v", it)
}
}
func TestDescribe_UnknownDigestErrors(t *testing.T) {
s := newTestStore(t, 0)
err := s.Describe("deadbeef", "x", "y")
if err == nil {
t.Fatal("未知 digest 应报错而非静默成功")
}
}
func TestPut_DoesNotClobberExistingDescription(t *testing.T) {
// 先到的描述可能来自更强的模型;后到的空值不该把它冲掉。
s := newTestStore(t, 0)
data := []byte("img")
d, _ := s.Put(data, Item{MIME: "image/png", Description: "详细描述", DescribedBy: "strong-model"})
// 第二次 Put 同内容但不带描述
if _, err := s.Put(data, Item{MIME: "image/png"}); err != nil {
t.Fatal(err)
}
it, _ := s.Stat(d)
if it.Description != "详细描述" || it.DescribedBy != "strong-model" {
t.Fatalf("重复 Put 的空描述不该冲掉已有描述: %+v", it)
}
} }
func TestPut_BackfillsMissingDimensions(t *testing.T) { func TestPut_BackfillsMissingDimensions(t *testing.T) {
@ -206,47 +169,22 @@ func TestPut_BackfillsMissingDimensions(t *testing.T) {
} }
} }
func TestSearch_FiltersByDescriptionAndKind(t *testing.T) { func TestStats_CountsByKind(t *testing.T) {
s := newTestStore(t, 0) s := newTestStore(t)
di, _ := s.Put([]byte("chart-img"), Item{MIME: "image/png"}) s.Put([]byte("i1"), Item{MIME: "image/png"})
da, _ := s.Put([]byte("speech-aud"), Item{MIME: "audio/wav"}) s.Put([]byte("i2"), Item{MIME: "image/jpeg"})
dn, _ := s.Put([]byte("no-desc"), Item{MIME: "image/png"}) s.Put([]byte("a1"), Item{MIME: "audio/wav"})
s.Describe(di, "一张蓝色的柱状图表", "vis")
s.Describe(da, "一段关于图表的讲解录音", "aud")
all, err := s.Search("图表", "", 10) st := s.Stats()
if err != nil { if st["count"].(int) != 3 {
t.Fatal(err) t.Fatalf("count 应为 3,实际 %v", st["count"])
} }
if len(all) != 2 { if _, ok := st["described"]; ok {
t.Fatalf("两条描述都含「图表」,应返回 2,实际 %d", len(all)) t.Fatal("媒体已不再有描述计数")
} }
byKind := st["by_kind"].(map[string]int)
imgs, _ := s.Search("图表", KindImage, 10) if byKind["image"] != 2 || byKind["audio"] != 1 {
if len(imgs) != 1 || imgs[0].Digest != di { t.Fatalf("by_kind 不对: %v", byKind)
t.Fatalf("按 image 过滤应只剩图片,实际 %d 条", len(imgs))
}
// 无描述的项不该出现在语义检索结果里
for _, it := range all {
if it.Digest == dn {
t.Fatal("无描述的项不该被 Search 返回")
}
}
}
func TestPending_ReturnsUndescribed(t *testing.T) {
s := newTestStore(t, 0)
described, _ := s.Put([]byte("has-desc"), Item{MIME: "image/png"})
undescribed, _ := s.Put([]byte("needs-desc"), Item{MIME: "image/png"})
s.Describe(described, "已有描述", "vis")
pending, err := s.Pending(10)
if err != nil {
t.Fatal(err)
}
if len(pending) != 1 || pending[0].Digest != undescribed {
t.Fatalf("应只返回未描述项,实际 %d 条", len(pending))
} }
} }
@ -278,28 +216,8 @@ func TestParseDataURL(t *testing.T) {
} }
} }
func TestStats_CountsByKindAndDescription(t *testing.T) {
s := newTestStore(t, 4096)
d1, _ := s.Put([]byte("i1"), Item{MIME: "image/png"})
s.Put([]byte("i2"), Item{MIME: "image/jpeg"})
s.Put([]byte("a1"), Item{MIME: "audio/wav"})
s.Describe(d1, "描述", "vis")
st := s.Stats()
if st["count"].(int) != 3 {
t.Fatalf("count 应为 3,实际 %v", st["count"])
}
if st["described"].(int) != 1 {
t.Fatalf("described 应为 1,实际 %v", st["described"])
}
byKind := st["by_kind"].(map[string]int)
if byKind["image"] != 2 || byKind["audio"] != 1 {
t.Fatalf("by_kind 不对: %v", byKind)
}
}
func TestPut_RejectsEmpty(t *testing.T) { func TestPut_RejectsEmpty(t *testing.T) {
s := newTestStore(t, 0) s := newTestStore(t)
if _, err := s.Put(nil, Item{MIME: "image/png"}); err == nil { if _, err := s.Put(nil, Item{MIME: "image/png"}); err == nil {
t.Fatal("空内容应报错") t.Fatal("空内容应报错")
} }
@ -313,7 +231,7 @@ func TestReopen_PersistsAcrossRestart(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
d, _ := s1.Put([]byte("persistent-img"), Item{MIME: "image/png", OriginPath: "/tmp/x.png"}) d, _ := s1.Put([]byte("persistent-img"), Item{MIME: "image/png", OriginPath: "/tmp/x.png"})
s1.Describe(d, "跨重启的描述", "vis") s1.SetVec(d, []float64{0.1, 0.2}, "test-space")
s1.Close() s1.Close()
s2, err := New(dir) s2, err := New(dir)
@ -326,8 +244,8 @@ func TestReopen_PersistsAcrossRestart(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("重开后应能查到: %v", err) t.Fatalf("重开后应能查到: %v", err)
} }
if it.Description != "跨重启的描述" { if it.OriginPath != "/tmp/x.png" || len(it.Vec) != 2 || it.VecModel != "test-space" {
t.Fatalf("元数据应持久化: %+v", it) t.Fatalf("元数据与向量应持久化: %+v", it)
} }
data, err := s2.Get(d) data, err := s2.Get(d)
if err != nil || string(data) != "persistent-img" { if err != nil || string(data) != "persistent-img" {
@ -335,38 +253,26 @@ func TestReopen_PersistsAcrossRestart(t *testing.T) {
} }
} }
func TestPending_ExcludesAttemptedButUndescribable(t *testing.T) { func TestStaleVecDigests_TracksModelSwitch(t *testing.T) {
// 「已尝试但无法描述」的项必须退出待描述队列。 // 模型切换后旧向量必须被重算:StaleVecDigests 是启动迁移的入口。
// s := newTestStore(t)
// 这些项被标记为 described_by=unsupported/content-missing 而 description d1, _ := s.Put([]byte("a"), Item{MIME: "image/png"})
// 仍为空。若 Pending 只看 description,它们每轮都会被取出来重试、 d2, _ := s.Put([]byte("b"), Item{MIME: "image/png"})
// 永久占着 LIMIT 的名额,真正需要描述的新项永远轮不到。 s.SetVec(d1, []float64{0.1}, "space-a")
s := newTestStore(t, 0)
fresh, _ := s.Put([]byte("needs-describe"), Item{MIME: "image/png"}) stale, err := s.StaleVecDigestsAll("space-a")
unsupported, _ := s.Put([]byte("cannot-describe"), Item{MIME: "application/octet-stream"})
described, _ := s.Put([]byte("已描述"), Item{MIME: "image/png"})
// 标记「尝试过但不支持」:description 空,described_by 非空
if err := s.Describe(unsupported, "", "unsupported"); err != nil {
t.Fatal(err)
}
if err := s.Describe(described, "一张图", "visionllm"); err != nil {
t.Fatal(err)
}
pending, err := s.Pending(10)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(pending) != 1 { if len(stale) != 1 || stale[0] != d2 {
var names []string t.Fatalf("只有未嵌入的 d2 需重算,实际 %v", stale)
for _, p := range pending {
names = append(names, shortDigest(p.Digest))
}
t.Fatalf("应只剩 1 条待描述,实际 %d 条: %v", len(pending), names)
} }
if pending[0].Digest != fresh {
t.Fatalf("待描述的应是未处理项,实际 %s", shortDigest(pending[0].Digest)) stale, err = s.StaleVecDigestsAll("space-b")
if err != nil {
t.Fatal(err)
}
if len(stale) != 2 {
t.Fatalf("换空间后两条都需重算,实际 %v", stale)
} }
} }

View File

@ -10,9 +10,9 @@ func TestQueryMedia_BasicSimilarity(t *testing.T) {
defer s.Close() defer s.Close()
// 入库三张带向量的媒体:两张图、一段音频 // 入库三张带向量的媒体:两张图、一段音频
d1, _ := s.Put([]byte("img1"), Item{MIME: "image/png", Description: "紫蓝红三色带"}) d1, _ := s.Put([]byte("img1"), Item{MIME: "image/png"})
d2, _ := s.Put([]byte("img2"), Item{MIME: "image/jpeg", Description: "蓝紫红渐变"}) d2, _ := s.Put([]byte("img2"), Item{MIME: "image/jpeg"})
d3, _ := s.Put([]byte("aud1"), Item{MIME: "audio/wav", Description: "一段语音"}) d3, _ := s.Put([]byte("aud1"), Item{MIME: "audio/wav"})
// 模拟视觉嵌入:img1 和 img2 向量接近,aud1 远离 // 模拟视觉嵌入:img1 和 img2 向量接近,aud1 远离
vec1 := []float64{0.9, 0.1, 0.0, 0.0} vec1 := []float64{0.9, 0.1, 0.0, 0.0}
@ -138,24 +138,24 @@ func TestStaleVecDigests(t *testing.T) {
s := newTestStore(t, 0) s := newTestStore(t, 0)
defer s.Close() defer s.Close()
// 有描述且 vec_model 匹配 → 非 stale // 有向量且 vec_model 匹配 → 非 stale
d1, _ := s.Put([]byte("img1"), Item{MIME: "image/png", Description: "图一"}) d1, _ := s.Put([]byte("img1"), Item{MIME: "image/png"})
s.SetVec(d1, []float64{0.1}, "clip-vit-b32") s.SetVec(d1, []float64{0.1}, "space-a")
// 有描述但 vec_model 旧 → stale // 有向量但 vec_model 旧 → stale
d2, _ := s.Put([]byte("img2"), Item{MIME: "image/png", Description: "图二"}) d2, _ := s.Put([]byte("img2"), Item{MIME: "image/png"})
s.SetVec(d2, []float64{0.2}, "clip-vit-b14") s.SetVec(d2, []float64{0.2}, "space-old")
// 有描述但从未嵌入(vec_model 空)→ stale // 从未嵌入(vec_model 空)→ stale
d3, _ := s.Put([]byte("img3"), Item{MIME: "image/png", Description: "图三"}) d3, _ := s.Put([]byte("img3"), Item{MIME: "image/png"})
// 无描述但有图片 → 也应被迁移(描述是可选语义通道,图片应独立于描述参与向量空间) // 与向量/描述无关的图片同样应被迁移:图片独立参与向量空间
d4, _ := s.Put([]byte("img4"), Item{MIME: "image/png"}) d4, _ := s.Put([]byte("img4"), Item{MIME: "image/png"})
// 音频不参与图片迁移(StaleVecDigests 只查 kind='image') // 音频不参与图片迁移(StaleVecDigests 只查 kind='image')
s.Put([]byte("aud1"), Item{MIME: "audio/wav", Description: "语音"}) s.Put([]byte("aud1"), Item{MIME: "audio/wav"})
stale, err := s.StaleVecDigests("clip-vit-b32") stale, err := s.StaleVecDigests("space-a")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }

View File

@ -123,31 +123,44 @@ func TestSmoke_VideoFramesDistinct(t *testing.T) {
} }
} }
func TestSmoke_DescribeThenRetrieve(t *testing.T) { func TestSmoke_NearestNeighborVectorRetrieve(t *testing.T) {
// 场景 C:视觉模型描述落库后,描述文字成为可检索的语义入口。 // 场景 C:图片只按自己的原生向量被检索。
// 这是本方案最关键的一环——blob 可能被淘汰,描述会长期留在记忆里。 // 没有描述文本参与——描述式索引是废弃的就机制。
s := newTestStore(t, 50*1024*1024) s := newTestStore(t)
pic, _ := s.Put(makePNG(400, 400, 0), Item{MIME: "image/png", Tool: "multimodal_see_picture"}) pic, _ := s.Put(makePNG(400, 400, 0), Item{MIME: "image/png", Tool: "multimodal_see_picture"})
if err := s.Describe(pic, "一张 400x400 的三色带图:上红、中绿、下蓝", "visionllm"); err != nil { s.SetVec(pic, []float64{1, 0, 0, 0}, "space")
t.Fatal(err) var frames []string
}
for i := 0; i < 6; i++ { for i := 0; i < 6; i++ {
d, _ := s.Put(makePNG(320, 240, i), Item{MIME: "image/jpeg", Tool: "multimodal_see_video"}) d, _ := s.Put(makePNG(320, 240, i), Item{MIME: "image/jpeg", Tool: "multimodal_see_video"})
if err := s.Describe(d, fmt.Sprintf("视频第 %d 帧:测试图卡,含彩条与计数器", i+1), "visionllm"); err != nil { s.SetVec(d, []float64{1, 1, float64(i) / 10, 0}, "space")
t.Fatal(err) frames = append(frames, d)
}
} }
if hits, _ := s.Search("三色带", KindImage, 10); len(hits) != 1 { hits, err := s.QueryMediaScored([]float64{1, 0, 0, 0}, "space", 10)
t.Fatalf("搜「三色带」应命中 1 条,实际 %d", len(hits)) if err != nil {
t.Fatal(err)
} }
if hits, _ := s.Search("计数器", KindImage, 10); len(hits) != 6 { if len(hits) != 7 {
t.Fatalf("搜「计数器」应命中 6 帧,实际 %d", len(hits)) t.Fatalf("7 份媒体都有同空间向量,应全部可召,实际 %d", len(hits))
} }
pend, _ := s.Pending(100) if hits[0].Item.Digest != pic {
if len(pend) != 0 { t.Fatalf("与查询同向的应是第一命中,实际 %s", shortDigest(hits[0].Item.Digest))
t.Fatalf("应全部已描述,仍有 %d 条待描述", len(pend)) }
// 不同向量空间/模型的条目不得参与:坐标系不同,余弦无意义。
foreign := frames[0]
if err := s.SetVec(foreign, []float64{1, 0, 0, 0}, "other-space"); err != nil {
t.Fatal(err)
}
hits, err = s.QueryMediaScored([]float64{1, 0, 0, 0}, "space", 10)
if err != nil {
t.Fatal(err)
}
for _, h := range hits {
if h.Item.Digest == foreign {
t.Fatal("另一套空间(other-space)的向量不该被 space 查询召回")
}
} }
} }
@ -204,7 +217,7 @@ func TestSmoke_DeleteRemovesOnlyThatContent(t *testing.T) {
} }
func TestSmoke_FullLifecycleAcrossRestart(t *testing.T) { func TestSmoke_FullLifecycleAcrossRestart(t *testing.T) {
// 端到端:入库 → 描述 → 删除一些内容 → 重启 → 检索, // 端到端:入库 → 嵌入 → 删除一些内容 → 重启 → 向量检索,
// 并确认磁盘与元数据不出现双向孤儿。记忆的意义就在于跨重启还在。 // 并确认磁盘与元数据不出现双向孤儿。记忆的意义就在于跨重启还在。
dir := t.TempDir() dir := t.TempDir()
s, err := New(dir) s, err := New(dir)
@ -214,10 +227,10 @@ func TestSmoke_FullLifecycleAcrossRestart(t *testing.T) {
png := makePNG(400, 400, 0) png := makePNG(400, 400, 0)
pic, _ := s.Put(png, Item{MIME: "image/png", Width: 400, Height: 400, Tool: "multimodal_see_picture"}) pic, _ := s.Put(png, Item{MIME: "image/png", Width: 400, Height: 400, Tool: "multimodal_see_picture"})
s.Describe(pic, "一张 400x400 的三色带图:上红、中绿、下蓝", "visionllm") s.SetVec(pic, []float64{1, 0, 0}, "space")
for i := 0; i < 6; i++ { for i := 0; i < 6; i++ {
d, _ := s.Put(makePNG(320, 240, i), Item{MIME: "image/jpeg", Tool: "multimodal_see_video"}) d, _ := s.Put(makePNG(320, 240, i), Item{MIME: "image/jpeg", Tool: "multimodal_see_video"})
s.Describe(d, fmt.Sprintf("视频第 %d 帧", i+1), "visionllm") s.SetVec(d, []float64{0, 1, float64(i)}, "space")
} }
for i := 0; i < 10; i++ { for i := 0; i < 10; i++ {
d, _ := s.Put(makePNG(64, 64, 2000+i), Item{MIME: "image/png", Tool: "cmd_run"}) d, _ := s.Put(makePNG(64, 64, 2000+i), Item{MIME: "image/png", Tool: "cmd_run"})
@ -241,20 +254,24 @@ func TestSmoke_FullLifecycleAcrossRestart(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("重开后查不到: %v", err) t.Fatalf("重开后查不到: %v", err)
} }
if it.Description == "" { if len(it.Vec) != 3 || it.VecModel != "space" {
t.Fatalf("元数据未持久化: %+v", it) t.Fatalf("向量未持久化: %+v", it)
} }
data, err := s2.Get(pic) data, err := s2.Get(pic)
if err != nil || !bytes.Equal(data, png) { if err != nil || !bytes.Equal(data, png) {
t.Fatalf("重开后内容不一致: %v", err) t.Fatalf("重开后内容不一致: %v", err)
} }
if hits, _ := s2.Search("三色带", KindImage, 10); len(hits) != 1 { hits, err := s2.QueryMediaScored([]float64{1, 0, 0}, "space", 10)
t.Fatal("重开后描述应仍可检索") if err != nil {
t.Fatal(err)
}
if len(hits) == 0 || hits[0].Item.Digest != pic {
t.Fatal("重开后向量检索应仍能命中")
} }
// 磁盘文件数 == 元数据条数:无「元数据在文件没了」也无「文件在元数据没了」 // 磁盘文件数 == 元数据条数:无「元数据在文件没了」也无「文件在元数据没了」
if n := blobFileCount(t, s2); n != beforeCount { if n := blobFileCount(t, s2); n != beforeCount {
t.Fatalf("磁盘 blob=%d 与元数据=%d 不一致", n, beforeCount) t.Fatalf("磁盘 blob=%d 与元数据=%d 不一致", n, beforeCount)
} }
t.Logf("跨重启:%d 条目、描述与内容全部完好", beforeCount) t.Logf("跨重启:%d 条目、向量与内容全部完好", beforeCount)
} }

View File

@ -39,7 +39,7 @@ func TestSoak_SustainedMixedLoad(t *testing.T) {
stop := make(chan struct{}) stop := make(chan struct{})
var wg sync.WaitGroup var wg sync.WaitGroup
var puts, gets, deletes, describes, searches atomic.Int64 var puts, gets, deletes, embeds, searches atomic.Int64
var fatal atomic.Int64 var fatal atomic.Int64
worker := func(name string, fn func(iter int) error) { worker := func(name string, fn func(iter int) error) {
@ -109,25 +109,26 @@ func TestSoak_SustainedMixedLoad(t *testing.T) {
return nil return nil
}) })
// 描述者 // 向量写入者:持续给新内容嵌入并删除(模拟启动迁移/短命媒体)
worker("describe", func(i int) error { worker("embed", func(i int) error {
pend, err := s.Pending(5) b := make([]byte, 1024)
rand.Read(b)
b = append([]byte(fmt.Sprintf("emb-%d-", i)), b...)
d, err := s.Put(b, Item{MIME: "image/png", Tool: "cmd_run"})
if err != nil { if err != nil {
return err return err
} }
for _, it := range pend { if err := s.SetVec(d, []float64{1, float64(i % 7)}, "soak-space"); err != nil {
// 忽略 unknown digest:GC 可能在 Pending 与 Describe 之间清掉它, return err
// 这是正常竞态而非缺陷。
_ = s.Describe(it.Digest, fmt.Sprintf("描述 %d 含图表与文字", i), "vis")
describes.Add(1)
} }
time.Sleep(2 * time.Millisecond) embeds.Add(1)
time.Sleep(time.Millisecond)
return nil return nil
}) })
// 检索者 // 检索者
worker("search", func(i int) error { worker("search", func(i int) error {
if _, err := s.Search("图表", KindImage, 20); err != nil { if _, err := s.QueryMediaScored([]float64{1, 0}, "soak-space", 20); err != nil {
return err return err
} }
if _, err := s.Stat(keep[i%keepN]); err != nil { if _, err := s.Stat(keep[i%keepN]); err != nil {
@ -146,8 +147,8 @@ func TestSoak_SustainedMixedLoad(t *testing.T) {
t.Fatalf("%d 个 worker 报致命错误", n) t.Fatalf("%d 个 worker 报致命错误", n)
} }
t.Logf("%v 内: put=%d get=%d delete=%d describe=%d search=%d", t.Logf("%v 内: put=%d get=%d delete=%d embed=%d search=%d",
dur, puts.Load(), gets.Load(), deletes.Load(), describes.Load(), searches.Load()) dur, puts.Load(), gets.Load(), deletes.Load(), embeds.Load(), searches.Load())
// 收尾断言 // 收尾断言
for i, d := range keep { for i, d := range keep {
@ -161,6 +162,5 @@ func TestSoak_SustainedMixedLoad(t *testing.T) {
} }
st := s.Stats() st := s.Stats()
t.Logf("收尾: 条目=%v 字节=%v 已描述=%v", t.Logf("收尾: 条目=%v 字节=%v 类型=%v", st["count"], st["total_bytes"], st["by_kind"])
st["count"], st["total_bytes"], st["described"])
} }

View File

@ -306,9 +306,9 @@ func TestStress_DeleteConcurrentWithReads(t *testing.T) {
} }
} }
func TestStress_DescribeConcurrentWithSearch(t *testing.T) { func TestStress_SetVecConcurrentWithQuery(t *testing.T) {
// 描述写入与检索并发。C 部分的后台描述任务会长期这样跑。 // 嵌入写入与向量检索并发(启动时的向量迁移就会长期这样跑)。
s := newTestStore(t, 0) s := newTestStore(t)
const n = 60 const n = 60
digests := make([]string, n) digests := make([]string, n)
for i := range digests { for i := range digests {
@ -320,55 +320,52 @@ func TestStress_DescribeConcurrentWithSearch(t *testing.T) {
} }
var wg sync.WaitGroup var wg sync.WaitGroup
var descErr, searchErr atomic.Int64 var writeErr, queryErr atomic.Int64
// 描述写入者 // 向量写入者
for w := 0; w < 4; w++ { for w := 0; w < 4; w++ {
wg.Add(1) wg.Add(1)
go func(wid int) { go func(wid int) {
defer wg.Done() defer wg.Done()
for i := wid; i < n; i += 4 { for i := wid; i < n; i += 4 {
desc := fmt.Sprintf("第 %d 张图,含蓝色图表与文字", i) vec := []float64{1, float64(i) / 100, 0}
if err := s.Describe(digests[i], desc, "vis-src"); err != nil { if err := s.SetVec(digests[i], vec, "space"); err != nil {
descErr.Add(1) writeErr.Add(1)
} }
} }
}(w) }(w)
} }
// 检索者 + Pending 消费者 // 检索者
for r := 0; r < 3; r++ { for r := 0; r < 3; r++ {
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
for i := 0; i < 50; i++ { for i := 0; i < 50; i++ {
if _, err := s.Search("图表", KindImage, 20); err != nil { if _, err := s.QueryMediaScored([]float64{1, 0, 0}, "space", 20); err != nil {
searchErr.Add(1) queryErr.Add(1)
}
if _, err := s.Pending(10); err != nil {
searchErr.Add(1)
} }
} }
}() }()
} }
wg.Wait() wg.Wait()
if v := descErr.Load(); v > 0 { if v := writeErr.Load(); v > 0 {
t.Fatalf("Describe 失败 %d 次", v) t.Fatalf("SetVec 失败 %d 次", v)
} }
if v := searchErr.Load(); v > 0 { if v := queryErr.Load(); v > 0 {
t.Fatalf("Search/Pending 失败 %d 次", v) t.Fatalf("QueryMediaScored 失败 %d 次", v)
} }
// 全部应已描述完 // 全部应已嵌入,且都在同一空间
pending, err := s.Pending(1000) stale, err := s.StaleVecDigestsAll("space")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(pending) != 0 { if len(stale) != 0 {
t.Fatalf("应全部描述完,仍有 %d 条未描述", len(pending)) t.Fatalf("应全部已嵌入,仍有 %d 条未嵌入", len(stale))
} }
got, err := s.Search("图表", KindImage, 1000) got, err := s.QueryMediaScored([]float64{1, 0, 0}, "space", 1000)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }

193
internal/memory/migrate.go Normal file
View File

@ -0,0 +1,193 @@
package memory
import (
"database/sql"
"encoding/json"
"fmt"
"regexp"
"strings"
"time"
)
// 旧媒体实体迁移。
//
// 历史上媒体进 L3 的方式是把正文 marker 反解成普通实体三元组:
//
// [image/png a1b2c3d4e5f6] 一张紫蓝红三色带图
// → 实体「图片 a1b2c3d4e5f6」(type=Media) -内容-> 「一张紫蓝红三色带图」
//
// 这条路径把「媒体」伪装成实体 + 用生成的描述文本当语义索引,正是要废弃的
// 将就机制。迁移做的事:把每条这类实体还原成原生记忆块,用
// sentence --contains--> block 结构边挂到它当时所属的句子上,
// 然后删掉旧实体与它的描述关系。块只按自己的向量被检索。
//
// 迁移是幂等的:实体处理完即删除,重复运行不会重复建块。
// legacyMediaDigestPattern 从旧媒体实体名尾部取出短 digest。
// 名字形如「图片 a1b2c3d4e5f6」——旧实现刻意为每种模态加中文前缀。
var legacyMediaDigestPattern = regexp.MustCompile(`([0-9a-f]{8,64})$`)
// LegacyMediaEntityDigest 从旧媒体实体名里取出短 digest,取不到返回空串。
func LegacyMediaEntityDigest(name string) string {
name = strings.TrimSpace(name)
if !strings.Contains(name, " ") {
return ""
}
m := legacyMediaDigestPattern.FindStringSubmatch(name)
if m == nil {
return ""
}
return m[1]
}
// LegacyMediaResolver 把一个短 digest 解析成可用于 L3 的一等记忆块。
// 解析失败(内容已不存在)返回 false,该实体将被直接删除而不建块。
type LegacyMediaResolver func(shortDigest string) (MemoryBlock, bool)
// MigrateLegacyMediaEntities 把 marker 反解出来的旧媒体实体迁移成原生块。
//
// 返回迁移的块数与删除的旧实体数。任何一步失败都会回滚整个迁移,
// 因为半途中断会留下既没有块也没有实体的句子——信息静默消失。
func (g *GraphDB) MigrateLegacyMediaEntities(resolve LegacyMediaResolver) (blocks, entities int, err error) {
if resolve == nil {
return 0, 0, nil
}
g.mu.Lock()
defer g.mu.Unlock()
rows, err := g.db.Query(`SELECT id, name FROM entities WHERE type = 'Media'`)
if err != nil {
return 0, 0, err
}
type legacyEntity struct {
id int64
name string
}
var legacy []legacyEntity
for rows.Next() {
var e legacyEntity
if err := rows.Scan(&e.id, &e.name); err != nil {
rows.Close()
return 0, 0, err
}
legacy = append(legacy, e)
}
if err := rows.Err(); err != nil {
rows.Close()
return 0, 0, err
}
rows.Close()
if len(legacy) == 0 {
return 0, 0, nil
}
tx, err := g.db.Begin()
if err != nil {
return 0, 0, err
}
defer tx.Rollback()
// 同一份字节可能被多个旧实体引用(重复注入的同一张图),
// 迁移后应指向同一个块:块的身份是内容,不是实体行。
blockIDForDigest := 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 !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
id = block.ID
blocks++
}
n, err := attachBlockToLegacySentences(tx, e.id, id)
if err != nil {
return 0, 0, err
}
_ = n
}
}
// 无论能否解析出内容,旧实体与它的描述关系都必须删除:
// 留着就等于继续用描述文本当媒体索引。
if _, err := tx.Exec(`DELETE FROM relations WHERE source_id = ? OR target_id = ?`, e.id, e.id); err != nil {
return 0, 0, err
}
if _, err := tx.Exec(`DELETE FROM entities WHERE id = ?`, e.id); err != nil {
return 0, 0, err
}
entities++
}
if err := tx.Commit(); err != nil {
return 0, 0, err
}
return blocks, entities, nil
}
// insertMigratedBlock 写一条迁移来的块(不经过 PutMemoryBlocks,避免重入锁)。
func insertMigratedBlock(tx *sql.Tx, block MemoryBlock) error {
if block.ID == "" {
return fmt.Errorf("migrated block id is required")
}
if !validBlockModality(block.Modality) {
return fmt.Errorf("migrated block %s has invalid modality %q", block.ID, block.Modality)
}
vectorJSON, err := json.Marshal(block.Vector)
if err != nil {
return err
}
created := block.CreatedAt
if created.IsZero() {
created = time.Now()
}
_, err = tx.Exec(`INSERT INTO memory_blocks (
id, modality, text_content, payload_digest, 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.Size, block.Width, block.Height, string(vectorJSON), block.Fingerprint,
block.Source, block.Tool, created, time.Now())
return err
}
// attachBlockToLegacySentences 把迁移出的块挂到该旧实体当时所属的句子上,
// 并保留那些句子(它们可能只有媒体关系,删实体后就再无关系引用)。
func attachBlockToLegacySentences(tx *sql.Tx, entityID int64, blockID string) (int, error) {
rows, err := tx.Query(`SELECT DISTINCT s.id FROM sentences s
JOIN relations r ON r.sentence_id = s.id
WHERE r.source_id = ? OR r.target_id = ?`, entityID, entityID)
if err != nil {
return 0, err
}
var sids []int64
for rows.Next() {
var sid int64
if err := rows.Scan(&sid); err != nil {
rows.Close()
return 0, err
}
sids = append(sids, sid)
}
if err := rows.Err(); err != nil {
rows.Close()
return 0, err
}
rows.Close()
n := 0
for _, sid := range sids {
if _, err := tx.Exec(`INSERT OR IGNORE INTO memory_block_edges
(source_kind, source_id, target_kind, target_id, edge_type)
VALUES ('sentence', ?, 'block', ?, 'contains')`,
fmt.Sprintf("%d", sid), blockID); err != nil {
return n, err
}
n++
}
return n, nil
}

View File

@ -1,14 +1,8 @@
//go:build onnxruntime //go:build onnxruntime
// Package qwen 的 ONNX 推理实现(构建标签 onnxruntime,与 internal/memory/clip 同模式)。 // Package qwen 提供 Qwen3-VL-Embedding 的完整图文共享 ONNX 编码器。
// // 文本和图像共用 token embedding、28 层 Transformer、last-token 池化与
// 加载契约:调用方传入模型目录,内核不硬编码模型名。 // fingerprint;Vision.onnx 只产生注入 Transformer 的中间特征。
//
// TextTower.onnx + 外部权重分片 — 文本塔图(input_ids/attention_mask → embedding)
// tokenizer.json — 字节级 BPE 词表与 merges
// embed_config.json — dim / max_length / instruction / pooling
//
// 图内已含 last-token 池化,输出即 [batch, dim];L2 归一化在 Go 侧做(便宜且便于测试)。
package qwen package qwen
import ( import (
@ -26,34 +20,39 @@ import (
ort "github.com/yalue/onnxruntime_go" ort "github.com/yalue/onnxruntime_go"
) )
// embedConfig 对应导出脚本产出的 embed_config.json。
type embedConfig struct { type embedConfig struct {
Dimension int `json:"dim"` Arch string `json:"arch"`
MaxLength int `json:"max_length"` Dimension int `json:"dim"`
Instruction string `json:"instruction"` MaxLength int `json:"max_length"`
Pooling string `json:"pooling"` Instruction string `json:"instruction"`
Pooling string `json:"pooling"`
ImageSize int `json:"image_size"`
PatchSize int `json:"patch_size"`
TemporalPatch int `json:"temporal_patch_size"`
SpatialMerge int `json:"spatial_merge_size"`
ImageMean []float64 `json:"image_mean"`
ImageStd []float64 `json:"image_std"`
RopeTheta float64 `json:"rope_theta"`
MRopeSection []int `json:"mrope_section"`
} }
// Embedder 是基于内嵌 ONNX 文本塔的稠密嵌入器。
//
// 只提供文本能力:导出的是文本塔,视觉塔未导出。EmbedImageDense 会明确报错,
// 而不是返回一个「看起来能用」的零向量——后者会让跨模态检索静默失效。
type Embedder struct { type Embedder struct {
mu sync.RWMutex mu sync.RWMutex
loaded bool loaded bool
config embedConfig config embedConfig
tok *Tokenizer tok *Tokenizer
sess *ort.DynamicAdvancedSession token *ort.DynamicAdvancedSession
fp string transform *ort.DynamicAdvancedSession
vision *ort.DynamicAdvancedSession
fp string
close sync.Once
} }
// New 从模型目录加载文本塔。
func New(modelDir string) (*Embedder, error) { func New(modelDir string) (*Embedder, error) {
if modelDir == "" { if modelDir == "" {
return nil, fmt.Errorf("qwen model dir not specified") return nil, fmt.Errorf("qwen model dir not specified")
} }
cfgRaw, err := os.ReadFile(filepath.Join(modelDir, "embed_config.json")) cfgRaw, err := os.ReadFile(filepath.Join(modelDir, "embed_config.json"))
if err != nil { if err != nil {
return nil, fmt.Errorf("read embed_config.json: %w", err) return nil, fmt.Errorf("read embed_config.json: %w", err)
@ -62,22 +61,20 @@ func New(modelDir string) (*Embedder, error) {
if err := json.Unmarshal(cfgRaw, &cfg); err != nil { if err := json.Unmarshal(cfgRaw, &cfg); err != nil {
return nil, fmt.Errorf("parse embed_config.json: %w", err) return nil, fmt.Errorf("parse embed_config.json: %w", err)
} }
if cfg.Dimension <= 0 { if cfg.Dimension != 2048 || cfg.MaxLength < 598 || cfg.Pooling != "last_token" {
return nil, fmt.Errorf("embed_config.json 的 dim 无效: %d", cfg.Dimension) return nil, fmt.Errorf("qwen: incompatible config dim=%d max_length=%d pooling=%q", cfg.Dimension, cfg.MaxLength, cfg.Pooling)
} }
if cfg.MaxLength <= 0 { if cfg.ImageSize != qwenImageSize || cfg.PatchSize != qwenPatchSize || cfg.TemporalPatch != qwenTemporalPatch || cfg.SpatialMerge != qwenSpatialMerge {
cfg.MaxLength = 512 return nil, fmt.Errorf("qwen: incompatible vision layout image=%d patch=%d temporal=%d merge=%d", cfg.ImageSize, cfg.PatchSize, cfg.TemporalPatch, cfg.SpatialMerge)
} }
if cfg.Pooling != "" && cfg.Pooling != "last_token" { if cfg.RopeTheta <= 0 || len(cfg.MRopeSection) != 3 || cfg.MRopeSection[0]+cfg.MRopeSection[1]+cfg.MRopeSection[2] != qwenRotaryHalfDim {
return nil, fmt.Errorf("不支持的池化方式 %q(导出脚本只产出 last_token)", cfg.Pooling) return nil, fmt.Errorf("qwen: incompatible rope theta=%g section=%v", cfg.RopeTheta, cfg.MRopeSection)
} }
tok, err := LoadTokenizer(modelDir) tok, err := LoadTokenizer(modelDir)
if err != nil { if err != nil {
return nil, err return nil, err
} }
tok.MaxLen = cfg.MaxLength
if !ort.IsInitialized() { if !ort.IsInitialized() {
if lib := findOnnxLib(); lib != "" { if lib := findOnnxLib(); lib != "" {
ort.SetSharedLibraryPath(lib) ort.SetSharedLibraryPath(lib)
@ -87,74 +84,269 @@ func New(modelDir string) (*Embedder, error) {
} }
} }
sess, err := ort.NewDynamicAdvancedSession( token, err := ort.NewDynamicAdvancedSession(
filepath.Join(modelDir, "TextTower.onnx"), filepath.Join(modelDir, "TokenEmbedding.onnx"),
[]string{"input_ids", "attention_mask"}, []string{"input_ids"}, []string{"hidden"}, nil,
[]string{"embedding"},
nil,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("create text tower session: %w", err) return nil, fmt.Errorf("create qwen token embedding session: %w", err)
}
transform, err := ort.NewDynamicAdvancedSession(
filepath.Join(modelDir, "Transformer.onnx"),
[]string{"hidden", "deepstack_0", "deepstack_1", "deepstack_2", "rotary_cos", "rotary_sin", "causal_mask"},
[]string{"embedding"}, nil,
)
if err != nil {
token.Destroy()
return nil, fmt.Errorf("create qwen transformer session: %w", err)
}
vision, err := ort.NewDynamicAdvancedSession(
filepath.Join(modelDir, "Vision.onnx"), []string{"pixel_values"},
[]string{"deepstack_feature_0", "deepstack_feature_1", "deepstack_feature_2", "vision_hidden_states"}, nil,
)
if err != nil {
token.Destroy()
transform.Destroy()
return nil, fmt.Errorf("create qwen vision session: %w", err)
} }
return &Embedder{ return &Embedder{
loaded: true, loaded: true, config: cfg, tok: tok,
config: cfg, token: token, transform: transform, vision: vision,
tok: tok, fp: computeFingerprint(modelDir),
sess: sess,
fp: computeFingerprint(modelDir),
}, nil }, nil
} }
// renderInput 按模型自带的对话模板拼输入(实现在 tokenizer.go,无构建标签)。
func (e *Embedder) renderInput(text string) string {
return renderInstructionInput(e.config.Instruction, text)
}
// VectorizeDense 把文本编码为 L2 归一化的稠密向量。
func (e *Embedder) VectorizeDense(text string) ([]float64, error) { func (e *Embedder) VectorizeDense(text string) ([]float64, error) {
e.mu.RLock() e.mu.RLock()
defer e.mu.RUnlock() defer e.mu.RUnlock()
if !e.loaded { if !e.loaded {
return nil, fmt.Errorf("qwen embedder not loaded") return nil, fmt.Errorf("qwen embedder not loaded")
} }
ids, _, position, _, err := e.tok.textModelInput(e.config.Instruction, text, e.config.MaxLength)
ids, err := e.tok.encodeModelInput(e.renderInput(text), e.config.MaxLength)
if err != nil { if err != nil {
return nil, err return nil, err
} }
hidden, err := e.runTokenEmbedding(ids)
if err != nil {
return nil, err
}
deep := make([][]float32, 3)
for i := range deep {
deep[i] = make([]float32, len(hidden))
}
return e.runTransformer(hidden, deep, position, len(ids))
}
seq := len(ids) func (e *Embedder) EmbedImageDense(raw []byte, _ string) ([]float64, error) {
inputIDs := make([]int64, seq) e.mu.RLock()
attn := make([]int64, seq) defer e.mu.RUnlock()
if !e.loaded {
return nil, fmt.Errorf("qwen embedder not loaded")
}
pixels, err := preprocessImage(raw)
if err != nil {
return nil, err
}
features, err := e.runVision(pixels)
if err != nil {
return nil, err
}
ids, _, position, visual, err := e.tok.imageModelInput(e.config.Instruction, e.config.MaxLength)
if err != nil {
return nil, err
}
hidden, err := e.runTokenEmbedding(ids)
if err != nil {
return nil, err
}
deep := make([][]float32, 3)
for i := range deep {
deep[i] = make([]float32, len(hidden))
}
visualIndex := 0
for tokenIndex, isVisual := range visual {
if !isVisual {
continue
}
dst := tokenIndex * e.config.Dimension
src := visualIndex * e.config.Dimension
copy(hidden[dst:dst+e.config.Dimension], features[3][src:src+e.config.Dimension])
for layer := range deep {
copy(deep[layer][dst:dst+e.config.Dimension], features[layer][src:src+e.config.Dimension])
}
visualIndex++
}
if visualIndex != qwenVisualTokens {
return nil, fmt.Errorf("qwen: injected visual tokens=%d, want %d", visualIndex, qwenVisualTokens)
}
return e.runTransformer(hidden, deep, position, len(ids))
}
func (e *Embedder) runTokenEmbedding(ids []int) ([]float32, error) {
inputIDs := make([]int64, len(ids))
for i, id := range ids { for i, id := range ids {
inputIDs[i] = int64(id) inputIDs[i] = int64(id)
attn[i] = 1
} }
in, err := ort.NewTensor(ort.Shape{1, int64(len(ids))}, inputIDs)
idTensor, err := ort.NewTensor(ort.Shape{1, int64(seq)}, inputIDs)
if err != nil { if err != nil {
return nil, fmt.Errorf("input_ids tensor: %w", err) return nil, fmt.Errorf("qwen token input: %w", err)
} }
defer idTensor.Destroy() defer in.Destroy()
outs := make([]ort.Value, 1)
if err := e.token.Run([]ort.Value{in}, outs); err != nil {
return nil, fmt.Errorf("qwen token embedding run: %w", err)
}
if outs[0] == nil {
return nil, fmt.Errorf("qwen token embedding output is nil")
}
defer outs[0].Destroy()
tensor, ok := outs[0].(*ort.Tensor[float32])
if !ok {
return nil, fmt.Errorf("qwen token embedding output type %T", outs[0])
}
shape := tensor.GetShape()
if len(shape) != 3 || shape[0] != 1 || shape[1] != int64(len(ids)) || shape[2] != int64(e.config.Dimension) {
return nil, fmt.Errorf("qwen token embedding shape=%v", shape)
}
return append([]float32(nil), tensor.GetData()...), nil
}
maskTensor, err := ort.NewTensor(ort.Shape{1, int64(seq)}, attn) func (e *Embedder) runVision(pixels []float32) ([][]float32, error) {
in, err := ort.NewTensor(ort.Shape{qwenImagePatches, qwenPatchVectorSize}, pixels)
if err != nil { if err != nil {
return nil, fmt.Errorf("attention_mask tensor: %w", err) return nil, fmt.Errorf("qwen vision input: %w", err)
} }
defer maskTensor.Destroy() defer in.Destroy()
outs := make([]ort.Value, 4)
if err := e.vision.Run([]ort.Value{in}, outs); err != nil {
return nil, fmt.Errorf("qwen vision run: %w", err)
}
features := make([][]float32, 4)
for i, value := range outs {
if value == nil {
return nil, fmt.Errorf("qwen vision output %d is nil", i)
}
defer value.Destroy()
tensor, ok := value.(*ort.Tensor[float32])
if !ok {
return nil, fmt.Errorf("qwen vision output %d type %T", i, value)
}
shape := tensor.GetShape()
if len(shape) != 2 || shape[0] != qwenVisualTokens || shape[1] != int64(e.config.Dimension) {
return nil, fmt.Errorf("qwen vision output %d shape=%v", i, shape)
}
features[i] = append([]float32(nil), tensor.GetData()...)
}
return features, nil
}
outTensor, err := ort.NewEmptyTensor[float32](ort.Shape{1, int64(e.config.Dimension)}) func (e *Embedder) runTransformer(hidden []float32, deep [][]float32, position []int64, seq int) ([]float64, error) {
if len(hidden) != seq*e.config.Dimension || len(deep) != 3 || len(position) != 3*seq {
return nil, fmt.Errorf("qwen: invalid transformer inputs hidden=%d deep=%d position=%d seq=%d", len(hidden), len(deep), len(position), seq)
}
cos, sin := e.rotary(position, seq)
causal := causalMask(seq)
hiddenTensor, err := ort.NewTensor(ort.Shape{1, int64(seq), int64(e.config.Dimension)}, hidden)
if err != nil { if err != nil {
return nil, fmt.Errorf("output tensor: %w", err) return nil, fmt.Errorf("qwen hidden tensor: %w", err)
} }
defer outTensor.Destroy() defer hiddenTensor.Destroy()
inputs := []ort.Value{hiddenTensor}
if err := e.sess.Run([]ort.Value{idTensor, maskTensor}, []ort.Value{outTensor}); err != nil { var deepTensors []*ort.Tensor[float32]
return nil, fmt.Errorf("text tower run: %w", err) for i, data := range deep {
if len(data) != len(hidden) {
return nil, fmt.Errorf("qwen deepstack %d length=%d, want %d", i, len(data), len(hidden))
}
t, err := ort.NewTensor(ort.Shape{1, int64(seq), int64(e.config.Dimension)}, data)
if err != nil {
return nil, fmt.Errorf("qwen deepstack %d tensor: %w", i, err)
}
deepTensors = append(deepTensors, t)
inputs = append(inputs, t)
} }
defer func() {
for _, t := range deepTensors {
t.Destroy()
}
}()
cosTensor, err := ort.NewTensor(ort.Shape{1, int64(seq), qwenRotaryDim}, cos)
if err != nil {
return nil, fmt.Errorf("qwen rotary cos: %w", err)
}
defer cosTensor.Destroy()
sinTensor, err := ort.NewTensor(ort.Shape{1, int64(seq), qwenRotaryDim}, sin)
if err != nil {
return nil, fmt.Errorf("qwen rotary sin: %w", err)
}
defer sinTensor.Destroy()
causalTensor, err := ort.NewTensor(ort.Shape{1, 1, int64(seq), int64(seq)}, causal)
if err != nil {
return nil, fmt.Errorf("qwen causal mask: %w", err)
}
defer causalTensor.Destroy()
inputs = append(inputs, cosTensor, sinTensor, causalTensor)
raw := outTensor.GetData() out, err := ort.NewEmptyTensor[float32](ort.Shape{1, int64(e.config.Dimension)})
if err != nil {
return nil, fmt.Errorf("qwen output tensor: %w", err)
}
defer out.Destroy()
if err := e.transform.Run(inputs, []ort.Value{out}); err != nil {
return nil, fmt.Errorf("qwen transformer run: %w", err)
}
return normalize(out.GetData()), nil
}
const (
qwenRotaryHalfDim = 64
qwenRotaryDim = 128
)
func (e *Embedder) rotary(position []int64, seq int) ([]float32, []float32) {
cos := make([]float32, seq*qwenRotaryDim)
sin := make([]float32, seq*qwenRotaryDim)
inv := make([]float64, qwenRotaryHalfDim)
for i := range inv {
inv[i] = 1 / math.Pow(e.config.RopeTheta, float64(2*i)/qwenRotaryDim)
}
for token := 0; token < seq; token++ {
freq := make([]float64, qwenRotaryHalfDim)
for i := range freq {
freq[i] = float64(position[token]) * inv[i]
}
for dim, offset := range []int{0, 1, 2} {
if dim == 0 {
continue
}
limit := e.config.MRopeSection[dim] * 3
for i := offset; i < limit; i += 3 {
freq[i] = float64(position[dim*seq+token]) * inv[i]
}
}
for i, f := range freq {
c, s := float32(math.Cos(f)), float32(math.Sin(f))
cos[token*qwenRotaryDim+i] = c
cos[token*qwenRotaryDim+qwenRotaryHalfDim+i] = c
sin[token*qwenRotaryDim+i] = s
sin[token*qwenRotaryDim+qwenRotaryHalfDim+i] = s
}
}
return cos, sin
}
func causalMask(seq int) []float32 {
out := make([]float32, seq*seq)
for row := 0; row < seq; row++ {
for col := row + 1; col < seq; col++ {
out[row*seq+col] = -math.MaxFloat32
}
}
return out
}
func normalize(raw []float32) []float64 {
out := make([]float64, len(raw)) out := make([]float64, len(raw))
var norm float64 var norm float64
for i, v := range raw { for i, v := range raw {
@ -167,48 +359,39 @@ func (e *Embedder) VectorizeDense(text string) ([]float64, error) {
out[i] /= norm out[i] /= norm
} }
} }
return out, nil return out
}
// EmbedImageDense 不支持:导出的是**文本塔**,视觉塔未导出。
//
// 明确报错而不是返回零向量或占位:调用方(mediaref.go)会 log 后跳过写向量,
// 若返回零向量则「写入了但检索不到」,失败会静默化。要支持图像检索需另外
// 导出视觉塔并实现 Qwen3-VL 的图像预处理(patch/merge/缩放规则)。
func (e *Embedder) EmbedImageDense(_ []byte, _ string) ([]float64, error) {
return nil, fmt.Errorf("qwen text tower 不支持图像嵌入;图像检索请用 clip 或 http 路径")
} }
func (e *Embedder) Fingerprint() string { return e.fp } func (e *Embedder) Fingerprint() string { return e.fp }
func (e *Embedder) Dim() int { return e.config.Dimension } func (e *Embedder) Dim() int { return e.config.Dimension }
func (e *Embedder) Loaded() bool { func (e *Embedder) Loaded() bool {
e.mu.RLock() e.mu.RLock()
defer e.mu.RUnlock() defer e.mu.RUnlock()
return e.loaded return e.loaded
} }
func (e *Embedder) Close() { func (e *Embedder) Close() {
e.mu.Lock() e.close.Do(func() {
defer e.mu.Unlock() e.mu.Lock()
if e.sess != nil { defer e.mu.Unlock()
e.sess.Destroy() if e.token != nil {
e.sess = nil e.token.Destroy()
} e.token = nil
e.loaded = false }
if e.transform != nil {
e.transform.Destroy()
e.transform = nil
}
if e.vision != nil {
e.vision.Destroy()
e.vision = nil
}
e.loaded = false
})
} }
// computeFingerprint 计算模型指纹,用于 vec_model 持久化与切换后重算判定。
//
// 为什么不直接哈希全部权重:这个模型目录有 6.5GB 外部权重分片,启动时读一遍
// 要几十秒,会阻塞 homeagent 启动。这里哈希「图文件 + 配置 + 全部外部权重的
// 文件名与大小」——换模型(哪怕只是换了权重)几乎必然改变文件集合或大小,
// 足以识别切换;代价是理论上存在「大小相同但内容不同」的漏判,对本地单机
// 部署可接受。
func computeFingerprint(modelDir string) string { func computeFingerprint(modelDir string) string {
h := sha256.New() h := sha256.New()
for _, name := range []string{"TokenEmbedding.onnx", "Transformer.onnx", "Vision.onnx", "embed_config.json"} {
for _, name := range []string{"TextTower.onnx", "embed_config.json"} {
if data, err := os.ReadFile(filepath.Join(modelDir, name)); err == nil { if data, err := os.ReadFile(filepath.Join(modelDir, name)); err == nil {
h.Write([]byte(name)) h.Write([]byte(name))
h.Write([]byte{0}) h.Write([]byte{0})
@ -216,35 +399,25 @@ func computeFingerprint(modelDir string) string {
h.Write([]byte{0}) h.Write([]byte{0})
} }
} }
entries, _ := os.ReadDir(modelDir) entries, _ := os.ReadDir(modelDir)
var names []string var names []string
for _, e := range entries { for _, entry := range entries {
n := e.Name() n := entry.Name()
// 外部权重分片:torch 新版导出器使用 onnx__<op>_<id> 与模型张量同名文件。 if strings.HasPrefix(n, "embed_tokens.") || strings.HasPrefix(n, "layers.") || strings.HasPrefix(n, "onnx__") || strings.HasSuffix(n, ".onnx.data") {
if strings.HasPrefix(n, "onnx__") || strings.HasSuffix(n, ".weight") || strings.HasSuffix(n, ".onnx.data") {
names = append(names, n) names = append(names, n)
} }
} }
sort.Strings(names) sort.Strings(names)
for _, n := range names { for _, n := range names {
info, err := os.Stat(filepath.Join(modelDir, n)) if info, err := os.Stat(filepath.Join(modelDir, n)); err == nil {
if err != nil { fmt.Fprintf(h, "%s:%d\n", n, info.Size())
continue
} }
fmt.Fprintf(h, "%s:%d\n", n, info.Size())
} }
return hex.EncodeToString(h.Sum(nil)) return hex.EncodeToString(h.Sum(nil))
} }
// findOnnxLib 在常见路径中查找 libonnxruntime.so。
func findOnnxLib() string { func findOnnxLib() string {
for _, p := range []string{ for _, p := range []string{"/opt/onnxruntime/libonnxruntime.so", "/usr/local/lib/libonnxruntime.so", "/usr/lib/libonnxruntime.so"} {
"/opt/onnxruntime/libonnxruntime.so",
"/opt/onnxruntime/lib/libonnxruntime.so",
"/usr/local/lib/libonnxruntime.so",
"/usr/lib/libonnxruntime.so",
} {
if _, err := os.Stat(p); err == nil { if _, err := os.Stat(p); err == nil {
return p return p
} }

View File

@ -4,7 +4,7 @@ package qwen
import "fmt" import "fmt"
// Embedder 在未启用 onnxruntime 时为 no-op 实现(与 internal/memory/clip 同模式)。 // Embedder 在未启用 onnxruntime 时为 no-op 实现。
// 默认构建不链接 onnxruntime,保持原有 fastText/TF-IDF 行为不变。 // 默认构建不链接 onnxruntime,保持原有 fastText/TF-IDF 行为不变。
type Embedder struct { type Embedder struct {
loaded bool loaded bool

View File

@ -0,0 +1,157 @@
//go:build onnxruntime
package qwen
import (
"bytes"
"fmt"
"image"
"image/color"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"math"
)
const (
qwenImageSize = 768
qwenPatchSize = 16
qwenTemporalPatch = 2
qwenSpatialMerge = 2
qwenImagePatches = (qwenImageSize / qwenPatchSize) * (qwenImageSize / qwenPatchSize)
qwenVisualTokens = qwenImagePatches / (qwenSpatialMerge * qwenSpatialMerge)
qwenPatchVectorSize = 3 * qwenTemporalPatch * qwenPatchSize * qwenPatchSize
)
// preprocessImage 把任意图片转成固定 768×768 视觉塔输入。
//
// Vision.onnx 是经过 PyTorch 逐输出验证的固定 48×48 patch 图。为避免拉伸物体,
// 这里保持宽高比缩放并在中心补中性灰(归一化后约为 0);这与直接把长方形
// 强拉成正方形相比更能保留 Qwen 的视觉语义。已是 768×768 的输入不做插值,
// 便于用跨语言冻结向量精确回归 patch 排列。
func preprocessImage(raw []byte) ([]float32, error) {
src, _, err := image.Decode(bytes.NewReader(raw))
if err != nil {
return nil, fmt.Errorf("qwen: decode image: %w", err)
}
b := src.Bounds()
if b.Dx() <= 0 || b.Dy() <= 0 {
return nil, fmt.Errorf("qwen: empty image")
}
scale := math.Min(float64(qwenImageSize)/float64(b.Dx()), float64(qwenImageSize)/float64(b.Dy()))
w := max(1, int(math.Round(float64(b.Dx())*scale)))
h := max(1, int(math.Round(float64(b.Dy())*scale)))
if w > qwenImageSize {
w = qwenImageSize
}
if h > qwenImageSize {
h = qwenImageSize
}
resized := resizeBicubic(src, w, h)
canvas := image.NewNRGBA(image.Rect(0, 0, qwenImageSize, qwenImageSize))
neutral := color.NRGBA{R: 128, G: 128, B: 128, A: 255}
for i := 0; i < len(canvas.Pix); i += 4 {
canvas.Pix[i], canvas.Pix[i+1], canvas.Pix[i+2], canvas.Pix[i+3] = neutral.R, neutral.G, neutral.B, neutral.A
}
ox, oy := (qwenImageSize-w)/2, (qwenImageSize-h)/2
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
canvas.SetNRGBA(ox+x, oy+y, resized.NRGBAAt(x, y))
}
}
// 与 transformers Qwen2VLImageProcessor 的排列严格一致:
// [grid_h/merge, grid_w/merge, merge_h, merge_w, channel,
// temporal_patch, patch_h, patch_w],然后 flatten。
out := make([]float32, 0, qwenImagePatches*qwenPatchVectorSize)
blocks := qwenImageSize / qwenPatchSize / qwenSpatialMerge
for bh := 0; bh < blocks; bh++ {
for bw := 0; bw < blocks; bw++ {
for mh := 0; mh < qwenSpatialMerge; mh++ {
for mw := 0; mw < qwenSpatialMerge; mw++ {
baseY := (bh*qwenSpatialMerge + mh) * qwenPatchSize
baseX := (bw*qwenSpatialMerge + mw) * qwenPatchSize
for c := 0; c < 3; c++ {
for temporal := 0; temporal < qwenTemporalPatch; temporal++ {
_ = temporal // 静态图复制同一图片形成 2 帧 temporal patch
for py := 0; py < qwenPatchSize; py++ {
for px := 0; px < qwenPatchSize; px++ {
p := canvas.NRGBAAt(baseX+px, baseY+py)
v := [3]uint8{p.R, p.G, p.B}[c]
out = append(out, float32(v)/127.5-1)
}
}
}
}
}
}
}
}
return out, nil
}
// resizeBicubic 使用半像素中心的 Catmull-Rom 三次卷积。
func resizeBicubic(src image.Image, dstW, dstH int) *image.NRGBA {
b := src.Bounds()
if b.Dx() == dstW && b.Dy() == dstH {
dst := image.NewNRGBA(image.Rect(0, 0, dstW, dstH))
for y := 0; y < dstH; y++ {
for x := 0; x < dstW; x++ {
dst.SetNRGBA(x, y, color.NRGBAModel.Convert(src.At(b.Min.X+x, b.Min.Y+y)).(color.NRGBA))
}
}
return dst
}
dst := image.NewNRGBA(image.Rect(0, 0, dstW, dstH))
sx, sy := float64(b.Dx())/float64(dstW), float64(b.Dy())/float64(dstH)
for y := 0; y < dstH; y++ {
fy := (float64(y)+0.5)*sy - 0.5
y0 := int(math.Floor(fy))
for x := 0; x < dstW; x++ {
fx := (float64(x)+0.5)*sx - 0.5
x0 := int(math.Floor(fx))
var sum [4]float64
var weight float64
for j := -1; j <= 2; j++ {
wy := cubicWeight(fy - float64(y0+j))
yy := min(max(y0+j, 0), b.Dy()-1)
for i := -1; i <= 2; i++ {
w := wy * cubicWeight(fx-float64(x0+i))
xx := min(max(x0+i, 0), b.Dx()-1)
p := color.NRGBAModel.Convert(src.At(b.Min.X+xx, b.Min.Y+yy)).(color.NRGBA)
sum[0] += float64(p.R) * w
sum[1] += float64(p.G) * w
sum[2] += float64(p.B) * w
sum[3] += float64(p.A) * w
weight += w
}
}
if weight == 0 {
weight = 1
}
dst.SetNRGBA(x, y, color.NRGBA{
R: clampByte(sum[0] / weight), G: clampByte(sum[1] / weight),
B: clampByte(sum[2] / weight), A: clampByte(sum[3] / weight),
})
}
}
return dst
}
func cubicWeight(x float64) float64 {
x = math.Abs(x)
if x <= 1 {
return 1.5*x*x*x - 2.5*x*x + 1
}
if x < 2 {
return -0.5*x*x*x + 2.5*x*x - 4*x + 2
}
return 0
}
func clampByte(v float64) uint8 {
return uint8(min(255, max(0, int(math.Round(v)))))
}

View File

@ -0,0 +1,86 @@
//go:build onnxruntime
package qwen
import (
"fmt"
"strings"
)
// imageModelInput 构造 Qwen3-VL 单图对话模板及对应 M-RoPE 位置。
// 固定 768×768 视觉塔产生 576 个合并后的视觉 token。
func (t *Tokenizer) imageModelInput(instruction string, maxLen int) (ids []int, attention, position []int64, visual []bool, err error) {
if instruction == "" {
instruction = DefaultInstruction
}
text := "<|im_start|>system\n" + instruction +
"<|im_end|>\n<|im_start|>user\n<|vision_start|>" +
strings.Repeat("<|image_pad|>", qwenVisualTokens) +
"<|vision_end|><|im_end|>\n<|im_start|>assistant\n"
ids, err = t.encodeModelInput(text, maxLen)
if err != nil {
return nil, nil, nil, nil, err
}
imageID, ok := t.SpecialID("<|image_pad|>")
if !ok {
return nil, nil, nil, nil, fmt.Errorf("tokenizer.json 缺少 <|image_pad|>")
}
visual = make([]bool, len(ids))
attention = make([]int64, len(ids))
position = make([]int64, 3*len(ids))
for i, id := range ids {
attention[i] = 1
visual[i] = id == imageID
}
current := int64(0)
for start := 0; start < len(ids); {
isVisual := visual[start]
end := start + 1
for end < len(ids) && visual[end] == isVisual {
end++
}
if !isVisual {
for i := start; i < end; i++ {
p := current + int64(i-start)
position[i] = p
position[len(ids)+i] = p
position[2*len(ids)+i] = p
}
current += int64(end - start)
} else {
if end-start != qwenVisualTokens {
return nil, nil, nil, nil, fmt.Errorf("qwen: image token count=%d, want %d", end-start, qwenVisualTokens)
}
side := qwenImageSize / qwenPatchSize / qwenSpatialMerge
for i := start; i < end; i++ {
j := i - start
position[i] = current
position[len(ids)+i] = current + int64(j/side)
position[2*len(ids)+i] = current + int64(j%side)
}
current += int64(side)
}
start = end
}
return ids, attention, position, visual, nil
}
// textModelInput 执行完整 tokenizer post_processor,并构造纯文本标准 RoPE 位置。
func (t *Tokenizer) textModelInput(instruction, text string, maxLen int) (ids []int, attention, position []int64, visual []bool, err error) {
ids, err = t.encodeModelInput(renderInstructionInput(instruction, text), maxLen)
if err != nil {
return nil, nil, nil, nil, err
}
attention = make([]int64, len(ids))
position = make([]int64, 3*len(ids))
visual = make([]bool, len(ids))
for i := range ids {
attention[i] = 1
position[i] = int64(i)
position[len(ids)+i] = int64(i)
position[2*len(ids)+i] = int64(i)
}
return ids, attention, position, visual, nil
}

View File

@ -14,8 +14,8 @@ import (
"sync" "sync"
"unicode/utf8" "unicode/utf8"
"github.com/yanyiwu/gojieba"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
"github.com/yanyiwu/gojieba"
) )
const downloadMaxWords = 200000 const downloadMaxWords = 200000

View File

@ -108,7 +108,7 @@ func TestStaticEmbedderSemanticSimilarity(t *testing.T) {
e := newSynthEmbedder(t, 300) e := newSynthEmbedder(t, 300)
pairs := []struct { pairs := []struct {
a, b string a, b string
related bool related bool
}{ }{
{"今天天气怎么样", "明天会不会下雨", true}, {"今天天气怎么样", "明天会不会下雨", true},

View File

@ -0,0 +1,51 @@
package vector
import "math"
// FuseVectors 把同一统一空间里的多个向量融合为一个向量:
// 逐维求和后重新 L2 归一化。
//
// 用途:文档/上下文事件既带文本、又带若干一等记忆块(图片/视频),
// 二者的向量来自同一模型、同一 fingerprint、同一维度。融合后,
// 一篇文档既能按文字、也能按它携带的图片内容被召回——
// 图片由自己的向量参与检索,不依赖任何生成的描述文本。
//
// 约定:调用方传入的向量应已是 L2 归一化的同空间向量。长度不一致的
// 向量会被跳过(不同模型/维度的残留);全空或全零返回 nil。
func FuseVectors(vectors ...[]float64) []float64 {
dim := 0
for _, v := range vectors {
if len(v) > dim {
dim = len(v)
}
}
if dim == 0 {
return nil
}
out := make([]float64, dim)
used := 0
for _, v := range vectors {
if len(v) != dim {
continue
}
for i, x := range v {
out[i] += x
}
used++
}
if used == 0 {
return nil
}
var norm float64
for _, x := range out {
norm += x * x
}
if norm == 0 {
return nil
}
norm = math.Sqrt(norm)
for i := range out {
out[i] /= norm
}
return out
}

View File

@ -47,7 +47,7 @@ const (
ModalityVideo MultimodalModality = "video" ModalityVideo MultimodalModality = "video"
) )
// ErrNotSupported 表示 Vectorizer 不支持图像嵌入,调用方按文本描述降级。 // ErrNotSupported 表示 Vectorizer 不支持该原生模态;调用方不得以描述文本冒充其向量。
var ErrNotSupported = fmt.Errorf("vectorizer does not support image embedding") var ErrNotSupported = fmt.Errorf("vectorizer does not support image embedding")
// Vector 是带权特征映射:feature → weight // Vector 是带权特征映射:feature → weight

View File

@ -52,8 +52,8 @@ func (f *fakeCoreSDK) PluginName() string { return "fake" }
func (f *fakeCoreSDK) Settings() pubsdk.SettingsAPI { return nil } func (f *fakeCoreSDK) Settings() pubsdk.SettingsAPI { return nil }
func (f *fakeCoreSDK) Memory() pubsdk.MemoryAPI { return nil } func (f *fakeCoreSDK) Memory() pubsdk.MemoryAPI { return nil }
func (f *fakeCoreSDK) TextMemory() pubsdk.TextMemoryAPI { return nil } func (f *fakeCoreSDK) TextMemory() pubsdk.TextMemoryAPI { return nil }
func (f *fakeCoreSDK) DocMemory() pubsdk.DocMemoryAPI { return f.docMem } func (f *fakeCoreSDK) DocMemory() pubsdk.DocMemoryAPI { return f.docMem }
func (f *fakeCoreSDK) Knowledge() pubsdk.KnowledgeAPI { return f.knowledge } func (f *fakeCoreSDK) Knowledge() pubsdk.KnowledgeAPI { return f.knowledge }
func (f *fakeCoreSDK) LLM() pubsdk.LLMAPI { return nil } func (f *fakeCoreSDK) LLM() pubsdk.LLMAPI { return nil }
func (f *fakeCoreSDK) Social() pubsdk.SocialAPI { return nil } func (f *fakeCoreSDK) Social() pubsdk.SocialAPI { return nil }
func (f *fakeCoreSDK) PluginMgr() pubsdk.PluginMgrAPI { return nil } func (f *fakeCoreSDK) PluginMgr() pubsdk.PluginMgrAPI { return nil }

View File

@ -322,7 +322,7 @@ func TestHealthcheckWithDocStore(t *testing.T) {
} }
defer os.RemoveAll(tmpDir) defer os.RemoveAll(tmpDir)
ds := doc.NewStore(tmpDir) ds := doc.NewStore(tmpDir, memory.TokenizeWords)
if err := ds.Start(); err != nil { if err := ds.Start(); err != nil {
t.Fatal(err) t.Fatal(err)
} }

View File

@ -70,7 +70,7 @@ func setupIntegrationWithProvider(t *testing.T, pm *agentAPI.ProviderManager) *t
t.Fatal(err) t.Fatal(err)
} }
docStore := doc.NewStore(filepath.Join(tmpDir, "documents")) docStore := doc.NewStore(filepath.Join(tmpDir, "documents"), memory.TokenizeWords)
if err := docStore.Start(); err != nil { if err := docStore.Start(); err != nil {
t.Fatal(err) t.Fatal(err)
} }

View File

@ -125,6 +125,53 @@ func (c *testWSClient) readMsg() (byte, []byte, error) {
func (c *testWSClient) close() { c.conn.Close() } func (c *testWSClient) close() { c.conn.Close() }
func (c *testWSClient) bindDevice(t *testing.T, deviceID, token string) {
t.Helper()
c.sendText(mustJSON(map[string]interface{}{
"op": "bind", "device_id": deviceID, "token": token,
}))
op, payload, err := c.readMsg()
if err != nil {
t.Fatalf("read bind_ack: %v", err)
}
if op != 0x1 {
t.Fatalf("expected bind_ack text frame, got %x", op)
}
var ack map[string]interface{}
if err := json.Unmarshal(payload, &ack); err != nil {
t.Fatalf("decode bind_ack: %v", err)
}
if ack["op"] != "bind_ack" || ack["ok"] != true {
t.Fatalf("bind rejected: %v", ack)
}
}
func (c *testWSClient) readHelloAck(t *testing.T) string {
t.Helper()
op, payload, err := c.readMsg()
if err != nil {
t.Fatalf("read hello_ack: %v", err)
}
if op != 0x1 {
t.Fatalf("expected hello_ack text frame, got %x", op)
}
var ack map[string]interface{}
if err := json.Unmarshal(payload, &ack); err != nil {
t.Fatalf("decode hello_ack: %v", err)
}
deviceID, _ := ack["device"].(string)
if ack["op"] != "hello_ack" || deviceID == "" {
t.Fatalf("expected hello_ack with device id, got %v", ack)
}
return deviceID
}
func (c *testWSClient) readHelloAckAndBind(t *testing.T, token string) {
t.Helper()
deviceID := c.readHelloAck(t)
c.bindDevice(t, deviceID, token)
}
// ===== 端到端:hello/bind/cmd + 二进制分块回传(录像协议)===== // ===== 端到端:hello/bind/cmd + 二进制分块回传(录像协议)=====
func TestWSBinaryChunkUpload(t *testing.T) { func TestWSBinaryChunkUpload(t *testing.T) {
@ -139,20 +186,9 @@ func TestWSBinaryChunkUpload(t *testing.T) {
cli := dialTestWS(t, url, token) cli := dialTestWS(t, url, token)
defer cli.close() defer cli.close()
// hello 登记 // hello 后必须完成 bind,设备才会注册并开始处理数据。
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"gui-test","name":"测试机","kind":"computer","caps":["cmd"]}}`)) cli.sendText([]byte(`{"op":"hello","device":{"device_id":"gui-test","name":"测试机","kind":"computer","caps":["cmd"]}}`))
op, payload, err := cli.readMsg() cli.readHelloAckAndBind(t, token)
if err != nil {
t.Fatalf("read hello_ack: %v", err)
}
if op != 0x1 {
t.Fatalf("expected text frame, got %x", op)
}
var ack map[string]interface{}
json.Unmarshal(payload, &ack)
if ack["op"] != "hello_ack" {
t.Fatalf("expected hello_ack, got %v", ack)
}
// 模拟设备收到 cmd 后以二进制分块回传(cmd_data_start → 0x2×N → cmd_data_end) // 模拟设备收到 cmd 后以二进制分块回传(cmd_data_start → 0x2×N → cmd_data_end)
videoData := make([]byte, 20000) // 跨多个 8KB 块 videoData := make([]byte, 20000) // 跨多个 8KB 块
@ -224,9 +260,7 @@ func TestWSBinaryMediaToFile(t *testing.T) {
defer cli.close() defer cli.close()
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"gui-media","name":"媒体机","kind":"computer","caps":["cmd"]}}`)) cli.sendText([]byte(`{"op":"hello","device":{"device_id":"gui-media","name":"媒体机","kind":"computer","caps":["cmd"]}}`))
if _, _, err := cli.readMsg(); err != nil { // hello_ack cli.readHelloAckAndBind(t, token)
t.Fatalf("read hello_ack: %v", err)
}
videoData := make([]byte, 30000) videoData := make([]byte, 30000)
for i := range videoData { for i := range videoData {
@ -290,9 +324,7 @@ func TestWSPushDataAudio(t *testing.T) {
defer cli.close() defer cli.close()
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"audio-dev","name":"音频机","kind":"speaker"}}`)) cli.sendText([]byte(`{"op":"hello","device":{"device_id":"audio-dev","name":"音频机","kind":"speaker"}}`))
if _, _, err := cli.readMsg(); err != nil { cli.readHelloAckAndBind(t, token)
t.Fatalf("read hello_ack: %v", err)
}
audioData := []byte("RIFF....fake-wav-audio-data-for-testing....") audioData := []byte("RIFF....fake-wav-audio-data-for-testing....")
@ -388,9 +420,7 @@ func TestScreenseeEndToEnd(t *testing.T) {
// 设备 hello + bind(bind 需 token 才能被授权流程识别,这里直接手动授权) // 设备 hello + bind(bind 需 token 才能被授权流程识别,这里直接手动授权)
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"see-dev","name":"屏幕机","kind":"computer","caps":["cmd"]}}`)) cli.sendText([]byte(`{"op":"hello","device":{"device_id":"see-dev","name":"屏幕机","kind":"computer","caps":["cmd"]}}`))
if _, _, err := cli.readMsg(); err != nil { cli.readHelloAckAndBind(t, token)
t.Fatalf("read hello_ack: %v", err)
}
// 设备侧循环收命令并回执(模拟 GUI screensee 实现) // 设备侧循环收命令并回执(模拟 GUI screensee 实现)
go func() { go func() {
@ -454,9 +484,7 @@ func TestComputeruseEndToEnd(t *testing.T) {
defer cli.close() defer cli.close()
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"cu-dev","name":"操控机","kind":"computer","caps":["cmd","computeruse"]}}`)) cli.sendText([]byte(`{"op":"hello","device":{"device_id":"cu-dev","name":"操控机","kind":"computer","caps":["cmd","computeruse"]}}`))
if _, _, err := cli.readMsg(); err != nil { cli.readHelloAckAndBind(t, token)
t.Fatalf("read hello_ack: %v", err)
}
// 设备侧收 computeruse 命令并回执 // 设备侧收 computeruse 命令并回执
var receivedCmd string var receivedCmd string
@ -544,9 +572,7 @@ func TestClipboardEndToEnd(t *testing.T) {
defer cli.close() defer cli.close()
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"clip-dev","name":"剪贴板机","kind":"computer","caps":["cmd"]}}`)) cli.sendText([]byte(`{"op":"hello","device":{"device_id":"clip-dev","name":"剪贴板机","kind":"computer","caps":["cmd"]}}`))
if _, _, err := cli.readMsg(); err != nil { cli.readHelloAckAndBind(t, token)
t.Fatalf("read hello_ack: %v", err)
}
// 设备侧响应剪贴板命令 // 设备侧响应剪贴板命令
go func() { go func() {
@ -666,9 +692,7 @@ func TestCapabilityMatrix(t *testing.T) {
defer cli.close() defer cli.close()
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"cam-only","name":"纯摄像头","kind":"camera","caps":["camera"]}}`)) cli.sendText([]byte(`{"op":"hello","device":{"device_id":"cam-only","name":"纯摄像头","kind":"camera","caps":["camera"]}}`))
if _, _, err := cli.readMsg(); err != nil { cli.readHelloAckAndBind(t, token)
t.Fatalf("read hello_ack: %v", err)
}
if _, err := dev.Execute("screensee", map[string]interface{}{"device_id": "cam-only"}); err == nil { if _, err := dev.Execute("screensee", map[string]interface{}{"device_id": "cam-only"}); err == nil {
t.Fatal("camera-only device should not support screensee") t.Fatal("camera-only device should not support screensee")
@ -698,9 +722,7 @@ func TestDeviceEventReport(t *testing.T) {
defer cli.close() defer cli.close()
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"cam-watch","name":"监控摄像头","kind":"camera","caps":["camera"]}}`)) cli.sendText([]byte(`{"op":"hello","device":{"device_id":"cam-watch","name":"监控摄像头","kind":"camera","caps":["camera"]}}`))
if _, _, err := cli.readMsg(); err != nil { cli.readHelloAckAndBind(t, token)
t.Fatalf("read hello_ack: %v", err)
}
// 设备主动上报:识别到未知人员驻留 // 设备主动上报:识别到未知人员驻留
cli.sendText(mustJSON(map[string]interface{}{ cli.sendText(mustJSON(map[string]interface{}{
@ -738,3 +760,105 @@ func TestDeviceEventReport(t *testing.T) {
t.Fatalf("unexpected second event: %v", events[1]) t.Fatalf("unexpected second event: %v", events[1])
} }
} }
func TestWSDoesNotExposeDeviceBeforeBind(t *testing.T) {
reg := NewRegistry()
token := "prebind-token"
reg.SetAcceptToken(func(provided string) bool { return provided == token })
srv := httptest.NewServer(http.HandlerFunc(reg.ServeWS))
defer srv.Close()
cli := dialTestWS(t, srv.URL, token)
defer cli.close()
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"prebind-dev","name":"待绑定设备","kind":"computer","caps":["cmd"]}}`))
deviceID := cli.readHelloAck(t)
if deviceID != "prebind-dev" {
t.Fatalf("unexpected device id: %s", deviceID)
}
if _, ok := reg.Get(deviceID); ok {
t.Fatal("device must not be registered before bind")
}
if reg.Online(deviceID) {
t.Fatal("device must not be online before bind")
}
if err := reg.PushJSON(deviceID, map[string]interface{}{"op": "cmd"}); err == nil {
t.Fatal("command push must fail before bind")
}
cli.sendText(mustJSON(map[string]interface{}{
"op": "cmd_result", "req_id": "prebind-result", "status": "ok",
}))
time.Sleep(20 * time.Millisecond)
if _, ok := reg.GetResult("prebind-result"); ok {
t.Fatal("result must be ignored before bind")
}
}
func TestWSRejectedBindDoesNotRegister(t *testing.T) {
reg := NewRegistry()
reg.SetAcceptToken(func(provided string) bool { return provided == "expected-token" })
srv := httptest.NewServer(http.HandlerFunc(reg.ServeWS))
defer srv.Close()
cli := dialTestWS(t, srv.URL, "")
defer cli.close()
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"rejected-dev","name":"拒绝设备","kind":"computer"}}`))
deviceID := cli.readHelloAck(t)
cli.sendText(mustJSON(map[string]interface{}{
"op": "bind", "device_id": deviceID, "token": "wrong-token",
}))
op, payload, err := cli.readMsg()
if err != nil {
t.Fatalf("read rejected bind_ack: %v", err)
}
if op != 0x1 {
t.Fatalf("expected rejected bind_ack text frame, got %x", op)
}
var ack map[string]interface{}
if err := json.Unmarshal(payload, &ack); err != nil {
t.Fatalf("decode rejected bind_ack: %v", err)
}
if ack["op"] != "bind_ack" || ack["ok"] != false {
t.Fatalf("expected rejected bind_ack, got %v", ack)
}
if _, ok := reg.Get(deviceID); ok {
t.Fatal("rejected device must not be registered")
}
if reg.Online(deviceID) {
t.Fatal("rejected device must not be online")
}
}
func TestWSHandshakeAuthorizationAllowsUnrelatedBindToken(t *testing.T) {
reg := NewRegistry()
transportToken := "transport-token"
reg.SetAcceptToken(func(provided string) bool { return provided == transportToken })
srv := httptest.NewServer(http.HandlerFunc(reg.ServeWS))
defer srv.Close()
cli := dialTestWS(t, srv.URL, transportToken)
defer cli.close()
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"transport-dev","name":"代理设备","kind":"computer"}}`))
deviceID := cli.readHelloAck(t)
cli.bindDevice(t, deviceID, "unrelated-body-token")
if !reg.Online(deviceID) {
t.Fatal("handshake-authorized device should be online after bind")
}
}
func TestAwaitResultReturnsResultDeliveredBeforeWaiter(t *testing.T) {
reg := NewRegistry()
want := map[string]interface{}{"status": "ok", "value": "early"}
reg.deliverResult("early-result", want)
got, err := reg.AwaitResult("early-result", 50*time.Millisecond)
if err != nil {
t.Fatalf("await early result: %v", err)
}
if got["status"] != want["status"] || got["value"] != want["value"] {
t.Fatalf("unexpected early result: %v", got)
}
}

View File

@ -104,8 +104,7 @@ var capabilityTools = map[string][]string{
// compatFullCaps 视为「全能力」的历史 caps 值:声明了这些的设备不参与能力裁剪。 // compatFullCaps 视为「全能力」的历史 caps 值:声明了这些的设备不参与能力裁剪。
var compatFullCaps = map[string]bool{ var compatFullCaps = map[string]bool{
"cmd": true, "cmdrun": true, "deviceinfo": true, "cmd": true, "cmdrun": true, "cmdresult": true,
"status": true, "cmdresult": true,
} }
// SupportsTool 判断设备是否支持某 agent 工具(基于其声明的 caps)。 // SupportsTool 判断设备是否支持某 agent 工具(基于其声明的 caps)。
@ -400,10 +399,15 @@ func (r *Registry) PushData(deviceID, reqID, kind, mime string, data []byte) err
return nil return nil
} }
// AwaitResult 等待某请求的结果(带超时)。 // AwaitResult 等待某请求的结果(带超时)。快速回执会先留在 results,
// 因而 PushCmd 后才开始等待也不会丢失。
func (r *Registry) AwaitResult(reqID string, timeout time.Duration) (map[string]interface{}, error) { func (r *Registry) AwaitResult(reqID string, timeout time.Duration) (map[string]interface{}, error) {
ch := make(chan map[string]interface{}, 1) ch := make(chan map[string]interface{}, 1)
r.mu.Lock() r.mu.Lock()
if e, ok := r.results[reqID]; ok {
r.mu.Unlock()
return e.Result, nil
}
r.cmdPending[reqID] = ch r.cmdPending[reqID] = ch
r.mu.Unlock() r.mu.Unlock()
defer func() { defer func() {
@ -419,11 +423,15 @@ func (r *Registry) AwaitResult(reqID string, timeout time.Duration) (map[string]
} }
} }
// deliverResult 设备回执结果时由 handleWS 调用。 // deliverResult 先留档再通知等待者,消除设备极速回执早于 AwaitResult 的竞态。
func (r *Registry) deliverResult(reqID string, res map[string]interface{}) { func (r *Registry) deliverResult(reqID string, res map[string]interface{}) {
r.mu.RLock() r.mu.Lock()
if r.results == nil {
r.results = make(map[string]resultEntry)
}
r.results[reqID] = resultEntry{Result: res, Time: time.Now()}
ch, ok := r.cmdPending[reqID] ch, ok := r.cmdPending[reqID]
r.mu.RUnlock() r.mu.Unlock()
if ok { if ok {
select { select {
case ch <- res: case ch <- res:
@ -621,6 +629,9 @@ func (r *Registry) ServeWS(w http.ResponseWriter, req *http.Request) {
return return
} }
token := req.URL.Query().Get("token") token := req.URL.Query().Get("token")
if token == "" {
token = strings.TrimSpace(req.Header.Get("X-API-Key"))
}
if token == "" { if token == "" {
for _, p := range req.Header.Values("Sec-WebSocket-Protocol") { for _, p := range req.Header.Values("Sec-WebSocket-Protocol") {
if strings.HasPrefix(p, "homeagent.") { if strings.HasPrefix(p, "homeagent.") {
@ -629,7 +640,8 @@ func (r *Registry) ServeWS(w http.ResponseWriter, req *http.Request) {
} }
} }
} }
if token != "" && !r.acceptBind(token) { handshakeAuthorized := token != "" && r.acceptBind(token)
if token != "" && !handshakeAuthorized {
http.Error(w, "unauthorized", http.StatusUnauthorized) http.Error(w, "unauthorized", http.StatusUnauthorized)
return return
} }
@ -639,7 +651,7 @@ func (r *Registry) ServeWS(w http.ResponseWriter, req *http.Request) {
return return
} }
log.Printf("[remotedevice] ws connected from %s", conn.RemoteAddr()) log.Printf("[remotedevice] ws connected from %s", conn.RemoteAddr())
go r.handleWS(conn, rw) go r.handleWS(conn, rw, handshakeAuthorized)
} }
// wsWriteLocked 在指定设备连接的写锁保护下执行写回调。 // wsWriteLocked 在指定设备连接的写锁保护下执行写回调。
@ -662,12 +674,20 @@ func (r *Registry) wsWriteLocked(deviceID string, fn func(w *bufio.Writer) error
return fn(w) return fn(w)
} }
func (r *Registry) handleWS(conn net.Conn, rw *bufio.ReadWriter) { func (r *Registry) handleWS(conn net.Conn, rw *bufio.ReadWriter, handshakeAuthorized bool) {
defer conn.Close() defer conn.Close()
var curID string var curID string
var pendingMeta *DeviceMeta
var bound bool
defer func() { defer func() {
if curID != "" { if curID != "" {
r.markOffline(curID) if bound {
r.markOffline(curID)
} else {
r.mu.Lock()
delete(r.conns, curID)
r.mu.Unlock()
}
} }
}() }()
@ -697,9 +717,12 @@ func (r *Registry) handleWS(conn net.Conn, rw *bufio.ReadWriter) {
dataAccum.chunks = append(dataAccum.chunks, payload) dataAccum.chunks = append(dataAccum.chunks, payload)
dataAccum.got += len(payload) dataAccum.got += len(payload)
// 防滥用:超出声明 total 的 2 倍或硬上限 64MB 时放弃聚合 // 防滥用:超出声明 total 的 2 倍或硬上限 64MB 时放弃聚合
limit := int64(dataAccum.total)*2 + 1024 limit := int64(64 << 20)
if limit < 64<<20 { if dataAccum.total > 0 {
limit = 64 << 20 declaredLimit := int64(dataAccum.total)*2 + 1024
if declaredLimit < limit {
limit = declaredLimit
}
} }
if int64(dataAccum.got) > limit { if int64(dataAccum.got) > limit {
log.Printf("[remotedevice] data accumulation exceeded limit for req %s, dropped", dataAccum.reqID) log.Printf("[remotedevice] data accumulation exceeded limit for req %s, dropped", dataAccum.reqID)
@ -713,6 +736,9 @@ func (r *Registry) handleWS(conn net.Conn, rw *bufio.ReadWriter) {
continue continue
} }
op, _ := msg["op"].(string) op, _ := msg["op"].(string)
if !bound && op != "hello" && op != "bind" {
continue
}
switch op { switch op {
case "hello": case "hello":
meta := metaFromMsg(msg) meta := metaFromMsg(msg)
@ -720,41 +746,35 @@ func (r *Registry) handleWS(conn net.Conn, rw *bufio.ReadWriter) {
continue continue
} }
meta.RemoteAddr = conn.RemoteAddr().String() meta.RemoteAddr = conn.RemoteAddr().String()
pendingMeta = &meta
curID = meta.DeviceID curID = meta.DeviceID
r.register(meta) // Bind 前不把连接暴露给查询或命令下发路径;此时只有当前读循环会写。
r.mu.Lock() if err := writeText(rw.Writer, mustJSON(map[string]interface{}{
r.conns[meta.DeviceID] = &wconn{deviceID: meta.DeviceID, w: rw.Writer} "op": "hello_ack",
r.mu.Unlock() "device": meta.DeviceID,
if err := r.wsWriteLocked(meta.DeviceID, func(w *bufio.Writer) error { "online": false,
return writeText(w, mustJSON(map[string]interface{}{ })); err != nil {
"op": "hello_ack",
"device": meta.DeviceID,
"online": true,
}))
}); err != nil {
return return
} }
case "bind": case "bind":
token, _ := msg["token"].(string) token, _ := msg["token"].(string)
if r.acceptBind(token) { id, _ := msg["device_id"].(string)
id, _ := msg["device_id"].(string) bindAuthorized := handshakeAuthorized || r.acceptBind(token)
if id != "" { if pendingMeta == nil || id == "" || id != pendingMeta.DeviceID || !bindAuthorized {
// 默认不授权:bind 仅验证 token + 登记设备;授权完全由用户手动 _ = writeText(rw.Writer, mustJSON(map[string]interface{}{
// (GUI 设备页 / REST /api/v1/device/auth)控制,绝不自动授权。 "op": "bind_ack", "ok": false, "error": "bind rejected",
} }))
err := r.wsWriteLocked(curID, func(w *bufio.Writer) error { return
return writeText(w, mustJSON(map[string]interface{}{"op": "bind_ack", "ok": true})) }
}) r.mu.Lock()
if err != nil { r.conns[id] = &wconn{deviceID: id, w: rw.Writer}
return r.mu.Unlock()
} bound = true
} else { r.register(*pendingMeta)
err := r.wsWriteLocked(curID, func(w *bufio.Writer) error { if err := r.wsWriteLocked(curID, func(w *bufio.Writer) error {
return writeText(w, mustJSON(map[string]interface{}{"op": "bind_ack", "ok": false, "error": "bad token"})) return writeText(w, mustJSON(map[string]interface{}{"op": "bind_ack", "ok": true}))
}) }); err != nil {
if err != nil { return
return
}
} }
case "status": case "status":
id, _ := msg["device_id"].(string) id, _ := msg["device_id"].(string)

View File

@ -10,7 +10,7 @@ type IndexerAPI interface {
// IndexContext is a neutral snapshot of the injected memory context. // IndexContext is a neutral snapshot of the injected memory context.
type IndexContext struct { type IndexContext struct {
Entities []Entity `json:"entities"` Entities []Entity `json:"entities"`
Relations []Relation `json:"relations"` Relations []Relation `json:"relations"`
Summary string `json:"summary"` Summary string `json:"summary"`
TokenEstimate int `json:"token_estimate"` TokenEstimate int `json:"token_estimate"`

View File

@ -7,7 +7,9 @@ type knowledgeImpl struct{ ks *knowledge.Store }
func NewKnowledge(ks *knowledge.Store) KnowledgeAPI { return &knowledgeImpl{ks: ks} } func NewKnowledge(ks *knowledge.Store) KnowledgeAPI { return &knowledgeImpl{ks: ks} }
func (k *knowledgeImpl) Search(query string, topK int) ([]*Knowledge, error) { func (k *knowledgeImpl) Search(query string, topK int) ([]*Knowledge, error) {
if k.ks == nil { return nil, nil } if k.ks == nil {
return nil, nil
}
got := k.ks.Search(query, topK) got := k.ks.Search(query, topK)
out := make([]*Knowledge, len(got)) out := make([]*Knowledge, len(got))
for i, item := range got { for i, item := range got {
@ -17,22 +19,30 @@ func (k *knowledgeImpl) Search(query string, topK int) ([]*Knowledge, error) {
} }
func (k *knowledgeImpl) Add(name, content string) error { func (k *knowledgeImpl) Add(name, content string) error {
if k.ks == nil { return nil } if k.ks == nil {
return nil
}
return k.ks.Add(name, content) return k.ks.Add(name, content)
} }
func (k *knowledgeImpl) List() ([]string, error) { func (k *knowledgeImpl) List() ([]string, error) {
if k.ks == nil { return nil, nil } if k.ks == nil {
return nil, nil
}
return k.ks.List(), nil return k.ks.List(), nil
} }
func (k *knowledgeImpl) Stats() map[string]interface{} { func (k *knowledgeImpl) Stats() map[string]interface{} {
if k.ks == nil { return map[string]interface{}{} } if k.ks == nil {
return map[string]interface{}{}
}
return k.ks.Stats() return k.ks.Stats()
} }
func (k *knowledgeImpl) Remove(name string) error { func (k *knowledgeImpl) Remove(name string) error {
if k.ks == nil { return nil } if k.ks == nil {
return nil
}
return k.ks.Remove(name) return k.ks.Remove(name)
} }

View File

@ -3,9 +3,7 @@ package sdk
import ( import (
"fmt" "fmt"
"log" "log"
"regexp"
"strconv" "strconv"
"strings"
"sync/atomic" "sync/atomic"
"time" "time"
@ -20,34 +18,15 @@ import (
// 这一层原先的缺陷是**静默裁字段**:插件把 Triple / Doc 交进来,包装层只挑 // 这一层原先的缺陷是**静默裁字段**:插件把 Triple / Doc 交进来,包装层只挑
// 自己认识的几个字段转成内部结构,其余丢弃且不报错。两侧都中招: // 自己认识的几个字段转成内部结构,其余丢弃且不报错。两侧都中招:
// - 图记忆:丢 Confidence/SubjectType/ObjectType/SentenceText,又走 Commit // - 图记忆:丢 Confidence/SubjectType/ObjectType/SentenceText,又走 Commit
// 而非 CommitWithMedia,于是 sentences 表没有落点,媒体引用无从挂起; // 而非 CommitWithMedia,于是 sentences 表没有落点,媒体块无从挂接;
// - 知识库:Query 只回 ID/Title/Content,Insert 只写这三个,读写两个方向 // - 知识库:Query 只回 ID/Title/Content,Insert 只写这三个,读写两个方向
// 都把媒体元数据裁掉;Remove 不解引用,媒体永久算「被引用」,GC 收不掉。 // 都把媒体元数据裁掉。
// //
// 现在的规则:内部结构有的字段一律透传;媒体一律走标记格式并挂到对应 owner。 // 现在的规则:内部结构有的字段一律透传;媒体一律变成一等记忆块。
// 媒体存储为 nil 时整条链路静默降级为纯文本行为(媒体是记忆增强,不是必需品)。 // 媒体存储为 nil 时整条链路静默降级为纯文本行为(媒体是记忆增强,不是必需品)。
// ---------- 媒体标记(本层内部) ----------
//
// 标记是媒体在**纯文本记忆**里的表示形式:
//
// [image/png a1b2c3d4e5f6] 一张紫蓝红三色带图
// └ label └ 短 digest └ 描述
//
// 之所以必须借文本承载:Doc.Content、sentences.text、文本记忆的 Input 全是
// 字符串,没有字段能挂结构化数据。描述文本是持久的语义记忆(检索靠它),
// digest 是回到字节的钥匙(反查靠它)。
//
// 格式与内核侧 graphmedia.go 的 mediaSummaryForEvent 一致——两边必须能互读
// 对方写下的标记,否则插件写入的媒体在内核归档时挂不上引用,且不报错。
const sdkShortDigestLen = 12 const sdkShortDigestLen = 12
// sdkMarkerPattern 拆解一条标记,捕获组依次为 label、短 digest、该行剩余描述。
// digest 放宽到 8-64 位以容忍完整 digest 手写的情况;描述取到行尾而非贪婪到底,
// 因为一条记忆可能挂多份媒体、各占一行。
var sdkMarkerPattern = regexp.MustCompile(`\[([^\[\]\s]+)\s+([0-9a-f]{8,64})\]([^\n]*)`)
func sdkShortDigest(d string) string { func sdkShortDigest(d string) string {
if len(d) > sdkShortDigestLen { if len(d) > sdkShortDigestLen {
return d[:sdkShortDigestLen] return d[:sdkShortDigestLen]
@ -55,34 +34,6 @@ func sdkShortDigest(d string) string {
return d return d
} }
// sdkMarkerFor 为一份已入库的媒体生成标记行。查不到就返回空串——
// 媒体可能已被 GC 清掉,此时不该凭空造出一条指向虚无的标记。
func sdkMarkerFor(ms *media.Store, digest string) string {
it, err := ms.Stat(digest)
if err != nil || it == nil {
return ""
}
label := string(it.Kind)
if it.MIME != "" {
label = it.MIME
}
if it.Description == "" {
// 「已入库但还没描述」与「压根没有媒体」必须可区分:
// 描述由后台循环异步补齐,占位符保证补齐前这份媒体也不会从文本里消失。
return fmt.Sprintf("[%s %s] (未描述)", label, sdkShortDigest(digest))
}
return fmt.Sprintf("[%s %s] %s", label, sdkShortDigest(digest), it.Description)
}
// sdkDigestsIn 返回文本里出现过的短 digest 集合,用于避免重复追加标记。
func sdkDigestsIn(s string) map[string]bool {
out := map[string]bool{}
for _, m := range sdkMarkerPattern.FindAllStringSubmatch(s, -1) {
out[m[2]] = true
}
return out
}
// sdkBlockSeq 保证块 ID 全局唯一:Graph 的 memory_blocks 以 id 为主键, // sdkBlockSeq 保证块 ID 全局唯一:Graph 的 memory_blocks 以 id 为主键,
// 不同文档里序号相同的块会在 L2→L3 迁移时相互覆盖。 // 不同文档里序号相同的块会在 L2→L3 迁移时相互覆盖。
var sdkBlockSeq int64 var sdkBlockSeq int64
@ -127,27 +78,8 @@ func sdkBlockForDigest(ms *media.Store, digest string) (memory.MemoryBlock, bool
}, true }, true
} }
// sdkBlocksFromText 把文本标记里的媒体变成一等块(去重)。
func sdkBlocksFromText(ms *media.Store, text string) []memory.MemoryBlock {
if ms == nil || text == "" {
return nil
}
seen := map[string]bool{}
var blocks []memory.MemoryBlock
for _, m := range sdkMarkerPattern.FindAllStringSubmatch(text, -1) {
full, err := ms.ResolvePrefix(m[2])
if err != nil || seen[full] {
continue
}
seen[full] = true
if b, ok := sdkBlockForDigest(ms, full); ok {
blocks = append(blocks, b)
}
}
return blocks
}
// sdkBlocksFromDigests 为显式 digest 列表构造一等块(去重)。 // sdkBlocksFromDigests 为显式 digest 列表构造一等块(去重)。
// digest 可以是短前缀,内部会先补全。
func sdkBlocksFromDigests(ms *media.Store, digests []string) []memory.MemoryBlock { func sdkBlocksFromDigests(ms *media.Store, digests []string) []memory.MemoryBlock {
if ms == nil || len(digests) == 0 { if ms == nil || len(digests) == 0 {
return nil return nil
@ -155,11 +87,15 @@ func sdkBlocksFromDigests(ms *media.Store, digests []string) []memory.MemoryBloc
seen := map[string]bool{} seen := map[string]bool{}
var blocks []memory.MemoryBlock var blocks []memory.MemoryBlock
for _, d := range digests { for _, d := range digests {
if d == "" || seen[d] { if d == "" {
continue continue
} }
seen[d] = true full, err := ms.ResolvePrefix(d)
if b, ok := sdkBlockForDigest(ms, d); ok { if err != nil || seen[full] {
continue
}
seen[full] = true
if b, ok := sdkBlockForDigest(ms, full); ok {
blocks = append(blocks, b) blocks = append(blocks, b)
} }
} }
@ -177,10 +113,9 @@ func sdkPutAttachment(ms *media.Store, a MediaAttachment, tool string) (string,
mime = "application/octet-stream" mime = "application/octet-stream"
} }
return ms.Put(a.Data, media.Item{ return ms.Put(a.Data, media.Item{
MIME: mime, MIME: mime,
Tool: tool, Tool: tool,
OriginPath: a.Name, OriginPath: a.Name,
Description: a.Description,
}) })
} }
if a.Digest == "" { if a.Digest == "" {
@ -193,32 +128,6 @@ func sdkPutAttachment(ms *media.Store, a MediaAttachment, tool string) (string,
return full, nil return full, nil
} }
// sdkAttachmentsFromText 从文本标记反解出附件元数据(不含字节),
// 让插件不必自己写正则去认标记。
func sdkAttachmentsFromText(ms *media.Store, s string) []MediaAttachment {
if ms == nil || s == "" {
return nil
}
var out []MediaAttachment
seen := map[string]bool{}
for _, m := range sdkMarkerPattern.FindAllStringSubmatch(s, -1) {
full, err := ms.ResolvePrefix(m[2])
if err != nil || seen[full] {
continue
}
seen[full] = true
att := MediaAttachment{Digest: full, MIME: m[1], Description: strings.TrimSpace(m[3])}
if it, err := ms.Stat(full); err == nil && it != nil {
att.MIME = it.MIME
if it.Description != "" {
att.Description = it.Description
}
}
out = append(out, att)
}
return out
}
// ---------- 图记忆 ---------- // ---------- 图记忆 ----------
type graphMemory struct { type graphMemory struct {
@ -260,10 +169,8 @@ func (m *graphMemory) Recall(query []string, depth int) ([]Entity, []Relation, e
return entities, relations, nil return entities, relations, nil
} }
// Commit 把插件的三元组写入图库,并把三元组句子里的媒体变成 L3 一等块。 // Commit 把插件的三元组写入图库,并把结构化 MediaDigests 变成 L3 一等块。
// // 媒体通过 sentence --contains--> block 结构边挂接,不读写任何正文 marker。
// 媒体的落点链是 SentenceText → sentences 表 → sentence_id → 块边。
// 旧实现丢掉 SentenceText 又走 Commit(不回 sentenceIDs),这条链一步都走不通。
func (m *graphMemory) Commit(triples []Triple) error { func (m *graphMemory) Commit(triples []Triple) error {
if m.db == nil { if m.db == nil {
return nil return nil
@ -278,9 +185,7 @@ func (m *graphMemory) Commit(triples []Triple) error {
SubjectType: t.SubjectType, SubjectType: t.SubjectType,
ObjectType: t.ObjectType, ObjectType: t.ObjectType,
SentenceText: t.SentenceText, SentenceText: t.SentenceText,
} MediaDigests: t.MediaDigests,
if len(t.MediaDigests) > 0 {
mt.SentenceText = m.sentenceWithMedia(mt.SentenceText, t.MediaDigests)
} }
ts = append(ts, mt) ts = append(ts, mt)
} }
@ -289,61 +194,28 @@ func (m *graphMemory) Commit(triples []Triple) error {
if err != nil { if err != nil {
return err return err
} }
m.bindSentences(sentenceIDs) m.bindSentences(sentenceIDs, ts)
return nil return nil
} }
// sentenceWithMedia 保证句子文本里带有这些 digest 的媒体标记。 // bindSentences 把每个三元组显式携带的媒体变成 L3 一等记忆块,
// 并以 sentence --contains--> block 结构边关联。
// //
// 让插件填 MediaDigests 就够,不必知道标记格式——否则格式写错的后果是 // 不再往句子文本里写 marker、也不再从文本反解 digest:归属由结构化字段直接给出。
// 引用静默挂不上。已出现过的 digest 不重复追加:插件可能既手写了标记又填了 func (m *graphMemory) bindSentences(sentenceIDs map[string]int64, triples []memory.Triple) {
// MediaDigests,重复标记会让同一份媒体产生两条一样的句子引用。
func (m *graphMemory) sentenceWithMedia(sentence string, digests []string) string {
present := sdkDigestsIn(sentence)
var add []string
for _, d := range digests {
if d == "" || present[sdkShortDigest(d)] {
continue
}
if m.ms == nil {
// 没有媒体存储时也把 digest 留在文本里:拿不到描述,
// 但将来存储可用时这条记忆仍能反查回字节。
add = append(add, fmt.Sprintf("[media %s] (未描述)", sdkShortDigest(d)))
present[sdkShortDigest(d)] = true
continue
}
full, err := m.ms.ResolvePrefix(d)
if err != nil {
log.Printf("[sdk media] 插件 %s 提交的 digest %s 无法解析: %v", m.plugin, d, err)
continue
}
if line := sdkMarkerFor(m.ms, full); line != "" {
add = append(add, line)
present[sdkShortDigest(full)] = true
}
}
if len(add) == 0 {
return sentence
}
if sentence == "" {
return strings.Join(add, "\n")
}
return sentence + "\n" + strings.Join(add, "\n")
}
// bindSentences 把每条句子里引用的媒体变成 L3 的一等记忆块,
// 并建立 sentence --contains--> block 的结构边。
// 不再写 media_refs:块本身就是图的一部分,不需要 owner 账本保活。
func (m *graphMemory) bindSentences(sentenceIDs map[string]int64) {
if m.ms == nil || m.db == nil || len(sentenceIDs) == 0 { if m.ms == nil || m.db == nil || len(sentenceIDs) == 0 {
return return
} }
bound := 0 bound := 0
for text, sid := range sentenceIDs { for _, t := range triples {
if len(t.MediaDigests) == 0 {
continue
}
sid := sentenceIDs[t.SentenceText]
if sid == 0 { if sid == 0 {
continue continue
} }
for _, b := range sdkBlocksFromText(m.ms, text) { for _, b := range sdkBlocksFromDigests(m.ms, t.MediaDigests) {
if err := m.db.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil { if err := m.db.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil {
log.Printf("[sdk media] 插件 %s 写入 L3 记忆块失败: %v", m.plugin, err) log.Printf("[sdk media] 插件 %s 写入 L3 记忆块失败: %v", m.plugin, err)
continue continue
@ -403,37 +275,21 @@ func NewTextMemoryWithMedia(plugin string, tm *text.Memory, ms *media.Store) Tex
return &textMemoryImpl{tm: tm, ms: ms, plugin: plugin} return &textMemoryImpl{tm: tm, ms: ms, plugin: plugin}
} }
// Append 追加一条文本事件;带附件时把媒体标记并进正文。 // Append 追加一条文本事件。
// //
// 文本记忆是追加写 JSONL,没有结构化块存储,所以媒体在这一层 // 文本记忆是追加写 JSONL 的原始日志,只有字符串字段,没有块容器;
// 只能以标记形式存在。这不是妥协——描述文本才是持久的语义记忆,blob 只是缓存。 // 因此附件在这里无法结构化存下。不假装用文本标记承载媒体——
// 需要保存媒体请用文档记忆或图记忆(它们持有一等记忆块)。
func (m *textMemoryImpl) Append(evt TextEvent) error { func (m *textMemoryImpl) Append(evt TextEvent) error {
if m.tm == nil { if m.tm == nil {
return nil return nil
} }
content := evt.Content if len(evt.Attachments) > 0 {
if len(evt.Attachments) > 0 && m.ms != nil { log.Printf("[sdk media] 插件 %s 向文本记忆追加了 %d 份附件,已忽略:"+
var lines []string "文本层是字符串日志,不具备块存储;请改用文档/图记忆保存媒体", m.plugin, len(evt.Attachments))
for _, a := range evt.Attachments {
d, err := sdkPutAttachment(m.ms, a, "plugin_text:"+m.plugin)
if err != nil {
log.Printf("[sdk media] 插件 %s 文本附件入库失败: %v", m.plugin, err)
continue
}
if line := sdkMarkerFor(m.ms, d); line != "" {
lines = append(lines, line)
}
}
if len(lines) > 0 {
if content == "" {
content = strings.Join(lines, "\n")
} else {
content += "\n" + strings.Join(lines, "\n")
}
}
} }
return m.tm.Append(text.Event{ return m.tm.Append(text.Event{
Timestamp: evt.Timestamp, Source: evt.Role, Input: content, AgentID: evt.Channel, Timestamp: evt.Timestamp, Source: evt.Role, Input: evt.Content, AgentID: evt.Channel,
}) })
} }
@ -449,7 +305,6 @@ func (m *textMemoryImpl) RecentEvents(n int) ([]TextEvent, error) {
for i, e := range got { for i, e := range got {
out[i] = TextEvent{ out[i] = TextEvent{
Role: e.Source, Content: e.Input, Timestamp: e.Timestamp, Channel: e.AgentID, Role: e.Source, Content: e.Input, Timestamp: e.Timestamp, Channel: e.AgentID,
Attachments: sdkAttachmentsFromText(m.ms, e.Input),
} }
} }
return out, nil return out, nil
@ -477,12 +332,8 @@ func NewDocMemoryWithMedia(plugin string, ds *doc.Store, ms *media.Store) DocMem
return &docMemoryImpl{ds: ds, ms: ms, plugin: plugin} return &docMemoryImpl{ds: ds, ms: ms, plugin: plugin}
} }
// Query 检索文档,并补齐媒体元数据。 // Query 检索文档,并从文档持有的一等块补齐媒体元数据。
// // 只返回 digest/MIME,不返回字节或生成式描述;需要字节时按 digest 单取。
// 旧实现只回 ID/Title/Content,插件即便拿到一篇带媒体的文档也看不出这里有
// 几份媒体、分别是什么。现在同时给出完整 digest 列表与 mime+描述,
// 但**不回字节**:一次检索可能命中几十份媒体,全塞回去会把跨进程消息撑爆,
// 需要字节时按 digest 单取。
func (m *docMemoryImpl) Query(text string, topK int) []*Doc { func (m *docMemoryImpl) Query(text string, topK int) []*Doc {
if m.ds == nil { if m.ds == nil {
return nil return nil
@ -498,30 +349,22 @@ func (m *docMemoryImpl) Query(text string, topK int) []*Doc {
// fillMedia 填充文档的媒体字段。 // fillMedia 填充文档的媒体字段。
// //
// 优先读一等记忆块(文档直接持有),为空时退回解析正文标记—— // 唯一的来源是文档直接持有的一等记忆块:媒体不靠正文标记、
// 历史文档与经旧版插件写入的文档只有标记、没有块。 // 也不靠任何生成的描述文本。CAS 只提供 MIME 等元数据。
func (m *docMemoryImpl) fillMedia(out *Doc, d *doc.Doc) { func (m *docMemoryImpl) fillMedia(out *Doc, d *doc.Doc) {
if m.ms == nil { if m.ms == nil || d == nil {
return return
} }
if d != nil && len(d.Blocks) > 0 { for _, b := range d.Blocks {
for _, b := range d.Blocks { if b.PayloadDigest == "" {
if b.PayloadDigest == "" { continue
continue
}
out.MediaDigests = append(out.MediaDigests, b.PayloadDigest)
att := MediaAttachment{Digest: b.PayloadDigest, MIME: b.MIME, Description: ""}
if it, err := m.ms.Stat(b.PayloadDigest); err == nil && it != nil {
att.MIME = it.MIME
att.Description = it.Description
}
out.Attachments = append(out.Attachments, att)
} }
return out.MediaDigests = append(out.MediaDigests, b.PayloadDigest)
} att := MediaAttachment{Digest: b.PayloadDigest, MIME: b.MIME}
out.Attachments = sdkAttachmentsFromText(m.ms, out.Content) if it, err := m.ms.Stat(b.PayloadDigest); err == nil && it != nil {
for _, a := range out.Attachments { att.MIME = it.MIME
out.MediaDigests = append(out.MediaDigests, a.Digest) }
out.Attachments = append(out.Attachments, att)
} }
} }
@ -543,15 +386,14 @@ func appendBlocks(existing []memory.MemoryBlock, add []memory.MemoryBlock) []mem
return existing return existing
} }
// Insert 写入文档。正文里已有的媒体标记会被挂成文档级引用, // Insert 写入文档。
// 避免插件写进来的媒体在下一次 GC 时被当作无主内容清掉。
func (m *docMemoryImpl) Insert(d *Doc) error { return m.InsertWithMedia(d, nil) } func (m *docMemoryImpl) Insert(d *Doc) error { return m.InsertWithMedia(d, nil) }
// InsertWithMedia 写入文档并关联媒体。 // InsertWithMedia 写入文档并关联媒体。
// //
// 标记由内核补进 Content——插件不必知道标记格式,也就不会因为格式写错导致 // 媒体直接成为文档持有的一等记忆块:落进 CAS 拿到 digest,
// 引用挂不上。补标记必须在 ds.Insert 之前完成:向量索引用 Summary+Content // 再变成块挂到文档上。不往正文写 marker——文档向量会融合这些块的
// 计算,标记进不去正文就检索不到这份媒体。 // 媒体向量(同一统一空间),图片按自己的向量被召回。
func (m *docMemoryImpl) InsertWithMedia(d *Doc, attachments []MediaAttachment) error { func (m *docMemoryImpl) InsertWithMedia(d *Doc, attachments []MediaAttachment) error {
if m.ds == nil || d == nil { if m.ds == nil || d == nil {
return nil return nil
@ -561,29 +403,26 @@ func (m *docMemoryImpl) InsertWithMedia(d *Doc, attachments []MediaAttachment) e
target.Source = "plugin:" + m.plugin target.Source = "plugin:" + m.plugin
} }
digests := m.storeAttachments(attachments, &target.Content) digests := m.storeAttachments(attachments)
// 一等记忆块:文档直接持有块本身,CAS 只提供字节与向量。 // 一等记忆块:文档直接持有块本身,CAS 只提供字节与向量。
// 不再写 media_refs——块随文档一同存活或被删除,无需 owner 账本。 target.Blocks = appendBlocks(target.Blocks, sdkBlocksFromDigests(m.ms, digests))
target.Blocks = appendBlocks(target.Blocks,
append(sdkBlocksFromDigests(m.ms, digests), sdkBlocksFromText(m.ms, target.Content)...))
if err := m.ds.Insert(target); err != nil { if err := m.ds.Insert(target); err != nil {
return err return err
} }
// 回填给调用方:ID 是新建时内核生成的,Content 含内核补的标记。 // 回填给调用方:ID 是新建时内核生成的。
d.ID = target.ID d.ID = target.ID
d.Content = target.Content
return nil return nil
} }
// storeAttachments 把附件落库并把标记追加进 content,返回全部完整 digest。 // storeAttachments 把附件落库,返回全部完整 digest。
func (m *docMemoryImpl) storeAttachments(atts []MediaAttachment, content *string) []string { func (m *docMemoryImpl) storeAttachments(atts []MediaAttachment) []string {
if m.ms == nil || len(atts) == 0 { if m.ms == nil || len(atts) == 0 {
return nil return nil
} }
present := sdkDigestsIn(*content) seen := map[string]bool{}
var digests, lines []string var digests []string
for _, a := range atts { for _, a := range atts {
full, err := sdkPutAttachment(m.ms, a, "plugin_doc:"+m.plugin) full, err := sdkPutAttachment(m.ms, a, "plugin_doc:"+m.plugin)
if err != nil { if err != nil {
@ -591,21 +430,11 @@ func (m *docMemoryImpl) storeAttachments(atts []MediaAttachment, content *string
log.Printf("[sdk media] 插件 %s 文档附件入库失败: %v", m.plugin, err) log.Printf("[sdk media] 插件 %s 文档附件入库失败: %v", m.plugin, err)
continue continue
} }
if seen[full] {
continue
}
seen[full] = true
digests = append(digests, full) digests = append(digests, full)
if present[sdkShortDigest(full)] {
continue // 插件自己写了标记,不重复追加
}
present[sdkShortDigest(full)] = true
if line := sdkMarkerFor(m.ms, full); line != "" {
lines = append(lines, line)
}
}
if len(lines) > 0 {
if *content == "" {
*content = strings.Join(lines, "\n")
} else {
*content += "\n" + strings.Join(lines, "\n")
}
} }
return digests return digests
} }

View File

@ -51,10 +51,10 @@ func newTestStores(t *testing.T) (*memory.GraphDB, *doc.Store, *text.Memory, *me
return g, ds, tm, ms return g, ds, tm, ms
} }
// putDescribed 存一份带描述的媒体,返回完整 digest。 // putMedia 存一份媒体,返回完整 digest。
func putDescribed(t *testing.T, ms *media.Store, payload, desc string) string { func putMedia(t *testing.T, ms *media.Store, payload string) string {
t.Helper() t.Helper()
d, err := ms.Put([]byte(payload), media.Item{MIME: "image/png", Description: desc}) d, err := ms.Put([]byte(payload), media.Item{MIME: "image/png"})
if err != nil { if err != nil {
t.Fatalf("media.Put: %v", err) t.Fatalf("media.Put: %v", err)
} }
@ -111,16 +111,17 @@ func TestGraphCommit_CarriesAllFields(t *testing.T) {
} }
} }
// 插件只给 digest,标记与句子由内核合成;引用必须挂到 graph_sentence owner 上。 // 插件只给 digest,句子由内核合成;块必须挂到该句子(sentence --contains--> block)。
func TestGraphCommit_BindsMediaFromDigests(t *testing.T) { func TestGraphCommit_BindsMediaFromDigests(t *testing.T) {
g, _, _, ms := newTestStores(t) g, _, _, ms := newTestStores(t)
digest := putDescribed(t, ms, "png-bytes", "一张紫蓝红三色带图") digest := putMedia(t, ms, "png-bytes")
m := NewGraphMemoryWithMedia("tester", g, ms) m := NewGraphMemoryWithMedia("tester", g, ms)
if err := m.Commit([]Triple{{ if err := m.Commit([]Triple{{
Subject: "配色图", Subject: "配色图",
Relation: "包含", Relation: "包含",
Object: "三色带", Object: "三色带",
SentenceText: "这张图是紫蓝红三色带。",
MediaDigests: []string{digest[:12]}, // 插件手里通常只有短 digest MediaDigests: []string{digest[:12]}, // 插件手里通常只有短 digest
}}); err != nil { }}); err != nil {
t.Fatalf("Commit: %v", err) t.Fatalf("Commit: %v", err)
@ -131,13 +132,13 @@ func TestGraphCommit_BindsMediaFromDigests(t *testing.T) {
t.Fatalf("Recall: %v", err) t.Fatalf("Recall: %v", err)
} }
if len(res.Relations) == 0 || res.Relations[0].SentenceID == 0 { if len(res.Relations) == 0 || res.Relations[0].SentenceID == 0 {
t.Fatal("没有句子落点 —— 媒体引用无从挂起") t.Fatal("没有句子落点 —— 媒体块无从挂接")
} }
sid := res.Relations[0].SentenceID sid := res.Relations[0].SentenceID
// 描述必须进句子:描述文本才是持久语义记忆,检索靠它。 // 句子文本保持原样:不再往正文里贴媒体标记。
if !strings.Contains(res.Relations[0].SentenceText, "三色带图") { if strings.Contains(res.Relations[0].SentenceText, digest[:12]) {
t.Errorf("句子里没有媒体描述: %q", res.Relations[0].SentenceText) t.Errorf("句子文本不该被媒体标记污染: %q", res.Relations[0].SentenceText)
} }
blocks, err := g.BlocksForNode("sentence", strconv.FormatInt(sid, 10)) blocks, err := g.BlocksForNode("sentence", strconv.FormatInt(sid, 10))
@ -149,19 +150,18 @@ func TestGraphCommit_BindsMediaFromDigests(t *testing.T) {
} }
} }
// 插件自己按格式写了标记又同时填了 MediaDigests,不能产生两条重复引用/两份标记。 // 同一个 digest 在同一三元组里重复出现(短/完整混写)时只能建一个块。
func TestGraphCommit_NoDuplicateMarker(t *testing.T) { func TestGraphCommit_DedupesRepeatedDigest(t *testing.T) {
g, _, _, ms := newTestStores(t) g, _, _, ms := newTestStores(t)
digest := putDescribed(t, ms, "dup-bytes", "重复标记测试图") digest := putMedia(t, ms, "dup-bytes")
short := digest[:12]
m := NewGraphMemoryWithMedia("tester", g, ms) m := NewGraphMemoryWithMedia("tester", g, ms)
if err := m.Commit([]Triple{{ if err := m.Commit([]Triple{{
Subject: "重复图", Subject: "重复图",
Relation: "标记", Relation: "标记",
Object: "一次", Object: "一次",
SentenceText: "看这个 [image/png " + short + "] 重复标记测试图", SentenceText: "同一张图说了两遍。",
MediaDigests: []string{short}, MediaDigests: []string{digest[:12], digest},
}}); err != nil { }}); err != nil {
t.Fatalf("Commit: %v", err) t.Fatalf("Commit: %v", err)
} }
@ -170,12 +170,16 @@ func TestGraphCommit_NoDuplicateMarker(t *testing.T) {
if len(res.Relations) == 0 { if len(res.Relations) == 0 {
t.Fatal("召回不到关系") t.Fatal("召回不到关系")
} }
if n := strings.Count(res.Relations[0].SentenceText, short); n != 1 { blocks, err := g.BlocksForNode("sentence", strconv.FormatInt(res.Relations[0].SentenceID, 10))
t.Errorf("句子里出现 %d 次 digest,期望 1 次: %q", n, res.Relations[0].SentenceText) if err != nil {
t.Fatal(err)
}
if len(blocks) != 1 {
t.Fatalf("同一 digest 应只产生 1 个块,实际 %d: %+v", len(blocks), blocks)
} }
} }
// mediaStore 为 nil 时仍要能提交(媒体是增强,不是必需品),digest 留在文本里备查。 // mediaStore 为 nil 时仍要能提交(媒体是增强,不是必需品);只是不会建块。
func TestGraphCommit_NilMediaStoreDegrades(t *testing.T) { func TestGraphCommit_NilMediaStoreDegrades(t *testing.T) {
g, _, _, _ := newTestStores(t) g, _, _, _ := newTestStores(t)
m := NewGraphMemory(g) m := NewGraphMemory(g)
@ -184,6 +188,7 @@ func TestGraphCommit_NilMediaStoreDegrades(t *testing.T) {
Subject: "无存储", Subject: "无存储",
Relation: "仍可", Relation: "仍可",
Object: "提交", Object: "提交",
SentenceText: "无媒体存储时的句子。",
MediaDigests: []string{"aabbccddeeff"}, MediaDigests: []string{"aabbccddeeff"},
}}); err != nil { }}); err != nil {
t.Fatalf("Commit 在无媒体存储时不该失败: %v", err) t.Fatalf("Commit 在无媒体存储时不该失败: %v", err)
@ -193,8 +198,12 @@ func TestGraphCommit_NilMediaStoreDegrades(t *testing.T) {
if len(res.Relations) == 0 { if len(res.Relations) == 0 {
t.Fatal("召回不到关系") t.Fatal("召回不到关系")
} }
if !strings.Contains(res.Relations[0].SentenceText, "aabbccddeeff") { blocks, err := g.BlocksForNode("sentence", strconv.FormatInt(res.Relations[0].SentenceID, 10))
t.Errorf("digest 应留在句子里以备将来反查: %q", res.Relations[0].SentenceText) if err != nil {
t.Fatal(err)
}
if len(blocks) != 0 {
t.Fatalf("无媒体存储时不该建块,实际 %+v", blocks)
} }
} }
@ -225,17 +234,16 @@ func TestGraphRecall_CarriesConfidence(t *testing.T) {
// ---------- 文档记忆(知识库) ---------- // ---------- 文档记忆(知识库) ----------
// 附件带 Data → 落进 CAS、标记补进正文、引用挂到文档 owner。 // 附件带 Data → 落进 CAS 并成为文档直接持有的一等块。
func TestDocInsertWithMedia_StoresAndBinds(t *testing.T) { func TestDocInsertWithMedia_StoresAndBinds(t *testing.T) {
_, ds, _, ms := newTestStores(t) _, ds, _, ms := newTestStores(t)
dm := NewDocMemoryWithMedia("tester", ds, ms) dm := NewDocMemoryWithMedia("tester", ds, ms)
d := &Doc{Title: "带图笔记", Content: "这是正文。"} d := &Doc{Title: "带图笔记", Content: "这是正文。"}
err := dm.InsertWithMedia(d, []MediaAttachment{{ err := dm.InsertWithMedia(d, []MediaAttachment{{
MIME: "image/png", MIME: "image/png",
Data: []byte("attachment-bytes"), Data: []byte("attachment-bytes"),
Name: "chart.png", Name: "chart.png",
Description: "一张柱状图",
}}) }})
if err != nil { if err != nil {
t.Fatalf("InsertWithMedia: %v", err) t.Fatalf("InsertWithMedia: %v", err)
@ -244,10 +252,9 @@ func TestDocInsertWithMedia_StoresAndBinds(t *testing.T) {
t.Fatal("ID 未回填 —— 插件拿不到刚写入文档的 id") t.Fatal("ID 未回填 —— 插件拿不到刚写入文档的 id")
} }
// 标记必须进正文:向量索引用 Summary+Content 计算, // 正文保持原样:不再往 Content 里拼任何媒体标记。
// 标记进不去正文就永远检索不到这份媒体。 if strings.Contains(d.Content, "image/png") {
if !strings.Contains(d.Content, "柱状图") { t.Errorf("正文不该被媒体标记污染: %q", d.Content)
t.Errorf("正文里没有媒体标记: %q", d.Content)
} }
blocks := ds.Blocks() blocks := ds.Blocks()
@ -264,7 +271,7 @@ func TestDocInsertWithMedia_StoresAndBinds(t *testing.T) {
// 只给 Digest 的附件是「引用已有内容」,不该报错也不该重复落盘。 // 只给 Digest 的附件是「引用已有内容」,不该报错也不该重复落盘。
func TestDocInsertWithMedia_DigestOnlyReference(t *testing.T) { func TestDocInsertWithMedia_DigestOnlyReference(t *testing.T) {
_, ds, _, ms := newTestStores(t) _, ds, _, ms := newTestStores(t)
digest := putDescribed(t, ms, "existing", "已有的图") digest := putMedia(t, ms, "existing")
before := ms.Stats()["count"] before := ms.Stats()["count"]
dm := NewDocMemoryWithMedia("tester", ds, ms) dm := NewDocMemoryWithMedia("tester", ds, ms)
@ -290,7 +297,7 @@ func TestDocQuery_FillsMediaMetadataWithoutBytes(t *testing.T) {
d := &Doc{Title: "紫蓝红三色带", Content: "配色说明"} d := &Doc{Title: "紫蓝红三色带", Content: "配色说明"}
if err := dm.InsertWithMedia(d, []MediaAttachment{{ if err := dm.InsertWithMedia(d, []MediaAttachment{{
MIME: "image/png", Data: []byte("query-bytes"), Description: "三色带图", MIME: "image/png", Data: []byte("query-bytes"),
}}); err != nil { }}); err != nil {
t.Fatalf("InsertWithMedia: %v", err) t.Fatalf("InsertWithMedia: %v", err)
} }
@ -315,38 +322,14 @@ func TestDocQuery_FillsMediaMetadataWithoutBytes(t *testing.T) {
t.Fatalf("Attachments = %v,期望 1 条", hit.Attachments) t.Fatalf("Attachments = %v,期望 1 条", hit.Attachments)
} }
att := hit.Attachments[0] att := hit.Attachments[0]
if att.MIME != "image/png" || att.Description != "三色带图" { if att.MIME != "image/png" || att.Digest != hit.MediaDigests[0] {
t.Errorf("附件元数据 = %+v,期望 mime=image/png desc=三色带图", att) t.Errorf("附件元数据 = %+v,期望 mime=image/png 且 digest 与 MediaDigests 一致", att)
} }
if len(att.Data) != 0 { if len(att.Data) != 0 {
t.Errorf("Attachments 不该带字节(%d 字节)—— 需要时按 digest 单取", len(att.Data)) t.Errorf("Attachments 不该带字节(%d 字节)—— 需要时按 digest 单取", len(att.Data))
} }
} }
// 历史文档只有标记、没有 media_refs(旧版插件写入的)。
// 此时要能从正文标记反解出附件,否则那些文档的媒体对插件永远不可见。
func TestDocQuery_FallsBackToMarkers(t *testing.T) {
_, ds, _, ms := newTestStores(t)
digest := putDescribed(t, ms, "legacy", "历史图片")
// 直接写底层 store,绕过 SDK 的绑定逻辑,模拟历史数据。
if err := ds.Insert(&doc.Doc{
Summary: "历史文档",
Content: "旧正文 [image/png " + digest[:12] + "] 历史图片",
}); err != nil {
t.Fatalf("Insert: %v", err)
}
dm := NewDocMemoryWithMedia("tester", ds, ms)
got := dm.Query("历史文档 旧正文", 3)
if len(got) == 0 {
t.Fatal("检索不到历史文档")
}
if len(got[0].MediaDigests) != 1 || got[0].MediaDigests[0] != digest {
t.Errorf("MediaDigests = %v,期望从标记反解出 [%s]", got[0].MediaDigests, digest)
}
}
// 文档被删除时它持有的一等记忆块随之消失,媒体不再被任何记忆块持有。 // 文档被删除时它持有的一等记忆块随之消失,媒体不再被任何记忆块持有。
func TestDocRemove_DropsBlocks(t *testing.T) { func TestDocRemove_DropsBlocks(t *testing.T) {
_, ds, _, ms := newTestStores(t) _, ds, _, ms := newTestStores(t)
@ -354,7 +337,7 @@ func TestDocRemove_DropsBlocks(t *testing.T) {
d := &Doc{Title: "待删除", Content: "正文"} d := &Doc{Title: "待删除", Content: "正文"}
if err := dm.InsertWithMedia(d, []MediaAttachment{{ if err := dm.InsertWithMedia(d, []MediaAttachment{{
MIME: "image/png", Data: []byte("to-be-freed"), Description: "会被释放的图", MIME: "image/png", Data: []byte("to-be-freed"),
}}); err != nil { }}); err != nil {
t.Fatalf("InsertWithMedia: %v", err) t.Fatalf("InsertWithMedia: %v", err)
} }
@ -395,9 +378,10 @@ func TestDocMemory_NilMediaStoreDegrades(t *testing.T) {
// ---------- 文本记忆 ---------- // ---------- 文本记忆 ----------
// 文本记忆是追加写 JSONL,没有稳定 owner_id 可挂引用, // 文本记忆是追加写 JSONL 的字符串日志,没有块容器。
// 媒体只能以标记形式留在正文里;读回时要能反解成结构化附件。 // 它不会愄造文本标记来承载媒体:附件被明确忽略并记录日志,
func TestTextMemory_AttachmentRoundTrip(t *testing.T) { // 需要保存媒体请用文档/图记忆。
func TestTextMemory_AttachmentsIgnoredNotFaked(t *testing.T) {
_, _, tm, ms := newTestStores(t) _, _, tm, ms := newTestStores(t)
m := NewTextMemoryWithMedia("tester", tm, ms) m := NewTextMemoryWithMedia("tester", tm, ms)
@ -405,7 +389,7 @@ func TestTextMemory_AttachmentRoundTrip(t *testing.T) {
Role: "user", Role: "user",
Content: "看这张图", Content: "看这张图",
Attachments: []MediaAttachment{{ Attachments: []MediaAttachment{{
MIME: "image/png", Data: []byte("text-mem-bytes"), Description: "文本记忆里的图", MIME: "image/png", Data: []byte("text-mem-bytes"),
}}, }},
}); err != nil { }); err != nil {
t.Fatalf("Append: %v", err) t.Fatalf("Append: %v", err)
@ -419,13 +403,10 @@ func TestTextMemory_AttachmentRoundTrip(t *testing.T) {
t.Fatal("读不到刚追加的事件") t.Fatal("读不到刚追加的事件")
} }
last := got[len(got)-1] last := got[len(got)-1]
if !strings.Contains(last.Content, "文本记忆里的图") { if last.Content != "看这张图" {
t.Errorf("正文里没有媒体标记: %q", last.Content) t.Errorf("正文应保持原样,实际 %q", last.Content)
} }
if len(last.Attachments) != 1 { if len(last.Attachments) != 0 {
t.Fatalf("Attachments = %+v,期望 1 条(标记应能反解)", last.Attachments) t.Errorf("文本层不该凭空造出附件(没有块存储可挂): %+v", last.Attachments)
}
if last.Attachments[0].Description != "文本记忆里的图" {
t.Errorf("附件描述 = %q", last.Attachments[0].Description)
} }
} }

View File

@ -63,7 +63,7 @@ func (v *VirtualInstance) initLocked() error {
} }
v.Knowledge = NewKnowledge(ks) v.Knowledge = NewKnowledge(ks)
ds := doc.NewStore(filepath.Join(v.dir, "documents")) ds := doc.NewStore(filepath.Join(v.dir, "documents"), memory.TokenizeWords)
if err := ds.Start(); err != nil { if err := ds.Start(); err != nil {
return fmt.Errorf("virtual doc store: %w", err) return fmt.Errorf("virtual doc store: %w", err)
} }

View File

@ -16,10 +16,10 @@ type SupervisorAPI interface {
// AgentStatus is a neutral snapshot of a supervised agent. // AgentStatus is a neutral snapshot of a supervised agent.
type AgentStatus struct { type AgentStatus struct {
ID types.AgentID `json:"id"` ID types.AgentID `json:"id"`
State types.AgentState `json:"state"` State types.AgentState `json:"state"`
Health types.HealthStatus `json:"health"` Health types.HealthStatus `json:"health"`
Uptime time.Duration `json:"uptime,omitempty"` Uptime time.Duration `json:"uptime,omitempty"`
Network types.NetworkCheckResult `json:"network,omitempty"` Network types.NetworkCheckResult `json:"network,omitempty"`
TrackerStats map[string]interface{} `json:"tracker_stats,omitempty"` TrackerStats map[string]interface{} `json:"tracker_stats,omitempty"`
} }

306
internal/tfidf/tfidf.go Normal file
View File

@ -0,0 +1,306 @@
// Package tfidf 提供 TF-IDF 向量化作为 MultimodalEmbedder 的一个实现。
//
// 设计:核心(内侧)只认 vector.MultimodalEmbedder 接口;本包是外层可插拔
// 的一个具体实现,与 Jina HTTP / ONNX CLIP 并列。启动时由 cmd/homed 按配置
// 选择注入,核心代码零改动。
//
// 稀疏向量 → 稠密桥接:TF-IDF 产出的是稀疏 map[feature]weight,通过特征哈希
// 投射到固定维度(默认 4096)的 []float64,供统一的 cosine 检索使用。
// 哈希碰撞在 4096 维下可接受(英文单词 ~50k,碰撞率 ~3%)。
package tfidf
import (
"fmt"
"hash/fnv"
"math"
"sort"
"strings"
"sync"
)
// Tokenizer 将文本拆分为词级 token。
type Tokenizer func(text string) []string
// Vector 是带权特征映射:feature → weight(稀疏表示)。
type Vector map[string]float64
// Embedder 实现 vector.MultimodalEmbedder,将 TF-IDF 稀疏向量投射为固定维度稠密向量。
type Embedder struct {
mu sync.RWMutex
tokenizer Tokenizer
docFreq map[string]int
totalDocs int
dim int
loaded bool
fingerprint string
}
// NewEmbedder 创建 TF-IDF 多模态嵌入器。
// dim 是投射后的稠密维度(默认 4096,哈希空间大小)。
func NewEmbedder(tokenizer Tokenizer, dim int) *Embedder {
if dim <= 0 {
dim = 4096
}
return &Embedder{
tokenizer: tokenizer,
docFreq: make(map[string]int),
dim: dim,
}
}
// Train 用文档集训练 IDF 统计。启动时调用一次。
func (e *Embedder) Train(docs []string) {
e.mu.Lock()
defer e.mu.Unlock()
e.totalDocs = len(docs)
e.docFreq = make(map[string]int)
for _, doc := range docs {
features := e.tokenizer(doc)
seen := make(map[string]bool)
for _, f := range features {
if !seen[f] {
e.docFreq[f]++
seen[f] = true
}
}
}
e.fingerprint = fmt.Sprintf("tfidf:d%d:f%d", e.totalDocs, len(e.docFreq))
e.loaded = true
}
// vectorize 将文本转为 TF-IDF 稀疏向量(内部方法,调用方已持锁)。
func (e *Embedder) vectorize(text string) Vector {
features := e.tokenizer(text)
tf := make(map[string]float64)
for _, f := range features {
tf[f]++
}
maxTF := 0.0
for _, c := range tf {
if c > maxTF {
maxTF = c
}
}
vec := make(Vector)
for f, count := range tf {
tfNorm := count / maxTF
if e.totalDocs < 3 {
vec[f] = tfNorm
continue
}
df := e.docFreq[f]
if df <= 0 {
continue
}
idf := math.Log(float64(e.totalDocs+1) / float64(df+1))
if idf < 0.1 {
continue
}
vec[f] = tfNorm * idf
}
return vec
}
// sparseToDense 将稀疏向量投射为固定维度稠密向量(FNV-1a 哈希映射)。
func (e *Embedder) sparseToDense(sparse Vector) []float64 {
dense := make([]float64, e.dim)
var norm float64
for feature, weight := range sparse {
idx := fnvHash(feature) % uint32(e.dim)
dense[idx] += weight
norm += weight * weight
}
// L2 归一化
if norm > 0 {
norm = math.Sqrt(norm)
for i := range dense {
dense[i] /= norm
}
}
return dense
}
func fnvHash(s string) uint32 {
h := fnv.New32a()
h.Write([]byte(s))
return h.Sum32()
}
// --- vector.MultimodalEmbedder 接口实现 ---
func (e *Embedder) VectorizeDense(text string) ([]float64, error) {
e.mu.RLock()
defer e.mu.RUnlock()
if !e.loaded {
return nil, fmt.Errorf("tfidf: not trained")
}
sparse := e.vectorize(text)
return e.sparseToDense(sparse), nil
}
func (e *Embedder) EmbedImageDense(img []byte, mime string) ([]float64, error) {
return nil, fmt.Errorf("tfidf: image embedding not supported")
}
func (e *Embedder) Fingerprint() string {
e.mu.RLock()
defer e.mu.RUnlock()
return e.fingerprint
}
func (e *Embedder) Dim() int { return e.dim }
func (e *Embedder) Loaded() bool {
e.mu.RLock()
defer e.mu.RUnlock()
return e.loaded
}
func (e *Embedder) Close() {}
// --- 检索(供 document.Store 使用,非接口方法)---
// DocHit 是一条检索命中。
type DocHit struct {
ID string
Score float64
}
// SearchableIndex 是支持 TF-IDF 倒排检索的索引。
// document.Store 在 TF-IDF 模式下使用此索引替代 brute-force。
type SearchableIndex struct {
mu sync.RWMutex
docs map[string]Vector // id → tfidf sparse vector
texts map[string]string // id → 原文
emb *Embedder
}
// NewSearchableIndex 创建可检索索引。
func NewSearchableIndex(emb *Embedder) *SearchableIndex {
return &SearchableIndex{
docs: make(map[string]Vector),
texts: make(map[string]string),
emb: emb,
}
}
// Add 添加或更新一条文档。
func (idx *SearchableIndex) Add(id, text string) {
idx.mu.Lock()
defer idx.mu.Unlock()
idx.emb.mu.RLock()
vec := idx.emb.vectorize(text)
idx.emb.mu.RUnlock()
idx.docs[id] = vec
idx.texts[id] = text
}
// Remove 移除一条文档。
func (idx *SearchableIndex) Remove(id string) {
idx.mu.Lock()
defer idx.mu.Unlock()
delete(idx.docs, id)
delete(idx.texts, id)
}
// Search 用查询文本检索 topK 个最相似的文档。
func (idx *SearchableIndex) Search(query string, topK int) []DocHit {
idx.mu.RLock()
defer idx.mu.RUnlock()
if len(idx.docs) == 0 {
return nil
}
idx.emb.mu.RLock()
qVec := idx.emb.vectorize(query)
idx.emb.mu.RUnlock()
type scored struct {
id string
score float64
}
var results []scored
for id, docVec := range idx.docs {
score := sparseCosine(qVec, docVec)
if score > 0.01 {
results = append(results, scored{id, score})
}
}
sort.Slice(results, func(i, j int) bool { return results[i].score > results[j].score })
if len(results) > topK {
results = results[:topK]
}
out := make([]DocHit, len(results))
for i, r := range results {
out[i] = DocHit{ID: r.id, Score: r.score}
}
return out
}
// sparseCosine 计算两个稀疏向量的余弦相似度。
func sparseCosine(a, b Vector) float64 {
var dot, na, nb float64
for f, va := range a {
dot += va * b[f]
na += va * va
}
for _, vb := range b {
nb += vb * vb
}
if na == 0 || nb == 0 {
return 0
}
return dot / math.Sqrt(na*nb)
}
// Text 返回文档原文(供调试/展示)。
func (idx *SearchableIndex) Text(id string) string {
idx.mu.RLock()
defer idx.mu.RUnlock()
return idx.texts[id]
}
// IDs 返回所有文档 ID(供重建索引)。
func (idx *SearchableIndex) IDs() []string {
idx.mu.RLock()
defer idx.mu.RUnlock()
ids := make([]string, 0, len(idx.docs))
for id := range idx.docs {
ids = append(ids, id)
}
sort.Strings(ids)
return ids
}
// Size 返回索引中的文档数。
func (idx *SearchableIndex) Size() int {
idx.mu.RLock()
defer idx.mu.RUnlock()
return len(idx.docs)
}
// Train 用文档集训练并建立索引。
func (idx *SearchableIndex) Train(docs map[string]string) {
idx.mu.Lock()
defer idx.mu.Unlock()
for id, text := range docs {
idx.emb.mu.RLock()
vec := idx.emb.vectorize(text)
idx.emb.mu.RUnlock()
idx.docs[id] = vec
idx.texts[id] = text
}
}
// TokenizeWords 是默认的中英文分词器(需外部注入 jieba 分词函数)。
// 外层 cmd/homed 负责注入,核心不直接依赖 jieba。
var TokenizeWords Tokenizer = func(text string) []string {
// 简单 fallback:按空白和标点拆分
return strings.Fields(text)
}