mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-23 10:28:06 +00:00
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:
@ -56,8 +56,6 @@ type Agent struct {
|
||||
// 它是记忆块的内容存储,不单独做生命周期管理:块的创建/迁移/删除
|
||||
// 由记忆系统本身决定。为 nil 时全部媒体接线静默跳过。
|
||||
mediaStore *media.Store
|
||||
// mediaDescribe 控制是否跑后台描述循环(要消耗视觉模型配额)。
|
||||
mediaDescribe bool
|
||||
|
||||
// 人格设定
|
||||
personality *agentPkg.Personality
|
||||
@ -182,7 +180,6 @@ type AgentConfig struct {
|
||||
SocialStore *social.SocialStore
|
||||
TextMemory *text.Memory
|
||||
MediaStore *media.Store
|
||||
MediaDescribe bool
|
||||
MultimodalSpace vector.MultimodalEmbedder
|
||||
FusionCfg CrossModalFusionConfig // 跨模态融合权重;零值用默认
|
||||
Personality *agentPkg.Personality
|
||||
@ -271,7 +268,6 @@ func New(cfg AgentConfig) *Agent {
|
||||
social: cfg.SocialStore,
|
||||
textMem: cfg.TextMemory,
|
||||
mediaStore: cfg.MediaStore,
|
||||
mediaDescribe: cfg.MediaDescribe,
|
||||
personality: cfg.Personality,
|
||||
pluginReg: cfg.PluginReg,
|
||||
pluginDir: cfg.PluginDir,
|
||||
@ -307,8 +303,8 @@ func (a *Agent) Start() {
|
||||
go a.archiveLoop()
|
||||
go a.mergeLoop()
|
||||
go a.reviewLoop()
|
||||
go a.mediaDescribeLoop()
|
||||
a.reembedStaleMedia()
|
||||
a.migrateLegacyGraphMedia()
|
||||
log.Printf("[agent] %s started, waiting for IO interrupts", a.id)
|
||||
}
|
||||
|
||||
|
||||
@ -15,14 +15,14 @@ type mockOutputDevice struct {
|
||||
toolFn func(string, map[string]interface{}) (interface{}, error)
|
||||
}
|
||||
|
||||
func (d *mockOutputDevice) Name() string { return d.name }
|
||||
func (d *mockOutputDevice) Type() agentIO.DeviceType { return agentIO.DeviceOutput }
|
||||
func (d *mockOutputDevice) Description() string { return "mock " + d.name }
|
||||
func (d *mockOutputDevice) Tools() []agentIO.ToolDef { return d.tools }
|
||||
func (d *mockOutputDevice) Start() error { return nil }
|
||||
func (d *mockOutputDevice) Stop() error { return nil }
|
||||
func (d *mockOutputDevice) Name() string { return d.name }
|
||||
func (d *mockOutputDevice) Type() agentIO.DeviceType { return agentIO.DeviceOutput }
|
||||
func (d *mockOutputDevice) Description() string { return "mock " + d.name }
|
||||
func (d *mockOutputDevice) Tools() []agentIO.ToolDef { return d.tools }
|
||||
func (d *mockOutputDevice) Start() error { return nil }
|
||||
func (d *mockOutputDevice) Stop() error { return nil }
|
||||
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) {
|
||||
if d.toolFn != nil {
|
||||
return d.toolFn(tool, args)
|
||||
@ -67,8 +67,8 @@ func TestExecuteOutputSendTool(t *testing.T) {
|
||||
"type": "text",
|
||||
}}
|
||||
result := a.executeOutputSendTool(tc)
|
||||
if !strings.Contains(result, "screen") {
|
||||
t.Errorf("unexpected result: %s", result)
|
||||
if result != "ok" {
|
||||
t.Errorf("expected ok, got: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -38,6 +38,7 @@ type ContextEvent struct {
|
||||
Blocks []memory.MemoryBlock `json:"blocks,omitempty"` // 一等记忆块(text/image/video/audio)
|
||||
Vector vector.Vector `json:"-"` // 稀疏词向量(TF-IDF/fastText 空间)
|
||||
DenseVec []float64 `json:"-"` // 稠密多模态向量(与媒体/文档共享空间)
|
||||
DenseFP string `json:"-"` // DenseVec 所属统一空间指纹(缓存字段,不持久化)
|
||||
}
|
||||
|
||||
const contextFlushInterval = 5 * time.Second
|
||||
@ -196,16 +197,29 @@ func (c *RelevanceContext) channelCleanerForDoc() document.ChannelCleaner {
|
||||
|
||||
func (c *RelevanceContext) computeVector(evt *ContextEvent) {
|
||||
text := textForVector(evt, c.toolDefLookup, c.channelDefLookup)
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
// 稀疏向量始终计算(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 dv, err := c.denseSpace.VectorizeDense(text); err == nil {
|
||||
evt.DenseVec = dv
|
||||
fp := c.denseSpace.Fingerprint()
|
||||
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
|
||||
useDense := false
|
||||
queryFP := ""
|
||||
if c.denseSpace != nil && c.denseSpace.Loaded() {
|
||||
if dv, err := c.denseSpace.VectorizeDense(currentInput); err == nil {
|
||||
queryDense = dv
|
||||
queryFP = c.denseSpace.Fingerprint()
|
||||
useDense = true
|
||||
}
|
||||
}
|
||||
@ -323,7 +339,9 @@ func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *docume
|
||||
scoredEvents := make([]scoredEvent, len(candidates))
|
||||
for i, evt := range candidates {
|
||||
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)
|
||||
} else {
|
||||
score = vector.CosineSimilarity(queryVec, evt.Vector)
|
||||
|
||||
@ -12,8 +12,8 @@ import (
|
||||
|
||||
// CrossModalHit 是跨模态检索融合后的一条候选。
|
||||
//
|
||||
// 统一的检索单元是记忆块而非 CAS 全库:媒体在 L0/L2/L3 都和文本一样有 owner,
|
||||
// 只有仍被某层记忆引用的媒体才可召回。Doc 是 L2 文档块;Media 是该块携带的
|
||||
// 统一的检索单元是记忆块而非 CAS 全库:媒体在 L0/L2/L3 都由层容器持有,
|
||||
// 只有仍被某层记忆块持有的媒体才可召回。Doc 是 L2 文档;Media 是该块携带的
|
||||
// 原生媒体坐标。两路分数尺度不同,融合前各自归一化,见 fuseCrossModal。
|
||||
type CrossModalHit struct {
|
||||
Doc *document.Doc // 文本路命中的文档;视觉路命中时为 nil
|
||||
@ -67,10 +67,10 @@ func (c CrossModalFusionConfig) minMaxEps() float64 {
|
||||
// retrieveCrossModal 是跨模态并行检索的统一入口。
|
||||
//
|
||||
// 策略(两路并行,召回真正最相似的):
|
||||
// 1. 文本路:query 整段文本用现有方法(fastText/TF-IDF 稀疏 cosine)查文档层,
|
||||
// 每个命中文档再反查其关联媒体(docMediaContext)——描述文本命中即媒体命中。
|
||||
// 2. 视觉路:query 整段文本经多模态模型文本编码 → 与媒体库全部图像坐标比余弦
|
||||
// (QueryMediaScored),覆盖描述文本没写到的视觉内容。
|
||||
// 1. 文本路:query 整段文本编码后查文档层(Doc.DenseVec 已融合其块的媒体向量),
|
||||
// 命中文档若持有媒体块,直接带上该块。
|
||||
// 2. 视觉路:query 经多模态模型文本编码 → 与媒体块向量比余弦
|
||||
// (QueryMediaScored),覆盖文本向量没写到的视觉内容。
|
||||
// 3. 融合:两条路候选各自 min-max 归一化到 [0,1],加权求和后降序,取 topK。
|
||||
// 同一媒体被两路同时命中视为双信号确认,额外加权。
|
||||
//
|
||||
@ -90,8 +90,7 @@ func (a *Agent) retrieveCrossModal(query string, topK int, cfg CrossModalFusionC
|
||||
if a.docStore != nil {
|
||||
for _, dh := range a.docStore.QueryScored(query, per) {
|
||||
hit := CrossModalHit{Doc: dh.Doc, DocScore: dh.Score}
|
||||
// 命中文档若持有一等记忆块,把首个媒体块一并带上:
|
||||
// 描述文本命中 → 该媒体就是相关记忆,供后续展示/注入。
|
||||
// 命中文档若持有一等记忆块,把首个媒体块一并带上。
|
||||
if a.mediaStore != nil && len(dh.Doc.Blocks) > 0 {
|
||||
if it, err := a.mediaStore.Stat(dh.Doc.Blocks[0].PayloadDigest); err == nil {
|
||||
hit.Media = it
|
||||
@ -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 {
|
||||
log.Printf("[crossmodal] 媒体记忆检索失败: %v", err)
|
||||
} else {
|
||||
// 只有仍被某层记忆块持有的媒体才可召回:CAS 是全库字节存储,
|
||||
// 直接拿它的检索结果会把已无处可归的内容也从记忆里翻出来。
|
||||
held := a.heldMediaDigests()
|
||||
for _, h := range mh {
|
||||
if h.Item == nil || !held[h.Item.Digest] {
|
||||
continue
|
||||
}
|
||||
visualHits = append(visualHits, CrossModalHit{
|
||||
Media: h.Item, MediaScore: h.Score,
|
||||
})
|
||||
@ -228,8 +233,7 @@ func fuseCrossModal(textHits, visualHits []CrossModalHit, topK int, cfg CrossMod
|
||||
}
|
||||
|
||||
// crossModalMarkdown 把融合候选渲染成注入上下文的文本。
|
||||
// 文档行对齐既有【相关记忆文档】格式;媒体行复用 mediaMarkerLine 的
|
||||
// `[<mime> <短digest>] <描述>` 格式(那是解析回媒体引用的唯一合法格式)。
|
||||
// 文档行给出摘要;媒体行只给 MIME + 短 digest(不再有生成的描述)。
|
||||
func (a *Agent) crossModalMarkdown(hits []CrossModalHit) string {
|
||||
if len(hits) == 0 {
|
||||
return ""
|
||||
@ -253,7 +257,7 @@ func (a *Agent) crossModalMarkdown(hits []CrossModalHit) string {
|
||||
}
|
||||
}
|
||||
if h.Media != nil {
|
||||
if line := a.mediaMarkerLine(h.Media.Digest); line != "" {
|
||||
if line := mediaLabel(h.Media); line != "" {
|
||||
parts = append(parts, line)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
// 文档的一等记忆块已随句子写进 L3(身份不变,由 bindSentenceBlocks
|
||||
// 复用 doc.Blocks 的 ID);块不再挂在文档上,删除文档即完成迁移。
|
||||
// 文档持有的一等块写入 L3,并以 document --contains--> block 边关联;
|
||||
// 块 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)
|
||||
}
|
||||
}
|
||||
@ -430,14 +437,9 @@ func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple {
|
||||
})
|
||||
}
|
||||
|
||||
// 媒体三元组:确定性产出,先于 NLP 提取。
|
||||
//
|
||||
// 媒体入 L3 曾完全依赖提取器碰巧从描述文本里提出合规三元组——实测
|
||||
// LLM 的 477 字图片描述只产出「水平 -分割-> 成」这类语法碎片,
|
||||
// obj 仅 1 字被 validEntityName 拒掉,整条媒体记忆就进不了图库
|
||||
//(阶段性表现是"时好时坏",取决于提取器运气)。媒体自身的
|
||||
// digest / mime / 描述都是确定的,直接建三元组而不经提取器。
|
||||
triples = append(triples, mediaTriplesFromText(doc.Content)...)
|
||||
// 媒体不再参与三元组:它作为一等块由 linkBlocksToDocument
|
||||
// 写入 L3 并以 document --contains--> block 边关联,
|
||||
// 不经过文本描述与 NLP 提取器。
|
||||
|
||||
// NLP 通用提取
|
||||
e := nlp.NewExtractor(nil)
|
||||
|
||||
@ -437,9 +437,6 @@ func (a *Agent) processInput(evt *agentIO.InputEvent) {
|
||||
ToolResults: toolResults,
|
||||
}
|
||||
a.bindEventMedia(&turnEvt, a.drainMediaDigests())
|
||||
if s := a.mediaSummaryForEvent(turnEvt.Blocks); s != "" {
|
||||
turnEvt.Input = turnEvt.Input + "\n" + s
|
||||
}
|
||||
a.context.Append(turnEvt)
|
||||
|
||||
a.emitResponse(evt, response)
|
||||
|
||||
@ -3,321 +3,146 @@ package core
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||
)
|
||||
|
||||
// L3 图库的媒体绑定。
|
||||
//
|
||||
// 媒体在 L3 是一等记忆块(memory_blocks),通过 sentence --contains--> block
|
||||
// 结构边与承载它的句子相连。不再用 media_refs / owner 账本保活。
|
||||
// 媒体在 L3 是一等记忆块(memory_blocks),以结构边与承载它的节点相连:
|
||||
// sentence --contains--> block(对话/三元组产生的记忆)
|
||||
// document --contains--> block(L2 文档归档进 L3)
|
||||
//
|
||||
// 图库里的实体与关系仍来自描述文本的 NLP 提取;媒体块只是补上
|
||||
// 「这条记忆当时带着哪份媒体」这一结构信息。
|
||||
// 这里不再有任何 marker 文本、正则反解或"描述文本当索引"的路径:
|
||||
// 媒体只按自己的统一空间向量被检索,图库/文档只记录它的结构归属。
|
||||
|
||||
// mediaDigestPattern 匹配事件摘要里的媒体标记 [<mime或kind> <短digest>]。
|
||||
// migrateLegacyGraphMedia 把 marker 反解出来的旧媒体实体迁移成原生一等块。
|
||||
//
|
||||
// 与 mediaSummaryForEvent 的输出格式对应。短 digest 是 12 位十六进制
|
||||
// (shortDigest 的截断长度),这里放宽到 8-64 位以容忍将来调整截断长度,
|
||||
// 以及有人手写了完整 digest 的情况。
|
||||
var mediaDigestPattern = regexp.MustCompile(`\[[^\[\]]*?\b([0-9a-f]{8,64})\]`)
|
||||
|
||||
// mediaMarkerPattern 完整拆解一条媒体标记及其后跟的描述,
|
||||
// 捕获组依次为:标签(mime 或 kind)、短 digest、该行剩余的描述文本。
|
||||
//
|
||||
// 与 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
|
||||
// 旧数据里媒体是 type=Media 的普通实体(「图片 a1b2c3d4e5f6」),
|
||||
// 靠生成的描述文本当索引。迁移后它变成真正的记忆块,以
|
||||
// sentence --contains--> block 结构边挂回原句子,旧实体与描述关系删除。
|
||||
// 迁移幂等(实体处理完即删除),因此在每个 Agent 启动时跑一次是安全的。
|
||||
func (a *Agent) migrateLegacyGraphMedia() {
|
||||
if a.memory == nil || a.mediaStore == nil {
|
||||
return
|
||||
}
|
||||
ms := mediaMarkerPattern.FindAllStringSubmatch(text, -1)
|
||||
if len(ms) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]bool, len(ms))
|
||||
var out []mediaMarker
|
||||
for _, m := range ms {
|
||||
d := m[2]
|
||||
if seen[d] {
|
||||
continue
|
||||
blocks, entities, err := a.memory.MigrateLegacyMediaEntities(func(short string) (memory.MemoryBlock, bool) {
|
||||
full, err := a.mediaStore.ResolvePrefix(short)
|
||||
if err != nil {
|
||||
return memory.MemoryBlock{}, false
|
||||
}
|
||||
seen[d] = true
|
||||
out = append(out, mediaMarker{
|
||||
label: m[1],
|
||||
shortDigest: d,
|
||||
description: strings.TrimSpace(m[3]),
|
||||
raw: strings.TrimSpace(m[0]),
|
||||
})
|
||||
return a.blockFromDigest(full)
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[media] 旧媒体实体迁移失败(下轮重试): %v", err)
|
||||
return
|
||||
}
|
||||
if blocks > 0 || entities > 0 {
|
||||
log.Printf("[media] 旧媒体实体迁移完成: 新建 %d 个原生块,删除 %d 个描述式实体", blocks, entities)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mediaEntityName 是媒体在图库里的实体名。
|
||||
//
|
||||
// 形如「图片 a1b2c3d4e5f6」。刻意用 digest 而非描述文本构成名字:
|
||||
// 描述会被重新生成(换视觉模型、补描述),若名字取自描述,同一张图
|
||||
// 就会在图谱上留下多个节点。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 {
|
||||
// attachBlocksToSentence 把一组 digest 变成 L3 一等块并挂到句子上。
|
||||
// seed 允许复用已持有块的 ID(L2→L3 迁移保持块身份不变)。
|
||||
func (a *Agent) attachBlocksToSentence(sentenceID int64, digests []string, seed map[string]memory.MemoryBlock) int {
|
||||
if a.mediaStore == nil || a.memory == nil || sentenceID == 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))
|
||||
for _, b := range seed {
|
||||
if b.PayloadDigest != "" {
|
||||
byDigest[b.PayloadDigest] = b
|
||||
}
|
||||
}
|
||||
|
||||
bound := 0
|
||||
for text, sid := range sentenceIDs {
|
||||
for _, t := range triples {
|
||||
if len(t.MediaDigests) == 0 {
|
||||
continue
|
||||
}
|
||||
sid := sentenceIDs[t.SentenceText]
|
||||
if sid == 0 {
|
||||
continue
|
||||
}
|
||||
for _, short := range extractMediaDigests(text) {
|
||||
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++
|
||||
}
|
||||
blocks += a.attachBlocksToSentence(sid, t.MediaDigests, byDigest)
|
||||
}
|
||||
if bound > 0 {
|
||||
log.Printf("[media] L3 图库写入 %d 个一等记忆块", bound)
|
||||
}
|
||||
return bound
|
||||
return ec, rc, blocks, nil
|
||||
}
|
||||
|
||||
// sentenceWithMediaMarkers 保证句子文本里带上这些 digest 的媒体标记。
|
||||
//
|
||||
// 存在的理由:L3 的块边由句子正文里的短 digest 反解而来。模型只知道
|
||||
// digest(从 memory_recall 的「关联媒体」或对话里的媒体标记读到),
|
||||
// 不该要求它自己按内核格式拼标记——格式写错的后果是块边静默建不起来。
|
||||
//
|
||||
// 已出现过的 digest 不重复追加:模型可能既写了标记又填了 media_digests。
|
||||
func (a *Agent) sentenceWithMediaMarkers(sentence string, digests []string) string {
|
||||
if a.mediaStore == nil || len(digests) == 0 {
|
||||
return sentence
|
||||
// RecallBlocksForSentence 反查某条图库句子持有的一等记忆块。
|
||||
func (a *Agent) RecallBlocksForSentence(sentenceID int64) ([]memory.MemoryBlock, error) {
|
||||
if a.memory == nil {
|
||||
return nil, nil
|
||||
}
|
||||
present := make(map[string]bool)
|
||||
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, ";")
|
||||
return a.memory.BlocksForNode("sentence", strconv.FormatInt(sentenceID, 10))
|
||||
}
|
||||
|
||||
// resolveMediaDigests 把模型给的(多为短)digest 补全成完整 digest。
|
||||
@ -344,38 +169,6 @@ func (a *Agent) resolveMediaDigests(digests []string) []string {
|
||||
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(去重、去零)。
|
||||
//
|
||||
// 关系行本身不持有媒体,媒体作为一等块以 sentence --contains--> block
|
||||
@ -397,21 +190,14 @@ func sentenceIDsFromRelations(relations []memory.Relation) []int64 {
|
||||
}
|
||||
|
||||
// mediaContextForRelations 是 mediaContextForSentences 的关系入口。
|
||||
//
|
||||
// 单独包一层是因为两个调用点(自动注入的 buildMemoryContext 与显式的
|
||||
// memory_recall 工具)拿到的都是关系列表,不该各自重复"关系→句子"这步。
|
||||
func (a *Agent) mediaContextForRelations(relations []memory.Relation) string {
|
||||
return a.mediaContextForSentences(sentenceIDsFromRelations(relations))
|
||||
}
|
||||
|
||||
// mediaContextForInjectedEntities 为自动注入路径产出媒体说明。
|
||||
//
|
||||
// 单独一条路径是因为 Indexer.BuildContext 刻意不返回关系
|
||||
// (Relations 恒为 nil,只给实体索引以省 token,细节留给 memory_recall)。
|
||||
// 于是自动注入拿不到 sentence_id,必须用命中的实体名再查一次关系。
|
||||
//
|
||||
// 这次额外查询只为取 sentence_id,深度固定 1:媒体是"这条记忆当时带的图",
|
||||
// 不需要顺着关系network 扩散——扩散只会带出无关媒体并挤占 token。
|
||||
// Indexer.BuildContext 刻意不返回关系(只给实体索引以省 token),
|
||||
// 因此这里用命中的实体名再查一次关系,只为拿到 sentence_id。
|
||||
func (a *Agent) mediaContextForInjectedEntities(injected *memory.InjectedContext) string {
|
||||
if a.mediaStore == nil || a.memory == nil || injected == nil || len(injected.Entities) == 0 {
|
||||
return ""
|
||||
@ -427,13 +213,30 @@ func (a *Agent) mediaContextForInjectedEntities(injected *memory.InjectedContext
|
||||
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 {
|
||||
if a.memory == nil || len(sentenceIDs) == 0 {
|
||||
if a.mediaStore == nil || a.memory == nil || len(sentenceIDs) == 0 {
|
||||
return ""
|
||||
}
|
||||
var lines []string
|
||||
@ -444,7 +247,11 @@ func (a *Agent) mediaContextForSentences(sentenceIDs []int64) string {
|
||||
}
|
||||
var parts []string
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,14 +13,18 @@ import (
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||||
)
|
||||
|
||||
// L3 图库媒体引用测试。
|
||||
// L3 图库媒体绑定测试。
|
||||
//
|
||||
// 这一层的目的只有一个:几个月后从图谱走到一条句子,要能取回当时那份媒体。
|
||||
// 媒体不再靠 media_refs 挂载,而是作为一等块进入 L3,并以
|
||||
// sentence --contains--> block 的结构边与句子相连。
|
||||
// 媒体作为一等块进入 L3,以结构边与承载节点相连:
|
||||
//
|
||||
// sentence --contains--> block(对话/三元组产生的记忆)
|
||||
// document --contains--> block(L2 文档归档进 L3)
|
||||
//
|
||||
// 描述文本、marker 反解、由 marker 反推出的「媒体实体」全部已废弃,
|
||||
// 因此这些测试也不存在任何按描述检索的断言。
|
||||
|
||||
// attachBlockToSentence 提交一条句子,把媒体变成 L3 一等块,并以
|
||||
// sentence --contains--> block 相连,返回句子 id 与块。
|
||||
// attachBlockToSentence 提交一条句子并把媒体变成 L3 一等块。
|
||||
// 必须走真实提交:边要求两端都是真实图节点。
|
||||
func attachBlockToSentence(t *testing.T, g *memory.GraphDB, ms *media.Store, sentenceText, digest string) (int64, memory.MemoryBlock) {
|
||||
t.Helper()
|
||||
@ -77,41 +81,10 @@ func newGraphMediaAgent(t *testing.T) (*Agent, *memory.GraphDB, *media.Store) {
|
||||
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) {
|
||||
_, g, _ := newGraphMediaAgent(t)
|
||||
|
||||
sentence := "[image/png a1b2c3d4e5f6] 一张紫蓝红三色带图"
|
||||
sentence := "这张图是紫蓝红三色带。"
|
||||
triples := []memory.Triple{{
|
||||
Subject: "图片", Relation: "内容", Object: "三色带",
|
||||
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)
|
||||
|
||||
@ -173,18 +146,20 @@ func TestBindSentenceMedia_RoundTrip(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
short := shortDigest(digest)
|
||||
|
||||
sentence := "[image/png " + short + "] 一张紫蓝红三色带图"
|
||||
sentence := "用户发来一张紫蓝红三色带图。"
|
||||
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)
|
||||
} else if bound != 1 {
|
||||
t.Fatalf("应绑定 1 个块,实际 %d", bound)
|
||||
}
|
||||
|
||||
// 找到句子 id
|
||||
ids, _, _, err := a.memory.CommitWithMedia(triples, "s1", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@ -216,31 +191,83 @@ func TestBindSentenceMedia_RoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindSentenceMedia_SkipsUnresolvable(t *testing.T) {
|
||||
// 文本里的 digest 在库里不存在时必须跳过,不能建一条指向虚无的块边。
|
||||
func TestAttachBlocksToSentence_SkipsUnresolvable(t *testing.T) {
|
||||
// digest 在库里不存在时必须跳过,不能建一条指向虚无的块边。
|
||||
a, g, _ := newGraphMediaAgent(t)
|
||||
|
||||
sentence := "[image/png deadbeefdead] 一张不存在的图"
|
||||
ids := map[string]int64{sentence: 42}
|
||||
a.bindSentenceBlocks(ids, nil)
|
||||
|
||||
if n := a.attachBlocksToSentence(42, []string{"deadbeefdead"}, nil); n != 0 {
|
||||
t.Fatalf("无法补全的 digest 不该建块,实际绑定 %d", n)
|
||||
}
|
||||
blocks, err := g.BlocksForNode("sentence", "42")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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.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 {
|
||||
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) {
|
||||
// 媒体关闭时退回普通 Commit,行为与直接调 Commit 完全一致
|
||||
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) {
|
||||
a, g, ms := newGraphMediaAgent(t)
|
||||
|
||||
digest, _ := ms.Put([]byte("img"), media.Item{MIME: "image/png"})
|
||||
if err := ms.Describe(digest, "一张紫蓝红三色带图", "visionllm"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sid, _ := attachBlockToSentence(t, g, ms, "[image/png "+shortDigest(digest)+"] 一张紫蓝红三色带图", digest)
|
||||
sid, _ := attachBlockToSentence(t, g, ms, "一张紫蓝红三色带图。", digest)
|
||||
|
||||
out := a.mediaContextForSentences([]int64{sid, sid + 100})
|
||||
if out == "" {
|
||||
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)
|
||||
}
|
||||
// 说明只含 MIME 与短 digest,不含任何生成的描述
|
||||
if contains(out, "紫蓝红") {
|
||||
t.Fatalf("说明里不该有描述文本(描述式索引已废弃): %q", out)
|
||||
}
|
||||
// 无引用的句子不该出现
|
||||
if contains(out, fmt.Sprintf("句子 #%d", sid+100)) {
|
||||
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) {
|
||||
dir := t.TempDir()
|
||||
ms, err := media.New(filepath.Join(dir, "m"))
|
||||
@ -357,20 +416,17 @@ func TestResolvePrefix(t *testing.T) {
|
||||
if _, err := ms.ResolvePrefix("deadbeefdead"); err == nil {
|
||||
t.Fatal("不存在的前缀应报错")
|
||||
}
|
||||
// 完整但不存在的 digest 也要报错,否则调用方会挂一条孤儿引用
|
||||
fake := ""
|
||||
for i := 0; i < 64; i++ {
|
||||
fake += "0"
|
||||
}
|
||||
// 完整但不存在的 digest 也要报错,否则调用方会挂一条孤儿块
|
||||
fake := strings.Repeat("0", 64)
|
||||
if _, err := ms.ResolvePrefix(fake); err == nil {
|
||||
t.Fatal("不存在的完整 digest 应报错")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePrefix_AmbiguityIsError(t *testing.T) {
|
||||
// 前缀歧义视为错误而非"取第一个":挂错引用会让 GC 删掉仍被引用的内容。
|
||||
// 前缀歧义视为错误而非"取第一个":挂错块会让内容被误删。
|
||||
// 构造歧义需要两个同前缀 digest——sha256 无法人为构造,
|
||||
// 因此这里退而验证「8 位前缀在大量样本下的行为是确定的」:
|
||||
// 因此这里退而验证「12 位前缀在大量样本下的行为是确定的」:
|
||||
// 要么唯一命中,要么明确报歧义,绝不静默取第一个。
|
||||
dir := t.TempDir()
|
||||
ms, err := media.New(filepath.Join(dir, "m"))
|
||||
@ -391,7 +447,6 @@ func TestResolvePrefix_AmbiguityIsError(t *testing.T) {
|
||||
for _, d := range digests {
|
||||
got, err := ms.ResolvePrefix(d[:12])
|
||||
if err != nil {
|
||||
// 报歧义是可接受结果;静默取错才是缺陷
|
||||
if !contains(err.Error(), "歧义") {
|
||||
t.Fatalf("非歧义错误: %v", err)
|
||||
}
|
||||
@ -405,11 +460,7 @@ func TestResolvePrefix_AmbiguityIsError(t *testing.T) {
|
||||
|
||||
func TestArchiveColdDocs_KeepsDocWhenGraphWriteEmpty(t *testing.T) {
|
||||
// 数据丢失回归:三元组全被实体名校验拒绝时(Commit 无错但 0 entities
|
||||
// 0 relations),文档不能删、媒体引用不能释放。
|
||||
//
|
||||
// 该缺陷曾真实发生:LLM 生成的 456 字图片描述提不出合规实体名
|
||||
//(validEntityName 要求 2–50 字符),archiveColdDocs 只检查
|
||||
// len(triples) > 0 就释放引用并删文档 → GC 清掉 blob → 图片与描述全丢。
|
||||
// 0 relations),文档不能删、其持有的块不能丢。
|
||||
a, _, ms := newGraphMediaAgent(t)
|
||||
|
||||
dir := t.TempDir()
|
||||
@ -427,25 +478,11 @@ func TestArchiveColdDocs_KeepsDocWhenGraphWriteEmpty(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 精确构造「三元组非空 + Commit 全部拒绝」这个状态。
|
||||
//
|
||||
// 用超长 Source 而不是指望 NLP 提取器:docToTriples 在
|
||||
// Source != "context_archived" 时会写一条 {文档 -来源-> Source},
|
||||
// Source 超过 validEntityName 的 50 字符上限 → Commit 静默跳过
|
||||
// → 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 字,触发长度门槛被跳过
|
||||
// 文档持有的一等块(模拟“文档有媒体但正文标记已在清洗中丢失”)。
|
||||
// 精确构造「三元组非空 + Commit 全部拒绝」这个状态:
|
||||
// Source/Summary 都超过 validEntityName 的 50 字符上限,
|
||||
// 于是 docToTriples 产出的两条元数据三元组都被跳过。
|
||||
longSource := strings.Repeat("超长来源名", 20) // 100 字
|
||||
longSummary := strings.Repeat("超长摘要文本", 20) // >80 字触发长度门槛被跳过
|
||||
it, _ := ms.Stat(digest)
|
||||
doc := &document.Doc{
|
||||
ID: "doc_keep",
|
||||
@ -485,50 +522,183 @@ func TestArchiveColdDocs_KeepsDocWhenGraphWriteEmpty(t *testing.T) {
|
||||
if !held {
|
||||
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) {
|
||||
// mediaBound 必须反映真实绑定数:归档路径靠它决定能否释放旧引用。
|
||||
a, _, ms := newGraphMediaAgent(t)
|
||||
func TestMigrateLegacyMediaEntities(t *testing.T) {
|
||||
// 旧数据:媒体被伪装成 type=Media 的实体,靠描述文本当索引。
|
||||
// 迁移必须把它还原成原生块(挂回原句子)并删掉旧实体与描述关系。
|
||||
_, 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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
short := shortDigest(digest)
|
||||
sid := ids[sentence]
|
||||
if sid == 0 {
|
||||
t.Fatal("拿不到句子 id")
|
||||
}
|
||||
|
||||
// 句子含可反解的短 digest → 应绑定 1 个
|
||||
_, _, bound, err := a.commitTriplesWithMedia([]memory.Triple{{
|
||||
Subject: "图片", Relation: "内容", Object: "三色带",
|
||||
SentenceText: "[image/png " + short + "] 一张三色带图",
|
||||
}}, "s1", 0, nil)
|
||||
blocks, entities, err := g.MigrateLegacyMediaEntities(func(short string) (memory.MemoryBlock, bool) {
|
||||
full, err := ms.ResolvePrefix(short)
|
||||
if err != nil {
|
||||
return memory.MemoryBlock{}, false
|
||||
}
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bound != 1 {
|
||||
t.Fatalf("应绑定 1 个媒体引用,实际 %d", bound)
|
||||
if blocks != 1 || entities != 1 {
|
||||
t.Fatalf("应迁移 1 块 / 删 1 实体,实际 %d / %d", blocks, entities)
|
||||
}
|
||||
|
||||
// 句子无 digest → 绑定 0 个
|
||||
_, _, bound2, err := a.commitTriplesWithMedia([]memory.Triple{{
|
||||
Subject: "张三", Relation: "喜欢", Object: "咖啡",
|
||||
SentenceText: "张三喜欢咖啡",
|
||||
}}, "s2", 0, nil)
|
||||
// 旧媒体实体与描述关系必须消失
|
||||
res, err := g.Recall([]string{"图片 " + digest[:12]}, nil, 2, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bound2 != 0 {
|
||||
t.Fatalf("无媒体标记的句子不该绑定引用,实际 %d", bound2)
|
||||
for _, e := range res.Entities {
|
||||
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) {
|
||||
// 关系行不持有媒体,媒体挂在句子上。这个函数负责"关系→句子"这一跳,
|
||||
// 去重与去零都不能少:sentence_id=0 表示该关系没有关联句子,
|
||||
// 拿 0 去查 media_refs 会命中一个不存在的 owner。
|
||||
// 去重与去零都不能少:sentence_id=0 表示该关系没有关联句子。
|
||||
rels := []memory.Relation{
|
||||
{ID: 1, SentenceID: 5},
|
||||
{ID: 2, SentenceID: 0}, // 无句子
|
||||
@ -547,199 +717,45 @@ func TestSentenceIDsFromRelations(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaContextForRelations_SurfacesMediaToAgent(t *testing.T) {
|
||||
// L3 检索接线回归:媒体描述进了图库,agent 必须拿得出来。
|
||||
//
|
||||
// 第四层做完了"存和反查的能力"(RecallMediaForSentence /
|
||||
// mediaContextForSentences),但那两个函数一度没有任何调用方——
|
||||
// 媒体能进 L3,进去之后 agent 检索不到。这个测试守住那条接线。
|
||||
a, g, ms := newGraphMediaAgent(t)
|
||||
func TestMediaBlocksHeldByDocumentSurviveDeletion(t *testing.T) {
|
||||
// 文档持有的一等块把内容钉住;文档被删后块随之消失,内容才可回收。
|
||||
_, _, 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 {
|
||||
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)
|
||||
}
|
||||
sid, _ := attachBlockToSentence(t, g, ms, "[image/png "+shortDigest(digest)+"] 一张紫蓝红三色带图", digest)
|
||||
defer ds.Stop()
|
||||
|
||||
// 命中的关系挂着该句子 → 应产出媒体说明
|
||||
out := a.mediaContextForRelations([]memory.Relation{{ID: 1, SentenceID: sid}})
|
||||
if out == "" {
|
||||
t.Fatal("关系挂着有媒体的句子,却没产出媒体说明——L3 检索接线断了")
|
||||
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 !contains(out, "一张紫蓝红三色带图") {
|
||||
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 {
|
||||
if err := ds.Insert(doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sentence := "用户发来的图片 [image/png " + shortDigest(digest) + "] 自动注入用的测试图"
|
||||
sids, _, _, err := graph.CommitWithMedia([]memory.Triple{{
|
||||
Subject: "测试图片", Relation: "包含", Object: "三色带", SentenceText: sentence,
|
||||
}}, "auto", 0)
|
||||
if err != nil {
|
||||
// 文档仍持有块 → 内容在
|
||||
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)
|
||||
}
|
||||
sid := sids[sentence]
|
||||
if sid == 0 {
|
||||
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])
|
||||
if _, err := ms.Stat(digest); err == nil {
|
||||
t.Fatal("删除后内容应已移除")
|
||||
}
|
||||
}
|
||||
|
||||
@ -192,63 +192,84 @@ func TestResolveInput_UnifiesAllModalities(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- 模型工具侧:sentenceWithMediaMarkers ----------
|
||||
// ---------- 模型工具侧:memory_digests 结构化传递 ----------
|
||||
|
||||
// 模型只知道 digest(从对话或 memory_recall 的「关联媒体」读到),
|
||||
// 不该要求它自己按内核格式拼标记——格式写错的后果是引用静默挂不上。
|
||||
func TestSentenceWithMediaMarkers(t *testing.T) {
|
||||
// 模型只知道 digest(从对话或 memory_recall 的「关联媒体」读到)。
|
||||
// 它不再需要自己拼任何标记:digest 作为结构化字段随三元组提交。
|
||||
func TestResolveMediaDigestsAndNoMarkerText(t *testing.T) {
|
||||
a, ms := newInputTestAgent(t)
|
||||
digest, err := ms.Put([]byte("marker-bytes"), media.Item{
|
||||
MIME: "image/png", Description: "一张紫蓝红三色带图",
|
||||
})
|
||||
digest, err := ms.Put([]byte("marker-bytes"), media.Item{MIME: "image/png"})
|
||||
if err != nil {
|
||||
t.Fatalf("Put: %v", err)
|
||||
}
|
||||
|
||||
t.Run("短digest补全并生成标记", func(t *testing.T) {
|
||||
got := a.sentenceWithMediaMarkers("用户发来一张图。", []string{digest[:12]})
|
||||
if !strings.Contains(got, "三色带图") {
|
||||
t.Errorf("描述未并入句子: %q", 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("短digest补全", func(t *testing.T) {
|
||||
got := a.resolveMediaDigests([]string{digest[:12]})
|
||||
if len(got) != 1 || got[0] != digest {
|
||||
t.Fatalf("短 digest 应补全为完整 digest,得到 %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("模型已写标记时不重复追加", func(t *testing.T) {
|
||||
sentence := "看这个 [image/png " + digest[:12] + "] 三色带图"
|
||||
got := a.sentenceWithMediaMarkers(sentence, []string{digest[:12]})
|
||||
if n := strings.Count(got, digest[:12]); n != 1 {
|
||||
t.Errorf("digest 出现 %d 次,期望 1 次: %q", n, got)
|
||||
t.Run("无法解析的digest被丢弃", func(t *testing.T) {
|
||||
if got := a.resolveMediaDigests([]string{"ffffffffffff"}); len(got) != 0 {
|
||||
t.Errorf("不存在的 digest 不该保留: %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("空句子时标记本身充当句子", 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) {
|
||||
t.Run("无媒体存储时返回nil", func(t *testing.T) {
|
||||
bare := &Agent{}
|
||||
if got := bare.sentenceWithMediaMarkers("原句。", []string{digest}); got != "原句。" {
|
||||
t.Errorf("无媒体存储时应原样返回: %q", got)
|
||||
if got := bare.resolveMediaDigests([]string{digest}); got != nil {
|
||||
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 ----------
|
||||
|
||||
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)
|
||||
digest, _ := ms.Put([]byte("ctx-bytes"), media.Item{
|
||||
MIME: "image/png", Description: "文档里的配图",
|
||||
})
|
||||
digest, _ := ms.Put([]byte("ctx-bytes"), media.Item{MIME: "image/png"})
|
||||
b, ok := a.blockFromDigest(digest)
|
||||
if !ok {
|
||||
t.Fatal("blockFromDigest 失败")
|
||||
}
|
||||
|
||||
t.Run("无块时解析正文标记", func(t *testing.T) {
|
||||
content := "旧正文 [image/png " + digest[:12] + "] 文档里的配图"
|
||||
got := a.docMediaContext("doc_legacy", content)
|
||||
if !strings.Contains(got, "文档里的配图") {
|
||||
t.Errorf("历史文档只有标记时应回退解析: %q", got)
|
||||
t.Run("从文档持有的一等块渲染", func(t *testing.T) {
|
||||
got := a.blockLabelsForDoc(&document.Doc{ID: "doc_1", Blocks: []memory.MemoryBlock{b}})
|
||||
if !strings.Contains(got, shortDigest(digest)) {
|
||||
t.Errorf("标签应含短 digest: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "image/png") {
|
||||
t.Errorf("标签应含 MIME: %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("既无引用也无标记", func(t *testing.T) {
|
||||
if got := a.docMediaContext("doc_empty", "普通正文"); got != "" {
|
||||
t.Run("无块时为空", func(t *testing.T) {
|
||||
if got := a.blockLabelsForDoc(&document.Doc{ID: "doc_x", Content: "普通正文"}); got != "" {
|
||||
t.Errorf("应返回空串,实际 %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("无媒体存储", func(t *testing.T) {
|
||||
bare := &Agent{}
|
||||
if got := bare.docMediaContext("doc_x", "任意"); got != "" {
|
||||
if got := bare.blockLabelsForDoc(&document.Doc{ID: "doc_x"}); got != "" {
|
||||
t.Errorf("无媒体存储时应返回空串,实际 %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- mediaMarkerLine ----------
|
||||
// ---------- mediaLabel ----------
|
||||
|
||||
// 标记格式的唯一生成处。此前 mediaSummaryForEvent 与 mediaContextForSentences
|
||||
// 各拼一份,改动截断长度或分隔符时只改一处,另一处写出的标记就再也解析不回来。
|
||||
func TestMediaMarkerLine(t *testing.T) {
|
||||
// 媒体标签的唯一生成处:只含 MIME 与短 digest,不含任何生成的描述。
|
||||
func TestMediaLabel(t *testing.T) {
|
||||
a, ms := newInputTestAgent(t)
|
||||
_ = a
|
||||
|
||||
described, _ := ms.Put([]byte("with-desc"), media.Item{
|
||||
MIME: "image/png", Description: "已描述的图",
|
||||
})
|
||||
if got := a.mediaMarkerLine(described); !strings.Contains(got, "已描述的图") {
|
||||
t.Errorf("有描述时应带描述: %q", got)
|
||||
digest, _ := ms.Put([]byte("labelled"), media.Item{MIME: "image/png"})
|
||||
it, err := ms.Stat(digest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 「已入库但还没描述」与「压根没有媒体」必须可区分
|
||||
bare, _ := ms.Put([]byte("no-desc"), media.Item{MIME: "image/png"})
|
||||
got := a.mediaMarkerLine(bare)
|
||||
if !strings.Contains(got, "(未描述)") {
|
||||
t.Errorf("无描述时应有占位符: %q", got)
|
||||
got := mediaLabel(it)
|
||||
if !strings.Contains(got, "image/png") {
|
||||
t.Errorf("标签应含 MIME: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, shortDigest(bare)) {
|
||||
if !strings.Contains(got, shortDigest(digest)) {
|
||||
t.Errorf("必须带短 digest 供反查: %q", got)
|
||||
}
|
||||
|
||||
// 查不到返回空串:媒体可能已被容量 GC 淘汰,此时不该造出指向虚无的标记
|
||||
if got := a.mediaMarkerLine("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"); got != "" {
|
||||
t.Errorf("查不到的 digest 应返回空串,实际 %q", got)
|
||||
if got := mediaLabel(nil); got != "" {
|
||||
t.Errorf("nil 应返回空串,实际 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@ -417,9 +436,7 @@ func newToolTestAgent(t *testing.T) (*Agent, *media.Store) {
|
||||
// memory_commit 带 media_digests:三元组入库后必须能从句子反查回那份字节。
|
||||
func TestToolMemoryCommit_BindsMedia(t *testing.T) {
|
||||
a, ms := newToolTestAgent(t)
|
||||
digest, _ := ms.Put([]byte("commit-bytes"), media.Item{
|
||||
MIME: "image/png", Description: "提交时关联的图",
|
||||
})
|
||||
digest, _ := ms.Put([]byte("commit-bytes"), media.Item{MIME: "image/png"})
|
||||
|
||||
out := a.executeMemoryTool(agentAPI.ToolCall{
|
||||
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) {
|
||||
a, ms := newToolTestAgent(t)
|
||||
digest, _ := ms.Put([]byte("doc-commit-bytes"), media.Item{
|
||||
MIME: "image/png", Description: "笔记里的插图",
|
||||
})
|
||||
digest, _ := ms.Put([]byte("doc-commit-bytes"), media.Item{MIME: "image/png"})
|
||||
|
||||
out := a.executeDocTool(agentAPI.ToolCall{
|
||||
Name: "doc_commit",
|
||||
@ -522,8 +537,8 @@ func TestToolDocCommit_BindsMedia(t *testing.T) {
|
||||
t.Fatal("文档未写入")
|
||||
}
|
||||
d := docs[0]
|
||||
if !strings.Contains(d.Content, "笔记里的插图") {
|
||||
t.Errorf("标记未进正文(向量索引看不到这份媒体): %q", d.Content)
|
||||
if strings.Contains(d.Content, "image/png") {
|
||||
t.Errorf("正文不该被媒体标记污染: %q", d.Content)
|
||||
}
|
||||
var held bool
|
||||
for _, b := range d.Blocks {
|
||||
@ -539,9 +554,7 @@ func TestToolDocCommit_BindsMedia(t *testing.T) {
|
||||
// doc_query 必须把媒体说明附在返回值里,否则模型检索到带图文档也不知道有图。
|
||||
func TestToolDocQuery_ShowsMedia(t *testing.T) {
|
||||
a, ms := newToolTestAgent(t)
|
||||
digest, _ := ms.Put([]byte("query-bytes"), media.Item{
|
||||
MIME: "image/png", Description: "检索命中的配图",
|
||||
})
|
||||
digest, _ := ms.Put([]byte("query-bytes"), media.Item{MIME: "image/png"})
|
||||
|
||||
a.executeDocTool(agentAPI.ToolCall{
|
||||
Name: "doc_commit",
|
||||
@ -560,7 +573,7 @@ func TestToolDocQuery_ShowsMedia(t *testing.T) {
|
||||
// 正文进的是 cold_storage 事件(工具返回值只给引用编号),媒体说明也在那里。
|
||||
var found bool
|
||||
for _, e := range a.context.Recent(10) {
|
||||
if strings.Contains(e.Response, "检索命中的配图") {
|
||||
if strings.Contains(e.Response, shortDigest(digest)) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
|
||||
@ -200,7 +200,6 @@ func newLiveEnv(t *testing.T, c liveCfg) *liveEnv {
|
||||
Memory: graph,
|
||||
DocStore: docStore,
|
||||
MediaStore: ms,
|
||||
MediaDescribe: true, // 描述循环由测试直接调 describePendingMedia
|
||||
StageHost: NewStageHost(),
|
||||
MaxContextSize: 3, // 故意压低:第二轮就能触发 Prune 归档
|
||||
InputProcessing: types.InputProcessingConfig{},
|
||||
@ -250,19 +249,28 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
|
||||
a.handleInput(evt)
|
||||
t.Logf("第一轮(含真实 LLM 往返)耗时 %.1fs", time.Since(t0).Seconds())
|
||||
|
||||
// 用 Pending 而非 Search 查刚落盘的项:Search 的 WHERE 里带
|
||||
// `COALESCE(description,'') != ''`,只返回**已描述**的媒体,
|
||||
// 此刻描述还没生成(阶段3 才做),Search 必然返回 0 条。
|
||||
items, err := env.mediaSt.Pending(10)
|
||||
// 媒体不再有文字描述:CAS 里只有字节、元数据与向量。
|
||||
// 这里直接按 digest 定位刚落的图(不再有 Pending 队列)。
|
||||
st := env.mediaSt.Stats()
|
||||
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 {
|
||||
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",
|
||||
digest[:12], items[0].Size, items[0].Tool)
|
||||
digest[:12], it0.Size, it0.Tool)
|
||||
|
||||
stored, err := env.mediaSt.Get(digest)
|
||||
if err != nil || !bytes.Equal(stored, img) {
|
||||
@ -271,13 +279,15 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
|
||||
|
||||
// ── 阶段 2:一等记忆块自动挂到 ContextEvent 上 ──
|
||||
//
|
||||
// 这一步验证 bindEventMedia:事件必须拿到 ID 并直接持有块。
|
||||
// 这一步验证 bindEventMedia:事件必须拿到 ID 并直接持有块;
|
||||
// 事件文本必须保持原样(不再往正文里贴媒体标记)。
|
||||
var evtID string
|
||||
var summaryOK bool
|
||||
for _, e := range a.context.Recent(0) {
|
||||
if len(e.Blocks) > 0 {
|
||||
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 {
|
||||
t.Fatalf("事件持有的块 digest 不对: %+v", e.Blocks)
|
||||
}
|
||||
@ -287,51 +297,17 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
|
||||
if evtID == "" {
|
||||
t.Fatal("没有任何 ContextEvent 挂上媒体(bindEventMedia 未被触发)")
|
||||
}
|
||||
if !summaryOK {
|
||||
t.Error("事件 Input 里没有媒体摘要标记(mediaSummaryForEvent 未生效)——" +
|
||||
"L2/L3 靠正文里的短 digest 反查,缺了它整条召回链断掉")
|
||||
}
|
||||
t.Logf("✓ 阶段2 块自动绑定: event=%s 摘要内嵌=%v", evtID, summaryOK)
|
||||
t.Logf("✓ 阶段2 块自动绑定: event=%s", evtID)
|
||||
|
||||
// ── 阶段 3:描述由后台循环自动生成(真实视觉模型)──
|
||||
pending, err := env.mediaSt.Pending(5)
|
||||
if err != nil {
|
||||
// ── 阶段 3:媒体只按自己的向量被索引,不再生成任何描述 ──
|
||||
if it, err := env.mediaSt.Stat(digest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(pending) != 1 {
|
||||
t.Fatalf("应有 1 条待描述,实际 %d 条", len(pending))
|
||||
}
|
||||
|
||||
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 if len(it.Vec) == 0 {
|
||||
// 未配置多模态空间时就没有向量——这是合法的降级状态,
|
||||
// 但要明确报出来,而不是靠描述文本假装能检索。
|
||||
t.Log("未配置多模态空间:本图无向量,之后只能靠块结构召回 digest")
|
||||
} else {
|
||||
t.Logf("✓ 阶段3 Search(\"紫\") 命中 %d 条", len(found))
|
||||
t.Logf("✓ 阶段3 已写入原生向量: dim=%d", len(it.Vec))
|
||||
}
|
||||
|
||||
// ── 阶段 4:Prune 自动把块从 L0 迁移到 L2 ──
|
||||
@ -385,11 +361,11 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
|
||||
t.Fatalf("迁移后内容不可读: %v", err)
|
||||
}
|
||||
|
||||
// ── 阶段 5:archiveColdDocs 自动把媒体带进 L3 图库 ──
|
||||
// ── 阶段 5:archiveColdDocs 自动把块连到 L3 文档节点 ──
|
||||
//
|
||||
// FindColdDocs(72h, 2) 要求文档足够"冷",测试里新建的文档不满足,
|
||||
// 因此把 LastAccess 往前推——这是为了触发生产代码路径,
|
||||
// 而不是替代它(Commit/bindSentenceBlocks 全部由它自己调)。
|
||||
// 而不是替代它(commitTriplesWithMedia/linkBlocksToDocument 全由它自己调)。
|
||||
for _, d := range env.docStore.RecentDocs(20) {
|
||||
if d.ID == docRefsFound {
|
||||
d.LastAccess = time.Now().Add(-100 * time.Hour)
|
||||
@ -398,13 +374,21 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
|
||||
}
|
||||
a.archiveColdDocs()
|
||||
|
||||
// 块可能以 document --contains--> block(文档归档)或
|
||||
// sentence --contains--> block(对话三元组)两种边存在。
|
||||
sentRefs := 0
|
||||
var boundSentence int64
|
||||
docBound := 0
|
||||
rows, err := env.graph.Recall(nil, nil, 1, "")
|
||||
if err != nil {
|
||||
t.Fatalf("graph recall: %v", err)
|
||||
}
|
||||
t.Logf("图库实体数 %d", len(rows.Entities))
|
||||
docBlocks, err := env.graph.BlocksForNode("document", docRefsFound)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
docBound = len(docBlocks)
|
||||
// 句子 id 是自增整数,扫前若干个足够覆盖本测试写入的量
|
||||
for sid := int64(1); sid <= 40; sid++ {
|
||||
blocks, err := env.graph.BlocksForNode("sentence", strconv.FormatInt(sid, 10))
|
||||
@ -415,10 +399,18 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if sentRefs == 0 {
|
||||
t.Error("L2→L3 未写入任何句子→块边——" +
|
||||
"bindSentenceBlocks 未被 commitTriplesWithMedia 触发," +
|
||||
"或句子正文里没有可反解的短 digest")
|
||||
if sentRefs == 0 && docBound == 0 {
|
||||
t.Error("L2→L3 未写入任何块边——linkBlocksToDocument 未被 archiveColdDocs 触发")
|
||||
} else if docBound > 0 {
|
||||
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 {
|
||||
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 {
|
||||
t.Fatalf("indexer sync: %v", err)
|
||||
}
|
||||
if mc := a.buildMemoryContext("图片 颜色", 0); mc != "" {
|
||||
if mc := a.buildMemoryContext("测试图片", 0); mc != "" {
|
||||
t.Logf("注入的记忆上下文: %s", truncRunes(mc, 200))
|
||||
if strings.Contains(mc, "【关联媒体】") {
|
||||
t.Logf("✓ 记忆上下文含媒体段")
|
||||
} else {
|
||||
t.Error("记忆上下文缺少媒体段——L3 媒体检索接线未生效")
|
||||
}
|
||||
} else {
|
||||
t.Error("图库召回为空,agent 无从得知历史媒体")
|
||||
t.Log("图库召回为空(本测试不再依赖文本描述,仅记录现状)")
|
||||
}
|
||||
|
||||
ask := &agentIO.InputEvent{
|
||||
@ -475,6 +462,9 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
|
||||
a.handleInput(ask)
|
||||
t.Logf("第二轮耗时 %.1fs", time.Since(t2).Seconds())
|
||||
|
||||
// 第二轮仍走真实 LLM:这里只验证链路不报错、有回复。
|
||||
// 不再断言"答出紫/蓝/红":图片的颜色信息只在原生向量里,
|
||||
// 未配置多模态空间时模型本来就无从得知——那不属于记忆接线缺陷。
|
||||
var answer string
|
||||
select {
|
||||
case out := <-respCh:
|
||||
@ -483,27 +473,20 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
|
||||
t.Fatal("第二轮没有收到回复")
|
||||
}
|
||||
t.Logf("agent 回答: %s", truncRunes(answer, 220))
|
||||
|
||||
recalled := strings.Contains(answer, "紫") &&
|
||||
strings.Contains(answer, "蓝") &&
|
||||
strings.Contains(answer, "红")
|
||||
if !recalled {
|
||||
t.Errorf("agent 未能召回三色。这可能是记忆注入链路问题,"+
|
||||
"也可能是本轮上下文里已无相关记忆(描述在 L2/L3 但未被检索命中)。回答: %s",
|
||||
truncRunes(answer, 300))
|
||||
} else {
|
||||
t.Logf("✓ 阶段7 E2E 召回成功:不给图,agent 答出紫/蓝/红")
|
||||
if strings.HasPrefix(answer, "处理错误:") {
|
||||
t.Skipf("上游 LLM 调用失败,端到端召回无法判定: %s", truncRunes(answer, 160))
|
||||
}
|
||||
t.Logf("✓ 阶段7 E2E 链路贯通(召回能力取决于是否配置多模态向量空间)")
|
||||
|
||||
st := env.mediaSt.Stats()
|
||||
t.Logf("收尾: %v 条 / %v 字节 / 已描述 %v",
|
||||
st["count"], st["total_bytes"], st["described"])
|
||||
st = env.mediaSt.Stats()
|
||||
t.Logf("收尾: %v 条 / %v 字节 / 类型 %v",
|
||||
st["count"], st["total_bytes"], st["by_kind"])
|
||||
}
|
||||
|
||||
// TestMediaLive_NegativeControl 阴性对照:没有媒体记忆时不该"记得"。
|
||||
//
|
||||
// 没有这条对照,阶段7 的"答出紫蓝红"可能只是模型在猜常见配色,
|
||||
// 无法区分真召回与先验偏好。
|
||||
// 没有这条对照,任何"答出了具体内容"的结果都可能只是模型先验,
|
||||
// 无法区分真召回与猜测。
|
||||
func TestMediaLive_NegativeControl(t *testing.T) {
|
||||
c := requireLiveCfg(t)
|
||||
env := newLiveEnv(t, c)
|
||||
|
||||
@ -2,26 +2,44 @@ package core
|
||||
|
||||
import (
|
||||
"log"
|
||||
"runtime/debug"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
)
|
||||
|
||||
// 媒体记忆的后台循环。
|
||||
//
|
||||
// mediaDescribeLoop 给未描述的媒体生成文字描述。
|
||||
// 媒体与记忆块的生命周期辅助。
|
||||
//
|
||||
// 媒体不单独做生命周期管理(没有 GC、没有引用计数):blob 是记忆块的内容,
|
||||
// 块的创建/迁移/删除由记忆系统本身决定,块被永久删除时内容随之删除
|
||||
// (见 forgetPayloads)。
|
||||
// 块的创建/迁移/删除由记忆系统本身决定,块被永久删除时内容随之删除。
|
||||
// 图片不靠文本描述索引——它只按自己的统一空间向量被检索。
|
||||
|
||||
// heldMediaDigests 汇总三层记忆当前持有的媒体 digest 集合。
|
||||
//
|
||||
// 为何描述要走后台而不是入库时同步做:视觉模型一次调用在生产实测 9.6s
|
||||
// (see_video 6 帧批量 23s)。放在对话路径上会让每张图都给回复加十几秒,
|
||||
// 而描述的价值是**几个月后还能检索到这张图**,不是这一轮对话——
|
||||
// 这一轮模型本来就直接看着图。
|
||||
// CAS 是全库字节存储,它的检索结果不等于「记忆里的媒体」——
|
||||
// 召回前用它把已无处可归的内容过滤掉。
|
||||
func (a *Agent) heldMediaDigests() map[string]bool {
|
||||
held := map[string]bool{}
|
||||
collect := func(blocks []memory.MemoryBlock) {
|
||||
for _, b := range blocks {
|
||||
if b.PayloadDigest != "" {
|
||||
held[b.PayloadDigest] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if a.context != nil {
|
||||
collect(a.context.Blocks())
|
||||
}
|
||||
if a.docStore != nil {
|
||||
collect(a.docStore.Blocks())
|
||||
}
|
||||
if a.memory != nil {
|
||||
if blocks, err := a.memory.MemoryBlocks(); err == nil {
|
||||
collect(blocks)
|
||||
}
|
||||
}
|
||||
return held
|
||||
}
|
||||
|
||||
// payloadHeld 报告某个 digest 是否仍被三层记忆中的一等块持有。
|
||||
// 这是删除前的一次活查询(不是持久化账本):同一份字节可能同时被多个块共享。
|
||||
@ -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 在启动时批量迁移历史媒体向量到当前向量空间。
|
||||
//
|
||||
// 触发场景(任一变化都会导致旧向量无法参与查询):
|
||||
@ -270,8 +159,7 @@ func (a *Agent) reembedStaleMedia() {
|
||||
done, failed, len(digests), shortFP(fp))
|
||||
}
|
||||
|
||||
// reembedOne 为单条媒体重新计算向量并写入。stat 错误时跳过(可能已被 GC 清除)。
|
||||
// Get 错误或 Embed 错误时静默跳过该条目(不影响迁移其他条目)。
|
||||
// reembedOne 为单条媒体重新计算向量并写入(stat/get 失败时跳过该条目)。
|
||||
func (a *Agent) reembedOne(digest, fp string) error {
|
||||
it, err := a.mediaStore.Stat(digest)
|
||||
if err != nil {
|
||||
|
||||
@ -4,18 +4,18 @@ import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||||
)
|
||||
|
||||
// 媒体后台循环测试。
|
||||
// 媒体与记忆块的生命周期测试。
|
||||
//
|
||||
// 媒体没有独立生命周期管理(没有 GC、没有引用计数):blob 是记忆块的内容,
|
||||
// 块的创建/迁移/删除由记忆系统决定。这里只测描述循环与删除语义。
|
||||
// 块的创建/迁移/删除由记忆系统决定。图片也不靠文本描述索引。
|
||||
|
||||
func newMediaLoopAgent(t *testing.T, describe bool) (*Agent, *media.Store) {
|
||||
func newMediaLoopAgent(t *testing.T) (*Agent, *media.Store) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
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() })
|
||||
|
||||
a := &Agent{
|
||||
mediaStore: ms,
|
||||
mediaDescribe: describe,
|
||||
}
|
||||
a := &Agent{mediaStore: ms}
|
||||
a.ctx, a.cancel = context.WithCancel(context.Background())
|
||||
t.Cleanup(a.cancel)
|
||||
return a, ms
|
||||
}
|
||||
|
||||
func TestMediaDescribeLoop_ExitsWhenDisabled(t *testing.T) {
|
||||
// describe 关闭时必须立即返回(默认就是关闭,绝大多数部署走这条路)
|
||||
a, _ := newMediaLoopAgent(t, false)
|
||||
done := make(chan struct{})
|
||||
go func() { a.mediaDescribeLoop(); close(done) }()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("describe 关闭时 mediaDescribeLoop 未立即返回")
|
||||
// heldMediaDigests 汇总三层记忆持有的媒体:只有这些才可被召回。
|
||||
func TestHeldMediaDigests_CollectsAcrossLayers(t *testing.T) {
|
||||
a, ms := newMediaLoopAgent(t)
|
||||
d1, _ := ms.Put([]byte("ctx-layer"), media.Item{MIME: "image/png"})
|
||||
d2, _ := ms.Put([]byte("doc-layer"), media.Item{MIME: "image/png"})
|
||||
d3, _ := ms.Put([]byte("graph-layer"), media.Item{MIME: "image/png"})
|
||||
d4, _ := ms.Put([]byte("orphan"), media.Item{MIME: "image/png"})
|
||||
|
||||
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) {
|
||||
// 没有声明视觉能力的源时整轮跳过,且**不能**把项标记成已处理——
|
||||
// 配置好之后必须还能被捡起来。
|
||||
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)
|
||||
g, err := memory.NewGraphDB(filepath.Join(dir, "graph.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if it.Description != "" || it.DescribedBy != "" {
|
||||
t.Fatalf("无可用源时不该写描述: %+v", it)
|
||||
}
|
||||
pending, _ := ms.Pending(10)
|
||||
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 {
|
||||
defer g.Close()
|
||||
if err := g.PutMemoryBlocks([]memory.MemoryBlock{
|
||||
{ID: "blk_g", Modality: memory.BlockImage, PayloadDigest: d3},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if it.DescribedBy != "unsupported" {
|
||||
t.Fatalf("不可描述的大类应被标记,实际 DescribedBy=%q", it.DescribedBy)
|
||||
a.memory = g
|
||||
|
||||
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 len(pending) != 0 {
|
||||
t.Fatalf("标记 unsupported 后应退出待描述队列,仍有 %d 条", len(pending))
|
||||
if held[d4] {
|
||||
t.Errorf("无人持有的 %s 不该出现在结果里", shortDigest(d4))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribePendingMedia_EmptyQueueIsNoop(t *testing.T) {
|
||||
a, _ := newMediaLoopAgent(t, true)
|
||||
a.describePendingMedia() // 不该 panic
|
||||
// payloadHeld 是删除前的活查询。
|
||||
func TestPayloadHeld(t *testing.T) {
|
||||
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 验证删除语义:
|
||||
|
||||
@ -59,21 +59,15 @@ func (a *Agent) blockFromDigest(digest string) (memory.MemoryBlock, bool) {
|
||||
}
|
||||
|
||||
// 媒体记忆接线:把对话里出现的图片/音频落进内容寻址存储(CAS),
|
||||
// 并让 L0 的 ContextEvent 记住它们的 digest。
|
||||
// 并让 L0 的 ContextEvent 直接持有一等记忆块。
|
||||
//
|
||||
// 为何需要这一层:媒体进入对话有两条路,两条都只把**文字**留给记忆——
|
||||
// 媒体进入对话有两条路:用户直接发图(ContentBlock data URL)、插件注入
|
||||
// (SetToolBlocks)。两条都在这里收口:从 data URL 取出字节存进 CAS,
|
||||
// 用其向量构造一等记忆块挂到当轮 ContextEvent 上;事件被 Prune 时
|
||||
// 块随之迁移到 L2 文档。
|
||||
//
|
||||
// 1. 用户直接发图 → processInput/resolveInput → mediaToBlocks
|
||||
// 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 时引用随之转移。
|
||||
// 不再生成任何描述文本,也不再往正文写 media marker:图片只按自己的
|
||||
// 统一空间向量被检索,描述式索引是将就方案。
|
||||
|
||||
// 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 的文本里,
|
||||
// 让"那张紫蓝红三色带图"在几个月后仍然可被检索到。
|
||||
func (a *Agent) mediaSummaryForEvent(blocks []memory.MemoryBlock) string {
|
||||
if a.mediaStore == nil || len(blocks) == 0 {
|
||||
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 {
|
||||
// 不再包含任何生成的描述文本:图片只按自己的向量被检索,标签仅提供
|
||||
// MIME 与短 digest,让模型知道有这份媒体、可据 digest 取回字节。
|
||||
// 查不到返回空串:内容可能已被删除,不该造出一条指向虚无的标签。
|
||||
func mediaLabel(it *media.Item) string {
|
||||
if it == nil {
|
||||
return ""
|
||||
}
|
||||
label := string(it.Kind)
|
||||
if it.MIME != "" {
|
||||
label = it.MIME
|
||||
}
|
||||
desc := it.Description
|
||||
if desc == "" {
|
||||
// 「已入库但还没描述」与「压根没有媒体」必须可区分:描述由后台循环
|
||||
// 异步补齐,占位符保证补齐前这份媒体也不会从文本里消失。
|
||||
desc = "(未描述)"
|
||||
}
|
||||
return fmt.Sprintf("[%s %s] %s", label, shortDigest(digest), desc)
|
||||
return fmt.Sprintf("[%s %s]", label, shortDigest(it.Digest))
|
||||
}
|
||||
|
||||
// newEventID 生成 ContextEvent 的稳定标识。
|
||||
|
||||
@ -106,8 +106,8 @@ func TestCaptureBlockMedia_NilStoreIsNoop(t *testing.T) {
|
||||
if len(evt.Blocks) != 0 || evt.ID != "" {
|
||||
t.Fatalf("nil store 时不该改动事件: %+v", evt)
|
||||
}
|
||||
if s := a.mediaSummaryForEvent(nil); s != "" {
|
||||
t.Fatalf("nil store 时摘要应为空,得到 %q", s)
|
||||
if s := mediaLabel(nil); s != "" {
|
||||
t.Fatalf("nil 媒体应产出空标签,得到 %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
@ -183,31 +183,27 @@ func TestBindEventMedia_LazyIDOnlyWhenNeeded(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaSummary_DescriptionIsThePersistentMemory(t *testing.T) {
|
||||
// 方案 C 的核心:描述文本才是持久语义记忆,blob 只是缓存。
|
||||
// blob 被容量 GC 淘汰后,描述仍留在 L0/L2/L3 的文本里可被检索。
|
||||
func TestMediaLabel_NoGeneratedDescription(t *testing.T) {
|
||||
// 标签只用来告诉模型「这条记忆带着哪份媒体、可用该 digest 取回字节」。
|
||||
// 它不包含任何生成的描述:描述式索引是把就机制,已彻底废弃。
|
||||
a, ms := newTestAgentWithMedia(t)
|
||||
|
||||
d, _ := ms.Put([]byte("img"), media.Item{MIME: "image/png"})
|
||||
b, ok := a.blockFromDigest(d)
|
||||
if !ok {
|
||||
t.Fatal("blockFromDigest 失败")
|
||||
it, err := ms.Stat(d)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s := a.mediaSummaryForEvent([]memory.MemoryBlock{b}); s == "" {
|
||||
t.Fatal("未描述项也应产出一行(标注未描述)")
|
||||
}
|
||||
|
||||
ms.Describe(d, "一张紫蓝红三色带图", "visionllm")
|
||||
s := a.mediaSummaryForEvent([]memory.MemoryBlock{b})
|
||||
s := mediaLabel(it)
|
||||
if s == "" {
|
||||
t.Fatal("应产出摘要")
|
||||
}
|
||||
if !strings.Contains(s, "紫蓝红三色带图") {
|
||||
t.Fatalf("摘要应含描述文本: %q", s)
|
||||
t.Fatal("应产出标签")
|
||||
}
|
||||
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) {
|
||||
|
||||
@ -190,11 +190,16 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
|
||||
Object: getString(m, "object"),
|
||||
SentenceText: getString(m, "sentence_text"),
|
||||
}
|
||||
// 模型显式关联的媒体:标记由内核补进句子文本,模型不必知道格式。
|
||||
// 没有 sentence_text 时 sentenceWithMediaMarkers 会用标记本身
|
||||
// 充当句子——媒体必须有句子落点,否则块边无法建立。
|
||||
// 模型显式关联的媒体:结构化字段随三元组一起提交,
|
||||
// 由 commitTriplesWithMedia 变成 L3 一等块并与句子建边——
|
||||
// 不再把 marker 写进句子文本。
|
||||
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 != "" {
|
||||
triples = append(triples, t)
|
||||
@ -531,10 +536,10 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string {
|
||||
if len(content) > 2000 {
|
||||
content = content[:2000] + "..."
|
||||
}
|
||||
// 媒体说明单独一行进冷存事件:正文可能被上面的 2000 字截断,
|
||||
// 而媒体标记往往在文档末尾——截掉之后模型就不知道这篇文档带过图。
|
||||
if mc := a.docMediaContext(d.ID, d.Content); mc != "" {
|
||||
content = content + "\n关联媒体: " + mc
|
||||
// 媒体块标签单独一行进冷存事件:正文可能被上面的 2000 字截断,
|
||||
// 截掉之后模型就不知道这篇文档带过图。
|
||||
if labels := a.blockLabelsForDoc(d); labels != "" {
|
||||
content = content + "\n关联媒体: " + labels
|
||||
}
|
||||
a.context.InsertByTimestamp(ContextEvent{
|
||||
Timestamp: d.CreatedAt,
|
||||
@ -572,11 +577,10 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string {
|
||||
Source: "manual",
|
||||
}
|
||||
|
||||
// 模型显式关联的媒体:标记补进正文后再写入。顺序关键——向量索引用
|
||||
// Summary+Content 计算,标记进不去正文就检索不到这份媒体。
|
||||
mediaDigests := a.resolveMediaDigests(getStringSlice(tc.Arguments, "media_digests"))
|
||||
doc.Content = a.sentenceWithMediaMarkers(doc.Content, mediaDigests)
|
||||
for _, d := range mediaDigests {
|
||||
// 模型显式关联的媒体:直接变成文档持有的一等块。
|
||||
// 不再往正文写 marker——文档向量会融合这些块的媒体向量,
|
||||
// 图片按自己的向量被检索。
|
||||
for _, d := range a.resolveMediaDigests(getStringSlice(tc.Arguments, "media_digests")) {
|
||||
if b, ok := a.blockFromDigest(d); ok {
|
||||
doc.Blocks = append(doc.Blocks, b)
|
||||
}
|
||||
|
||||
@ -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.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.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.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"})
|
||||
|
||||
@ -7,11 +7,11 @@ import (
|
||||
)
|
||||
|
||||
type bilingualEvent struct {
|
||||
idx int
|
||||
source string
|
||||
topic string
|
||||
text string // cleaned text for vectorization
|
||||
label string // short description
|
||||
idx int
|
||||
source string
|
||||
topic string
|
||||
text string // cleaned text for vectorization
|
||||
label string // short description
|
||||
}
|
||||
|
||||
func TestBilingualPruningAccuracy(t *testing.T) {
|
||||
@ -40,16 +40,16 @@ func TestBilingualPruningAccuracy(t *testing.T) {
|
||||
t.Logf("%s: %d words", cfg.name, len(e.words))
|
||||
|
||||
type scored struct {
|
||||
idx int
|
||||
topic string
|
||||
label string
|
||||
score float64
|
||||
idx int
|
||||
topic string
|
||||
label string
|
||||
score float64
|
||||
}
|
||||
|
||||
queries := []struct {
|
||||
q string
|
||||
qTopic string
|
||||
desc string
|
||||
q string
|
||||
qTopic string
|
||||
desc string
|
||||
}{
|
||||
{"老大说了关于 React 组件的事情", "老大私聊", "中英混合:老大+React"},
|
||||
{"帮我查一下 Nginx 反向代理配置", "服务器运维", "中英混合:Nginx+反向代理"},
|
||||
@ -189,10 +189,10 @@ func TestBilingualVectorizeClean(t *testing.T) {
|
||||
|
||||
func genBilingualEvents() []bilingualEvent {
|
||||
entries := []struct {
|
||||
topic string
|
||||
zh string // Chinese description
|
||||
en string // English terms mixed in
|
||||
source string
|
||||
topic string
|
||||
zh string // Chinese description
|
||||
en string // English terms mixed in
|
||||
source string
|
||||
}{
|
||||
{"大学招生", "河南医药大学录取分数线", "", "qq"},
|
||||
{"大学招生", "医学院专业排名", "medical university ranking", "agent"},
|
||||
|
||||
@ -40,7 +40,7 @@ type MemoryBlock struct {
|
||||
}
|
||||
|
||||
// MemoryBlockEdge 是 L3 中连接一等记忆节点的结构化语义边。
|
||||
// source/target kind 当前允许 block、entity、sentence。
|
||||
// source/target kind 当前允许 block、entity、sentence、document。
|
||||
type MemoryBlockEdge struct {
|
||||
ID int64 `json:"id"`
|
||||
SourceKind string `json:"source_kind"`
|
||||
@ -111,6 +111,19 @@ func (g *GraphDB) PutMemoryBlocks(blocks []MemoryBlock) error {
|
||||
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 层实际持有的一等记忆节点。
|
||||
func (g *GraphDB) MemoryBlocks() ([]MemoryBlock, error) {
|
||||
g.mu.RLock()
|
||||
@ -144,7 +157,7 @@ func (g *GraphDB) MemoryBlocks() ([]MemoryBlock, error) {
|
||||
}
|
||||
|
||||
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) {
|
||||
@ -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)
|
||||
case "sentence":
|
||||
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:
|
||||
return false, fmt.Errorf("invalid graph node kind %q", kind)
|
||||
}
|
||||
|
||||
@ -28,13 +28,13 @@ func cleanQQTemplate(text string) string {
|
||||
}
|
||||
|
||||
type cleanTestEvent struct {
|
||||
idx int
|
||||
source string
|
||||
input string
|
||||
response string
|
||||
rawText string
|
||||
idx int
|
||||
source string
|
||||
input string
|
||||
response string
|
||||
rawText string
|
||||
cleanedText string
|
||||
topic string
|
||||
topic string
|
||||
}
|
||||
|
||||
func TestCleanStressPrecision(t *testing.T) {
|
||||
@ -59,53 +59,53 @@ func TestCleanStressPrecision(t *testing.T) {
|
||||
}
|
||||
t.Logf("topics: %v, events: %d", usedTopics, len(events))
|
||||
|
||||
for _, qTopic := range usedTopics {
|
||||
query := queryForTopic(qTopic)
|
||||
qVec := e.Vectorize(query)
|
||||
for _, qTopic := range usedTopics {
|
||||
query := queryForTopic(qTopic)
|
||||
qVec := e.Vectorize(query)
|
||||
|
||||
type scored struct {
|
||||
idx int
|
||||
topic string
|
||||
text string
|
||||
score float64
|
||||
}
|
||||
all := make([]scored, len(events))
|
||||
for i, ev := range events {
|
||||
text := ev.rawText
|
||||
if cleanMode {
|
||||
text = ev.cleanedText
|
||||
type scored struct {
|
||||
idx int
|
||||
topic string
|
||||
text string
|
||||
score float64
|
||||
}
|
||||
all := make([]scored, len(events))
|
||||
for i, ev := range events {
|
||||
text := ev.rawText
|
||||
if cleanMode {
|
||||
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
|
||||
sources []string
|
||||
}{
|
||||
"大学招生": {[]string{"河南医药大学", "录取分数线", "专业排名", "高考志愿", "招生简章"}, []string{"qq", "qq", "agent"}},
|
||||
"老大私聊": {[]string{"老大私聊消息", "回复老大", "任务安排", "汇报工作", "收到"}, []string{"qq", "agent", "agent"}},
|
||||
"前端开发": {[]string{"前端组件封装", "页面路由配置", "界面布局设计", "交互逻辑开发", "代码调试优化"}, []string{"cli", "cli", "agent"}},
|
||||
"大学招生": {[]string{"河南医药大学", "录取分数线", "专业排名", "高考志愿", "招生简章"}, []string{"qq", "qq", "agent"}},
|
||||
"老大私聊": {[]string{"老大私聊消息", "回复老大", "任务安排", "汇报工作", "收到"}, []string{"qq", "agent", "agent"}},
|
||||
"前端开发": {[]string{"前端组件封装", "页面路由配置", "界面布局设计", "交互逻辑开发", "代码调试优化"}, []string{"cli", "cli", "agent"}},
|
||||
"服务器运维": {[]string{"反向代理配置", "容器部署方案", "证书续期", "数据库备份恢复", "监控告警处理"}, []string{"cli", "agent", "agent"}},
|
||||
"股票基金": {[]string{"基金定投策略", "股票涨跌分析", "理财收益计算", "市场行情分析", "投资风险管理"}, []string{"qq", "qq", "agent"}},
|
||||
"股票基金": {[]string{"基金定投策略", "股票涨跌分析", "理财收益计算", "市场行情分析", "投资风险管理"}, []string{"qq", "qq", "agent"}},
|
||||
}
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
@ -292,13 +292,13 @@ func genStressEvents(n int) []cleanTestEvent {
|
||||
cleaned := cleanEventText(src, input, response)
|
||||
raw := rawEventText(src, input, response)
|
||||
events = append(events, cleanTestEvent{
|
||||
idx: i,
|
||||
source: src,
|
||||
input: input,
|
||||
response: response,
|
||||
rawText: raw,
|
||||
idx: i,
|
||||
source: src,
|
||||
input: input,
|
||||
response: response,
|
||||
rawText: raw,
|
||||
cleanedText: cleaned,
|
||||
topic: tp,
|
||||
topic: tp,
|
||||
})
|
||||
}
|
||||
return events
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -13,26 +13,26 @@ import (
|
||||
|
||||
// contentPOS 有实义的词性标签:只保留名词/动词/形容词/专名等
|
||||
var contentPOS = map[string]bool{
|
||||
"n": true, // 普通名词
|
||||
"nr": true, // 人名
|
||||
"ns": true, // 地名
|
||||
"nt": true, // 机构名
|
||||
"nw": true, // 作品名/URL
|
||||
"nz": true, // 其他专名
|
||||
"v": true, // 动词
|
||||
"vd": true, // 副动词
|
||||
"vn": true, // 名动词
|
||||
"a": true, // 形容词
|
||||
"ad": true, // 副形词
|
||||
"an": true, // 名形词
|
||||
"i": true, // 成语
|
||||
"l": true, // 习用语
|
||||
"j": true, // 简称
|
||||
"s": true, // 处所词
|
||||
"f": true, // 方位词
|
||||
"b": true, // 区别词
|
||||
"z": true, // 状态词
|
||||
"t": true, // 时间词
|
||||
"n": true, // 普通名词
|
||||
"nr": true, // 人名
|
||||
"ns": true, // 地名
|
||||
"nt": true, // 机构名
|
||||
"nw": true, // 作品名/URL
|
||||
"nz": true, // 其他专名
|
||||
"v": true, // 动词
|
||||
"vd": true, // 副动词
|
||||
"vn": true, // 名动词
|
||||
"a": true, // 形容词
|
||||
"ad": true, // 副形词
|
||||
"an": true, // 名形词
|
||||
"i": true, // 成语
|
||||
"l": true, // 习用语
|
||||
"j": true, // 简称
|
||||
"s": true, // 处所词
|
||||
"f": true, // 方位词
|
||||
"b": true, // 区别词
|
||||
"z": true, // 状态词
|
||||
"t": true, // 时间词
|
||||
"eng": true, // 英文
|
||||
"x": true, // 非语素字
|
||||
"zg": true, // 其他
|
||||
|
||||
@ -37,6 +37,7 @@ type Doc struct {
|
||||
Blocks []memory.MemoryBlock `json:"blocks,omitempty"` // 一等记忆块(text/image/video/audio)
|
||||
Vector tfidf.Vector `json:"vector,omitempty"` // TF-IDF 稀疏向量(fallback 时持久化)
|
||||
DenseVec []float64 `json:"dense_vec,omitempty"` // 多模态稠密向量(主路径)
|
||||
DenseFP string `json:"dense_fp,omitempty"` // DenseVec 所属统一空间指纹,变化时触发重算
|
||||
}
|
||||
|
||||
// Store — 文档记忆存储。
|
||||
@ -113,7 +114,7 @@ func (s *Store) SetDenseSpace(ds vector.MultimodalEmbedder) {
|
||||
s.denseSpace = ds
|
||||
}
|
||||
|
||||
// BuildDenseIndex 为所有文档计算稠密向量。
|
||||
// BuildDenseIndex 为所有文档计算稠密向量(文本 ⊕ 媒体块)。
|
||||
func (s *Store) BuildDenseIndex(ds vector.MultimodalEmbedder) {
|
||||
if ds == nil || !ds.Loaded() {
|
||||
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())
|
||||
count := 0
|
||||
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
|
||||
}
|
||||
text := doc.Summary + " " + doc.Content
|
||||
vec, err := ds.VectorizeDense(text)
|
||||
if err != nil {
|
||||
log.Printf("[document memory] dense embed failed %s: %v", doc.ID[:min(16, len(doc.ID))], err)
|
||||
vec := s.denseFor(doc)
|
||||
if vec == nil {
|
||||
continue
|
||||
}
|
||||
doc.DenseVec = vec
|
||||
doc.DenseFP = ds.Fingerprint()
|
||||
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 路径变更时调用)。
|
||||
func (s *Store) Reindex() {
|
||||
s.mu.Lock()
|
||||
@ -172,11 +194,10 @@ func (s *Store) Insert(doc *Doc) error {
|
||||
|
||||
text := doc.Summary + " " + doc.Content
|
||||
|
||||
// 主路径:稠密向量
|
||||
// 主路径:稠密向量(文本 ⊕ 媒体块)
|
||||
if s.denseSpace != nil && s.denseSpace.Loaded() && len(doc.DenseVec) == 0 {
|
||||
if dv, err := s.denseSpace.VectorizeDense(text); err == nil {
|
||||
doc.DenseVec = dv
|
||||
}
|
||||
doc.DenseVec = s.denseFor(doc)
|
||||
doc.DenseFP = s.denseSpace.Fingerprint()
|
||||
}
|
||||
|
||||
// Fallback 路径:缓存文本,延迟训练
|
||||
@ -232,6 +253,10 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry, _ interface{
|
||||
d.Tags = tags
|
||||
d.Entities = entities
|
||||
d.Blocks = blocksFromEntries(entries)
|
||||
d.DenseVec = s.denseFor(d)
|
||||
if s.denseSpace != nil {
|
||||
d.DenseFP = s.denseSpace.Fingerprint()
|
||||
}
|
||||
s.dirty = true
|
||||
return d, nil
|
||||
}
|
||||
@ -249,6 +274,10 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry, _ interface{
|
||||
Blocks: blocksFromEntries(entries),
|
||||
}
|
||||
s.docs[id] = doc
|
||||
doc.DenseVec = s.denseFor(doc)
|
||||
if s.denseSpace != nil {
|
||||
doc.DenseFP = s.denseSpace.Fingerprint()
|
||||
}
|
||||
text := summary + " " + content
|
||||
if s.tfidfIdx != nil {
|
||||
s.tfidfIdx.Add(id, text)
|
||||
|
||||
@ -15,7 +15,7 @@ func TestInsertAndQuery(t *testing.T) {
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
s := NewStore(dir)
|
||||
s := NewStore(dir, memory.TokenizeWords)
|
||||
if err := s.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@ -48,7 +48,7 @@ func TestQuery(t *testing.T) {
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
s := NewStore(dir)
|
||||
s := NewStore(dir, memory.TokenizeWords)
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
|
||||
@ -73,7 +73,7 @@ func TestContextToDoc(t *testing.T) {
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
s := NewStore(dir)
|
||||
s := NewStore(dir, memory.TokenizeWords)
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
|
||||
@ -104,7 +104,7 @@ func TestFindColdDocs(t *testing.T) {
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
s := NewStore(dir)
|
||||
s := NewStore(dir, memory.TokenizeWords)
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
|
||||
@ -136,7 +136,7 @@ func TestRecentDocs(t *testing.T) {
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
s := NewStore(dir)
|
||||
s := NewStore(dir, memory.TokenizeWords)
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
|
||||
@ -160,7 +160,7 @@ func TestReindex(t *testing.T) {
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
s := NewStore(dir)
|
||||
s := NewStore(dir, memory.TokenizeWords)
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
|
||||
@ -211,7 +211,7 @@ func TestInsertEmptyDoc(t *testing.T) {
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
s := NewStore(dir)
|
||||
s := NewStore(dir, memory.TokenizeWords)
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
|
||||
@ -232,13 +232,13 @@ func TestPersistence(t *testing.T) {
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
// 写
|
||||
s1 := NewStore(dir)
|
||||
s1 := NewStore(dir, memory.TokenizeWords)
|
||||
s1.Start()
|
||||
s1.Insert(&Doc{Summary: "持久化测试", Content: "应该被保存到磁盘", Source: "manual"})
|
||||
s1.Stop()
|
||||
|
||||
// 读
|
||||
s2 := NewStore(dir)
|
||||
s2 := NewStore(dir, memory.TokenizeWords)
|
||||
s2.Start()
|
||||
defer s2.Stop()
|
||||
|
||||
@ -269,7 +269,7 @@ func TestFlushNoDirty(t *testing.T) {
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
s := NewStore(dir)
|
||||
s := NewStore(dir, memory.TokenizeWords)
|
||||
s.Start()
|
||||
|
||||
// 不插任何文档,flush 不应报错
|
||||
@ -283,7 +283,7 @@ func TestRemove(t *testing.T) {
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
s := NewStore(dir)
|
||||
s := NewStore(dir, memory.TokenizeWords)
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
|
||||
@ -329,7 +329,7 @@ func TestRemoveNonexistent(t *testing.T) {
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
s := NewStore(dir)
|
||||
s := NewStore(dir, memory.TokenizeWords)
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
|
||||
@ -437,7 +437,7 @@ func TestContextToDocContentPreservesRawToolOutput(t *testing.T) {
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
s := NewStore(dir)
|
||||
s := NewStore(dir, memory.TokenizeWords)
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
|
||||
|
||||
@ -45,6 +45,10 @@ type Triple struct {
|
||||
SubjectType string `json:"subject_type,omitempty"`
|
||||
ObjectType string `json:"object_type,omitempty"`
|
||||
SentenceText string `json:"sentence_text,omitempty"` // 原始句子文本,Commit时写入sentences表
|
||||
// MediaDigests 是该三元组显式携带的媒体 digest(完整或前缀)。
|
||||
// 媒体不再靠正文 marker 反解:结构化字段直接给出归属,
|
||||
// 由调用方(core)把它变成 L3 一等块并与句子建立结构边。
|
||||
MediaDigests []string `json:"media_digests,omitempty"`
|
||||
}
|
||||
|
||||
type GraphDB struct {
|
||||
@ -134,6 +138,11 @@ func (g *GraphDB) initSchema() error {
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
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_digest ON memory_blocks(payload_digest)`,
|
||||
`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
|
||||
}
|
||||
|
||||
// CleanupOrphanedSentences 删除没有任何关系引用的句子,返回删除数
|
||||
// CleanupOrphanedSentences 删除既无关系引用、也无媒体块边的句子,返回删除数。
|
||||
//
|
||||
// 两个条件都必须看:旧媒体实体被迁移成原生块后,那些句子可能只靠
|
||||
// sentence --contains--> block 存活,若只看 relations 引用就会被误删,
|
||||
// 连带把块边变成悬空引用。
|
||||
func (g *GraphDB) CleanupOrphanedSentences() (int, error) {
|
||||
result, err := g.db.Exec(
|
||||
`DELETE FROM sentences WHERE id NOT IN (
|
||||
SELECT DISTINCT sentence_id FROM relations WHERE sentence_id != 0
|
||||
)`,
|
||||
)
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
tx, err := g.db.Begin()
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@ -10,19 +10,19 @@ import (
|
||||
)
|
||||
|
||||
type Indexer struct {
|
||||
db *GraphDB
|
||||
vec *vector.Store
|
||||
veczer *vector.TFIDFVectorizer
|
||||
mu sync.RWMutex
|
||||
trained bool
|
||||
recalled map[string]bool // 已通过工具调用显式召回的实体名,自动注入时跳过
|
||||
db *GraphDB
|
||||
vec *vector.Store
|
||||
veczer *vector.TFIDFVectorizer
|
||||
mu sync.RWMutex
|
||||
trained bool
|
||||
recalled map[string]bool // 已通过工具调用显式召回的实体名,自动注入时跳过
|
||||
}
|
||||
|
||||
func NewIndexer(db *GraphDB) *Indexer {
|
||||
return &Indexer{
|
||||
db: db,
|
||||
vec: vector.NewStore(),
|
||||
veczer: vector.NewTFIDFVectorizer(TokenizeWords),
|
||||
db: db,
|
||||
vec: vector.NewStore(),
|
||||
veczer: vector.NewTFIDFVectorizer(TokenizeWords),
|
||||
recalled: make(map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
@ -69,11 +69,6 @@ type Item struct {
|
||||
OriginPath string `json:"origin_path,omitempty"`
|
||||
// Tool 是注入这条媒体的工具名(如 multimodal_see_picture)。
|
||||
Tool string `json:"tool,omitempty"`
|
||||
// Description 是视觉/音频模型生成的文字描述,供 L2/L3 检索。
|
||||
// 空表示未描述(未开启描述、模型不可用或描述失败)。
|
||||
Description string `json:"description,omitempty"`
|
||||
// DescribedBy 记录描述来自哪个源,让后续读者能判断可靠性。
|
||||
DescribedBy string `json:"described_by,omitempty"`
|
||||
// FirstSeen/LastSeen 是首末次入库时间。
|
||||
FirstSeen time.Time `json:"first_seen"`
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
@ -127,8 +122,6 @@ func (s *Store) initSchema() error {
|
||||
height INTEGER DEFAULT 0,
|
||||
origin_path TEXT,
|
||||
tool TEXT,
|
||||
description TEXT,
|
||||
described_by TEXT,
|
||||
first_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(`
|
||||
INSERT INTO media (digest, kind, mime, size, width, height,
|
||||
origin_path, tool, description, described_by,
|
||||
first_seen, last_seen)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
origin_path, tool, first_seen, last_seen)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(digest) DO UPDATE SET
|
||||
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,
|
||||
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
|
||||
`, 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 {
|
||||
return "", fmt.Errorf("media: upsert meta: %w", err)
|
||||
}
|
||||
@ -242,115 +230,12 @@ func (s *Store) Stat(digest string) (*Item, error) {
|
||||
defer s.mu.RUnlock()
|
||||
return s.scanOne(s.db.QueryRow(`
|
||||
SELECT digest, kind, mime, size, width, height, origin_path, tool,
|
||||
description, described_by, first_seen, last_seen,
|
||||
first_seen, last_seen,
|
||||
vec, vec_model
|
||||
FROM media WHERE digest = ?`, digest))
|
||||
}
|
||||
|
||||
// Describe 写入(或覆盖)文字描述。
|
||||
//
|
||||
// 与 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)。
|
||||
// Stat 返回元数据,不读内容。
|
||||
//
|
||||
// 这不是 GC,也不看引用计数:调用方是记忆系统本身——当它把一个记忆块
|
||||
// 永久地从三层记忆中删掉(而非在层间迁移)时,媒体作为块的内容一并删除。
|
||||
@ -376,13 +261,11 @@ func (s *Store) Stats() map[string]interface{} {
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
out := map[string]interface{}{"blob_dir": s.blobDir}
|
||||
var count, described int
|
||||
var count int
|
||||
var total int64
|
||||
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["total_bytes"] = total
|
||||
out["described"] = described
|
||||
|
||||
byKind := map[string]int{}
|
||||
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()
|
||||
|
||||
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
|
||||
FROM media WHERE vec IS NOT NULL AND vec != ''`
|
||||
var args []interface{}
|
||||
@ -546,17 +429,15 @@ func (s *Store) queryMediaScored(queryVec []float64, model string, topK int) ([]
|
||||
for rows.Next() {
|
||||
var it Item
|
||||
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,
|
||||
&origin, &tool, &desc, &by, &it.FirstSeen, &it.LastSeen,
|
||||
&origin, &tool, &it.FirstSeen, &it.LastSeen,
|
||||
&vecJSON, &vecModel); err != nil {
|
||||
continue
|
||||
}
|
||||
it.Kind = Kind(kind)
|
||||
it.OriginPath = origin.String
|
||||
it.Tool = tool.String
|
||||
it.Description = desc.String
|
||||
it.DescribedBy = by.String
|
||||
if !vecJSON.Valid || vecJSON.String == "" {
|
||||
continue
|
||||
}
|
||||
@ -623,17 +504,15 @@ func (s *Store) scanRows(r rowScanner) (*Item, error) { return scanItem(r) }
|
||||
func scanItem(r rowScanner) (*Item, error) {
|
||||
var it Item
|
||||
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,
|
||||
&origin, &tool, &desc, &by, &it.FirstSeen, &it.LastSeen,
|
||||
&origin, &tool, &it.FirstSeen, &it.LastSeen,
|
||||
&vecJSON, &vecModel); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
it.Kind = Kind(kind)
|
||||
it.OriginPath = origin.String
|
||||
it.Tool = tool.String
|
||||
it.Description = desc.String
|
||||
it.DescribedBy = by.String
|
||||
if vecJSON.Valid && vecJSON.String != "" {
|
||||
var v []float64
|
||||
if err := json.Unmarshal([]byte(vecJSON.String), &v); err == nil {
|
||||
|
||||
@ -142,50 +142,13 @@ func TestDelete_UnknownDigestIsNoop(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribe_OverwritesExplicitly(t *testing.T) {
|
||||
s := newTestStore(t, 0)
|
||||
d, _ := s.Put([]byte("img"), Item{MIME: "image/png"})
|
||||
|
||||
if err := s.Describe(d, "一只橘猫", "vis-a"); err != nil {
|
||||
func TestDescribe_Removed(t *testing.T) {
|
||||
// 媒体不再有文字描述:描述式索引是废弃的就机制。
|
||||
// 这里只保留一个编译期断言,确保 API 不会静默回归。
|
||||
s := newTestStore(t)
|
||||
if _, err := s.Put([]byte("img"), Item{MIME: "image/png"}); err != nil {
|
||||
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) {
|
||||
@ -206,47 +169,22 @@ func TestPut_BackfillsMissingDimensions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearch_FiltersByDescriptionAndKind(t *testing.T) {
|
||||
s := newTestStore(t, 0)
|
||||
di, _ := s.Put([]byte("chart-img"), Item{MIME: "image/png"})
|
||||
da, _ := s.Put([]byte("speech-aud"), Item{MIME: "audio/wav"})
|
||||
dn, _ := s.Put([]byte("no-desc"), Item{MIME: "image/png"})
|
||||
s.Describe(di, "一张蓝色的柱状图表", "vis")
|
||||
s.Describe(da, "一段关于图表的讲解录音", "aud")
|
||||
func TestStats_CountsByKind(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
s.Put([]byte("i1"), Item{MIME: "image/png"})
|
||||
s.Put([]byte("i2"), Item{MIME: "image/jpeg"})
|
||||
s.Put([]byte("a1"), Item{MIME: "audio/wav"})
|
||||
|
||||
all, err := s.Search("图表", "", 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
st := s.Stats()
|
||||
if st["count"].(int) != 3 {
|
||||
t.Fatalf("count 应为 3,实际 %v", st["count"])
|
||||
}
|
||||
if len(all) != 2 {
|
||||
t.Fatalf("两条描述都含「图表」,应返回 2,实际 %d", len(all))
|
||||
if _, ok := st["described"]; ok {
|
||||
t.Fatal("媒体已不再有描述计数")
|
||||
}
|
||||
|
||||
imgs, _ := s.Search("图表", KindImage, 10)
|
||||
if len(imgs) != 1 || imgs[0].Digest != di {
|
||||
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))
|
||||
byKind := st["by_kind"].(map[string]int)
|
||||
if byKind["image"] != 2 || byKind["audio"] != 1 {
|
||||
t.Fatalf("by_kind 不对: %v", byKind)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
s := newTestStore(t, 0)
|
||||
s := newTestStore(t)
|
||||
if _, err := s.Put(nil, Item{MIME: "image/png"}); err == nil {
|
||||
t.Fatal("空内容应报错")
|
||||
}
|
||||
@ -313,7 +231,7 @@ func TestReopen_PersistsAcrossRestart(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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()
|
||||
|
||||
s2, err := New(dir)
|
||||
@ -326,8 +244,8 @@ func TestReopen_PersistsAcrossRestart(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("重开后应能查到: %v", err)
|
||||
}
|
||||
if it.Description != "跨重启的描述" {
|
||||
t.Fatalf("元数据应持久化: %+v", it)
|
||||
if it.OriginPath != "/tmp/x.png" || len(it.Vec) != 2 || it.VecModel != "test-space" {
|
||||
t.Fatalf("元数据与向量应持久化: %+v", it)
|
||||
}
|
||||
data, err := s2.Get(d)
|
||||
if err != nil || string(data) != "persistent-img" {
|
||||
@ -335,38 +253,26 @@ func TestReopen_PersistsAcrossRestart(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPending_ExcludesAttemptedButUndescribable(t *testing.T) {
|
||||
// 「已尝试但无法描述」的项必须退出待描述队列。
|
||||
//
|
||||
// 这些项被标记为 described_by=unsupported/content-missing 而 description
|
||||
// 仍为空。若 Pending 只看 description,它们每轮都会被取出来重试、
|
||||
// 永久占着 LIMIT 的名额,真正需要描述的新项永远轮不到。
|
||||
s := newTestStore(t, 0)
|
||||
func TestStaleVecDigests_TracksModelSwitch(t *testing.T) {
|
||||
// 模型切换后旧向量必须被重算:StaleVecDigests 是启动迁移的入口。
|
||||
s := newTestStore(t)
|
||||
d1, _ := s.Put([]byte("a"), Item{MIME: "image/png"})
|
||||
d2, _ := s.Put([]byte("b"), Item{MIME: "image/png"})
|
||||
s.SetVec(d1, []float64{0.1}, "space-a")
|
||||
|
||||
fresh, _ := s.Put([]byte("needs-describe"), Item{MIME: "image/png"})
|
||||
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)
|
||||
stale, err := s.StaleVecDigestsAll("space-a")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(pending) != 1 {
|
||||
var names []string
|
||||
for _, p := range pending {
|
||||
names = append(names, shortDigest(p.Digest))
|
||||
}
|
||||
t.Fatalf("应只剩 1 条待描述,实际 %d 条: %v", len(pending), names)
|
||||
if len(stale) != 1 || stale[0] != d2 {
|
||||
t.Fatalf("只有未嵌入的 d2 需重算,实际 %v", stale)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,9 +10,9 @@ func TestQueryMedia_BasicSimilarity(t *testing.T) {
|
||||
defer s.Close()
|
||||
|
||||
// 入库三张带向量的媒体:两张图、一段音频
|
||||
d1, _ := s.Put([]byte("img1"), Item{MIME: "image/png", Description: "紫蓝红三色带"})
|
||||
d2, _ := s.Put([]byte("img2"), Item{MIME: "image/jpeg", Description: "蓝紫红渐变"})
|
||||
d3, _ := s.Put([]byte("aud1"), Item{MIME: "audio/wav", Description: "一段语音"})
|
||||
d1, _ := s.Put([]byte("img1"), Item{MIME: "image/png"})
|
||||
d2, _ := s.Put([]byte("img2"), Item{MIME: "image/jpeg"})
|
||||
d3, _ := s.Put([]byte("aud1"), Item{MIME: "audio/wav"})
|
||||
|
||||
// 模拟视觉嵌入:img1 和 img2 向量接近,aud1 远离
|
||||
vec1 := []float64{0.9, 0.1, 0.0, 0.0}
|
||||
@ -138,24 +138,24 @@ func TestStaleVecDigests(t *testing.T) {
|
||||
s := newTestStore(t, 0)
|
||||
defer s.Close()
|
||||
|
||||
// 有描述且 vec_model 匹配 → 非 stale
|
||||
d1, _ := s.Put([]byte("img1"), Item{MIME: "image/png", Description: "图一"})
|
||||
s.SetVec(d1, []float64{0.1}, "clip-vit-b32")
|
||||
// 有向量且 vec_model 匹配 → 非 stale
|
||||
d1, _ := s.Put([]byte("img1"), Item{MIME: "image/png"})
|
||||
s.SetVec(d1, []float64{0.1}, "space-a")
|
||||
|
||||
// 有描述但 vec_model 旧 → stale
|
||||
d2, _ := s.Put([]byte("img2"), Item{MIME: "image/png", Description: "图二"})
|
||||
s.SetVec(d2, []float64{0.2}, "clip-vit-b14")
|
||||
// 有向量但 vec_model 旧 → stale
|
||||
d2, _ := s.Put([]byte("img2"), Item{MIME: "image/png"})
|
||||
s.SetVec(d2, []float64{0.2}, "space-old")
|
||||
|
||||
// 有描述但从未嵌入(vec_model 空)→ stale
|
||||
d3, _ := s.Put([]byte("img3"), Item{MIME: "image/png", Description: "图三"})
|
||||
// 从未嵌入(vec_model 空)→ stale
|
||||
d3, _ := s.Put([]byte("img3"), Item{MIME: "image/png"})
|
||||
|
||||
// 无描述但有图片 → 也应被迁移(描述是可选语义通道,图片应独立于描述参与向量空间)
|
||||
// 与向量/描述无关的图片同样应被迁移:图片独立参与向量空间
|
||||
d4, _ := s.Put([]byte("img4"), Item{MIME: "image/png"})
|
||||
|
||||
// 音频不参与图片迁移(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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@ -123,31 +123,44 @@ func TestSmoke_VideoFramesDistinct(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSmoke_DescribeThenRetrieve(t *testing.T) {
|
||||
// 场景 C:视觉模型描述落库后,描述文字成为可检索的语义入口。
|
||||
// 这是本方案最关键的一环——blob 可能被淘汰,描述会长期留在记忆里。
|
||||
s := newTestStore(t, 50*1024*1024)
|
||||
func TestSmoke_NearestNeighborVectorRetrieve(t *testing.T) {
|
||||
// 场景 C:图片只按自己的原生向量被检索。
|
||||
// 没有描述文本参与——描述式索引是废弃的就机制。
|
||||
s := newTestStore(t)
|
||||
|
||||
pic, _ := s.Put(makePNG(400, 400, 0), Item{MIME: "image/png", Tool: "multimodal_see_picture"})
|
||||
if err := s.Describe(pic, "一张 400x400 的三色带图:上红、中绿、下蓝", "visionllm"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.SetVec(pic, []float64{1, 0, 0, 0}, "space")
|
||||
var frames []string
|
||||
for i := 0; i < 6; i++ {
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.SetVec(d, []float64{1, 1, float64(i) / 10, 0}, "space")
|
||||
frames = append(frames, d)
|
||||
}
|
||||
|
||||
if hits, _ := s.Search("三色带", KindImage, 10); len(hits) != 1 {
|
||||
t.Fatalf("搜「三色带」应命中 1 条,实际 %d", len(hits))
|
||||
hits, err := s.QueryMediaScored([]float64{1, 0, 0, 0}, "space", 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hits, _ := s.Search("计数器", KindImage, 10); len(hits) != 6 {
|
||||
t.Fatalf("搜「计数器」应命中 6 帧,实际 %d", len(hits))
|
||||
if len(hits) != 7 {
|
||||
t.Fatalf("7 份媒体都有同空间向量,应全部可召,实际 %d", len(hits))
|
||||
}
|
||||
pend, _ := s.Pending(100)
|
||||
if len(pend) != 0 {
|
||||
t.Fatalf("应全部已描述,仍有 %d 条待描述", len(pend))
|
||||
if hits[0].Item.Digest != pic {
|
||||
t.Fatalf("与查询同向的应是第一命中,实际 %s", shortDigest(hits[0].Item.Digest))
|
||||
}
|
||||
|
||||
// 不同向量空间/模型的条目不得参与:坐标系不同,余弦无意义。
|
||||
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) {
|
||||
// 端到端:入库 → 描述 → 删除一些内容 → 重启 → 检索,
|
||||
// 端到端:入库 → 嵌入 → 删除一些内容 → 重启 → 向量检索,
|
||||
// 并确认磁盘与元数据不出现双向孤儿。记忆的意义就在于跨重启还在。
|
||||
dir := t.TempDir()
|
||||
s, err := New(dir)
|
||||
@ -214,10 +227,10 @@ func TestSmoke_FullLifecycleAcrossRestart(t *testing.T) {
|
||||
|
||||
png := makePNG(400, 400, 0)
|
||||
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++ {
|
||||
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++ {
|
||||
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 {
|
||||
t.Fatalf("重开后查不到: %v", err)
|
||||
}
|
||||
if it.Description == "" {
|
||||
t.Fatalf("元数据未持久化: %+v", it)
|
||||
if len(it.Vec) != 3 || it.VecModel != "space" {
|
||||
t.Fatalf("向量未持久化: %+v", it)
|
||||
}
|
||||
data, err := s2.Get(pic)
|
||||
if err != nil || !bytes.Equal(data, png) {
|
||||
t.Fatalf("重开后内容不一致: %v", err)
|
||||
}
|
||||
if hits, _ := s2.Search("三色带", KindImage, 10); len(hits) != 1 {
|
||||
t.Fatal("重开后描述应仍可检索")
|
||||
hits, err := s2.QueryMediaScored([]float64{1, 0, 0}, "space", 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(hits) == 0 || hits[0].Item.Digest != pic {
|
||||
t.Fatal("重开后向量检索应仍能命中")
|
||||
}
|
||||
|
||||
// 磁盘文件数 == 元数据条数:无「元数据在文件没了」也无「文件在元数据没了」
|
||||
if n := blobFileCount(t, s2); n != beforeCount {
|
||||
t.Fatalf("磁盘 blob=%d 与元数据=%d 不一致", n, beforeCount)
|
||||
}
|
||||
t.Logf("跨重启:%d 条目、描述与内容全部完好", beforeCount)
|
||||
t.Logf("跨重启:%d 条目、向量与内容全部完好", beforeCount)
|
||||
}
|
||||
|
||||
@ -39,7 +39,7 @@ func TestSoak_SustainedMixedLoad(t *testing.T) {
|
||||
|
||||
stop := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
var puts, gets, deletes, describes, searches atomic.Int64
|
||||
var puts, gets, deletes, embeds, searches atomic.Int64
|
||||
var fatal atomic.Int64
|
||||
|
||||
worker := func(name string, fn func(iter int) error) {
|
||||
@ -109,25 +109,26 @@ func TestSoak_SustainedMixedLoad(t *testing.T) {
|
||||
return nil
|
||||
})
|
||||
|
||||
// 描述者
|
||||
worker("describe", func(i int) error {
|
||||
pend, err := s.Pending(5)
|
||||
// 向量写入者:持续给新内容嵌入并删除(模拟启动迁移/短命媒体)
|
||||
worker("embed", func(i int) error {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
for _, it := range pend {
|
||||
// 忽略 unknown digest:GC 可能在 Pending 与 Describe 之间清掉它,
|
||||
// 这是正常竞态而非缺陷。
|
||||
_ = s.Describe(it.Digest, fmt.Sprintf("描述 %d 含图表与文字", i), "vis")
|
||||
describes.Add(1)
|
||||
if err := s.SetVec(d, []float64{1, float64(i % 7)}, "soak-space"); err != nil {
|
||||
return err
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
embeds.Add(1)
|
||||
time.Sleep(time.Millisecond)
|
||||
return nil
|
||||
})
|
||||
|
||||
// 检索者
|
||||
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
|
||||
}
|
||||
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.Logf("%v 内: put=%d get=%d delete=%d describe=%d search=%d",
|
||||
dur, puts.Load(), gets.Load(), deletes.Load(), describes.Load(), searches.Load())
|
||||
t.Logf("%v 内: put=%d get=%d delete=%d embed=%d search=%d",
|
||||
dur, puts.Load(), gets.Load(), deletes.Load(), embeds.Load(), searches.Load())
|
||||
|
||||
// 收尾断言
|
||||
for i, d := range keep {
|
||||
@ -161,6 +162,5 @@ func TestSoak_SustainedMixedLoad(t *testing.T) {
|
||||
}
|
||||
|
||||
st := s.Stats()
|
||||
t.Logf("收尾: 条目=%v 字节=%v 已描述=%v",
|
||||
st["count"], st["total_bytes"], st["described"])
|
||||
t.Logf("收尾: 条目=%v 字节=%v 类型=%v", st["count"], st["total_bytes"], st["by_kind"])
|
||||
}
|
||||
|
||||
@ -306,9 +306,9 @@ func TestStress_DeleteConcurrentWithReads(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStress_DescribeConcurrentWithSearch(t *testing.T) {
|
||||
// 描述写入与检索并发。C 部分的后台描述任务会长期这样跑。
|
||||
s := newTestStore(t, 0)
|
||||
func TestStress_SetVecConcurrentWithQuery(t *testing.T) {
|
||||
// 嵌入写入与向量检索并发(启动时的向量迁移就会长期这样跑)。
|
||||
s := newTestStore(t)
|
||||
const n = 60
|
||||
digests := make([]string, n)
|
||||
for i := range digests {
|
||||
@ -320,55 +320,52 @@ func TestStress_DescribeConcurrentWithSearch(t *testing.T) {
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var descErr, searchErr atomic.Int64
|
||||
var writeErr, queryErr atomic.Int64
|
||||
|
||||
// 描述写入者
|
||||
// 向量写入者
|
||||
for w := 0; w < 4; w++ {
|
||||
wg.Add(1)
|
||||
go func(wid int) {
|
||||
defer wg.Done()
|
||||
for i := wid; i < n; i += 4 {
|
||||
desc := fmt.Sprintf("第 %d 张图,含蓝色图表与文字", i)
|
||||
if err := s.Describe(digests[i], desc, "vis-src"); err != nil {
|
||||
descErr.Add(1)
|
||||
vec := []float64{1, float64(i) / 100, 0}
|
||||
if err := s.SetVec(digests[i], vec, "space"); err != nil {
|
||||
writeErr.Add(1)
|
||||
}
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
|
||||
// 检索者 + Pending 消费者
|
||||
// 检索者
|
||||
for r := 0; r < 3; r++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 50; i++ {
|
||||
if _, err := s.Search("图表", KindImage, 20); err != nil {
|
||||
searchErr.Add(1)
|
||||
}
|
||||
if _, err := s.Pending(10); err != nil {
|
||||
searchErr.Add(1)
|
||||
if _, err := s.QueryMediaScored([]float64{1, 0, 0}, "space", 20); err != nil {
|
||||
queryErr.Add(1)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if v := descErr.Load(); v > 0 {
|
||||
t.Fatalf("Describe 失败 %d 次", v)
|
||||
if v := writeErr.Load(); v > 0 {
|
||||
t.Fatalf("SetVec 失败 %d 次", v)
|
||||
}
|
||||
if v := searchErr.Load(); v > 0 {
|
||||
t.Fatalf("Search/Pending 失败 %d 次", v)
|
||||
if v := queryErr.Load(); v > 0 {
|
||||
t.Fatalf("QueryMediaScored 失败 %d 次", v)
|
||||
}
|
||||
|
||||
// 全部应已描述完
|
||||
pending, err := s.Pending(1000)
|
||||
// 全部应已嵌入,且都在同一空间
|
||||
stale, err := s.StaleVecDigestsAll("space")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(pending) != 0 {
|
||||
t.Fatalf("应全部描述完,仍有 %d 条未描述", len(pending))
|
||||
if len(stale) != 0 {
|
||||
t.Fatalf("应全部已嵌入,仍有 %d 条未嵌入", len(stale))
|
||||
}
|
||||
got, err := s.Search("图表", KindImage, 1000)
|
||||
got, err := s.QueryMediaScored([]float64{1, 0, 0}, "space", 1000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
193
internal/memory/migrate.go
Normal file
193
internal/memory/migrate.go
Normal 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
|
||||
}
|
||||
@ -1,14 +1,8 @@
|
||||
//go:build onnxruntime
|
||||
|
||||
// Package qwen 的 ONNX 推理实现(构建标签 onnxruntime,与 internal/memory/clip 同模式)。
|
||||
//
|
||||
// 加载契约:调用方传入模型目录,内核不硬编码模型名。
|
||||
//
|
||||
// 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 提供 Qwen3-VL-Embedding 的完整图文共享 ONNX 编码器。
|
||||
// 文本和图像共用 token embedding、28 层 Transformer、last-token 池化与
|
||||
// fingerprint;Vision.onnx 只产生注入 Transformer 的中间特征。
|
||||
package qwen
|
||||
|
||||
import (
|
||||
@ -26,34 +20,39 @@ import (
|
||||
ort "github.com/yalue/onnxruntime_go"
|
||||
)
|
||||
|
||||
// embedConfig 对应导出脚本产出的 embed_config.json。
|
||||
type embedConfig struct {
|
||||
Dimension int `json:"dim"`
|
||||
MaxLength int `json:"max_length"`
|
||||
Instruction string `json:"instruction"`
|
||||
Pooling string `json:"pooling"`
|
||||
Arch string `json:"arch"`
|
||||
Dimension int `json:"dim"`
|
||||
MaxLength int `json:"max_length"`
|
||||
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 {
|
||||
mu sync.RWMutex
|
||||
|
||||
loaded bool
|
||||
config embedConfig
|
||||
tok *Tokenizer
|
||||
sess *ort.DynamicAdvancedSession
|
||||
fp string
|
||||
loaded bool
|
||||
config embedConfig
|
||||
tok *Tokenizer
|
||||
token *ort.DynamicAdvancedSession
|
||||
transform *ort.DynamicAdvancedSession
|
||||
vision *ort.DynamicAdvancedSession
|
||||
fp string
|
||||
close sync.Once
|
||||
}
|
||||
|
||||
// New 从模型目录加载文本塔。
|
||||
func New(modelDir string) (*Embedder, error) {
|
||||
if modelDir == "" {
|
||||
return nil, fmt.Errorf("qwen model dir not specified")
|
||||
}
|
||||
|
||||
cfgRaw, err := os.ReadFile(filepath.Join(modelDir, "embed_config.json"))
|
||||
if err != nil {
|
||||
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 {
|
||||
return nil, fmt.Errorf("parse embed_config.json: %w", err)
|
||||
}
|
||||
if cfg.Dimension <= 0 {
|
||||
return nil, fmt.Errorf("embed_config.json 的 dim 无效: %d", cfg.Dimension)
|
||||
if cfg.Dimension != 2048 || cfg.MaxLength < 598 || cfg.Pooling != "last_token" {
|
||||
return nil, fmt.Errorf("qwen: incompatible config dim=%d max_length=%d pooling=%q", cfg.Dimension, cfg.MaxLength, cfg.Pooling)
|
||||
}
|
||||
if cfg.MaxLength <= 0 {
|
||||
cfg.MaxLength = 512
|
||||
if cfg.ImageSize != qwenImageSize || cfg.PatchSize != qwenPatchSize || cfg.TemporalPatch != qwenTemporalPatch || cfg.SpatialMerge != qwenSpatialMerge {
|
||||
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" {
|
||||
return nil, fmt.Errorf("不支持的池化方式 %q(导出脚本只产出 last_token)", cfg.Pooling)
|
||||
if cfg.RopeTheta <= 0 || len(cfg.MRopeSection) != 3 || cfg.MRopeSection[0]+cfg.MRopeSection[1]+cfg.MRopeSection[2] != qwenRotaryHalfDim {
|
||||
return nil, fmt.Errorf("qwen: incompatible rope theta=%g section=%v", cfg.RopeTheta, cfg.MRopeSection)
|
||||
}
|
||||
|
||||
tok, err := LoadTokenizer(modelDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tok.MaxLen = cfg.MaxLength
|
||||
|
||||
if !ort.IsInitialized() {
|
||||
if lib := findOnnxLib(); lib != "" {
|
||||
ort.SetSharedLibraryPath(lib)
|
||||
@ -87,74 +84,269 @@ func New(modelDir string) (*Embedder, error) {
|
||||
}
|
||||
}
|
||||
|
||||
sess, err := ort.NewDynamicAdvancedSession(
|
||||
filepath.Join(modelDir, "TextTower.onnx"),
|
||||
[]string{"input_ids", "attention_mask"},
|
||||
[]string{"embedding"},
|
||||
nil,
|
||||
token, err := ort.NewDynamicAdvancedSession(
|
||||
filepath.Join(modelDir, "TokenEmbedding.onnx"),
|
||||
[]string{"input_ids"}, []string{"hidden"}, 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{
|
||||
loaded: true,
|
||||
config: cfg,
|
||||
tok: tok,
|
||||
sess: sess,
|
||||
fp: computeFingerprint(modelDir),
|
||||
loaded: true, config: cfg, tok: tok,
|
||||
token: token, transform: transform, vision: vision,
|
||||
fp: computeFingerprint(modelDir),
|
||||
}, 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) {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
if !e.loaded {
|
||||
return nil, fmt.Errorf("qwen embedder not loaded")
|
||||
}
|
||||
|
||||
ids, err := e.tok.encodeModelInput(e.renderInput(text), e.config.MaxLength)
|
||||
ids, _, position, _, err := e.tok.textModelInput(e.config.Instruction, text, 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))
|
||||
}
|
||||
return e.runTransformer(hidden, deep, position, len(ids))
|
||||
}
|
||||
|
||||
seq := len(ids)
|
||||
inputIDs := make([]int64, seq)
|
||||
attn := make([]int64, seq)
|
||||
func (e *Embedder) EmbedImageDense(raw []byte, _ string) ([]float64, error) {
|
||||
e.mu.RLock()
|
||||
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 {
|
||||
inputIDs[i] = int64(id)
|
||||
attn[i] = 1
|
||||
}
|
||||
|
||||
idTensor, err := ort.NewTensor(ort.Shape{1, int64(seq)}, inputIDs)
|
||||
in, err := ort.NewTensor(ort.Shape{1, int64(len(ids))}, inputIDs)
|
||||
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 {
|
||||
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 {
|
||||
return nil, fmt.Errorf("output tensor: %w", err)
|
||||
return nil, fmt.Errorf("qwen hidden tensor: %w", err)
|
||||
}
|
||||
defer outTensor.Destroy()
|
||||
|
||||
if err := e.sess.Run([]ort.Value{idTensor, maskTensor}, []ort.Value{outTensor}); err != nil {
|
||||
return nil, fmt.Errorf("text tower run: %w", err)
|
||||
defer hiddenTensor.Destroy()
|
||||
inputs := []ort.Value{hiddenTensor}
|
||||
var deepTensors []*ort.Tensor[float32]
|
||||
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))
|
||||
var norm float64
|
||||
for i, v := range raw {
|
||||
@ -167,48 +359,39 @@ func (e *Embedder) VectorizeDense(text string) ([]float64, error) {
|
||||
out[i] /= norm
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// 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 路径")
|
||||
return out
|
||||
}
|
||||
|
||||
func (e *Embedder) Fingerprint() string { return e.fp }
|
||||
func (e *Embedder) Dim() int { return e.config.Dimension }
|
||||
|
||||
func (e *Embedder) Loaded() bool {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
return e.loaded
|
||||
}
|
||||
|
||||
func (e *Embedder) Close() {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if e.sess != nil {
|
||||
e.sess.Destroy()
|
||||
e.sess = nil
|
||||
}
|
||||
e.loaded = false
|
||||
e.close.Do(func() {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if e.token != nil {
|
||||
e.token.Destroy()
|
||||
e.token = nil
|
||||
}
|
||||
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 {
|
||||
h := sha256.New()
|
||||
|
||||
for _, name := range []string{"TextTower.onnx", "embed_config.json"} {
|
||||
for _, name := range []string{"TokenEmbedding.onnx", "Transformer.onnx", "Vision.onnx", "embed_config.json"} {
|
||||
if data, err := os.ReadFile(filepath.Join(modelDir, name)); err == nil {
|
||||
h.Write([]byte(name))
|
||||
h.Write([]byte{0})
|
||||
@ -216,35 +399,25 @@ func computeFingerprint(modelDir string) string {
|
||||
h.Write([]byte{0})
|
||||
}
|
||||
}
|
||||
|
||||
entries, _ := os.ReadDir(modelDir)
|
||||
var names []string
|
||||
for _, e := range entries {
|
||||
n := e.Name()
|
||||
// 外部权重分片:torch 新版导出器使用 onnx__<op>_<id> 与模型张量同名文件。
|
||||
if strings.HasPrefix(n, "onnx__") || strings.HasSuffix(n, ".weight") || strings.HasSuffix(n, ".onnx.data") {
|
||||
for _, entry := range entries {
|
||||
n := entry.Name()
|
||||
if strings.HasPrefix(n, "embed_tokens.") || strings.HasPrefix(n, "layers.") || strings.HasPrefix(n, "onnx__") || strings.HasSuffix(n, ".onnx.data") {
|
||||
names = append(names, n)
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, n := range names {
|
||||
info, err := os.Stat(filepath.Join(modelDir, n))
|
||||
if err != nil {
|
||||
continue
|
||||
if info, err := os.Stat(filepath.Join(modelDir, n)); err == nil {
|
||||
fmt.Fprintf(h, "%s:%d\n", n, info.Size())
|
||||
}
|
||||
fmt.Fprintf(h, "%s:%d\n", n, info.Size())
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// findOnnxLib 在常见路径中查找 libonnxruntime.so。
|
||||
func findOnnxLib() string {
|
||||
for _, p := range []string{
|
||||
"/opt/onnxruntime/libonnxruntime.so",
|
||||
"/opt/onnxruntime/lib/libonnxruntime.so",
|
||||
"/usr/local/lib/libonnxruntime.so",
|
||||
"/usr/lib/libonnxruntime.so",
|
||||
} {
|
||||
for _, p := range []string{"/opt/onnxruntime/libonnxruntime.so", "/usr/local/lib/libonnxruntime.so", "/usr/lib/libonnxruntime.so"} {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return p
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@ package qwen
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Embedder 在未启用 onnxruntime 时为 no-op 实现(与 internal/memory/clip 同模式)。
|
||||
// Embedder 在未启用 onnxruntime 时为 no-op 实现。
|
||||
// 默认构建不链接 onnxruntime,保持原有 fastText/TF-IDF 行为不变。
|
||||
type Embedder struct {
|
||||
loaded bool
|
||||
|
||||
157
internal/memory/qwen/image.go
Normal file
157
internal/memory/qwen/image.go
Normal 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)))))
|
||||
}
|
||||
86
internal/memory/qwen/model_input.go
Normal file
86
internal/memory/qwen/model_input.go
Normal 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
|
||||
}
|
||||
@ -14,8 +14,8 @@ import (
|
||||
"sync"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/yanyiwu/gojieba"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
|
||||
"github.com/yanyiwu/gojieba"
|
||||
)
|
||||
|
||||
const downloadMaxWords = 200000
|
||||
|
||||
@ -108,7 +108,7 @@ func TestStaticEmbedderSemanticSimilarity(t *testing.T) {
|
||||
e := newSynthEmbedder(t, 300)
|
||||
|
||||
pairs := []struct {
|
||||
a, b string
|
||||
a, b string
|
||||
related bool
|
||||
}{
|
||||
{"今天天气怎么样", "明天会不会下雨", true},
|
||||
|
||||
51
internal/memory/vector/fuse.go
Normal file
51
internal/memory/vector/fuse.go
Normal 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
|
||||
}
|
||||
@ -47,7 +47,7 @@ const (
|
||||
ModalityVideo MultimodalModality = "video"
|
||||
)
|
||||
|
||||
// ErrNotSupported 表示 Vectorizer 不支持图像嵌入,调用方按文本描述降级。
|
||||
// ErrNotSupported 表示 Vectorizer 不支持该原生模态;调用方不得以描述文本冒充其向量。
|
||||
var ErrNotSupported = fmt.Errorf("vectorizer does not support image embedding")
|
||||
|
||||
// Vector 是带权特征映射:feature → weight
|
||||
|
||||
@ -52,8 +52,8 @@ func (f *fakeCoreSDK) PluginName() string { return "fake" }
|
||||
func (f *fakeCoreSDK) Settings() pubsdk.SettingsAPI { return nil }
|
||||
func (f *fakeCoreSDK) Memory() pubsdk.MemoryAPI { return nil }
|
||||
func (f *fakeCoreSDK) TextMemory() pubsdk.TextMemoryAPI { return nil }
|
||||
func (f *fakeCoreSDK) DocMemory() pubsdk.DocMemoryAPI { return f.docMem }
|
||||
func (f *fakeCoreSDK) Knowledge() pubsdk.KnowledgeAPI { return f.knowledge }
|
||||
func (f *fakeCoreSDK) DocMemory() pubsdk.DocMemoryAPI { return f.docMem }
|
||||
func (f *fakeCoreSDK) Knowledge() pubsdk.KnowledgeAPI { return f.knowledge }
|
||||
func (f *fakeCoreSDK) LLM() pubsdk.LLMAPI { return nil }
|
||||
func (f *fakeCoreSDK) Social() pubsdk.SocialAPI { return nil }
|
||||
func (f *fakeCoreSDK) PluginMgr() pubsdk.PluginMgrAPI { return nil }
|
||||
|
||||
@ -322,7 +322,7 @@ func TestHealthcheckWithDocStore(t *testing.T) {
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
ds := doc.NewStore(tmpDir)
|
||||
ds := doc.NewStore(tmpDir, memory.TokenizeWords)
|
||||
if err := ds.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@ -70,7 +70,7 @@ func setupIntegrationWithProvider(t *testing.T, pm *agentAPI.ProviderManager) *t
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@ -125,6 +125,53 @@ func (c *testWSClient) readMsg() (byte, []byte, error) {
|
||||
|
||||
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 + 二进制分块回传(录像协议)=====
|
||||
|
||||
func TestWSBinaryChunkUpload(t *testing.T) {
|
||||
@ -139,20 +186,9 @@ func TestWSBinaryChunkUpload(t *testing.T) {
|
||||
cli := dialTestWS(t, url, token)
|
||||
defer cli.close()
|
||||
|
||||
// hello 登记
|
||||
// hello 后必须完成 bind,设备才会注册并开始处理数据。
|
||||
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"gui-test","name":"测试机","kind":"computer","caps":["cmd"]}}`))
|
||||
op, payload, err := cli.readMsg()
|
||||
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)
|
||||
}
|
||||
cli.readHelloAckAndBind(t, token)
|
||||
|
||||
// 模拟设备收到 cmd 后以二进制分块回传(cmd_data_start → 0x2×N → cmd_data_end)
|
||||
videoData := make([]byte, 20000) // 跨多个 8KB 块
|
||||
@ -224,9 +260,7 @@ func TestWSBinaryMediaToFile(t *testing.T) {
|
||||
defer cli.close()
|
||||
|
||||
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"gui-media","name":"媒体机","kind":"computer","caps":["cmd"]}}`))
|
||||
if _, _, err := cli.readMsg(); err != nil { // hello_ack
|
||||
t.Fatalf("read hello_ack: %v", err)
|
||||
}
|
||||
cli.readHelloAckAndBind(t, token)
|
||||
|
||||
videoData := make([]byte, 30000)
|
||||
for i := range videoData {
|
||||
@ -290,9 +324,7 @@ func TestWSPushDataAudio(t *testing.T) {
|
||||
defer cli.close()
|
||||
|
||||
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"audio-dev","name":"音频机","kind":"speaker"}}`))
|
||||
if _, _, err := cli.readMsg(); err != nil {
|
||||
t.Fatalf("read hello_ack: %v", err)
|
||||
}
|
||||
cli.readHelloAckAndBind(t, token)
|
||||
|
||||
audioData := []byte("RIFF....fake-wav-audio-data-for-testing....")
|
||||
|
||||
@ -388,9 +420,7 @@ func TestScreenseeEndToEnd(t *testing.T) {
|
||||
|
||||
// 设备 hello + bind(bind 需 token 才能被授权流程识别,这里直接手动授权)
|
||||
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"see-dev","name":"屏幕机","kind":"computer","caps":["cmd"]}}`))
|
||||
if _, _, err := cli.readMsg(); err != nil {
|
||||
t.Fatalf("read hello_ack: %v", err)
|
||||
}
|
||||
cli.readHelloAckAndBind(t, token)
|
||||
|
||||
// 设备侧循环收命令并回执(模拟 GUI screensee 实现)
|
||||
go func() {
|
||||
@ -454,9 +484,7 @@ func TestComputeruseEndToEnd(t *testing.T) {
|
||||
defer cli.close()
|
||||
|
||||
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"cu-dev","name":"操控机","kind":"computer","caps":["cmd","computeruse"]}}`))
|
||||
if _, _, err := cli.readMsg(); err != nil {
|
||||
t.Fatalf("read hello_ack: %v", err)
|
||||
}
|
||||
cli.readHelloAckAndBind(t, token)
|
||||
|
||||
// 设备侧收 computeruse 命令并回执
|
||||
var receivedCmd string
|
||||
@ -544,9 +572,7 @@ func TestClipboardEndToEnd(t *testing.T) {
|
||||
defer cli.close()
|
||||
|
||||
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"clip-dev","name":"剪贴板机","kind":"computer","caps":["cmd"]}}`))
|
||||
if _, _, err := cli.readMsg(); err != nil {
|
||||
t.Fatalf("read hello_ack: %v", err)
|
||||
}
|
||||
cli.readHelloAckAndBind(t, token)
|
||||
|
||||
// 设备侧响应剪贴板命令
|
||||
go func() {
|
||||
@ -666,9 +692,7 @@ func TestCapabilityMatrix(t *testing.T) {
|
||||
defer cli.close()
|
||||
|
||||
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"cam-only","name":"纯摄像头","kind":"camera","caps":["camera"]}}`))
|
||||
if _, _, err := cli.readMsg(); err != nil {
|
||||
t.Fatalf("read hello_ack: %v", err)
|
||||
}
|
||||
cli.readHelloAckAndBind(t, token)
|
||||
|
||||
if _, err := dev.Execute("screensee", map[string]interface{}{"device_id": "cam-only"}); err == nil {
|
||||
t.Fatal("camera-only device should not support screensee")
|
||||
@ -698,9 +722,7 @@ func TestDeviceEventReport(t *testing.T) {
|
||||
defer cli.close()
|
||||
|
||||
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"cam-watch","name":"监控摄像头","kind":"camera","caps":["camera"]}}`))
|
||||
if _, _, err := cli.readMsg(); err != nil {
|
||||
t.Fatalf("read hello_ack: %v", err)
|
||||
}
|
||||
cli.readHelloAckAndBind(t, token)
|
||||
|
||||
// 设备主动上报:识别到未知人员驻留
|
||||
cli.sendText(mustJSON(map[string]interface{}{
|
||||
@ -738,3 +760,105 @@ func TestDeviceEventReport(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -104,8 +104,7 @@ var capabilityTools = map[string][]string{
|
||||
|
||||
// compatFullCaps 视为「全能力」的历史 caps 值:声明了这些的设备不参与能力裁剪。
|
||||
var compatFullCaps = map[string]bool{
|
||||
"cmd": true, "cmdrun": true, "deviceinfo": true,
|
||||
"status": true, "cmdresult": true,
|
||||
"cmd": true, "cmdrun": true, "cmdresult": true,
|
||||
}
|
||||
|
||||
// SupportsTool 判断设备是否支持某 agent 工具(基于其声明的 caps)。
|
||||
@ -400,10 +399,15 @@ func (r *Registry) PushData(deviceID, reqID, kind, mime string, data []byte) err
|
||||
return nil
|
||||
}
|
||||
|
||||
// AwaitResult 等待某请求的结果(带超时)。
|
||||
// AwaitResult 等待某请求的结果(带超时)。快速回执会先留在 results,
|
||||
// 因而 PushCmd 后才开始等待也不会丢失。
|
||||
func (r *Registry) AwaitResult(reqID string, timeout time.Duration) (map[string]interface{}, error) {
|
||||
ch := make(chan map[string]interface{}, 1)
|
||||
r.mu.Lock()
|
||||
if e, ok := r.results[reqID]; ok {
|
||||
r.mu.Unlock()
|
||||
return e.Result, nil
|
||||
}
|
||||
r.cmdPending[reqID] = ch
|
||||
r.mu.Unlock()
|
||||
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{}) {
|
||||
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]
|
||||
r.mu.RUnlock()
|
||||
r.mu.Unlock()
|
||||
if ok {
|
||||
select {
|
||||
case ch <- res:
|
||||
@ -621,6 +629,9 @@ func (r *Registry) ServeWS(w http.ResponseWriter, req *http.Request) {
|
||||
return
|
||||
}
|
||||
token := req.URL.Query().Get("token")
|
||||
if token == "" {
|
||||
token = strings.TrimSpace(req.Header.Get("X-API-Key"))
|
||||
}
|
||||
if token == "" {
|
||||
for _, p := range req.Header.Values("Sec-WebSocket-Protocol") {
|
||||
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)
|
||||
return
|
||||
}
|
||||
@ -639,7 +651,7 @@ func (r *Registry) ServeWS(w http.ResponseWriter, req *http.Request) {
|
||||
return
|
||||
}
|
||||
log.Printf("[remotedevice] ws connected from %s", conn.RemoteAddr())
|
||||
go r.handleWS(conn, rw)
|
||||
go r.handleWS(conn, rw, handshakeAuthorized)
|
||||
}
|
||||
|
||||
// wsWriteLocked 在指定设备连接的写锁保护下执行写回调。
|
||||
@ -662,12 +674,20 @@ func (r *Registry) wsWriteLocked(deviceID string, fn func(w *bufio.Writer) error
|
||||
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()
|
||||
var curID string
|
||||
var pendingMeta *DeviceMeta
|
||||
var bound bool
|
||||
defer func() {
|
||||
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.got += len(payload)
|
||||
// 防滥用:超出声明 total 的 2 倍或硬上限 64MB 时放弃聚合
|
||||
limit := int64(dataAccum.total)*2 + 1024
|
||||
if limit < 64<<20 {
|
||||
limit = 64 << 20
|
||||
limit := int64(64 << 20)
|
||||
if dataAccum.total > 0 {
|
||||
declaredLimit := int64(dataAccum.total)*2 + 1024
|
||||
if declaredLimit < limit {
|
||||
limit = declaredLimit
|
||||
}
|
||||
}
|
||||
if int64(dataAccum.got) > limit {
|
||||
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
|
||||
}
|
||||
op, _ := msg["op"].(string)
|
||||
if !bound && op != "hello" && op != "bind" {
|
||||
continue
|
||||
}
|
||||
switch op {
|
||||
case "hello":
|
||||
meta := metaFromMsg(msg)
|
||||
@ -720,41 +746,35 @@ func (r *Registry) handleWS(conn net.Conn, rw *bufio.ReadWriter) {
|
||||
continue
|
||||
}
|
||||
meta.RemoteAddr = conn.RemoteAddr().String()
|
||||
pendingMeta = &meta
|
||||
curID = meta.DeviceID
|
||||
r.register(meta)
|
||||
r.mu.Lock()
|
||||
r.conns[meta.DeviceID] = &wconn{deviceID: meta.DeviceID, w: rw.Writer}
|
||||
r.mu.Unlock()
|
||||
if err := r.wsWriteLocked(meta.DeviceID, func(w *bufio.Writer) error {
|
||||
return writeText(w, mustJSON(map[string]interface{}{
|
||||
"op": "hello_ack",
|
||||
"device": meta.DeviceID,
|
||||
"online": true,
|
||||
}))
|
||||
}); err != nil {
|
||||
// Bind 前不把连接暴露给查询或命令下发路径;此时只有当前读循环会写。
|
||||
if err := writeText(rw.Writer, mustJSON(map[string]interface{}{
|
||||
"op": "hello_ack",
|
||||
"device": meta.DeviceID,
|
||||
"online": false,
|
||||
})); err != nil {
|
||||
return
|
||||
}
|
||||
case "bind":
|
||||
token, _ := msg["token"].(string)
|
||||
if r.acceptBind(token) {
|
||||
id, _ := msg["device_id"].(string)
|
||||
if id != "" {
|
||||
// 默认不授权:bind 仅验证 token + 登记设备;授权完全由用户手动
|
||||
// (GUI 设备页 / REST /api/v1/device/auth)控制,绝不自动授权。
|
||||
}
|
||||
err := r.wsWriteLocked(curID, func(w *bufio.Writer) error {
|
||||
return writeText(w, mustJSON(map[string]interface{}{"op": "bind_ack", "ok": true}))
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
err := r.wsWriteLocked(curID, func(w *bufio.Writer) error {
|
||||
return writeText(w, mustJSON(map[string]interface{}{"op": "bind_ack", "ok": false, "error": "bad token"}))
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
id, _ := msg["device_id"].(string)
|
||||
bindAuthorized := handshakeAuthorized || r.acceptBind(token)
|
||||
if pendingMeta == nil || id == "" || id != pendingMeta.DeviceID || !bindAuthorized {
|
||||
_ = writeText(rw.Writer, mustJSON(map[string]interface{}{
|
||||
"op": "bind_ack", "ok": false, "error": "bind rejected",
|
||||
}))
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.conns[id] = &wconn{deviceID: id, w: rw.Writer}
|
||||
r.mu.Unlock()
|
||||
bound = true
|
||||
r.register(*pendingMeta)
|
||||
if err := r.wsWriteLocked(curID, func(w *bufio.Writer) error {
|
||||
return writeText(w, mustJSON(map[string]interface{}{"op": "bind_ack", "ok": true}))
|
||||
}); err != nil {
|
||||
return
|
||||
}
|
||||
case "status":
|
||||
id, _ := msg["device_id"].(string)
|
||||
|
||||
@ -10,7 +10,7 @@ type IndexerAPI interface {
|
||||
|
||||
// IndexContext is a neutral snapshot of the injected memory context.
|
||||
type IndexContext struct {
|
||||
Entities []Entity `json:"entities"`
|
||||
Entities []Entity `json:"entities"`
|
||||
Relations []Relation `json:"relations"`
|
||||
Summary string `json:"summary"`
|
||||
TokenEstimate int `json:"token_estimate"`
|
||||
|
||||
@ -7,7 +7,9 @@ type knowledgeImpl struct{ ks *knowledge.Store }
|
||||
func NewKnowledge(ks *knowledge.Store) KnowledgeAPI { return &knowledgeImpl{ks: ks} }
|
||||
|
||||
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)
|
||||
out := make([]*Knowledge, len(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 {
|
||||
if k.ks == nil { return nil }
|
||||
if k.ks == nil {
|
||||
return nil
|
||||
}
|
||||
return k.ks.Add(name, content)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
func (k *knowledgeImpl) Remove(name string) error {
|
||||
if k.ks == nil { return nil }
|
||||
if k.ks == nil {
|
||||
return nil
|
||||
}
|
||||
return k.ks.Remove(name)
|
||||
}
|
||||
|
||||
|
||||
@ -3,9 +3,7 @@ package sdk
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
@ -20,34 +18,15 @@ import (
|
||||
// 这一层原先的缺陷是**静默裁字段**:插件把 Triple / Doc 交进来,包装层只挑
|
||||
// 自己认识的几个字段转成内部结构,其余丢弃且不报错。两侧都中招:
|
||||
// - 图记忆:丢 Confidence/SubjectType/ObjectType/SentenceText,又走 Commit
|
||||
// 而非 CommitWithMedia,于是 sentences 表没有落点,媒体引用无从挂起;
|
||||
// 而非 CommitWithMedia,于是 sentences 表没有落点,媒体块无从挂接;
|
||||
// - 知识库:Query 只回 ID/Title/Content,Insert 只写这三个,读写两个方向
|
||||
// 都把媒体元数据裁掉;Remove 不解引用,媒体永久算「被引用」,GC 收不掉。
|
||||
// 都把媒体元数据裁掉。
|
||||
//
|
||||
// 现在的规则:内部结构有的字段一律透传;媒体一律走标记格式并挂到对应 owner。
|
||||
// 现在的规则:内部结构有的字段一律透传;媒体一律变成一等记忆块。
|
||||
// 媒体存储为 nil 时整条链路静默降级为纯文本行为(媒体是记忆增强,不是必需品)。
|
||||
|
||||
// ---------- 媒体标记(本层内部) ----------
|
||||
//
|
||||
// 标记是媒体在**纯文本记忆**里的表示形式:
|
||||
//
|
||||
// [image/png a1b2c3d4e5f6] 一张紫蓝红三色带图
|
||||
// └ label └ 短 digest └ 描述
|
||||
//
|
||||
// 之所以必须借文本承载:Doc.Content、sentences.text、文本记忆的 Input 全是
|
||||
// 字符串,没有字段能挂结构化数据。描述文本是持久的语义记忆(检索靠它),
|
||||
// digest 是回到字节的钥匙(反查靠它)。
|
||||
//
|
||||
// 格式与内核侧 graphmedia.go 的 mediaSummaryForEvent 一致——两边必须能互读
|
||||
// 对方写下的标记,否则插件写入的媒体在内核归档时挂不上引用,且不报错。
|
||||
|
||||
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 {
|
||||
if len(d) > sdkShortDigestLen {
|
||||
return d[:sdkShortDigestLen]
|
||||
@ -55,34 +34,6 @@ func sdkShortDigest(d string) string {
|
||||
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 为主键,
|
||||
// 不同文档里序号相同的块会在 L2→L3 迁移时相互覆盖。
|
||||
var sdkBlockSeq int64
|
||||
@ -127,27 +78,8 @@ func sdkBlockForDigest(ms *media.Store, digest string) (memory.MemoryBlock, bool
|
||||
}, 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 列表构造一等块(去重)。
|
||||
// digest 可以是短前缀,内部会先补全。
|
||||
func sdkBlocksFromDigests(ms *media.Store, digests []string) []memory.MemoryBlock {
|
||||
if ms == nil || len(digests) == 0 {
|
||||
return nil
|
||||
@ -155,11 +87,15 @@ func sdkBlocksFromDigests(ms *media.Store, digests []string) []memory.MemoryBloc
|
||||
seen := map[string]bool{}
|
||||
var blocks []memory.MemoryBlock
|
||||
for _, d := range digests {
|
||||
if d == "" || seen[d] {
|
||||
if d == "" {
|
||||
continue
|
||||
}
|
||||
seen[d] = true
|
||||
if b, ok := sdkBlockForDigest(ms, d); ok {
|
||||
full, err := ms.ResolvePrefix(d)
|
||||
if err != nil || seen[full] {
|
||||
continue
|
||||
}
|
||||
seen[full] = true
|
||||
if b, ok := sdkBlockForDigest(ms, full); ok {
|
||||
blocks = append(blocks, b)
|
||||
}
|
||||
}
|
||||
@ -177,10 +113,9 @@ func sdkPutAttachment(ms *media.Store, a MediaAttachment, tool string) (string,
|
||||
mime = "application/octet-stream"
|
||||
}
|
||||
return ms.Put(a.Data, media.Item{
|
||||
MIME: mime,
|
||||
Tool: tool,
|
||||
OriginPath: a.Name,
|
||||
Description: a.Description,
|
||||
MIME: mime,
|
||||
Tool: tool,
|
||||
OriginPath: a.Name,
|
||||
})
|
||||
}
|
||||
if a.Digest == "" {
|
||||
@ -193,32 +128,6 @@ func sdkPutAttachment(ms *media.Store, a MediaAttachment, tool string) (string,
|
||||
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 {
|
||||
@ -260,10 +169,8 @@ func (m *graphMemory) Recall(query []string, depth int) ([]Entity, []Relation, e
|
||||
return entities, relations, nil
|
||||
}
|
||||
|
||||
// Commit 把插件的三元组写入图库,并把三元组句子里的媒体变成 L3 一等块。
|
||||
//
|
||||
// 媒体的落点链是 SentenceText → sentences 表 → sentence_id → 块边。
|
||||
// 旧实现丢掉 SentenceText 又走 Commit(不回 sentenceIDs),这条链一步都走不通。
|
||||
// Commit 把插件的三元组写入图库,并把结构化 MediaDigests 变成 L3 一等块。
|
||||
// 媒体通过 sentence --contains--> block 结构边挂接,不读写任何正文 marker。
|
||||
func (m *graphMemory) Commit(triples []Triple) error {
|
||||
if m.db == nil {
|
||||
return nil
|
||||
@ -278,9 +185,7 @@ func (m *graphMemory) Commit(triples []Triple) error {
|
||||
SubjectType: t.SubjectType,
|
||||
ObjectType: t.ObjectType,
|
||||
SentenceText: t.SentenceText,
|
||||
}
|
||||
if len(t.MediaDigests) > 0 {
|
||||
mt.SentenceText = m.sentenceWithMedia(mt.SentenceText, t.MediaDigests)
|
||||
MediaDigests: t.MediaDigests,
|
||||
}
|
||||
ts = append(ts, mt)
|
||||
}
|
||||
@ -289,61 +194,28 @@ func (m *graphMemory) Commit(triples []Triple) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.bindSentences(sentenceIDs)
|
||||
m.bindSentences(sentenceIDs, ts)
|
||||
return nil
|
||||
}
|
||||
|
||||
// sentenceWithMedia 保证句子文本里带有这些 digest 的媒体标记。
|
||||
// bindSentences 把每个三元组显式携带的媒体变成 L3 一等记忆块,
|
||||
// 并以 sentence --contains--> block 结构边关联。
|
||||
//
|
||||
// 让插件填 MediaDigests 就够,不必知道标记格式——否则格式写错的后果是
|
||||
// 引用静默挂不上。已出现过的 digest 不重复追加:插件可能既手写了标记又填了
|
||||
// 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) {
|
||||
// 不再往句子文本里写 marker、也不再从文本反解 digest:归属由结构化字段直接给出。
|
||||
func (m *graphMemory) bindSentences(sentenceIDs map[string]int64, triples []memory.Triple) {
|
||||
if m.ms == nil || m.db == nil || len(sentenceIDs) == 0 {
|
||||
return
|
||||
}
|
||||
bound := 0
|
||||
for text, sid := range sentenceIDs {
|
||||
for _, t := range triples {
|
||||
if len(t.MediaDigests) == 0 {
|
||||
continue
|
||||
}
|
||||
sid := sentenceIDs[t.SentenceText]
|
||||
if sid == 0 {
|
||||
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 {
|
||||
log.Printf("[sdk media] 插件 %s 写入 L3 记忆块失败: %v", m.plugin, err)
|
||||
continue
|
||||
@ -403,37 +275,21 @@ func NewTextMemoryWithMedia(plugin string, tm *text.Memory, ms *media.Store) Tex
|
||||
return &textMemoryImpl{tm: tm, ms: ms, plugin: plugin}
|
||||
}
|
||||
|
||||
// Append 追加一条文本事件;带附件时把媒体标记并进正文。
|
||||
// Append 追加一条文本事件。
|
||||
//
|
||||
// 文本记忆是追加写 JSONL,没有结构化块存储,所以媒体在这一层
|
||||
// 只能以标记形式存在。这不是妥协——描述文本才是持久的语义记忆,blob 只是缓存。
|
||||
// 文本记忆是追加写 JSONL 的原始日志,只有字符串字段,没有块容器;
|
||||
// 因此附件在这里无法结构化存下。不假装用文本标记承载媒体——
|
||||
// 需要保存媒体请用文档记忆或图记忆(它们持有一等记忆块)。
|
||||
func (m *textMemoryImpl) Append(evt TextEvent) error {
|
||||
if m.tm == nil {
|
||||
return nil
|
||||
}
|
||||
content := evt.Content
|
||||
if len(evt.Attachments) > 0 && m.ms != nil {
|
||||
var lines []string
|
||||
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")
|
||||
}
|
||||
}
|
||||
if len(evt.Attachments) > 0 {
|
||||
log.Printf("[sdk media] 插件 %s 向文本记忆追加了 %d 份附件,已忽略:"+
|
||||
"文本层是字符串日志,不具备块存储;请改用文档/图记忆保存媒体", m.plugin, len(evt.Attachments))
|
||||
}
|
||||
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 {
|
||||
out[i] = TextEvent{
|
||||
Role: e.Source, Content: e.Input, Timestamp: e.Timestamp, Channel: e.AgentID,
|
||||
Attachments: sdkAttachmentsFromText(m.ms, e.Input),
|
||||
}
|
||||
}
|
||||
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}
|
||||
}
|
||||
|
||||
// Query 检索文档,并补齐媒体元数据。
|
||||
//
|
||||
// 旧实现只回 ID/Title/Content,插件即便拿到一篇带媒体的文档也看不出这里有
|
||||
// 几份媒体、分别是什么。现在同时给出完整 digest 列表与 mime+描述,
|
||||
// 但**不回字节**:一次检索可能命中几十份媒体,全塞回去会把跨进程消息撑爆,
|
||||
// 需要字节时按 digest 单取。
|
||||
// Query 检索文档,并从文档持有的一等块补齐媒体元数据。
|
||||
// 只返回 digest/MIME,不返回字节或生成式描述;需要字节时按 digest 单取。
|
||||
func (m *docMemoryImpl) Query(text string, topK int) []*Doc {
|
||||
if m.ds == nil {
|
||||
return nil
|
||||
@ -498,30 +349,22 @@ func (m *docMemoryImpl) Query(text string, topK int) []*Doc {
|
||||
|
||||
// fillMedia 填充文档的媒体字段。
|
||||
//
|
||||
// 优先读一等记忆块(文档直接持有),为空时退回解析正文标记——
|
||||
// 历史文档与经旧版插件写入的文档只有标记、没有块。
|
||||
// 唯一的来源是文档直接持有的一等记忆块:媒体不靠正文标记、
|
||||
// 也不靠任何生成的描述文本。CAS 只提供 MIME 等元数据。
|
||||
func (m *docMemoryImpl) fillMedia(out *Doc, d *doc.Doc) {
|
||||
if m.ms == nil {
|
||||
if m.ms == nil || d == nil {
|
||||
return
|
||||
}
|
||||
if d != nil && len(d.Blocks) > 0 {
|
||||
for _, b := range d.Blocks {
|
||||
if b.PayloadDigest == "" {
|
||||
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)
|
||||
for _, b := range d.Blocks {
|
||||
if b.PayloadDigest == "" {
|
||||
continue
|
||||
}
|
||||
return
|
||||
}
|
||||
out.Attachments = sdkAttachmentsFromText(m.ms, out.Content)
|
||||
for _, a := range out.Attachments {
|
||||
out.MediaDigests = append(out.MediaDigests, a.Digest)
|
||||
out.MediaDigests = append(out.MediaDigests, b.PayloadDigest)
|
||||
att := MediaAttachment{Digest: b.PayloadDigest, MIME: b.MIME}
|
||||
if it, err := m.ms.Stat(b.PayloadDigest); err == nil && it != nil {
|
||||
att.MIME = it.MIME
|
||||
}
|
||||
out.Attachments = append(out.Attachments, att)
|
||||
}
|
||||
}
|
||||
|
||||
@ -543,15 +386,14 @@ func appendBlocks(existing []memory.MemoryBlock, add []memory.MemoryBlock) []mem
|
||||
return existing
|
||||
}
|
||||
|
||||
// Insert 写入文档。正文里已有的媒体标记会被挂成文档级引用,
|
||||
// 避免插件写进来的媒体在下一次 GC 时被当作无主内容清掉。
|
||||
// Insert 写入文档。
|
||||
func (m *docMemoryImpl) Insert(d *Doc) error { return m.InsertWithMedia(d, nil) }
|
||||
|
||||
// InsertWithMedia 写入文档并关联媒体。
|
||||
//
|
||||
// 标记由内核补进 Content——插件不必知道标记格式,也就不会因为格式写错导致
|
||||
// 引用挂不上。补标记必须在 ds.Insert 之前完成:向量索引用 Summary+Content
|
||||
// 计算,标记进不去正文就检索不到这份媒体。
|
||||
// 媒体直接成为文档持有的一等记忆块:落进 CAS 拿到 digest,
|
||||
// 再变成块挂到文档上。不往正文写 marker——文档向量会融合这些块的
|
||||
// 媒体向量(同一统一空间),图片按自己的向量被召回。
|
||||
func (m *docMemoryImpl) InsertWithMedia(d *Doc, attachments []MediaAttachment) error {
|
||||
if m.ds == nil || d == nil {
|
||||
return nil
|
||||
@ -561,29 +403,26 @@ func (m *docMemoryImpl) InsertWithMedia(d *Doc, attachments []MediaAttachment) e
|
||||
target.Source = "plugin:" + m.plugin
|
||||
}
|
||||
|
||||
digests := m.storeAttachments(attachments, &target.Content)
|
||||
digests := m.storeAttachments(attachments)
|
||||
|
||||
// 一等记忆块:文档直接持有块本身,CAS 只提供字节与向量。
|
||||
// 不再写 media_refs——块随文档一同存活或被删除,无需 owner 账本。
|
||||
target.Blocks = appendBlocks(target.Blocks,
|
||||
append(sdkBlocksFromDigests(m.ms, digests), sdkBlocksFromText(m.ms, target.Content)...))
|
||||
target.Blocks = appendBlocks(target.Blocks, sdkBlocksFromDigests(m.ms, digests))
|
||||
|
||||
if err := m.ds.Insert(target); err != nil {
|
||||
return err
|
||||
}
|
||||
// 回填给调用方:ID 是新建时内核生成的,Content 含内核补的标记。
|
||||
// 回填给调用方:ID 是新建时内核生成的。
|
||||
d.ID = target.ID
|
||||
d.Content = target.Content
|
||||
return nil
|
||||
}
|
||||
|
||||
// storeAttachments 把附件落库并把标记追加进 content,返回全部完整 digest。
|
||||
func (m *docMemoryImpl) storeAttachments(atts []MediaAttachment, content *string) []string {
|
||||
// storeAttachments 把附件落库,返回全部完整 digest。
|
||||
func (m *docMemoryImpl) storeAttachments(atts []MediaAttachment) []string {
|
||||
if m.ms == nil || len(atts) == 0 {
|
||||
return nil
|
||||
}
|
||||
present := sdkDigestsIn(*content)
|
||||
var digests, lines []string
|
||||
seen := map[string]bool{}
|
||||
var digests []string
|
||||
for _, a := range atts {
|
||||
full, err := sdkPutAttachment(m.ms, a, "plugin_doc:"+m.plugin)
|
||||
if err != nil {
|
||||
@ -591,21 +430,11 @@ func (m *docMemoryImpl) storeAttachments(atts []MediaAttachment, content *string
|
||||
log.Printf("[sdk media] 插件 %s 文档附件入库失败: %v", m.plugin, err)
|
||||
continue
|
||||
}
|
||||
if seen[full] {
|
||||
continue
|
||||
}
|
||||
seen[full] = true
|
||||
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
|
||||
}
|
||||
|
||||
@ -51,10 +51,10 @@ func newTestStores(t *testing.T) (*memory.GraphDB, *doc.Store, *text.Memory, *me
|
||||
return g, ds, tm, ms
|
||||
}
|
||||
|
||||
// putDescribed 存一份带描述的媒体,返回完整 digest。
|
||||
func putDescribed(t *testing.T, ms *media.Store, payload, desc string) string {
|
||||
// putMedia 存一份媒体,返回完整 digest。
|
||||
func putMedia(t *testing.T, ms *media.Store, payload string) string {
|
||||
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 {
|
||||
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) {
|
||||
g, _, _, ms := newTestStores(t)
|
||||
digest := putDescribed(t, ms, "png-bytes", "一张紫蓝红三色带图")
|
||||
digest := putMedia(t, ms, "png-bytes")
|
||||
|
||||
m := NewGraphMemoryWithMedia("tester", g, ms)
|
||||
if err := m.Commit([]Triple{{
|
||||
Subject: "配色图",
|
||||
Relation: "包含",
|
||||
Object: "三色带",
|
||||
SentenceText: "这张图是紫蓝红三色带。",
|
||||
MediaDigests: []string{digest[:12]}, // 插件手里通常只有短 digest
|
||||
}}); err != nil {
|
||||
t.Fatalf("Commit: %v", err)
|
||||
@ -131,13 +132,13 @@ func TestGraphCommit_BindsMediaFromDigests(t *testing.T) {
|
||||
t.Fatalf("Recall: %v", err)
|
||||
}
|
||||
if len(res.Relations) == 0 || res.Relations[0].SentenceID == 0 {
|
||||
t.Fatal("没有句子落点 —— 媒体引用无从挂起")
|
||||
t.Fatal("没有句子落点 —— 媒体块无从挂接")
|
||||
}
|
||||
sid := res.Relations[0].SentenceID
|
||||
|
||||
// 描述必须进句子:描述文本才是持久语义记忆,检索靠它。
|
||||
if !strings.Contains(res.Relations[0].SentenceText, "三色带图") {
|
||||
t.Errorf("句子里没有媒体描述: %q", res.Relations[0].SentenceText)
|
||||
// 句子文本保持原样:不再往正文里贴媒体标记。
|
||||
if strings.Contains(res.Relations[0].SentenceText, digest[:12]) {
|
||||
t.Errorf("句子文本不该被媒体标记污染: %q", res.Relations[0].SentenceText)
|
||||
}
|
||||
|
||||
blocks, err := g.BlocksForNode("sentence", strconv.FormatInt(sid, 10))
|
||||
@ -149,19 +150,18 @@ func TestGraphCommit_BindsMediaFromDigests(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 插件自己按格式写了标记又同时填了 MediaDigests,不能产生两条重复引用/两份标记。
|
||||
func TestGraphCommit_NoDuplicateMarker(t *testing.T) {
|
||||
// 同一个 digest 在同一三元组里重复出现(短/完整混写)时只能建一个块。
|
||||
func TestGraphCommit_DedupesRepeatedDigest(t *testing.T) {
|
||||
g, _, _, ms := newTestStores(t)
|
||||
digest := putDescribed(t, ms, "dup-bytes", "重复标记测试图")
|
||||
short := digest[:12]
|
||||
digest := putMedia(t, ms, "dup-bytes")
|
||||
|
||||
m := NewGraphMemoryWithMedia("tester", g, ms)
|
||||
if err := m.Commit([]Triple{{
|
||||
Subject: "重复图",
|
||||
Relation: "标记",
|
||||
Object: "一次",
|
||||
SentenceText: "看这个 [image/png " + short + "] 重复标记测试图",
|
||||
MediaDigests: []string{short},
|
||||
SentenceText: "同一张图说了两遍。",
|
||||
MediaDigests: []string{digest[:12], digest},
|
||||
}}); err != nil {
|
||||
t.Fatalf("Commit: %v", err)
|
||||
}
|
||||
@ -170,12 +170,16 @@ func TestGraphCommit_NoDuplicateMarker(t *testing.T) {
|
||||
if len(res.Relations) == 0 {
|
||||
t.Fatal("召回不到关系")
|
||||
}
|
||||
if n := strings.Count(res.Relations[0].SentenceText, short); n != 1 {
|
||||
t.Errorf("句子里出现 %d 次 digest,期望 1 次: %q", n, res.Relations[0].SentenceText)
|
||||
blocks, err := g.BlocksForNode("sentence", strconv.FormatInt(res.Relations[0].SentenceID, 10))
|
||||
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) {
|
||||
g, _, _, _ := newTestStores(t)
|
||||
m := NewGraphMemory(g)
|
||||
@ -184,6 +188,7 @@ func TestGraphCommit_NilMediaStoreDegrades(t *testing.T) {
|
||||
Subject: "无存储",
|
||||
Relation: "仍可",
|
||||
Object: "提交",
|
||||
SentenceText: "无媒体存储时的句子。",
|
||||
MediaDigests: []string{"aabbccddeeff"},
|
||||
}}); err != nil {
|
||||
t.Fatalf("Commit 在无媒体存储时不该失败: %v", err)
|
||||
@ -193,8 +198,12 @@ func TestGraphCommit_NilMediaStoreDegrades(t *testing.T) {
|
||||
if len(res.Relations) == 0 {
|
||||
t.Fatal("召回不到关系")
|
||||
}
|
||||
if !strings.Contains(res.Relations[0].SentenceText, "aabbccddeeff") {
|
||||
t.Errorf("digest 应留在句子里以备将来反查: %q", res.Relations[0].SentenceText)
|
||||
blocks, err := g.BlocksForNode("sentence", strconv.FormatInt(res.Relations[0].SentenceID, 10))
|
||||
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) {
|
||||
_, ds, _, ms := newTestStores(t)
|
||||
dm := NewDocMemoryWithMedia("tester", ds, ms)
|
||||
|
||||
d := &Doc{Title: "带图笔记", Content: "这是正文。"}
|
||||
err := dm.InsertWithMedia(d, []MediaAttachment{{
|
||||
MIME: "image/png",
|
||||
Data: []byte("attachment-bytes"),
|
||||
Name: "chart.png",
|
||||
Description: "一张柱状图",
|
||||
MIME: "image/png",
|
||||
Data: []byte("attachment-bytes"),
|
||||
Name: "chart.png",
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("InsertWithMedia: %v", err)
|
||||
@ -244,10 +252,9 @@ func TestDocInsertWithMedia_StoresAndBinds(t *testing.T) {
|
||||
t.Fatal("ID 未回填 —— 插件拿不到刚写入文档的 id")
|
||||
}
|
||||
|
||||
// 标记必须进正文:向量索引用 Summary+Content 计算,
|
||||
// 标记进不去正文就永远检索不到这份媒体。
|
||||
if !strings.Contains(d.Content, "柱状图") {
|
||||
t.Errorf("正文里没有媒体标记: %q", d.Content)
|
||||
// 正文保持原样:不再往 Content 里拼任何媒体标记。
|
||||
if strings.Contains(d.Content, "image/png") {
|
||||
t.Errorf("正文不该被媒体标记污染: %q", d.Content)
|
||||
}
|
||||
|
||||
blocks := ds.Blocks()
|
||||
@ -264,7 +271,7 @@ func TestDocInsertWithMedia_StoresAndBinds(t *testing.T) {
|
||||
// 只给 Digest 的附件是「引用已有内容」,不该报错也不该重复落盘。
|
||||
func TestDocInsertWithMedia_DigestOnlyReference(t *testing.T) {
|
||||
_, ds, _, ms := newTestStores(t)
|
||||
digest := putDescribed(t, ms, "existing", "已有的图")
|
||||
digest := putMedia(t, ms, "existing")
|
||||
before := ms.Stats()["count"]
|
||||
|
||||
dm := NewDocMemoryWithMedia("tester", ds, ms)
|
||||
@ -290,7 +297,7 @@ func TestDocQuery_FillsMediaMetadataWithoutBytes(t *testing.T) {
|
||||
|
||||
d := &Doc{Title: "紫蓝红三色带", Content: "配色说明"}
|
||||
if err := dm.InsertWithMedia(d, []MediaAttachment{{
|
||||
MIME: "image/png", Data: []byte("query-bytes"), Description: "三色带图",
|
||||
MIME: "image/png", Data: []byte("query-bytes"),
|
||||
}}); err != nil {
|
||||
t.Fatalf("InsertWithMedia: %v", err)
|
||||
}
|
||||
@ -315,38 +322,14 @@ func TestDocQuery_FillsMediaMetadataWithoutBytes(t *testing.T) {
|
||||
t.Fatalf("Attachments = %v,期望 1 条", hit.Attachments)
|
||||
}
|
||||
att := hit.Attachments[0]
|
||||
if att.MIME != "image/png" || att.Description != "三色带图" {
|
||||
t.Errorf("附件元数据 = %+v,期望 mime=image/png desc=三色带图", att)
|
||||
if att.MIME != "image/png" || att.Digest != hit.MediaDigests[0] {
|
||||
t.Errorf("附件元数据 = %+v,期望 mime=image/png 且 digest 与 MediaDigests 一致", att)
|
||||
}
|
||||
if len(att.Data) != 0 {
|
||||
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) {
|
||||
_, ds, _, ms := newTestStores(t)
|
||||
@ -354,7 +337,7 @@ func TestDocRemove_DropsBlocks(t *testing.T) {
|
||||
|
||||
d := &Doc{Title: "待删除", Content: "正文"}
|
||||
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 {
|
||||
t.Fatalf("InsertWithMedia: %v", err)
|
||||
}
|
||||
@ -395,9 +378,10 @@ func TestDocMemory_NilMediaStoreDegrades(t *testing.T) {
|
||||
|
||||
// ---------- 文本记忆 ----------
|
||||
|
||||
// 文本记忆是追加写 JSONL,没有稳定 owner_id 可挂引用,
|
||||
// 媒体只能以标记形式留在正文里;读回时要能反解成结构化附件。
|
||||
func TestTextMemory_AttachmentRoundTrip(t *testing.T) {
|
||||
// 文本记忆是追加写 JSONL 的字符串日志,没有块容器。
|
||||
// 它不会愄造文本标记来承载媒体:附件被明确忽略并记录日志,
|
||||
// 需要保存媒体请用文档/图记忆。
|
||||
func TestTextMemory_AttachmentsIgnoredNotFaked(t *testing.T) {
|
||||
_, _, tm, ms := newTestStores(t)
|
||||
m := NewTextMemoryWithMedia("tester", tm, ms)
|
||||
|
||||
@ -405,7 +389,7 @@ func TestTextMemory_AttachmentRoundTrip(t *testing.T) {
|
||||
Role: "user",
|
||||
Content: "看这张图",
|
||||
Attachments: []MediaAttachment{{
|
||||
MIME: "image/png", Data: []byte("text-mem-bytes"), Description: "文本记忆里的图",
|
||||
MIME: "image/png", Data: []byte("text-mem-bytes"),
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("Append: %v", err)
|
||||
@ -419,13 +403,10 @@ func TestTextMemory_AttachmentRoundTrip(t *testing.T) {
|
||||
t.Fatal("读不到刚追加的事件")
|
||||
}
|
||||
last := got[len(got)-1]
|
||||
if !strings.Contains(last.Content, "文本记忆里的图") {
|
||||
t.Errorf("正文里没有媒体标记: %q", last.Content)
|
||||
if last.Content != "看这张图" {
|
||||
t.Errorf("正文应保持原样,实际 %q", last.Content)
|
||||
}
|
||||
if len(last.Attachments) != 1 {
|
||||
t.Fatalf("Attachments = %+v,期望 1 条(标记应能反解)", last.Attachments)
|
||||
}
|
||||
if last.Attachments[0].Description != "文本记忆里的图" {
|
||||
t.Errorf("附件描述 = %q", last.Attachments[0].Description)
|
||||
if len(last.Attachments) != 0 {
|
||||
t.Errorf("文本层不该凭空造出附件(没有块存储可挂): %+v", last.Attachments)
|
||||
}
|
||||
}
|
||||
|
||||
@ -63,7 +63,7 @@ func (v *VirtualInstance) initLocked() error {
|
||||
}
|
||||
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 {
|
||||
return fmt.Errorf("virtual doc store: %w", err)
|
||||
}
|
||||
@ -105,4 +105,4 @@ func (v *VirtualInstance) Cleanup() {
|
||||
func (v *VirtualInstance) Reset(scope string) (*VirtualInstance, error) {
|
||||
v.Cleanup()
|
||||
return NewVirtualInstance(scope)
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,10 +16,10 @@ type SupervisorAPI interface {
|
||||
|
||||
// AgentStatus is a neutral snapshot of a supervised agent.
|
||||
type AgentStatus struct {
|
||||
ID types.AgentID `json:"id"`
|
||||
State types.AgentState `json:"state"`
|
||||
Health types.HealthStatus `json:"health"`
|
||||
Uptime time.Duration `json:"uptime,omitempty"`
|
||||
Network types.NetworkCheckResult `json:"network,omitempty"`
|
||||
TrackerStats map[string]interface{} `json:"tracker_stats,omitempty"`
|
||||
ID types.AgentID `json:"id"`
|
||||
State types.AgentState `json:"state"`
|
||||
Health types.HealthStatus `json:"health"`
|
||||
Uptime time.Duration `json:"uptime,omitempty"`
|
||||
Network types.NetworkCheckResult `json:"network,omitempty"`
|
||||
TrackerStats map[string]interface{} `json:"tracker_stats,omitempty"`
|
||||
}
|
||||
|
||||
306
internal/tfidf/tfidf.go
Normal file
306
internal/tfidf/tfidf.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user