mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-04 00:03:59 +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:
@ -505,7 +505,6 @@ func main() {
|
|||||||
SocialStore: socialStore,
|
SocialStore: socialStore,
|
||||||
TextMemory: textMem,
|
TextMemory: textMem,
|
||||||
MediaStore: mediaStore,
|
MediaStore: mediaStore,
|
||||||
MediaDescribe: cfgReg.GetBool("core.memory.media.describe_on_ingest", false),
|
|
||||||
Personality: personality,
|
Personality: personality,
|
||||||
PluginReg: pluginReg,
|
PluginReg: pluginReg,
|
||||||
PluginDir: cfg.Plugin.Dir,
|
PluginDir: cfg.Plugin.Dir,
|
||||||
|
|||||||
20
deploy/systemd/embed-sidecar.service
Normal file
20
deploy/systemd/embed-sidecar.service
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Jina v5-omni-nano Embedding Sidecar for HomeAgent
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
WorkingDirectory=/home/newqqagent
|
||||||
|
ExecStart=/usr/local/bin/python3 /home/program/TrueAgent/scripts/embed_sidecar.py
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
Environment=JINA_MODEL_DIR=/home/newqqagent/models/jina-v5-omni-nano
|
||||||
|
Environment=JINA_PORT=18999
|
||||||
|
Environment=JINA_DIMENSION=768
|
||||||
|
Environment=OMP_NUM_THREADS=8
|
||||||
|
Environment=MKL_NUM_THREADS=8
|
||||||
|
Environment=TOKENIZERS_PARALLELISM=false
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@ -56,8 +56,6 @@ type Agent struct {
|
|||||||
// 它是记忆块的内容存储,不单独做生命周期管理:块的创建/迁移/删除
|
// 它是记忆块的内容存储,不单独做生命周期管理:块的创建/迁移/删除
|
||||||
// 由记忆系统本身决定。为 nil 时全部媒体接线静默跳过。
|
// 由记忆系统本身决定。为 nil 时全部媒体接线静默跳过。
|
||||||
mediaStore *media.Store
|
mediaStore *media.Store
|
||||||
// mediaDescribe 控制是否跑后台描述循环(要消耗视觉模型配额)。
|
|
||||||
mediaDescribe bool
|
|
||||||
|
|
||||||
// 人格设定
|
// 人格设定
|
||||||
personality *agentPkg.Personality
|
personality *agentPkg.Personality
|
||||||
@ -182,7 +180,6 @@ type AgentConfig struct {
|
|||||||
SocialStore *social.SocialStore
|
SocialStore *social.SocialStore
|
||||||
TextMemory *text.Memory
|
TextMemory *text.Memory
|
||||||
MediaStore *media.Store
|
MediaStore *media.Store
|
||||||
MediaDescribe bool
|
|
||||||
MultimodalSpace vector.MultimodalEmbedder
|
MultimodalSpace vector.MultimodalEmbedder
|
||||||
FusionCfg CrossModalFusionConfig // 跨模态融合权重;零值用默认
|
FusionCfg CrossModalFusionConfig // 跨模态融合权重;零值用默认
|
||||||
Personality *agentPkg.Personality
|
Personality *agentPkg.Personality
|
||||||
@ -271,7 +268,6 @@ func New(cfg AgentConfig) *Agent {
|
|||||||
social: cfg.SocialStore,
|
social: cfg.SocialStore,
|
||||||
textMem: cfg.TextMemory,
|
textMem: cfg.TextMemory,
|
||||||
mediaStore: cfg.MediaStore,
|
mediaStore: cfg.MediaStore,
|
||||||
mediaDescribe: cfg.MediaDescribe,
|
|
||||||
personality: cfg.Personality,
|
personality: cfg.Personality,
|
||||||
pluginReg: cfg.PluginReg,
|
pluginReg: cfg.PluginReg,
|
||||||
pluginDir: cfg.PluginDir,
|
pluginDir: cfg.PluginDir,
|
||||||
@ -307,8 +303,8 @@ func (a *Agent) Start() {
|
|||||||
go a.archiveLoop()
|
go a.archiveLoop()
|
||||||
go a.mergeLoop()
|
go a.mergeLoop()
|
||||||
go a.reviewLoop()
|
go a.reviewLoop()
|
||||||
go a.mediaDescribeLoop()
|
|
||||||
a.reembedStaleMedia()
|
a.reembedStaleMedia()
|
||||||
|
a.migrateLegacyGraphMedia()
|
||||||
log.Printf("[agent] %s started, waiting for IO interrupts", a.id)
|
log.Printf("[agent] %s started, waiting for IO interrupts", a.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -15,14 +15,14 @@ type mockOutputDevice struct {
|
|||||||
toolFn func(string, map[string]interface{}) (interface{}, error)
|
toolFn func(string, map[string]interface{}) (interface{}, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *mockOutputDevice) Name() string { return d.name }
|
func (d *mockOutputDevice) Name() string { return d.name }
|
||||||
func (d *mockOutputDevice) Type() agentIO.DeviceType { return agentIO.DeviceOutput }
|
func (d *mockOutputDevice) Type() agentIO.DeviceType { return agentIO.DeviceOutput }
|
||||||
func (d *mockOutputDevice) Description() string { return "mock " + d.name }
|
func (d *mockOutputDevice) Description() string { return "mock " + d.name }
|
||||||
func (d *mockOutputDevice) Tools() []agentIO.ToolDef { return d.tools }
|
func (d *mockOutputDevice) Tools() []agentIO.ToolDef { return d.tools }
|
||||||
func (d *mockOutputDevice) Start() error { return nil }
|
func (d *mockOutputDevice) Start() error { return nil }
|
||||||
func (d *mockOutputDevice) Stop() error { return nil }
|
func (d *mockOutputDevice) Stop() error { return nil }
|
||||||
func (d *mockOutputDevice) OutputCapabilities() agentIO.OutputCapability { return d.caps }
|
func (d *mockOutputDevice) OutputCapabilities() agentIO.OutputCapability { return d.caps }
|
||||||
func (d *mockOutputDevice) ChannelDef() agentIO.ChannelDef { return agentIO.ChannelDef{} }
|
func (d *mockOutputDevice) ChannelDef() agentIO.ChannelDef { return agentIO.ChannelDef{} }
|
||||||
func (d *mockOutputDevice) Execute(tool string, args map[string]interface{}) (interface{}, error) {
|
func (d *mockOutputDevice) Execute(tool string, args map[string]interface{}) (interface{}, error) {
|
||||||
if d.toolFn != nil {
|
if d.toolFn != nil {
|
||||||
return d.toolFn(tool, args)
|
return d.toolFn(tool, args)
|
||||||
@ -67,8 +67,8 @@ func TestExecuteOutputSendTool(t *testing.T) {
|
|||||||
"type": "text",
|
"type": "text",
|
||||||
}}
|
}}
|
||||||
result := a.executeOutputSendTool(tc)
|
result := a.executeOutputSendTool(tc)
|
||||||
if !strings.Contains(result, "screen") {
|
if result != "ok" {
|
||||||
t.Errorf("unexpected result: %s", result)
|
t.Errorf("expected ok, got: %s", result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -38,6 +38,7 @@ type ContextEvent struct {
|
|||||||
Blocks []memory.MemoryBlock `json:"blocks,omitempty"` // 一等记忆块(text/image/video/audio)
|
Blocks []memory.MemoryBlock `json:"blocks,omitempty"` // 一等记忆块(text/image/video/audio)
|
||||||
Vector vector.Vector `json:"-"` // 稀疏词向量(TF-IDF/fastText 空间)
|
Vector vector.Vector `json:"-"` // 稀疏词向量(TF-IDF/fastText 空间)
|
||||||
DenseVec []float64 `json:"-"` // 稠密多模态向量(与媒体/文档共享空间)
|
DenseVec []float64 `json:"-"` // 稠密多模态向量(与媒体/文档共享空间)
|
||||||
|
DenseFP string `json:"-"` // DenseVec 所属统一空间指纹(缓存字段,不持久化)
|
||||||
}
|
}
|
||||||
|
|
||||||
const contextFlushInterval = 5 * time.Second
|
const contextFlushInterval = 5 * time.Second
|
||||||
@ -196,16 +197,29 @@ func (c *RelevanceContext) channelCleanerForDoc() document.ChannelCleaner {
|
|||||||
|
|
||||||
func (c *RelevanceContext) computeVector(evt *ContextEvent) {
|
func (c *RelevanceContext) computeVector(evt *ContextEvent) {
|
||||||
text := textForVector(evt, c.toolDefLookup, c.channelDefLookup)
|
text := textForVector(evt, c.toolDefLookup, c.channelDefLookup)
|
||||||
if text == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// 稀疏向量始终计算(TF-IDF/fastText,退化时仍可用)
|
// 稀疏向量始终计算(TF-IDF/fastText,退化时仍可用)
|
||||||
evt.Vector = c.embedder.Vectorize(text)
|
if text != "" {
|
||||||
// 稠密向量仅在配置了多模态空间时计算
|
evt.Vector = c.embedder.Vectorize(text)
|
||||||
|
}
|
||||||
|
// 稠密向量:文本向量 ⊕ 本事件持有的一等记忆块媒体向量(同一统一空间)。
|
||||||
|
// 只有媒体的输入(无文本)也要有可比较的坐标,因此不再按 text=="" 提前返回。
|
||||||
if c.denseSpace != nil && c.denseSpace.Loaded() {
|
if c.denseSpace != nil && c.denseSpace.Loaded() {
|
||||||
if dv, err := c.denseSpace.VectorizeDense(text); err == nil {
|
fp := c.denseSpace.Fingerprint()
|
||||||
evt.DenseVec = dv
|
var parts [][]float64
|
||||||
|
if text != "" {
|
||||||
|
if dv, err := c.denseSpace.VectorizeDense(text); err == nil && len(dv) > 0 {
|
||||||
|
parts = append(parts, dv)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
for _, b := range evt.Blocks {
|
||||||
|
// 只融合同指纹的块向量:另一套坐标系的向量混进来会算出
|
||||||
|
// 两边都不像的方向。
|
||||||
|
if len(b.Vector) > 0 && b.Fingerprint == fp {
|
||||||
|
parts = append(parts, b.Vector)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
evt.DenseVec = vector.FuseVectors(parts...)
|
||||||
|
evt.DenseFP = fp
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -312,9 +326,11 @@ func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *docume
|
|||||||
// 优先使用稠密向量余弦(与媒体/文档共享空间);退化到稀疏词向量。
|
// 优先使用稠密向量余弦(与媒体/文档共享空间);退化到稀疏词向量。
|
||||||
var queryDense []float64
|
var queryDense []float64
|
||||||
useDense := false
|
useDense := false
|
||||||
|
queryFP := ""
|
||||||
if c.denseSpace != nil && c.denseSpace.Loaded() {
|
if c.denseSpace != nil && c.denseSpace.Loaded() {
|
||||||
if dv, err := c.denseSpace.VectorizeDense(currentInput); err == nil {
|
if dv, err := c.denseSpace.VectorizeDense(currentInput); err == nil {
|
||||||
queryDense = dv
|
queryDense = dv
|
||||||
|
queryFP = c.denseSpace.Fingerprint()
|
||||||
useDense = true
|
useDense = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -323,7 +339,9 @@ func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *docume
|
|||||||
scoredEvents := make([]scoredEvent, len(candidates))
|
scoredEvents := make([]scoredEvent, len(candidates))
|
||||||
for i, evt := range candidates {
|
for i, evt := range candidates {
|
||||||
var score float64
|
var score float64
|
||||||
if useDense && len(evt.DenseVec) == len(queryDense) {
|
// 只在同一统一空间内比稠密余弦:换了模型/维度后旧事件的向量
|
||||||
|
// 属于另一个坐标系,拿来比会得到无意义的分数。
|
||||||
|
if useDense && evt.DenseFP == queryFP && len(evt.DenseVec) == len(queryDense) {
|
||||||
score = vector.DenseCosine(queryDense, evt.DenseVec)
|
score = vector.DenseCosine(queryDense, evt.DenseVec)
|
||||||
} else {
|
} else {
|
||||||
score = vector.CosineSimilarity(queryVec, evt.Vector)
|
score = vector.CosineSimilarity(queryVec, evt.Vector)
|
||||||
|
|||||||
@ -12,8 +12,8 @@ import (
|
|||||||
|
|
||||||
// CrossModalHit 是跨模态检索融合后的一条候选。
|
// CrossModalHit 是跨模态检索融合后的一条候选。
|
||||||
//
|
//
|
||||||
// 统一的检索单元是记忆块而非 CAS 全库:媒体在 L0/L2/L3 都和文本一样有 owner,
|
// 统一的检索单元是记忆块而非 CAS 全库:媒体在 L0/L2/L3 都由层容器持有,
|
||||||
// 只有仍被某层记忆引用的媒体才可召回。Doc 是 L2 文档块;Media 是该块携带的
|
// 只有仍被某层记忆块持有的媒体才可召回。Doc 是 L2 文档;Media 是该块携带的
|
||||||
// 原生媒体坐标。两路分数尺度不同,融合前各自归一化,见 fuseCrossModal。
|
// 原生媒体坐标。两路分数尺度不同,融合前各自归一化,见 fuseCrossModal。
|
||||||
type CrossModalHit struct {
|
type CrossModalHit struct {
|
||||||
Doc *document.Doc // 文本路命中的文档;视觉路命中时为 nil
|
Doc *document.Doc // 文本路命中的文档;视觉路命中时为 nil
|
||||||
@ -67,10 +67,10 @@ func (c CrossModalFusionConfig) minMaxEps() float64 {
|
|||||||
// retrieveCrossModal 是跨模态并行检索的统一入口。
|
// retrieveCrossModal 是跨模态并行检索的统一入口。
|
||||||
//
|
//
|
||||||
// 策略(两路并行,召回真正最相似的):
|
// 策略(两路并行,召回真正最相似的):
|
||||||
// 1. 文本路:query 整段文本用现有方法(fastText/TF-IDF 稀疏 cosine)查文档层,
|
// 1. 文本路:query 整段文本编码后查文档层(Doc.DenseVec 已融合其块的媒体向量),
|
||||||
// 每个命中文档再反查其关联媒体(docMediaContext)——描述文本命中即媒体命中。
|
// 命中文档若持有媒体块,直接带上该块。
|
||||||
// 2. 视觉路:query 整段文本经多模态模型文本编码 → 与媒体库全部图像坐标比余弦
|
// 2. 视觉路:query 经多模态模型文本编码 → 与媒体块向量比余弦
|
||||||
// (QueryMediaScored),覆盖描述文本没写到的视觉内容。
|
// (QueryMediaScored),覆盖文本向量没写到的视觉内容。
|
||||||
// 3. 融合:两条路候选各自 min-max 归一化到 [0,1],加权求和后降序,取 topK。
|
// 3. 融合:两条路候选各自 min-max 归一化到 [0,1],加权求和后降序,取 topK。
|
||||||
// 同一媒体被两路同时命中视为双信号确认,额外加权。
|
// 同一媒体被两路同时命中视为双信号确认,额外加权。
|
||||||
//
|
//
|
||||||
@ -90,8 +90,7 @@ func (a *Agent) retrieveCrossModal(query string, topK int, cfg CrossModalFusionC
|
|||||||
if a.docStore != nil {
|
if a.docStore != nil {
|
||||||
for _, dh := range a.docStore.QueryScored(query, per) {
|
for _, dh := range a.docStore.QueryScored(query, per) {
|
||||||
hit := CrossModalHit{Doc: dh.Doc, DocScore: dh.Score}
|
hit := CrossModalHit{Doc: dh.Doc, DocScore: dh.Score}
|
||||||
// 命中文档若持有一等记忆块,把首个媒体块一并带上:
|
// 命中文档若持有一等记忆块,把首个媒体块一并带上。
|
||||||
// 描述文本命中 → 该媒体就是相关记忆,供后续展示/注入。
|
|
||||||
if a.mediaStore != nil && len(dh.Doc.Blocks) > 0 {
|
if a.mediaStore != nil && len(dh.Doc.Blocks) > 0 {
|
||||||
if it, err := a.mediaStore.Stat(dh.Doc.Blocks[0].PayloadDigest); err == nil {
|
if it, err := a.mediaStore.Stat(dh.Doc.Blocks[0].PayloadDigest); err == nil {
|
||||||
hit.Media = it
|
hit.Media = it
|
||||||
@ -110,7 +109,13 @@ func (a *Agent) retrieveCrossModal(query string, topK int, cfg CrossModalFusionC
|
|||||||
} else if mh, err := a.mediaStore.QueryMediaScored(qv, a.multimodalSpace.Fingerprint(), per); err != nil {
|
} else if mh, err := a.mediaStore.QueryMediaScored(qv, a.multimodalSpace.Fingerprint(), per); err != nil {
|
||||||
log.Printf("[crossmodal] 媒体记忆检索失败: %v", err)
|
log.Printf("[crossmodal] 媒体记忆检索失败: %v", err)
|
||||||
} else {
|
} else {
|
||||||
|
// 只有仍被某层记忆块持有的媒体才可召回:CAS 是全库字节存储,
|
||||||
|
// 直接拿它的检索结果会把已无处可归的内容也从记忆里翻出来。
|
||||||
|
held := a.heldMediaDigests()
|
||||||
for _, h := range mh {
|
for _, h := range mh {
|
||||||
|
if h.Item == nil || !held[h.Item.Digest] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
visualHits = append(visualHits, CrossModalHit{
|
visualHits = append(visualHits, CrossModalHit{
|
||||||
Media: h.Item, MediaScore: h.Score,
|
Media: h.Item, MediaScore: h.Score,
|
||||||
})
|
})
|
||||||
@ -228,8 +233,7 @@ func fuseCrossModal(textHits, visualHits []CrossModalHit, topK int, cfg CrossMod
|
|||||||
}
|
}
|
||||||
|
|
||||||
// crossModalMarkdown 把融合候选渲染成注入上下文的文本。
|
// crossModalMarkdown 把融合候选渲染成注入上下文的文本。
|
||||||
// 文档行对齐既有【相关记忆文档】格式;媒体行复用 mediaMarkerLine 的
|
// 文档行给出摘要;媒体行只给 MIME + 短 digest(不再有生成的描述)。
|
||||||
// `[<mime> <短digest>] <描述>` 格式(那是解析回媒体引用的唯一合法格式)。
|
|
||||||
func (a *Agent) crossModalMarkdown(hits []CrossModalHit) string {
|
func (a *Agent) crossModalMarkdown(hits []CrossModalHit) string {
|
||||||
if len(hits) == 0 {
|
if len(hits) == 0 {
|
||||||
return ""
|
return ""
|
||||||
@ -253,7 +257,7 @@ func (a *Agent) crossModalMarkdown(hits []CrossModalHit) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if h.Media != nil {
|
if h.Media != nil {
|
||||||
if line := a.mediaMarkerLine(h.Media.Digest); line != "" {
|
if line := mediaLabel(h.Media); line != "" {
|
||||||
parts = append(parts, line)
|
parts = append(parts, line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -204,8 +204,15 @@ func (a *Agent) archiveColdDocs() {
|
|||||||
}
|
}
|
||||||
log.Printf("[agent] doc→graph: %s → %d entities, %d relations, %d blocks", doc.ID, ec, rc, blocks)
|
log.Printf("[agent] doc→graph: %s → %d entities, %d relations, %d blocks", doc.ID, ec, rc, blocks)
|
||||||
|
|
||||||
// 文档的一等记忆块已随句子写进 L3(身份不变,由 bindSentenceBlocks
|
// 文档持有的一等块写入 L3,并以 document --contains--> block 边关联;
|
||||||
// 复用 doc.Blocks 的 ID);块不再挂在文档上,删除文档即完成迁移。
|
// 块 ID 原样保留(迁移而非重建)。块迁走后删除文档即完成迁移。
|
||||||
|
if len(doc.Blocks) > 0 {
|
||||||
|
if bound := a.linkBlocksToDocument(doc.ID, doc.Blocks); bound != len(doc.Blocks) {
|
||||||
|
log.Printf("[agent] doc→graph: %s 块迁移不完整 (%d/%d),保留文档待下轮重试",
|
||||||
|
doc.ID, bound, len(doc.Blocks))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
a.docStore.Remove(doc.ID)
|
a.docStore.Remove(doc.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -430,14 +437,9 @@ func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 媒体三元组:确定性产出,先于 NLP 提取。
|
// 媒体不再参与三元组:它作为一等块由 linkBlocksToDocument
|
||||||
//
|
// 写入 L3 并以 document --contains--> block 边关联,
|
||||||
// 媒体入 L3 曾完全依赖提取器碰巧从描述文本里提出合规三元组——实测
|
// 不经过文本描述与 NLP 提取器。
|
||||||
// LLM 的 477 字图片描述只产出「水平 -分割-> 成」这类语法碎片,
|
|
||||||
// obj 仅 1 字被 validEntityName 拒掉,整条媒体记忆就进不了图库
|
|
||||||
//(阶段性表现是"时好时坏",取决于提取器运气)。媒体自身的
|
|
||||||
// digest / mime / 描述都是确定的,直接建三元组而不经提取器。
|
|
||||||
triples = append(triples, mediaTriplesFromText(doc.Content)...)
|
|
||||||
|
|
||||||
// NLP 通用提取
|
// NLP 通用提取
|
||||||
e := nlp.NewExtractor(nil)
|
e := nlp.NewExtractor(nil)
|
||||||
|
|||||||
@ -437,9 +437,6 @@ func (a *Agent) processInput(evt *agentIO.InputEvent) {
|
|||||||
ToolResults: toolResults,
|
ToolResults: toolResults,
|
||||||
}
|
}
|
||||||
a.bindEventMedia(&turnEvt, a.drainMediaDigests())
|
a.bindEventMedia(&turnEvt, a.drainMediaDigests())
|
||||||
if s := a.mediaSummaryForEvent(turnEvt.Blocks); s != "" {
|
|
||||||
turnEvt.Input = turnEvt.Input + "\n" + s
|
|
||||||
}
|
|
||||||
a.context.Append(turnEvt)
|
a.context.Append(turnEvt)
|
||||||
|
|
||||||
a.emitResponse(evt, response)
|
a.emitResponse(evt, response)
|
||||||
|
|||||||
@ -3,321 +3,146 @@ package core
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"regexp"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||||
|
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||||
)
|
)
|
||||||
|
|
||||||
// L3 图库的媒体绑定。
|
// L3 图库的媒体绑定。
|
||||||
//
|
//
|
||||||
// 媒体在 L3 是一等记忆块(memory_blocks),通过 sentence --contains--> block
|
// 媒体在 L3 是一等记忆块(memory_blocks),以结构边与承载它的节点相连:
|
||||||
// 结构边与承载它的句子相连。不再用 media_refs / owner 账本保活。
|
// sentence --contains--> block(对话/三元组产生的记忆)
|
||||||
|
// document --contains--> block(L2 文档归档进 L3)
|
||||||
//
|
//
|
||||||
// 图库里的实体与关系仍来自描述文本的 NLP 提取;媒体块只是补上
|
// 这里不再有任何 marker 文本、正则反解或"描述文本当索引"的路径:
|
||||||
// 「这条记忆当时带着哪份媒体」这一结构信息。
|
// 媒体只按自己的统一空间向量被检索,图库/文档只记录它的结构归属。
|
||||||
|
|
||||||
// mediaDigestPattern 匹配事件摘要里的媒体标记 [<mime或kind> <短digest>]。
|
// migrateLegacyGraphMedia 把 marker 反解出来的旧媒体实体迁移成原生一等块。
|
||||||
//
|
//
|
||||||
// 与 mediaSummaryForEvent 的输出格式对应。短 digest 是 12 位十六进制
|
// 旧数据里媒体是 type=Media 的普通实体(「图片 a1b2c3d4e5f6」),
|
||||||
// (shortDigest 的截断长度),这里放宽到 8-64 位以容忍将来调整截断长度,
|
// 靠生成的描述文本当索引。迁移后它变成真正的记忆块,以
|
||||||
// 以及有人手写了完整 digest 的情况。
|
// sentence --contains--> block 结构边挂回原句子,旧实体与描述关系删除。
|
||||||
var mediaDigestPattern = regexp.MustCompile(`\[[^\[\]]*?\b([0-9a-f]{8,64})\]`)
|
// 迁移幂等(实体处理完即删除),因此在每个 Agent 启动时跑一次是安全的。
|
||||||
|
func (a *Agent) migrateLegacyGraphMedia() {
|
||||||
// mediaMarkerPattern 完整拆解一条媒体标记及其后跟的描述,
|
if a.memory == nil || a.mediaStore == nil {
|
||||||
// 捕获组依次为:标签(mime 或 kind)、短 digest、该行剩余的描述文本。
|
return
|
||||||
//
|
|
||||||
// 与 mediaSummaryForEvent 的输出格式严格对应:
|
|
||||||
//
|
|
||||||
// [image/png a1b2c3d4e5f6] 一张紫蓝红三色带图
|
|
||||||
//
|
|
||||||
// 描述取到行尾而非贪婪到底:一条事件可能挂多个媒体,各占一行。
|
|
||||||
var mediaMarkerPattern = regexp.MustCompile(`\[([^\[\]\s]+)\s+([0-9a-f]{8,64})\]([^\n]*)`)
|
|
||||||
|
|
||||||
// mediaMarker 是从文档正文里解析出的一条媒体标记。
|
|
||||||
type mediaMarker struct {
|
|
||||||
label string // mime 或 kind,如 image/png
|
|
||||||
shortDigest string
|
|
||||||
description string
|
|
||||||
raw string // 原始整段,用作三元组的 SentenceText
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseMediaMarkers 从文本里解析全部媒体标记。
|
|
||||||
//
|
|
||||||
// 为何需要它而不只是 extractMediaDigests:媒体入 L3 曾完全依赖 NLP 提取器
|
|
||||||
// 碰巧从描述文本里提出合规三元组——实测 LLM 的 477 字图片描述只产出
|
|
||||||
// 「水平 -分割-> 成」这种语法碎片,obj 仅 1 字被 validEntityName 拒掉,
|
|
||||||
// 于是整条媒体记忆进不了图库。而媒体自身的信息(digest / mime / 描述)
|
|
||||||
// 是确定的,不该受提取器运气支配。
|
|
||||||
func parseMediaMarkers(text string) []mediaMarker {
|
|
||||||
if text == "" {
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
ms := mediaMarkerPattern.FindAllStringSubmatch(text, -1)
|
blocks, entities, err := a.memory.MigrateLegacyMediaEntities(func(short string) (memory.MemoryBlock, bool) {
|
||||||
if len(ms) == 0 {
|
full, err := a.mediaStore.ResolvePrefix(short)
|
||||||
return nil
|
if err != nil {
|
||||||
}
|
return memory.MemoryBlock{}, false
|
||||||
seen := make(map[string]bool, len(ms))
|
|
||||||
var out []mediaMarker
|
|
||||||
for _, m := range ms {
|
|
||||||
d := m[2]
|
|
||||||
if seen[d] {
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
seen[d] = true
|
return a.blockFromDigest(full)
|
||||||
out = append(out, mediaMarker{
|
})
|
||||||
label: m[1],
|
if err != nil {
|
||||||
shortDigest: d,
|
log.Printf("[media] 旧媒体实体迁移失败(下轮重试): %v", err)
|
||||||
description: strings.TrimSpace(m[3]),
|
return
|
||||||
raw: strings.TrimSpace(m[0]),
|
}
|
||||||
})
|
if blocks > 0 || entities > 0 {
|
||||||
|
log.Printf("[media] 旧媒体实体迁移完成: 新建 %d 个原生块,删除 %d 个描述式实体", blocks, entities)
|
||||||
}
|
}
|
||||||
return out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// mediaEntityName 是媒体在图库里的实体名。
|
// attachBlocksToSentence 把一组 digest 变成 L3 一等块并挂到句子上。
|
||||||
//
|
// seed 允许复用已持有块的 ID(L2→L3 迁移保持块身份不变)。
|
||||||
// 形如「图片 a1b2c3d4e5f6」。刻意用 digest 而非描述文本构成名字:
|
func (a *Agent) attachBlocksToSentence(sentenceID int64, digests []string, seed map[string]memory.MemoryBlock) int {
|
||||||
// 描述会被重新生成(换视觉模型、补描述),若名字取自描述,同一张图
|
if a.mediaStore == nil || a.memory == nil || sentenceID == 0 {
|
||||||
// 就会在图谱上留下多个节点。digest 不变则名字不变。
|
|
||||||
// 长度也天然合规(validEntityName 要求 2–50 字符)。
|
|
||||||
func mediaEntityName(label, shortDigest string) string {
|
|
||||||
kind := "媒体"
|
|
||||||
switch {
|
|
||||||
case strings.HasPrefix(label, "image"):
|
|
||||||
kind = "图片"
|
|
||||||
case strings.HasPrefix(label, "audio"):
|
|
||||||
kind = "音频"
|
|
||||||
case strings.HasPrefix(label, "video"):
|
|
||||||
kind = "视频"
|
|
||||||
}
|
|
||||||
return kind + " " + shortDigest
|
|
||||||
}
|
|
||||||
|
|
||||||
// mediaTriplesFromText 为文本里的每条媒体标记产出确定的三元组。
|
|
||||||
//
|
|
||||||
// 这是媒体进 L3 的可靠路径:不经过 NLP 提取器,因此不受它对描述性文本
|
|
||||||
// 提取能力的影响。每条媒体至少产出一条「<媒体实体> -内容-> <描述摘要>」,
|
|
||||||
// 且 SentenceText 用原始标记段,保证 bindSentenceMedia 的正则必然能
|
|
||||||
// 反解到 digest——绑定从概率事件变成确定行为。
|
|
||||||
//
|
|
||||||
// 描述摘要截到 40 字:validEntityName 上限 50 字符,留出余量;
|
|
||||||
// 图谱节点名过长会让可视化和实体合并都难以处理,完整描述留在
|
|
||||||
// SentenceText 与 media 表里。
|
|
||||||
func mediaTriplesFromText(text string) []memory.Triple {
|
|
||||||
markers := parseMediaMarkers(text)
|
|
||||||
if len(markers) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var out []memory.Triple
|
|
||||||
for _, m := range markers {
|
|
||||||
name := mediaEntityName(m.label, m.shortDigest)
|
|
||||||
|
|
||||||
// 类型三元组恒可产出,不依赖描述是否存在
|
|
||||||
out = append(out, memory.Triple{
|
|
||||||
Subject: name,
|
|
||||||
SubjectType: "Media",
|
|
||||||
Relation: "类型",
|
|
||||||
Object: m.label,
|
|
||||||
ObjectType: "MimeType",
|
|
||||||
Confidence: 1.0,
|
|
||||||
SentenceText: m.raw,
|
|
||||||
})
|
|
||||||
|
|
||||||
desc := summarizeForEntity(m.description, 40)
|
|
||||||
if desc == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, memory.Triple{
|
|
||||||
Subject: name,
|
|
||||||
SubjectType: "Media",
|
|
||||||
Relation: "内容",
|
|
||||||
Object: desc,
|
|
||||||
ObjectType: "Description",
|
|
||||||
Confidence: 1.0,
|
|
||||||
SentenceText: m.raw,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// summarizeForEntity 把描述压成可作实体名的短串。
|
|
||||||
//
|
|
||||||
// 取首个句子边界之前的内容,再按 rune 截断——直接按字节截会切坏 UTF-8,
|
|
||||||
// 图库里就会出现乱码实体名。空白与 Markdown 强调符号一并清掉,
|
|
||||||
// 否则「**整体构成**」这类标记会进实体名。
|
|
||||||
func summarizeForEntity(s string, maxRunes int) string {
|
|
||||||
s = strings.TrimSpace(s)
|
|
||||||
if s == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
s = strings.NewReplacer("**", "", "*", "", "\n", " ", "\t", " ").Replace(s)
|
|
||||||
for _, sep := range []string{"。", ";", ",", ". ", "; "} {
|
|
||||||
if i := strings.Index(s, sep); i > 0 {
|
|
||||||
s = s[:i]
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s = strings.TrimSpace(s)
|
|
||||||
r := []rune(s)
|
|
||||||
if len(r) > maxRunes {
|
|
||||||
r = r[:maxRunes]
|
|
||||||
}
|
|
||||||
out := strings.TrimSpace(string(r))
|
|
||||||
// 太短的残片(如单字)过不了 validEntityName,直接放弃比写进去更好
|
|
||||||
if len([]rune(out)) < 2 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractMediaDigests 从文本里找出所有媒体标记的 digest。
|
|
||||||
//
|
|
||||||
// 为何靠正则从文本反解,而不是让三元组结构携带 digest:三元组是 NLP
|
|
||||||
// 提取器从纯文本产出的(nlp.ToMemoryTriple 只填 Subject/Relation/Object/
|
|
||||||
// Confidence/SentenceText),提取链路上没有任何位置能塞进结构化的 digest。
|
|
||||||
// 若要贯通就得改 internal/nlp 的整条数据流——而媒体标记本身就是我们
|
|
||||||
// 自己按固定格式写进文本的,反解是这里最省的可靠做法。
|
|
||||||
func extractMediaDigests(text string) []string {
|
|
||||||
if text == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
matches := mediaDigestPattern.FindAllStringSubmatch(text, -1)
|
|
||||||
if len(matches) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
seen := make(map[string]bool, len(matches))
|
|
||||||
var out []string
|
|
||||||
for _, m := range matches {
|
|
||||||
d := m[1]
|
|
||||||
if seen[d] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[d] = true
|
|
||||||
out = append(out, d)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// bindSentenceBlocks 把句子文本里提到的媒体变成 L3 的一等记忆块,
|
|
||||||
// 并建立 sentence --contains--> block 结构边。
|
|
||||||
//
|
|
||||||
// seed 是本批文档已持有的一等块:迁移时按 digest 复用它们的身份(ID 不变),
|
|
||||||
// 真正做到“同一个块从 L2 移到 L3”,而不是另建一个同内容的新块。
|
|
||||||
// 返回本次写入 L3 的块数。
|
|
||||||
func (a *Agent) bindSentenceBlocks(sentenceIDs map[string]int64, seed []memory.MemoryBlock) int {
|
|
||||||
if a.mediaStore == nil || a.memory == nil || len(sentenceIDs) == 0 {
|
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
bound := 0
|
||||||
|
for _, d := range digests {
|
||||||
|
full, err := a.mediaStore.ResolvePrefix(d)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[media] digest %s 无法解析: %v", d, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b, ok := seed[full]
|
||||||
|
if !ok {
|
||||||
|
if b, ok = a.blockFromDigest(full); !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := a.memory.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil {
|
||||||
|
log.Printf("[media] L3 块写入失败 (%s): %v", shortDigest(full), err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := a.memory.AddMemoryBlockEdge("sentence", strconv.FormatInt(sentenceID, 10), "block", b.ID, "contains"); err != nil {
|
||||||
|
log.Printf("[media] 句子→块边建立失败 (%s): %v", shortDigest(full), err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
bound++
|
||||||
|
}
|
||||||
|
return bound
|
||||||
|
}
|
||||||
|
|
||||||
|
// linkBlocksToDocument 把文档持有的块写入 L3,并建立
|
||||||
|
// document --contains--> block 边。块的 ID 原样保留(迁移而非重建)。
|
||||||
|
func (a *Agent) linkBlocksToDocument(docID string, blocks []memory.MemoryBlock) int {
|
||||||
|
if a.memory == nil || docID == "" || len(blocks) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if err := a.memory.PutDocumentNode(docID, ""); err != nil {
|
||||||
|
log.Printf("[media] 写入 L3 文档节点失败 (%s): %v", docID, err)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if err := a.memory.PutMemoryBlocks(blocks); err != nil {
|
||||||
|
log.Printf("[media] 写入 L3 记忆块失败 (doc %s): %v", docID, err)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
bound := 0
|
||||||
|
for _, b := range blocks {
|
||||||
|
if err := a.memory.AddMemoryBlockEdge("document", docID, "block", b.ID, "contains"); err != nil {
|
||||||
|
log.Printf("[media] 文档→块边建立失败 (%s): %v", shortDigest(b.PayloadDigest), err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
bound++
|
||||||
|
}
|
||||||
|
return bound
|
||||||
|
}
|
||||||
|
|
||||||
|
// commitTriplesWithMedia 提交三元组并把三元组显式携带的媒体变成 L3 一等块。
|
||||||
|
//
|
||||||
|
// seed 是调用方已持有的一等块(如 L2 文档的 Blocks),用于保持块身份;
|
||||||
|
// 普通对话路径传 nil。blocks 是本次写入 L3 的块数。
|
||||||
|
func (a *Agent) commitTriplesWithMedia(triples []memory.Triple, sessionID string, turnID int, seed []memory.MemoryBlock) (entities, relations, blocks int, err error) {
|
||||||
|
if a.memory == nil {
|
||||||
|
return 0, 0, 0, fmt.Errorf("graph memory 未启用")
|
||||||
|
}
|
||||||
|
if a.mediaStore == nil {
|
||||||
|
ec, rc, cErr := a.memory.Commit(triples, sessionID, turnID)
|
||||||
|
return ec, rc, 0, cErr
|
||||||
|
}
|
||||||
|
sentenceIDs, ec, rc, err := a.memory.CommitWithMedia(triples, sessionID, turnID)
|
||||||
|
if err != nil {
|
||||||
|
return ec, rc, 0, err
|
||||||
|
}
|
||||||
byDigest := make(map[string]memory.MemoryBlock, len(seed))
|
byDigest := make(map[string]memory.MemoryBlock, len(seed))
|
||||||
for _, b := range seed {
|
for _, b := range seed {
|
||||||
if b.PayloadDigest != "" {
|
if b.PayloadDigest != "" {
|
||||||
byDigest[b.PayloadDigest] = b
|
byDigest[b.PayloadDigest] = b
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, t := range triples {
|
||||||
bound := 0
|
if len(t.MediaDigests) == 0 {
|
||||||
for text, sid := range sentenceIDs {
|
continue
|
||||||
|
}
|
||||||
|
sid := sentenceIDs[t.SentenceText]
|
||||||
if sid == 0 {
|
if sid == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, short := range extractMediaDigests(text) {
|
blocks += a.attachBlocksToSentence(sid, t.MediaDigests, byDigest)
|
||||||
full, err := a.mediaStore.ResolvePrefix(short)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
b, ok := byDigest[full]
|
|
||||||
if !ok {
|
|
||||||
if b, ok = a.blockFromDigest(full); !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := a.memory.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil {
|
|
||||||
log.Printf("[media] L3 记忆块写入失败 (%s): %v", shortDigest(full), err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := a.memory.AddMemoryBlockEdge("sentence", strconv.FormatInt(sid, 10), "block", b.ID, "contains"); err != nil {
|
|
||||||
log.Printf("[media] 句子→块边建立失败 (%s): %v", shortDigest(full), err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
bound++
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if bound > 0 {
|
return ec, rc, blocks, nil
|
||||||
log.Printf("[media] L3 图库写入 %d 个一等记忆块", bound)
|
|
||||||
}
|
|
||||||
return bound
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// sentenceWithMediaMarkers 保证句子文本里带上这些 digest 的媒体标记。
|
// RecallBlocksForSentence 反查某条图库句子持有的一等记忆块。
|
||||||
//
|
func (a *Agent) RecallBlocksForSentence(sentenceID int64) ([]memory.MemoryBlock, error) {
|
||||||
// 存在的理由:L3 的块边由句子正文里的短 digest 反解而来。模型只知道
|
if a.memory == nil {
|
||||||
// digest(从 memory_recall 的「关联媒体」或对话里的媒体标记读到),
|
return nil, nil
|
||||||
// 不该要求它自己按内核格式拼标记——格式写错的后果是块边静默建不起来。
|
|
||||||
//
|
|
||||||
// 已出现过的 digest 不重复追加:模型可能既写了标记又填了 media_digests。
|
|
||||||
func (a *Agent) sentenceWithMediaMarkers(sentence string, digests []string) string {
|
|
||||||
if a.mediaStore == nil || len(digests) == 0 {
|
|
||||||
return sentence
|
|
||||||
}
|
}
|
||||||
present := make(map[string]bool)
|
return a.memory.BlocksForNode("sentence", strconv.FormatInt(sentenceID, 10))
|
||||||
for _, d := range extractMediaDigests(sentence) {
|
|
||||||
present[d] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
var add []string
|
|
||||||
for _, d := range digests {
|
|
||||||
if d == "" || present[shortDigest(d)] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// 模型给的多半是短 digest(它在上下文里看到的就是短的),补全成完整
|
|
||||||
// digest 才能定位内容。补不上就跳过:内容可能已被删除。
|
|
||||||
full, err := a.mediaStore.ResolvePrefix(d)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("[media] 模型提交的 digest %s 无法解析: %v", d, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if line := a.mediaMarkerLine(full); line != "" {
|
|
||||||
add = append(add, line)
|
|
||||||
present[shortDigest(full)] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(add) == 0 {
|
|
||||||
return sentence
|
|
||||||
}
|
|
||||||
if sentence == "" {
|
|
||||||
return strings.Join(add, "\n")
|
|
||||||
}
|
|
||||||
return sentence + "\n" + strings.Join(add, "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// docMediaContext 为一篇文档产出媒体说明,供 doc_query 拼进工具返回值。
|
|
||||||
//
|
|
||||||
// 文档的一等记忆块随文档 JSON 持久化;这里只有正文,因此从正文标记反解。
|
|
||||||
func (a *Agent) docMediaContext(docID, content string) string {
|
|
||||||
if a.mediaStore == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
// 文档的一等记忆块随文档 JSON 持久化;这里只有正文,退回解析标记。
|
|
||||||
var digests []string
|
|
||||||
for _, short := range extractMediaDigests(content) {
|
|
||||||
full, err := a.mediaStore.ResolvePrefix(short)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
digests = append(digests, full)
|
|
||||||
}
|
|
||||||
var lines []string
|
|
||||||
for _, d := range digests {
|
|
||||||
if line := a.mediaMarkerLine(d); line != "" {
|
|
||||||
lines = append(lines, line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(lines) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return strings.Join(lines, ";")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveMediaDigests 把模型给的(多为短)digest 补全成完整 digest。
|
// resolveMediaDigests 把模型给的(多为短)digest 补全成完整 digest。
|
||||||
@ -344,38 +169,6 @@ func (a *Agent) resolveMediaDigests(digests []string) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// bindDocMedia 把一组完整 digest 挂到文档 owner 上,返回成功条数。
|
|
||||||
//
|
|
||||||
// commitTriplesWithMedia 提交三元组并把句子里的媒体变成 L3 一等块。
|
|
||||||
//
|
|
||||||
// seed 是调用方已持有的一等块(如 L2 文档的 Blocks),用于保持块身份;
|
|
||||||
// 普通对话路径传 nil。blocks 是本次写入 L3 的块数。
|
|
||||||
func (a *Agent) commitTriplesWithMedia(triples []memory.Triple, sessionID string, turnID int, seed []memory.MemoryBlock) (entities, relations, blocks int, err error) {
|
|
||||||
if a.memory == nil {
|
|
||||||
return 0, 0, 0, fmt.Errorf("graph memory 未启用")
|
|
||||||
}
|
|
||||||
// 媒体存储关闭时退回普通 Commit,省掉 sentenceIDs 的 map 分配。
|
|
||||||
if a.mediaStore == nil {
|
|
||||||
ec, rc, cErr := a.memory.Commit(triples, sessionID, turnID)
|
|
||||||
return ec, rc, 0, cErr
|
|
||||||
}
|
|
||||||
sentenceIDs, ec, rc, err := a.memory.CommitWithMedia(triples, sessionID, turnID)
|
|
||||||
if err != nil {
|
|
||||||
return ec, rc, 0, err
|
|
||||||
}
|
|
||||||
return ec, rc, a.bindSentenceBlocks(sentenceIDs, seed), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecallBlocksForSentence 反查某条图库句子持有的一等记忆块。
|
|
||||||
//
|
|
||||||
// 这是整层的目的:几个月后从图谱走到一条句子,要能取回当时那份媒体。
|
|
||||||
func (a *Agent) RecallBlocksForSentence(sentenceID int64) ([]memory.MemoryBlock, error) {
|
|
||||||
if a.memory == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return a.memory.BlocksForNode("sentence", strconv.FormatInt(sentenceID, 10))
|
|
||||||
}
|
|
||||||
|
|
||||||
// sentenceIDsFromRelations 收集一批关系引用的句子 id(去重、去零)。
|
// sentenceIDsFromRelations 收集一批关系引用的句子 id(去重、去零)。
|
||||||
//
|
//
|
||||||
// 关系行本身不持有媒体,媒体作为一等块以 sentence --contains--> block
|
// 关系行本身不持有媒体,媒体作为一等块以 sentence --contains--> block
|
||||||
@ -397,21 +190,14 @@ func sentenceIDsFromRelations(relations []memory.Relation) []int64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// mediaContextForRelations 是 mediaContextForSentences 的关系入口。
|
// mediaContextForRelations 是 mediaContextForSentences 的关系入口。
|
||||||
//
|
|
||||||
// 单独包一层是因为两个调用点(自动注入的 buildMemoryContext 与显式的
|
|
||||||
// memory_recall 工具)拿到的都是关系列表,不该各自重复"关系→句子"这步。
|
|
||||||
func (a *Agent) mediaContextForRelations(relations []memory.Relation) string {
|
func (a *Agent) mediaContextForRelations(relations []memory.Relation) string {
|
||||||
return a.mediaContextForSentences(sentenceIDsFromRelations(relations))
|
return a.mediaContextForSentences(sentenceIDsFromRelations(relations))
|
||||||
}
|
}
|
||||||
|
|
||||||
// mediaContextForInjectedEntities 为自动注入路径产出媒体说明。
|
// mediaContextForInjectedEntities 为自动注入路径产出媒体说明。
|
||||||
//
|
//
|
||||||
// 单独一条路径是因为 Indexer.BuildContext 刻意不返回关系
|
// Indexer.BuildContext 刻意不返回关系(只给实体索引以省 token),
|
||||||
// (Relations 恒为 nil,只给实体索引以省 token,细节留给 memory_recall)。
|
// 因此这里用命中的实体名再查一次关系,只为拿到 sentence_id。
|
||||||
// 于是自动注入拿不到 sentence_id,必须用命中的实体名再查一次关系。
|
|
||||||
//
|
|
||||||
// 这次额外查询只为取 sentence_id,深度固定 1:媒体是"这条记忆当时带的图",
|
|
||||||
// 不需要顺着关系network 扩散——扩散只会带出无关媒体并挤占 token。
|
|
||||||
func (a *Agent) mediaContextForInjectedEntities(injected *memory.InjectedContext) string {
|
func (a *Agent) mediaContextForInjectedEntities(injected *memory.InjectedContext) string {
|
||||||
if a.mediaStore == nil || a.memory == nil || injected == nil || len(injected.Entities) == 0 {
|
if a.mediaStore == nil || a.memory == nil || injected == nil || len(injected.Entities) == 0 {
|
||||||
return ""
|
return ""
|
||||||
@ -427,13 +213,30 @@ func (a *Agent) mediaContextForInjectedEntities(injected *memory.InjectedContext
|
|||||||
return a.mediaContextForRelations(res.Relations)
|
return a.mediaContextForRelations(res.Relations)
|
||||||
}
|
}
|
||||||
|
|
||||||
// mediaContextForSentences 给一组句子附上媒体说明,供召回时拼进提示词。
|
// blockLabelsForDoc 渲染文档持有块的标签(MIME + 短 digest),供 doc_query 展示。
|
||||||
|
func (a *Agent) blockLabelsForDoc(d *document.Doc) string {
|
||||||
|
if a.mediaStore == nil || d == nil || len(d.Blocks) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var parts []string
|
||||||
|
for _, b := range d.Blocks {
|
||||||
|
it, err := a.mediaStore.Stat(b.PayloadDigest)
|
||||||
|
if err != nil || it == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if line := mediaLabel(it); line != "" {
|
||||||
|
parts = append(parts, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ";")
|
||||||
|
}
|
||||||
|
|
||||||
|
// mediaContextForSentences 给一组句子附上其持有的一等块标签。
|
||||||
//
|
//
|
||||||
// 输出形如「句子 #12 关联媒体:[image/png a1b2c3d4e5f6] 一张紫蓝红三色带图」。
|
// 标签只含 MIME 与短 digest:图片按向量检索,标签的作用是告诉模型
|
||||||
// 描述文本本就在句子里,这里补的是「内容是否还在、能否重新看图」这个信息——
|
// "这条记忆当时带着哪份媒体、可用该 digest 取回字节"。
|
||||||
// 描述永存而字节可能已被淘汰,两者状态不同。
|
|
||||||
func (a *Agent) mediaContextForSentences(sentenceIDs []int64) string {
|
func (a *Agent) mediaContextForSentences(sentenceIDs []int64) string {
|
||||||
if a.memory == nil || len(sentenceIDs) == 0 {
|
if a.mediaStore == nil || a.memory == nil || len(sentenceIDs) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
var lines []string
|
var lines []string
|
||||||
@ -444,7 +247,11 @@ func (a *Agent) mediaContextForSentences(sentenceIDs []int64) string {
|
|||||||
}
|
}
|
||||||
var parts []string
|
var parts []string
|
||||||
for _, b := range blocks {
|
for _, b := range blocks {
|
||||||
if line := a.mediaMarkerLine(b.PayloadDigest); line != "" {
|
it, err := a.mediaStore.Stat(b.PayloadDigest)
|
||||||
|
if err != nil || it == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if line := mediaLabel(it); line != "" {
|
||||||
parts = append(parts, line)
|
parts = append(parts, line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,14 +13,18 @@ import (
|
|||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||||||
)
|
)
|
||||||
|
|
||||||
// L3 图库媒体引用测试。
|
// L3 图库媒体绑定测试。
|
||||||
//
|
//
|
||||||
// 这一层的目的只有一个:几个月后从图谱走到一条句子,要能取回当时那份媒体。
|
// 这一层的目的只有一个:几个月后从图谱走到一条句子,要能取回当时那份媒体。
|
||||||
// 媒体不再靠 media_refs 挂载,而是作为一等块进入 L3,并以
|
// 媒体作为一等块进入 L3,以结构边与承载节点相连:
|
||||||
// sentence --contains--> block 的结构边与句子相连。
|
//
|
||||||
|
// sentence --contains--> block(对话/三元组产生的记忆)
|
||||||
|
// document --contains--> block(L2 文档归档进 L3)
|
||||||
|
//
|
||||||
|
// 描述文本、marker 反解、由 marker 反推出的「媒体实体」全部已废弃,
|
||||||
|
// 因此这些测试也不存在任何按描述检索的断言。
|
||||||
|
|
||||||
// attachBlockToSentence 提交一条句子,把媒体变成 L3 一等块,并以
|
// attachBlockToSentence 提交一条句子并把媒体变成 L3 一等块。
|
||||||
// sentence --contains--> block 相连,返回句子 id 与块。
|
|
||||||
// 必须走真实提交:边要求两端都是真实图节点。
|
// 必须走真实提交:边要求两端都是真实图节点。
|
||||||
func attachBlockToSentence(t *testing.T, g *memory.GraphDB, ms *media.Store, sentenceText, digest string) (int64, memory.MemoryBlock) {
|
func attachBlockToSentence(t *testing.T, g *memory.GraphDB, ms *media.Store, sentenceText, digest string) (int64, memory.MemoryBlock) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@ -77,41 +81,10 @@ func newGraphMediaAgent(t *testing.T) (*Agent, *memory.GraphDB, *media.Store) {
|
|||||||
return &Agent{memory: g, mediaStore: ms}, g, ms
|
return &Agent{memory: g, mediaStore: ms}, g, ms
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExtractMediaDigests(t *testing.T) {
|
|
||||||
// 与 mediaSummaryForEvent 的输出格式对应
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
text string
|
|
||||||
want []string
|
|
||||||
}{
|
|
||||||
{"事件摘要格式", "媒体内容:\n[image/png a1b2c3d4e5f6] 一张紫蓝红三色带图", []string{"a1b2c3d4e5f6"}},
|
|
||||||
{"kind 兜底格式", "[image abcdef0123456789] (未描述)", []string{"abcdef0123456789"}},
|
|
||||||
{"一句多个", "[image aaaaaaaaaaaa] 图一;[image bbbbbbbbbbbb] 图二", []string{"aaaaaaaaaaaa", "bbbbbbbbbbbb"}},
|
|
||||||
{"去重", "[image cccccccccccc] x [image/png cccccccccccc] y", []string{"cccccccccccc"}},
|
|
||||||
{"无标记", "普通句子,没有媒体", nil},
|
|
||||||
{"空串", "", nil},
|
|
||||||
// 非十六进制、过短的方括号内容不能误命中,否则会拿一个假前缀去 ResolvePrefix
|
|
||||||
{"非 digest 方括号", "[注意] 这是普通标注 [TODO]", nil},
|
|
||||||
{"过短", "[image abc] 太短", nil},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, c := range cases {
|
|
||||||
got := extractMediaDigests(c.text)
|
|
||||||
if len(got) != len(c.want) {
|
|
||||||
t.Fatalf("%s: 得到 %v,期望 %v", c.name, got, c.want)
|
|
||||||
}
|
|
||||||
for i := range got {
|
|
||||||
if got[i] != c.want[i] {
|
|
||||||
t.Fatalf("%s: 第 %d 个得到 %q,期望 %q", c.name, i, got[i], c.want[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCommitWithMedia_ReturnsSentenceIDs(t *testing.T) {
|
func TestCommitWithMedia_ReturnsSentenceIDs(t *testing.T) {
|
||||||
_, g, _ := newGraphMediaAgent(t)
|
_, g, _ := newGraphMediaAgent(t)
|
||||||
|
|
||||||
sentence := "[image/png a1b2c3d4e5f6] 一张紫蓝红三色带图"
|
sentence := "这张图是紫蓝红三色带。"
|
||||||
triples := []memory.Triple{{
|
triples := []memory.Triple{{
|
||||||
Subject: "图片", Relation: "内容", Object: "三色带",
|
Subject: "图片", Relation: "内容", Object: "三色带",
|
||||||
SentenceText: sentence,
|
SentenceText: sentence,
|
||||||
@ -164,7 +137,7 @@ func TestCommit_StillWorksAfterRefactor(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBindSentenceMedia_RoundTrip(t *testing.T) {
|
func TestCommitTriplesWithMedia_RoundTrip(t *testing.T) {
|
||||||
// 整层的核心断言:写入 → 提交 → 反查取回原始字节
|
// 整层的核心断言:写入 → 提交 → 反查取回原始字节
|
||||||
a, _, ms := newGraphMediaAgent(t)
|
a, _, ms := newGraphMediaAgent(t)
|
||||||
|
|
||||||
@ -173,18 +146,20 @@ func TestBindSentenceMedia_RoundTrip(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
short := shortDigest(digest)
|
|
||||||
|
|
||||||
sentence := "[image/png " + short + "] 一张紫蓝红三色带图"
|
sentence := "用户发来一张紫蓝红三色带图。"
|
||||||
triples := []memory.Triple{{
|
triples := []memory.Triple{{
|
||||||
Subject: "图片", Relation: "内容", Object: "三色带", SentenceText: sentence,
|
Subject: "图片", Relation: "内容", Object: "三色带",
|
||||||
|
SentenceText: sentence,
|
||||||
|
MediaDigests: []string{digest[:12]}, // 模型手里通常只有短 digest
|
||||||
}}
|
}}
|
||||||
|
|
||||||
if _, _, _, err := a.commitTriplesWithMedia(triples, "s1", 0, nil); err != nil {
|
if _, _, bound, err := a.commitTriplesWithMedia(triples, "s1", 0, nil); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
} else if bound != 1 {
|
||||||
|
t.Fatalf("应绑定 1 个块,实际 %d", bound)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 找到句子 id
|
|
||||||
ids, _, _, err := a.memory.CommitWithMedia(triples, "s1", 0)
|
ids, _, _, err := a.memory.CommitWithMedia(triples, "s1", 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@ -216,31 +191,83 @@ func TestBindSentenceMedia_RoundTrip(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBindSentenceMedia_SkipsUnresolvable(t *testing.T) {
|
func TestAttachBlocksToSentence_SkipsUnresolvable(t *testing.T) {
|
||||||
// 文本里的 digest 在库里不存在时必须跳过,不能建一条指向虚无的块边。
|
// digest 在库里不存在时必须跳过,不能建一条指向虚无的块边。
|
||||||
a, g, _ := newGraphMediaAgent(t)
|
a, g, _ := newGraphMediaAgent(t)
|
||||||
|
if n := a.attachBlocksToSentence(42, []string{"deadbeefdead"}, nil); n != 0 {
|
||||||
sentence := "[image/png deadbeefdead] 一张不存在的图"
|
t.Fatalf("无法补全的 digest 不该建块,实际绑定 %d", n)
|
||||||
ids := map[string]int64{sentence: 42}
|
}
|
||||||
a.bindSentenceBlocks(ids, nil)
|
|
||||||
|
|
||||||
blocks, err := g.BlocksForNode("sentence", "42")
|
blocks, err := g.BlocksForNode("sentence", "42")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(blocks) != 0 {
|
if len(blocks) != 0 {
|
||||||
t.Fatalf("无法补全的 digest 不该建块,实际 %+v", blocks)
|
t.Fatalf("不该有块,实际 %+v", blocks)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBindSentenceMedia_NilStoreNoop(t *testing.T) {
|
func TestAttachBlocksToSentence_NilStoreNoop(t *testing.T) {
|
||||||
a := &Agent{}
|
a := &Agent{}
|
||||||
a.bindSentenceBlocks(map[string]int64{"[image aaaaaaaaaaaa] x": 1}, nil)
|
if n := a.attachBlocksToSentence(1, []string{"aaaaaaaaaaaa"}, nil); n != 0 {
|
||||||
|
t.Fatalf("媒体关闭时应静默无操作,实际 %d", n)
|
||||||
|
}
|
||||||
if got, err := a.RecallBlocksForSentence(1); err != nil || got != nil {
|
if got, err := a.RecallBlocksForSentence(1); err != nil || got != nil {
|
||||||
t.Fatalf("媒体关闭时应静默无操作,实际 %v / %v", got, err)
|
t.Fatalf("媒体关闭时应静默无操作,实际 %v / %v", got, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAttachBlocksToSentence_ReusesSeedIdentity(t *testing.T) {
|
||||||
|
// L2→L3 迁移必须保持块身份:同一个块换层,而不是另建一个同内容的新块。
|
||||||
|
a, g, ms := newGraphMediaAgent(t)
|
||||||
|
digest, _ := ms.Put([]byte("seed-img"), media.Item{MIME: "image/png"})
|
||||||
|
seedBlock, ok := a.blockFromDigest(digest)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("blockFromDigest 失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
ids, _, _, err := g.CommitWithMedia([]memory.Triple{{
|
||||||
|
Subject: "迁移", Relation: "包含", Object: "媒体", SentenceText: "迁移测试句。",
|
||||||
|
}}, "seed", 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sid := ids["迁移测试句。"]
|
||||||
|
|
||||||
|
byDigest := map[string]memory.MemoryBlock{digest: seedBlock}
|
||||||
|
if n := a.attachBlocksToSentence(sid, []string{digest}, byDigest); n != 1 {
|
||||||
|
t.Fatalf("应绑定 1 个块,实际 %d", n)
|
||||||
|
}
|
||||||
|
blocks, err := g.BlocksForNode("sentence", strconv.FormatInt(sid, 10))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(blocks) != 1 || blocks[0].ID != seedBlock.ID {
|
||||||
|
t.Fatalf("块身份应保持为 %s,实际 %+v", seedBlock.ID, blocks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinkBlocksToDocument_CreatesDocumentNodeEdge(t *testing.T) {
|
||||||
|
// 文档归档进 L3:块原样迁入,document --contains--> block 边建立。
|
||||||
|
a, g, ms := newGraphMediaAgent(t)
|
||||||
|
|
||||||
|
digest, _ := ms.Put([]byte("doc-img"), media.Item{MIME: "image/png"})
|
||||||
|
b, ok := a.blockFromDigest(digest)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("blockFromDigest 失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
if n := a.linkBlocksToDocument("doc_42", []memory.MemoryBlock{b}); n != 1 {
|
||||||
|
t.Fatalf("应建立 1 条文档→块边,实际 %d", n)
|
||||||
|
}
|
||||||
|
blocks, err := g.BlocksForNode("document", "doc_42")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(blocks) != 1 || blocks[0].ID != b.ID {
|
||||||
|
t.Fatalf("文档应持有块 %s,实际 %+v", b.ID, blocks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCommitTriplesWithMedia_FallsBackWithoutStore(t *testing.T) {
|
func TestCommitTriplesWithMedia_FallsBackWithoutStore(t *testing.T) {
|
||||||
// 媒体关闭时退回普通 Commit,行为与直接调 Commit 完全一致
|
// 媒体关闭时退回普通 Commit,行为与直接调 Commit 完全一致
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
@ -262,73 +289,105 @@ func TestCommitTriplesWithMedia_FallsBackWithoutStore(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMediaBlocksHeldByDocumentSurviveGC(t *testing.T) {
|
|
||||||
// 文档持有的一等块把内容钉住;文档被删后块随之消失,内容才可回收。
|
|
||||||
a, g, ms := newGraphMediaAgent(t)
|
|
||||||
_ = a
|
|
||||||
|
|
||||||
digest, err := ms.Put([]byte("doc image"), media.Item{MIME: "image/png"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
dir := t.TempDir()
|
|
||||||
ds := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
|
|
||||||
if err := ds.Start(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer ds.Stop()
|
|
||||||
|
|
||||||
it, _ := ms.Stat(digest)
|
|
||||||
doc := &document.Doc{
|
|
||||||
ID: "doc_1", Summary: "带图的文档", Content: "正文",
|
|
||||||
Blocks: []memory.MemoryBlock{{ID: "blk_doc_1", Modality: memory.BlockImage,
|
|
||||||
PayloadDigest: it.Digest, MIME: it.MIME, Size: it.Size}},
|
|
||||||
}
|
|
||||||
if err := ds.Insert(doc); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
_ = g
|
|
||||||
|
|
||||||
// 文档仍持有块 → 内容在
|
|
||||||
if _, err := ms.Stat(digest); err != nil {
|
|
||||||
t.Fatal("有文档块持有内容时不该被清")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除文档 → 一并删除其内容(与文本块一致:删块即删内容)
|
|
||||||
ds.Remove(doc.ID)
|
|
||||||
if blocks := ds.Blocks(); len(blocks) != 0 {
|
|
||||||
t.Fatalf("删除文档后不该还有块,实际 %+v", blocks)
|
|
||||||
}
|
|
||||||
if err := ms.Delete(digest); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, err := ms.Stat(digest); err == nil {
|
|
||||||
t.Fatal("删除后内容应已移除")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMediaContextForSentences(t *testing.T) {
|
func TestMediaContextForSentences(t *testing.T) {
|
||||||
a, g, ms := newGraphMediaAgent(t)
|
a, g, ms := newGraphMediaAgent(t)
|
||||||
|
|
||||||
digest, _ := ms.Put([]byte("img"), media.Item{MIME: "image/png"})
|
digest, _ := ms.Put([]byte("img"), media.Item{MIME: "image/png"})
|
||||||
if err := ms.Describe(digest, "一张紫蓝红三色带图", "visionllm"); err != nil {
|
sid, _ := attachBlockToSentence(t, g, ms, "一张紫蓝红三色带图。", digest)
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
sid, _ := attachBlockToSentence(t, g, ms, "[image/png "+shortDigest(digest)+"] 一张紫蓝红三色带图", digest)
|
|
||||||
|
|
||||||
out := a.mediaContextForSentences([]int64{sid, sid + 100})
|
out := a.mediaContextForSentences([]int64{sid, sid + 100})
|
||||||
if out == "" {
|
if out == "" {
|
||||||
t.Fatal("应产出媒体说明")
|
t.Fatal("应产出媒体说明")
|
||||||
}
|
}
|
||||||
if !contains(out, fmt.Sprintf("句子 #%d", sid)) || !contains(out, "一张紫蓝红三色带图") {
|
if !contains(out, fmt.Sprintf("句子 #%d", sid)) || !contains(out, shortDigest(digest)) {
|
||||||
t.Fatalf("说明内容不对: %q", out)
|
t.Fatalf("说明内容不对: %q", out)
|
||||||
}
|
}
|
||||||
|
// 说明只含 MIME 与短 digest,不含任何生成的描述
|
||||||
|
if contains(out, "紫蓝红") {
|
||||||
|
t.Fatalf("说明里不该有描述文本(描述式索引已废弃): %q", out)
|
||||||
|
}
|
||||||
// 无引用的句子不该出现
|
// 无引用的句子不该出现
|
||||||
if contains(out, fmt.Sprintf("句子 #%d", sid+100)) {
|
if contains(out, fmt.Sprintf("句子 #%d", sid+100)) {
|
||||||
t.Fatalf("无引用的句子不该出现: %q", out)
|
t.Fatalf("无引用的句子不该出现: %q", out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMediaContextForRelations_SurfacesMediaToAgent(t *testing.T) {
|
||||||
|
// L3 检索接线回归:媒体作为一等块进了图库,agent 必须拿得出来。
|
||||||
|
a, g, ms := newGraphMediaAgent(t)
|
||||||
|
|
||||||
|
digest, err := ms.Put([]byte("img bytes"), media.Item{MIME: "image/png"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sid, _ := attachBlockToSentence(t, g, ms, "一张紫蓝红三色带图。", digest)
|
||||||
|
|
||||||
|
// 命中的关系挂着该句子 → 应产出媒体说明
|
||||||
|
out := a.mediaContextForRelations([]memory.Relation{{ID: 1, SentenceID: sid}})
|
||||||
|
if out == "" {
|
||||||
|
t.Fatal("关系挂着有媒体的句子,却没产出媒体说明——L3 检索接线断了")
|
||||||
|
}
|
||||||
|
if !contains(out, shortDigest(digest)) {
|
||||||
|
t.Errorf("媒体说明里应含短 digest 供反查: %q", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 没挂媒体的关系不该产出噪声
|
||||||
|
if out := a.mediaContextForRelations([]memory.Relation{{ID: 2, SentenceID: 99}}); out != "" {
|
||||||
|
t.Errorf("无媒体的句子不该产出说明: %q", out)
|
||||||
|
}
|
||||||
|
if out := a.mediaContextForRelations(nil); out != "" {
|
||||||
|
t.Errorf("空关系不该产出说明: %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildMemoryContext_IncludesMediaSection(t *testing.T) {
|
||||||
|
// buildMemoryContext 是自动注入路径(每次 LLM 调用都走)。
|
||||||
|
// 媒体说明必须出现在这里,否则 agent 只有显式调 memory_recall 才知道有图。
|
||||||
|
a, graph, ms := newGraphMediaAgent(t)
|
||||||
|
|
||||||
|
digest, err := ms.Put([]byte("auto inject"), media.Item{MIME: "image/png"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sentence := "用户发来的图片。"
|
||||||
|
sids, _, _, err := graph.CommitWithMedia([]memory.Triple{{
|
||||||
|
Subject: "测试图片", Relation: "包含", Object: "三色带", SentenceText: sentence,
|
||||||
|
}}, "auto", 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sid := sids[sentence]
|
||||||
|
if sid == 0 {
|
||||||
|
t.Fatal("拿不到句子 id")
|
||||||
|
}
|
||||||
|
if err := graph.PutMemoryBlocks([]memory.MemoryBlock{{
|
||||||
|
ID: "blk_auto_1", Modality: memory.BlockImage,
|
||||||
|
PayloadDigest: digest, MIME: "image/png",
|
||||||
|
}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := graph.AddMemoryBlockEdge("sentence", strconv.FormatInt(sid, 10), "block", "blk_auto_1", "contains"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
a.indexer = memory.NewIndexer(graph)
|
||||||
|
if err := a.indexer.Sync(); err != nil {
|
||||||
|
t.Fatalf("indexer sync: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := a.buildMemoryContext("测试图片", 0)
|
||||||
|
if out == "" {
|
||||||
|
t.Skip("图库召回未命中(indexer 检索策略所致),无法验证媒体段注入")
|
||||||
|
}
|
||||||
|
if !contains(out, "【关联媒体】") {
|
||||||
|
t.Errorf("自动注入的记忆上下文缺少媒体段: %q", out)
|
||||||
|
}
|
||||||
|
if !contains(out, shortDigest(digest)) {
|
||||||
|
t.Errorf("媒体段里应含短 digest: %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolvePrefix(t *testing.T) {
|
func TestResolvePrefix(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
ms, err := media.New(filepath.Join(dir, "m"))
|
ms, err := media.New(filepath.Join(dir, "m"))
|
||||||
@ -357,20 +416,17 @@ func TestResolvePrefix(t *testing.T) {
|
|||||||
if _, err := ms.ResolvePrefix("deadbeefdead"); err == nil {
|
if _, err := ms.ResolvePrefix("deadbeefdead"); err == nil {
|
||||||
t.Fatal("不存在的前缀应报错")
|
t.Fatal("不存在的前缀应报错")
|
||||||
}
|
}
|
||||||
// 完整但不存在的 digest 也要报错,否则调用方会挂一条孤儿引用
|
// 完整但不存在的 digest 也要报错,否则调用方会挂一条孤儿块
|
||||||
fake := ""
|
fake := strings.Repeat("0", 64)
|
||||||
for i := 0; i < 64; i++ {
|
|
||||||
fake += "0"
|
|
||||||
}
|
|
||||||
if _, err := ms.ResolvePrefix(fake); err == nil {
|
if _, err := ms.ResolvePrefix(fake); err == nil {
|
||||||
t.Fatal("不存在的完整 digest 应报错")
|
t.Fatal("不存在的完整 digest 应报错")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolvePrefix_AmbiguityIsError(t *testing.T) {
|
func TestResolvePrefix_AmbiguityIsError(t *testing.T) {
|
||||||
// 前缀歧义视为错误而非"取第一个":挂错引用会让 GC 删掉仍被引用的内容。
|
// 前缀歧义视为错误而非"取第一个":挂错块会让内容被误删。
|
||||||
// 构造歧义需要两个同前缀 digest——sha256 无法人为构造,
|
// 构造歧义需要两个同前缀 digest——sha256 无法人为构造,
|
||||||
// 因此这里退而验证「8 位前缀在大量样本下的行为是确定的」:
|
// 因此这里退而验证「12 位前缀在大量样本下的行为是确定的」:
|
||||||
// 要么唯一命中,要么明确报歧义,绝不静默取第一个。
|
// 要么唯一命中,要么明确报歧义,绝不静默取第一个。
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
ms, err := media.New(filepath.Join(dir, "m"))
|
ms, err := media.New(filepath.Join(dir, "m"))
|
||||||
@ -391,7 +447,6 @@ func TestResolvePrefix_AmbiguityIsError(t *testing.T) {
|
|||||||
for _, d := range digests {
|
for _, d := range digests {
|
||||||
got, err := ms.ResolvePrefix(d[:12])
|
got, err := ms.ResolvePrefix(d[:12])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// 报歧义是可接受结果;静默取错才是缺陷
|
|
||||||
if !contains(err.Error(), "歧义") {
|
if !contains(err.Error(), "歧义") {
|
||||||
t.Fatalf("非歧义错误: %v", err)
|
t.Fatalf("非歧义错误: %v", err)
|
||||||
}
|
}
|
||||||
@ -405,11 +460,7 @@ func TestResolvePrefix_AmbiguityIsError(t *testing.T) {
|
|||||||
|
|
||||||
func TestArchiveColdDocs_KeepsDocWhenGraphWriteEmpty(t *testing.T) {
|
func TestArchiveColdDocs_KeepsDocWhenGraphWriteEmpty(t *testing.T) {
|
||||||
// 数据丢失回归:三元组全被实体名校验拒绝时(Commit 无错但 0 entities
|
// 数据丢失回归:三元组全被实体名校验拒绝时(Commit 无错但 0 entities
|
||||||
// 0 relations),文档不能删、媒体引用不能释放。
|
// 0 relations),文档不能删、其持有的块不能丢。
|
||||||
//
|
|
||||||
// 该缺陷曾真实发生:LLM 生成的 456 字图片描述提不出合规实体名
|
|
||||||
//(validEntityName 要求 2–50 字符),archiveColdDocs 只检查
|
|
||||||
// len(triples) > 0 就释放引用并删文档 → GC 清掉 blob → 图片与描述全丢。
|
|
||||||
a, _, ms := newGraphMediaAgent(t)
|
a, _, ms := newGraphMediaAgent(t)
|
||||||
|
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
@ -427,25 +478,11 @@ func TestArchiveColdDocs_KeepsDocWhenGraphWriteEmpty(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 精确构造「三元组非空 + Commit 全部拒绝」这个状态。
|
// 精确构造「三元组非空 + Commit 全部拒绝」这个状态:
|
||||||
//
|
// Source/Summary 都超过 validEntityName 的 50 字符上限,
|
||||||
// 用超长 Source 而不是指望 NLP 提取器:docToTriples 在
|
// 于是 docToTriples 产出的两条元数据三元组都被跳过。
|
||||||
// Source != "context_archived" 时会写一条 {文档 -来源-> Source},
|
longSource := strings.Repeat("超长来源名", 20) // 100 字
|
||||||
// Source 超过 validEntityName 的 50 字符上限 → Commit 静默跳过
|
longSummary := strings.Repeat("超长摘要文本", 20) // >80 字触发长度门槛被跳过
|
||||||
// → len(triples)==1 但 ec=0 rc=0。构造是确定的,不依赖提取器的
|
|
||||||
// 具体行为(提取器行为随版本变化,测试不该押在它身上)。
|
|
||||||
//
|
|
||||||
// 正文里刻意**不放**媒体标记:mediaTriplesFromText 会为标记产出
|
|
||||||
// 合规的「图片 <digest>」三元组,那样 ec/rc 就不为 0,这个用例
|
|
||||||
// 也就测不到「全被拒绝」这个状态了。媒体引用直接用 AddRef 挂上,
|
|
||||||
// 模拟「文档持有媒体但正文的媒体标记已在清洗中丢失」这一情形——
|
|
||||||
// 那正是最危险的组合:有引用要释放,却没有句子能承载它。
|
|
||||||
longSource := strings.Repeat("超长来源名", 20) // 100 字,远超 50 字符上限
|
|
||||||
// Summary 也必须超长:docToTriples 会为合理 summary 写一条
|
|
||||||
// {文档 -主题-> summary},那条能通过校验,ec/rc 就不为 0 了。
|
|
||||||
// 这里要的是「三元组全部被拒」这一个状态。
|
|
||||||
longSummary := strings.Repeat("超长摘要文本", 20) // >80 字,触发长度门槛被跳过
|
|
||||||
// 文档持有的一等块(模拟“文档有媒体但正文标记已在清洗中丢失”)。
|
|
||||||
it, _ := ms.Stat(digest)
|
it, _ := ms.Stat(digest)
|
||||||
doc := &document.Doc{
|
doc := &document.Doc{
|
||||||
ID: "doc_keep",
|
ID: "doc_keep",
|
||||||
@ -485,50 +522,183 @@ func TestArchiveColdDocs_KeepsDocWhenGraphWriteEmpty(t *testing.T) {
|
|||||||
if !held {
|
if !held {
|
||||||
t.Error("文档或块被释放了——图库没有句子承载它,内容会被删除")
|
t.Error("文档或块被释放了——图库没有句子承载它,内容会被删除")
|
||||||
}
|
}
|
||||||
if _, err := ms.Stat(digest); err != nil {
|
}
|
||||||
t.Fatalf("未归档成功时内容不该被删: %v", err)
|
|
||||||
|
func TestArchiveColdDocs_MigratesBlocksToGraph(t *testing.T) {
|
||||||
|
// 归档成功时块必须迁进 L3 并以 document --contains--> block 关联,
|
||||||
|
// 然后文档才被删除(迁移而非复制/引用保活)。
|
||||||
|
a, g, ms := newGraphMediaAgent(t)
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
ds := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
|
||||||
|
if err := ds.Start(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer ds.Stop()
|
||||||
|
a.docStore = ds
|
||||||
|
a.embedder = memory.NewStaticEmbedder()
|
||||||
|
|
||||||
|
digest, _ := ms.Put([]byte("archived-image"), media.Item{MIME: "image/png"})
|
||||||
|
it, _ := ms.Stat(digest)
|
||||||
|
doc := &document.Doc{
|
||||||
|
ID: "doc_arch",
|
||||||
|
Summary: "带图的冷文档",
|
||||||
|
Content: "张三把三色带图交给了李四。",
|
||||||
|
Source: "manual",
|
||||||
|
Blocks: []memory.MemoryBlock{{ID: "blk_arch_1", Modality: memory.BlockImage,
|
||||||
|
PayloadDigest: it.Digest, MIME: it.MIME, Size: it.Size}},
|
||||||
|
}
|
||||||
|
if err := ds.Insert(doc); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, d := range ds.RecentDocs(10) {
|
||||||
|
if d.ID == doc.ID {
|
||||||
|
d.LastAccess = time.Now().Add(-200 * time.Hour)
|
||||||
|
d.AccessCount = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.archiveColdDocs()
|
||||||
|
|
||||||
|
if d := ds.Get("doc_arch"); d != nil {
|
||||||
|
t.Fatal("块已迁入 L3,文档应被删除")
|
||||||
|
}
|
||||||
|
blocks, err := g.BlocksForNode("document", "doc_arch")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(blocks) != 1 || blocks[0].ID != "blk_arch_1" {
|
||||||
|
t.Fatalf("L3 文档节点应持有原块(身份不变),实际 %+v", blocks)
|
||||||
|
}
|
||||||
|
if _, err := ms.Get(digest); err != nil {
|
||||||
|
t.Fatalf("块被 L3 持有,内容应仍可读: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCommitTriplesWithMedia_ReportsBoundCount(t *testing.T) {
|
func TestMigrateLegacyMediaEntities(t *testing.T) {
|
||||||
// mediaBound 必须反映真实绑定数:归档路径靠它决定能否释放旧引用。
|
// 旧数据:媒体被伪装成 type=Media 的实体,靠描述文本当索引。
|
||||||
a, _, ms := newGraphMediaAgent(t)
|
// 迁移必须把它还原成原生块(挂回原句子)并删掉旧实体与描述关系。
|
||||||
|
_, g, ms := newGraphMediaAgent(t)
|
||||||
|
|
||||||
digest, err := ms.Put([]byte("img"), media.Item{MIME: "image/png"})
|
digest, _ := ms.Put([]byte("legacy-img"), media.Item{MIME: "image/png"})
|
||||||
|
sentence := "老数据里的三色带图 [image/png " + digest[:12] + "]"
|
||||||
|
// 直接构造旧的实体/关系形态(不走已删除的 marker 代码)。
|
||||||
|
ids, _, _, err := g.CommitWithMedia([]memory.Triple{{
|
||||||
|
Subject: "图片 " + digest[:12],
|
||||||
|
SubjectType: "Media",
|
||||||
|
Relation: "内容",
|
||||||
|
Object: "三色带的描述文本",
|
||||||
|
ObjectType: "Description",
|
||||||
|
SentenceText: sentence,
|
||||||
|
}}, "legacy", 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
short := shortDigest(digest)
|
sid := ids[sentence]
|
||||||
|
if sid == 0 {
|
||||||
|
t.Fatal("拿不到句子 id")
|
||||||
|
}
|
||||||
|
|
||||||
// 句子含可反解的短 digest → 应绑定 1 个
|
blocks, entities, err := g.MigrateLegacyMediaEntities(func(short string) (memory.MemoryBlock, bool) {
|
||||||
_, _, bound, err := a.commitTriplesWithMedia([]memory.Triple{{
|
full, err := ms.ResolvePrefix(short)
|
||||||
Subject: "图片", Relation: "内容", Object: "三色带",
|
if err != nil {
|
||||||
SentenceText: "[image/png " + short + "] 一张三色带图",
|
return memory.MemoryBlock{}, false
|
||||||
}}, "s1", 0, nil)
|
}
|
||||||
|
it, err := ms.Stat(full)
|
||||||
|
if err != nil {
|
||||||
|
return memory.MemoryBlock{}, false
|
||||||
|
}
|
||||||
|
return memory.MemoryBlock{
|
||||||
|
ID: "blk_legacy_" + short, Modality: memory.BlockImage,
|
||||||
|
PayloadDigest: it.Digest, MIME: it.MIME, Size: it.Size,
|
||||||
|
}, true
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if bound != 1 {
|
if blocks != 1 || entities != 1 {
|
||||||
t.Fatalf("应绑定 1 个媒体引用,实际 %d", bound)
|
t.Fatalf("应迁移 1 块 / 删 1 实体,实际 %d / %d", blocks, entities)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 句子无 digest → 绑定 0 个
|
// 旧媒体实体与描述关系必须消失
|
||||||
_, _, bound2, err := a.commitTriplesWithMedia([]memory.Triple{{
|
res, err := g.Recall([]string{"图片 " + digest[:12]}, nil, 2, "")
|
||||||
Subject: "张三", Relation: "喜欢", Object: "咖啡",
|
|
||||||
SentenceText: "张三喜欢咖啡",
|
|
||||||
}}, "s2", 0, nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if bound2 != 0 {
|
for _, e := range res.Entities {
|
||||||
t.Fatalf("无媒体标记的句子不该绑定引用,实际 %d", bound2)
|
if e.Type == "Media" {
|
||||||
|
t.Fatalf("旧媒体实体仍存在: %+v", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 块必须挂回原句子
|
||||||
|
got, err := g.BlocksForNode("sentence", strconv.FormatInt(sid, 10))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got) != 1 || got[0].PayloadDigest != digest {
|
||||||
|
t.Fatalf("句子应持有原生块,实际 %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 幂等:再跑一遍不应重复建块
|
||||||
|
blocks2, entities2, err := g.MigrateLegacyMediaEntities(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if blocks2 != 0 || entities2 != 0 {
|
||||||
|
t.Fatalf("无 resolver 时应空操作,实际 %d / %d", blocks2, entities2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanupOrphanedSentences_KeepsBlockBackedSentences(t *testing.T) {
|
||||||
|
// 旧媒体实体被删除后,承载它的句子可能再无关系引用,
|
||||||
|
// 但它还挂着媒体块——清理孤儿句子时不能把它删掉。
|
||||||
|
a, g, ms := newGraphMediaAgent(t)
|
||||||
|
|
||||||
|
digest, _ := ms.Put([]byte("orphan-img"), media.Item{MIME: "image/png"})
|
||||||
|
sentence := "只靠媒体块存活的句子。"
|
||||||
|
ids, _, _, err := g.CommitWithMedia([]memory.Triple{{
|
||||||
|
Subject: "媒体载体", Relation: "包含", Object: "内容", SentenceText: sentence,
|
||||||
|
}}, "orphan", 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sid := ids[sentence]
|
||||||
|
|
||||||
|
b, ok := a.blockFromDigest(digest)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("blockFromDigest 失败")
|
||||||
|
}
|
||||||
|
if err := g.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := g.AddMemoryBlockEdge("sentence", strconv.FormatInt(sid, 10), "block", b.ID, "contains"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解除关系引用,句子只剩块边
|
||||||
|
res, err := g.Recall([]string{"媒体载体"}, nil, 2, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, r := range res.Relations {
|
||||||
|
if err := g.ClearSentenceID(r.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := g.CleanupOrphanedSentences(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
blocks, err := g.BlocksForNode("sentence", strconv.FormatInt(sid, 10))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(blocks) != 1 {
|
||||||
|
t.Fatalf("承载媒体块的句子被误删,块反查失败: %+v", blocks)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSentenceIDsFromRelations(t *testing.T) {
|
func TestSentenceIDsFromRelations(t *testing.T) {
|
||||||
// 关系行不持有媒体,媒体挂在句子上。这个函数负责"关系→句子"这一跳,
|
// 关系行不持有媒体,媒体挂在句子上。这个函数负责"关系→句子"这一跳,
|
||||||
// 去重与去零都不能少:sentence_id=0 表示该关系没有关联句子,
|
// 去重与去零都不能少:sentence_id=0 表示该关系没有关联句子。
|
||||||
// 拿 0 去查 media_refs 会命中一个不存在的 owner。
|
|
||||||
rels := []memory.Relation{
|
rels := []memory.Relation{
|
||||||
{ID: 1, SentenceID: 5},
|
{ID: 1, SentenceID: 5},
|
||||||
{ID: 2, SentenceID: 0}, // 无句子
|
{ID: 2, SentenceID: 0}, // 无句子
|
||||||
@ -547,199 +717,45 @@ func TestSentenceIDsFromRelations(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMediaContextForRelations_SurfacesMediaToAgent(t *testing.T) {
|
func TestMediaBlocksHeldByDocumentSurviveDeletion(t *testing.T) {
|
||||||
// L3 检索接线回归:媒体描述进了图库,agent 必须拿得出来。
|
// 文档持有的一等块把内容钉住;文档被删后块随之消失,内容才可回收。
|
||||||
//
|
_, _, ms := newGraphMediaAgent(t)
|
||||||
// 第四层做完了"存和反查的能力"(RecallMediaForSentence /
|
|
||||||
// mediaContextForSentences),但那两个函数一度没有任何调用方——
|
|
||||||
// 媒体能进 L3,进去之后 agent 检索不到。这个测试守住那条接线。
|
|
||||||
a, g, ms := newGraphMediaAgent(t)
|
|
||||||
|
|
||||||
digest, err := ms.Put([]byte("img bytes"), media.Item{MIME: "image/png"})
|
digest, err := ms.Put([]byte("doc image"), media.Item{MIME: "image/png"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := ms.Describe(digest, "一张紫蓝红三色带图", "visionllm"); err != nil {
|
dir := t.TempDir()
|
||||||
|
ds := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
|
||||||
|
if err := ds.Start(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
sid, _ := attachBlockToSentence(t, g, ms, "[image/png "+shortDigest(digest)+"] 一张紫蓝红三色带图", digest)
|
defer ds.Stop()
|
||||||
|
|
||||||
// 命中的关系挂着该句子 → 应产出媒体说明
|
it, _ := ms.Stat(digest)
|
||||||
out := a.mediaContextForRelations([]memory.Relation{{ID: 1, SentenceID: sid}})
|
doc := &document.Doc{
|
||||||
if out == "" {
|
ID: "doc_1", Summary: "带图的文档", Content: "正文",
|
||||||
t.Fatal("关系挂着有媒体的句子,却没产出媒体说明——L3 检索接线断了")
|
Blocks: []memory.MemoryBlock{{ID: "blk_doc_1", Modality: memory.BlockImage,
|
||||||
|
PayloadDigest: it.Digest, MIME: it.MIME, Size: it.Size}},
|
||||||
}
|
}
|
||||||
if !contains(out, "一张紫蓝红三色带图") {
|
if err := ds.Insert(doc); err != nil {
|
||||||
t.Errorf("媒体说明里应含描述文本: %q", out)
|
|
||||||
}
|
|
||||||
if !contains(out, shortDigest(digest)) {
|
|
||||||
t.Errorf("媒体说明里应含短 digest 供反查: %q", out)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 没挂媒体的关系不该产出噪声
|
|
||||||
if out := a.mediaContextForRelations([]memory.Relation{{ID: 2, SentenceID: 99}}); out != "" {
|
|
||||||
t.Errorf("无媒体的句子不该产出说明: %q", out)
|
|
||||||
}
|
|
||||||
if out := a.mediaContextForRelations(nil); out != "" {
|
|
||||||
t.Errorf("空关系不该产出说明: %q", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildMemoryContext_IncludesMediaSection(t *testing.T) {
|
|
||||||
// buildMemoryContext 是自动注入路径(每次 LLM 调用都走)。
|
|
||||||
// 媒体说明必须出现在这里,否则 agent 只有显式调 memory_recall 才知道有图。
|
|
||||||
a, graph, ms := newGraphMediaAgent(t)
|
|
||||||
|
|
||||||
digest, err := ms.Put([]byte("auto inject"), media.Item{MIME: "image/png"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := ms.Describe(digest, "自动注入用的测试图", "visionllm"); err != nil {
|
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
sentence := "用户发来的图片 [image/png " + shortDigest(digest) + "] 自动注入用的测试图"
|
// 文档仍持有块 → 内容在
|
||||||
sids, _, _, err := graph.CommitWithMedia([]memory.Triple{{
|
if _, err := ms.Stat(digest); err != nil {
|
||||||
Subject: "测试图片", Relation: "包含", Object: "三色带", SentenceText: sentence,
|
t.Fatal("有文档块持有内容时不该被清")
|
||||||
}}, "auto", 0)
|
}
|
||||||
if err != nil {
|
|
||||||
|
// 删除文档 → 一并删除其内容(与文本块一致:删块即删内容)
|
||||||
|
ds.Remove(doc.ID)
|
||||||
|
if blocks := ds.Blocks(); len(blocks) != 0 {
|
||||||
|
t.Fatalf("删除文档后不该还有块,实际 %+v", blocks)
|
||||||
|
}
|
||||||
|
if err := ms.Delete(digest); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
sid := sids[sentence]
|
if _, err := ms.Stat(digest); err == nil {
|
||||||
if sid == 0 {
|
t.Fatal("删除后内容应已移除")
|
||||||
t.Fatal("拿不到句子 id")
|
|
||||||
}
|
|
||||||
attachBlockToSentence(t, graph, ms, sentence, digest)
|
|
||||||
|
|
||||||
a.indexer = memory.NewIndexer(graph)
|
|
||||||
if err := a.indexer.Sync(); err != nil {
|
|
||||||
t.Fatalf("indexer sync: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
out := a.buildMemoryContext("测试图片", 0)
|
|
||||||
if out == "" {
|
|
||||||
t.Skip("图库召回未命中(indexer 检索策略所致),无法验证媒体段注入")
|
|
||||||
}
|
|
||||||
if !contains(out, "【关联媒体】") {
|
|
||||||
t.Errorf("自动注入的记忆上下文缺少媒体段: %q", out)
|
|
||||||
}
|
|
||||||
if !contains(out, "自动注入用的测试图") {
|
|
||||||
t.Errorf("媒体段里应含描述文本: %q", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseMediaMarkers(t *testing.T) {
|
|
||||||
// 与 mediaSummaryForEvent 的输出格式严格对应
|
|
||||||
text := "用户发来图片\n媒体内容:\n" +
|
|
||||||
"[image/png a1b2c3d4e5f6] 一张紫蓝红三色带图\n" +
|
|
||||||
"[audio/wav bbbbccccdddd] 一段三秒的钢琴声\n" +
|
|
||||||
"[image/png a1b2c3d4e5f6] 重复的同一张图"
|
|
||||||
|
|
||||||
ms := parseMediaMarkers(text)
|
|
||||||
if len(ms) != 2 {
|
|
||||||
t.Fatalf("应解析出 2 条去重后的标记,实际 %d: %+v", len(ms), ms)
|
|
||||||
}
|
|
||||||
if ms[0].label != "image/png" || ms[0].shortDigest != "a1b2c3d4e5f6" {
|
|
||||||
t.Errorf("第一条解析错误: %+v", ms[0])
|
|
||||||
}
|
|
||||||
if ms[0].description != "一张紫蓝红三色带图" {
|
|
||||||
t.Errorf("描述应取到行尾且不跨行: %q", ms[0].description)
|
|
||||||
}
|
|
||||||
if ms[1].label != "audio/wav" {
|
|
||||||
t.Errorf("第二条 label 错误: %+v", ms[1])
|
|
||||||
}
|
|
||||||
// raw 用作 SentenceText,必须含 digest 才能被 bindSentenceMedia 反解
|
|
||||||
if !contains(ms[0].raw, "a1b2c3d4e5f6") {
|
|
||||||
t.Errorf("raw 必须含 digest: %q", ms[0].raw)
|
|
||||||
}
|
|
||||||
if n := parseMediaMarkers("没有任何标记的普通文本"); n != nil {
|
|
||||||
t.Errorf("无标记应返回 nil,实际 %+v", n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMediaEntityName(t *testing.T) {
|
|
||||||
// 实体名必须由 digest 而非描述构成:描述会被重新生成,
|
|
||||||
// 若名字取自描述,同一张图会在图谱上留下多个节点。
|
|
||||||
cases := []struct{ label, digest, want string }{
|
|
||||||
{"image/png", "a1b2c3d4e5f6", "图片 a1b2c3d4e5f6"},
|
|
||||||
{"audio/wav", "bbbbccccdddd", "音频 bbbbccccdddd"},
|
|
||||||
{"video/mp4", "ccccddddeeee", "视频 ccccddddeeee"},
|
|
||||||
{"application/octet-stream", "ddddeeeeffff", "媒体 ddddeeeeffff"},
|
|
||||||
}
|
|
||||||
for _, c := range cases {
|
|
||||||
got := mediaEntityName(c.label, c.digest)
|
|
||||||
if got != c.want {
|
|
||||||
t.Errorf("mediaEntityName(%q,%q) = %q,期望 %q", c.label, c.digest, got, c.want)
|
|
||||||
}
|
|
||||||
// 必须过 validEntityName 的 2–50 字符门槛,否则 Commit 会静默跳过
|
|
||||||
if n := len([]rune(got)); n < 2 || n > 50 {
|
|
||||||
t.Errorf("实体名长度 %d 不在 2–50 之间: %q", n, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSummarizeForEntity(t *testing.T) {
|
|
||||||
cases := []struct{ in, want string }{
|
|
||||||
{"一张紫蓝红三色带图。还有更多内容。", "一张紫蓝红三色带图"},
|
|
||||||
{"**整体构成**:正方形画布", "整体构成:正方形画布"}, // Markdown 强调符被清掉
|
|
||||||
{"", ""},
|
|
||||||
{"短", ""}, // 单字过不了 validEntityName,宁可不写
|
|
||||||
// 无句子边界时按 rune 截到 40(不是按字节,否则切坏 UTF-8 会在图库里留乱码)
|
|
||||||
{"没有句子边界的一长串文字需要按 rune 截断以免切坏 UTF-8 编码导致图库里出现乱码实体名字符",
|
|
||||||
"没有句子边界的一长串文字需要按 rune 截断以免切坏 UTF-8 编码导致图库"},
|
|
||||||
}
|
|
||||||
for _, c := range cases {
|
|
||||||
got := summarizeForEntity(c.in, 40)
|
|
||||||
if got != c.want {
|
|
||||||
t.Errorf("summarizeForEntity(%q) = %q,期望 %q", c.in, got, c.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMediaTriplesFromText_DeterministicRegardlessOfNLP(t *testing.T) {
|
|
||||||
// 核心回归:媒体入 L3 不再依赖 NLP 提取器的运气。
|
|
||||||
//
|
|
||||||
// 实测 LLM 的 477 字图片描述经提取器只产出「水平 -分割-> 成」,
|
|
||||||
// obj 仅 1 字被 validEntityName 拒掉 → ec=0 rc=0 → 媒体记忆进不了图库,
|
|
||||||
// 且时好时坏取决于描述文本。这里验证确定性路径。
|
|
||||||
longDesc := "这张图片是一张纯色块构成的抽象图像,不包含任何文字、人物、物体或可识别的场景。" +
|
|
||||||
"整体构成:一个小尺寸的正方形图像,被水平分割成三条颜色条带。"
|
|
||||||
text := "媒体内容:\n[image/png 89e293b42546] " + longDesc
|
|
||||||
|
|
||||||
triples := mediaTriplesFromText(text)
|
|
||||||
if len(triples) < 2 {
|
|
||||||
t.Fatalf("应至少产出类型+内容两条三元组,实际 %d", len(triples))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 每条都必须能通过 validEntityName(经 Commit 实证)
|
|
||||||
g, err := memory.NewGraphDB(filepath.Join(t.TempDir(), "g.db"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer g.Close()
|
|
||||||
sids, ec, rc, err := g.CommitWithMedia(triples, "det", 0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if ec == 0 || rc == 0 {
|
|
||||||
t.Fatalf("确定性三元组应能写入图库,实际 ec=%d rc=%d", ec, rc)
|
|
||||||
}
|
|
||||||
if len(sids) == 0 {
|
|
||||||
t.Fatal("应返回句子 id 供 bindSentenceMedia 绑定")
|
|
||||||
}
|
|
||||||
// SentenceText 必须含 digest,否则绑定还是断的
|
|
||||||
for st := range sids {
|
|
||||||
if !contains(st, "89e293b42546") {
|
|
||||||
t.Errorf("句子必须含短 digest 供反解: %q", st)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 描述为空时仍应产出类型三元组——媒体节点不能因为没描述就不存在
|
|
||||||
bare := mediaTriplesFromText("[image/png 89e293b42546]")
|
|
||||||
if len(bare) != 1 {
|
|
||||||
t.Fatalf("无描述时应只有类型三元组,实际 %d 条", len(bare))
|
|
||||||
}
|
|
||||||
if bare[0].Relation != "类型" {
|
|
||||||
t.Errorf("无描述时那条应是类型三元组: %+v", bare[0])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -192,63 +192,84 @@ func TestResolveInput_UnifiesAllModalities(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 模型工具侧:sentenceWithMediaMarkers ----------
|
// ---------- 模型工具侧:memory_digests 结构化传递 ----------
|
||||||
|
|
||||||
// 模型只知道 digest(从对话或 memory_recall 的「关联媒体」读到),
|
// 模型只知道 digest(从对话或 memory_recall 的「关联媒体」读到)。
|
||||||
// 不该要求它自己按内核格式拼标记——格式写错的后果是引用静默挂不上。
|
// 它不再需要自己拼任何标记:digest 作为结构化字段随三元组提交。
|
||||||
func TestSentenceWithMediaMarkers(t *testing.T) {
|
func TestResolveMediaDigestsAndNoMarkerText(t *testing.T) {
|
||||||
a, ms := newInputTestAgent(t)
|
a, ms := newInputTestAgent(t)
|
||||||
digest, err := ms.Put([]byte("marker-bytes"), media.Item{
|
digest, err := ms.Put([]byte("marker-bytes"), media.Item{MIME: "image/png"})
|
||||||
MIME: "image/png", Description: "一张紫蓝红三色带图",
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Put: %v", err)
|
t.Fatalf("Put: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Run("短digest补全并生成标记", func(t *testing.T) {
|
t.Run("短digest补全", func(t *testing.T) {
|
||||||
got := a.sentenceWithMediaMarkers("用户发来一张图。", []string{digest[:12]})
|
got := a.resolveMediaDigests([]string{digest[:12]})
|
||||||
if !strings.Contains(got, "三色带图") {
|
if len(got) != 1 || got[0] != digest {
|
||||||
t.Errorf("描述未并入句子: %q", got)
|
t.Fatalf("短 digest 应补全为完整 digest,得到 %v", got)
|
||||||
}
|
|
||||||
if !strings.Contains(got, digest[:12]) {
|
|
||||||
t.Errorf("digest 未并入句子(反查会失效): %q", got)
|
|
||||||
}
|
|
||||||
// 反解必须成功,否则 bindSentenceMedia 挂不上引用
|
|
||||||
if got := extractMediaDigests(got); len(got) != 1 {
|
|
||||||
t.Errorf("生成的标记无法被 extractMediaDigests 反解: %v", got)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("模型已写标记时不重复追加", func(t *testing.T) {
|
t.Run("无法解析的digest被丢弃", func(t *testing.T) {
|
||||||
sentence := "看这个 [image/png " + digest[:12] + "] 三色带图"
|
if got := a.resolveMediaDigests([]string{"ffffffffffff"}); len(got) != 0 {
|
||||||
got := a.sentenceWithMediaMarkers(sentence, []string{digest[:12]})
|
t.Errorf("不存在的 digest 不该保留: %v", got)
|
||||||
if n := strings.Count(got, digest[:12]); n != 1 {
|
|
||||||
t.Errorf("digest 出现 %d 次,期望 1 次: %q", n, got)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("空句子时标记本身充当句子", func(t *testing.T) {
|
t.Run("无媒体存储时返回nil", func(t *testing.T) {
|
||||||
got := a.sentenceWithMediaMarkers("", []string{digest})
|
|
||||||
if got == "" {
|
|
||||||
t.Error("媒体必须有句子落点,否则 media_refs 无从挂起")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("无法解析的digest被跳过", func(t *testing.T) {
|
|
||||||
got := a.sentenceWithMediaMarkers("原句。", []string{"ffffffffffff"})
|
|
||||||
if got != "原句。" {
|
|
||||||
t.Errorf("不存在的 digest 不该造出标记: %q", got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("无媒体存储时原样返回", func(t *testing.T) {
|
|
||||||
bare := &Agent{}
|
bare := &Agent{}
|
||||||
if got := bare.sentenceWithMediaMarkers("原句。", []string{digest}); got != "原句。" {
|
if got := bare.resolveMediaDigests([]string{digest}); got != nil {
|
||||||
t.Errorf("无媒体存储时应原样返回: %q", got)
|
t.Errorf("无媒体存储时应返回 nil: %v", got)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 句子文本必须保持原样:媒体归属走结构化块边,不往文本里贴 marker。
|
||||||
|
func TestMemoryCommit_DoesNotPolluteSentenceText(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
g, err := memory.NewGraphDB(filepath.Join(dir, "graph.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer g.Close()
|
||||||
|
ms, err := media.New(filepath.Join(dir, "media"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer ms.Close()
|
||||||
|
a := &Agent{memory: g, mediaStore: ms}
|
||||||
|
|
||||||
|
digest, _ := ms.Put([]byte("clean-sentence"), media.Item{MIME: "image/png"})
|
||||||
|
|
||||||
|
sentence := "用户发来一张图。"
|
||||||
|
triples := []memory.Triple{{
|
||||||
|
Subject: "用户", Relation: "发来", Object: "图片",
|
||||||
|
SentenceText: sentence,
|
||||||
|
MediaDigests: a.resolveMediaDigests([]string{digest[:12]}),
|
||||||
|
}}
|
||||||
|
if _, _, _, err := a.commitTriplesWithMedia(triples, "s1", 0, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := a.memory.Recall([]string{"用户"}, nil, 2, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(res.Relations) == 0 {
|
||||||
|
t.Fatal("召回为空")
|
||||||
|
}
|
||||||
|
if res.Relations[0].SentenceText != sentence {
|
||||||
|
t.Errorf("句子文本被污染: %q", res.Relations[0].SentenceText)
|
||||||
|
}
|
||||||
|
blocks, err := a.memory.BlocksForNode("sentence", strconv.FormatInt(res.Relations[0].SentenceID, 10))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(blocks) != 1 || blocks[0].PayloadDigest != digest {
|
||||||
|
t.Errorf("块应挂到句子,实际 %+v", blocks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- resolveMediaDigests ----------
|
// ---------- resolveMediaDigests ----------
|
||||||
|
|
||||||
func TestResolveMediaDigests(t *testing.T) {
|
func TestResolveMediaDigests(t *testing.T) {
|
||||||
@ -319,63 +340,61 @@ func TestDocCommit_StoresBlocks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- docMediaContext ----------
|
// ---------- 文档持有块标签(doc_query 展示用) ----------
|
||||||
|
|
||||||
func TestDocMediaContext(t *testing.T) {
|
func TestBlockLabelsForDoc(t *testing.T) {
|
||||||
a, ms := newInputTestAgent(t)
|
a, ms := newInputTestAgent(t)
|
||||||
digest, _ := ms.Put([]byte("ctx-bytes"), media.Item{
|
digest, _ := ms.Put([]byte("ctx-bytes"), media.Item{MIME: "image/png"})
|
||||||
MIME: "image/png", Description: "文档里的配图",
|
b, ok := a.blockFromDigest(digest)
|
||||||
})
|
if !ok {
|
||||||
|
t.Fatal("blockFromDigest 失败")
|
||||||
|
}
|
||||||
|
|
||||||
t.Run("无块时解析正文标记", func(t *testing.T) {
|
t.Run("从文档持有的一等块渲染", func(t *testing.T) {
|
||||||
content := "旧正文 [image/png " + digest[:12] + "] 文档里的配图"
|
got := a.blockLabelsForDoc(&document.Doc{ID: "doc_1", Blocks: []memory.MemoryBlock{b}})
|
||||||
got := a.docMediaContext("doc_legacy", content)
|
if !strings.Contains(got, shortDigest(digest)) {
|
||||||
if !strings.Contains(got, "文档里的配图") {
|
t.Errorf("标签应含短 digest: %q", got)
|
||||||
t.Errorf("历史文档只有标记时应回退解析: %q", got)
|
}
|
||||||
|
if !strings.Contains(got, "image/png") {
|
||||||
|
t.Errorf("标签应含 MIME: %q", got)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("既无引用也无标记", func(t *testing.T) {
|
t.Run("无块时为空", func(t *testing.T) {
|
||||||
if got := a.docMediaContext("doc_empty", "普通正文"); got != "" {
|
if got := a.blockLabelsForDoc(&document.Doc{ID: "doc_x", Content: "普通正文"}); got != "" {
|
||||||
t.Errorf("应返回空串,实际 %q", got)
|
t.Errorf("应返回空串,实际 %q", got)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("无媒体存储", func(t *testing.T) {
|
t.Run("无媒体存储", func(t *testing.T) {
|
||||||
bare := &Agent{}
|
bare := &Agent{}
|
||||||
if got := bare.docMediaContext("doc_x", "任意"); got != "" {
|
if got := bare.blockLabelsForDoc(&document.Doc{ID: "doc_x"}); got != "" {
|
||||||
t.Errorf("无媒体存储时应返回空串,实际 %q", got)
|
t.Errorf("无媒体存储时应返回空串,实际 %q", got)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- mediaMarkerLine ----------
|
// ---------- mediaLabel ----------
|
||||||
|
|
||||||
// 标记格式的唯一生成处。此前 mediaSummaryForEvent 与 mediaContextForSentences
|
// 媒体标签的唯一生成处:只含 MIME 与短 digest,不含任何生成的描述。
|
||||||
// 各拼一份,改动截断长度或分隔符时只改一处,另一处写出的标记就再也解析不回来。
|
func TestMediaLabel(t *testing.T) {
|
||||||
func TestMediaMarkerLine(t *testing.T) {
|
|
||||||
a, ms := newInputTestAgent(t)
|
a, ms := newInputTestAgent(t)
|
||||||
|
_ = a
|
||||||
|
|
||||||
described, _ := ms.Put([]byte("with-desc"), media.Item{
|
digest, _ := ms.Put([]byte("labelled"), media.Item{MIME: "image/png"})
|
||||||
MIME: "image/png", Description: "已描述的图",
|
it, err := ms.Stat(digest)
|
||||||
})
|
if err != nil {
|
||||||
if got := a.mediaMarkerLine(described); !strings.Contains(got, "已描述的图") {
|
t.Fatal(err)
|
||||||
t.Errorf("有描述时应带描述: %q", got)
|
|
||||||
}
|
}
|
||||||
|
got := mediaLabel(it)
|
||||||
// 「已入库但还没描述」与「压根没有媒体」必须可区分
|
if !strings.Contains(got, "image/png") {
|
||||||
bare, _ := ms.Put([]byte("no-desc"), media.Item{MIME: "image/png"})
|
t.Errorf("标签应含 MIME: %q", got)
|
||||||
got := a.mediaMarkerLine(bare)
|
|
||||||
if !strings.Contains(got, "(未描述)") {
|
|
||||||
t.Errorf("无描述时应有占位符: %q", got)
|
|
||||||
}
|
}
|
||||||
if !strings.Contains(got, shortDigest(bare)) {
|
if !strings.Contains(got, shortDigest(digest)) {
|
||||||
t.Errorf("必须带短 digest 供反查: %q", got)
|
t.Errorf("必须带短 digest 供反查: %q", got)
|
||||||
}
|
}
|
||||||
|
if got := mediaLabel(nil); got != "" {
|
||||||
// 查不到返回空串:媒体可能已被容量 GC 淘汰,此时不该造出指向虚无的标记
|
t.Errorf("nil 应返回空串,实际 %q", got)
|
||||||
if got := a.mediaMarkerLine("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"); got != "" {
|
|
||||||
t.Errorf("查不到的 digest 应返回空串,实际 %q", got)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -417,9 +436,7 @@ func newToolTestAgent(t *testing.T) (*Agent, *media.Store) {
|
|||||||
// memory_commit 带 media_digests:三元组入库后必须能从句子反查回那份字节。
|
// memory_commit 带 media_digests:三元组入库后必须能从句子反查回那份字节。
|
||||||
func TestToolMemoryCommit_BindsMedia(t *testing.T) {
|
func TestToolMemoryCommit_BindsMedia(t *testing.T) {
|
||||||
a, ms := newToolTestAgent(t)
|
a, ms := newToolTestAgent(t)
|
||||||
digest, _ := ms.Put([]byte("commit-bytes"), media.Item{
|
digest, _ := ms.Put([]byte("commit-bytes"), media.Item{MIME: "image/png"})
|
||||||
MIME: "image/png", Description: "提交时关联的图",
|
|
||||||
})
|
|
||||||
|
|
||||||
out := a.executeMemoryTool(agentAPI.ToolCall{
|
out := a.executeMemoryTool(agentAPI.ToolCall{
|
||||||
Name: "memory_commit",
|
Name: "memory_commit",
|
||||||
@ -498,12 +515,10 @@ func TestToolMemoryCommit_CarriesSentenceText(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// doc_commit 带 media_digests:标记进正文(否则检索不到)+ 引用挂文档 owner(否则 GC 会清)。
|
// doc_commit 带 media_digests:媒体成为文档直接持有的一等块;正文保持原样。
|
||||||
func TestToolDocCommit_BindsMedia(t *testing.T) {
|
func TestToolDocCommit_BindsMedia(t *testing.T) {
|
||||||
a, ms := newToolTestAgent(t)
|
a, ms := newToolTestAgent(t)
|
||||||
digest, _ := ms.Put([]byte("doc-commit-bytes"), media.Item{
|
digest, _ := ms.Put([]byte("doc-commit-bytes"), media.Item{MIME: "image/png"})
|
||||||
MIME: "image/png", Description: "笔记里的插图",
|
|
||||||
})
|
|
||||||
|
|
||||||
out := a.executeDocTool(agentAPI.ToolCall{
|
out := a.executeDocTool(agentAPI.ToolCall{
|
||||||
Name: "doc_commit",
|
Name: "doc_commit",
|
||||||
@ -522,8 +537,8 @@ func TestToolDocCommit_BindsMedia(t *testing.T) {
|
|||||||
t.Fatal("文档未写入")
|
t.Fatal("文档未写入")
|
||||||
}
|
}
|
||||||
d := docs[0]
|
d := docs[0]
|
||||||
if !strings.Contains(d.Content, "笔记里的插图") {
|
if strings.Contains(d.Content, "image/png") {
|
||||||
t.Errorf("标记未进正文(向量索引看不到这份媒体): %q", d.Content)
|
t.Errorf("正文不该被媒体标记污染: %q", d.Content)
|
||||||
}
|
}
|
||||||
var held bool
|
var held bool
|
||||||
for _, b := range d.Blocks {
|
for _, b := range d.Blocks {
|
||||||
@ -539,9 +554,7 @@ func TestToolDocCommit_BindsMedia(t *testing.T) {
|
|||||||
// doc_query 必须把媒体说明附在返回值里,否则模型检索到带图文档也不知道有图。
|
// doc_query 必须把媒体说明附在返回值里,否则模型检索到带图文档也不知道有图。
|
||||||
func TestToolDocQuery_ShowsMedia(t *testing.T) {
|
func TestToolDocQuery_ShowsMedia(t *testing.T) {
|
||||||
a, ms := newToolTestAgent(t)
|
a, ms := newToolTestAgent(t)
|
||||||
digest, _ := ms.Put([]byte("query-bytes"), media.Item{
|
digest, _ := ms.Put([]byte("query-bytes"), media.Item{MIME: "image/png"})
|
||||||
MIME: "image/png", Description: "检索命中的配图",
|
|
||||||
})
|
|
||||||
|
|
||||||
a.executeDocTool(agentAPI.ToolCall{
|
a.executeDocTool(agentAPI.ToolCall{
|
||||||
Name: "doc_commit",
|
Name: "doc_commit",
|
||||||
@ -560,7 +573,7 @@ func TestToolDocQuery_ShowsMedia(t *testing.T) {
|
|||||||
// 正文进的是 cold_storage 事件(工具返回值只给引用编号),媒体说明也在那里。
|
// 正文进的是 cold_storage 事件(工具返回值只给引用编号),媒体说明也在那里。
|
||||||
var found bool
|
var found bool
|
||||||
for _, e := range a.context.Recent(10) {
|
for _, e := range a.context.Recent(10) {
|
||||||
if strings.Contains(e.Response, "检索命中的配图") {
|
if strings.Contains(e.Response, shortDigest(digest)) {
|
||||||
found = true
|
found = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -200,7 +200,6 @@ func newLiveEnv(t *testing.T, c liveCfg) *liveEnv {
|
|||||||
Memory: graph,
|
Memory: graph,
|
||||||
DocStore: docStore,
|
DocStore: docStore,
|
||||||
MediaStore: ms,
|
MediaStore: ms,
|
||||||
MediaDescribe: true, // 描述循环由测试直接调 describePendingMedia
|
|
||||||
StageHost: NewStageHost(),
|
StageHost: NewStageHost(),
|
||||||
MaxContextSize: 3, // 故意压低:第二轮就能触发 Prune 归档
|
MaxContextSize: 3, // 故意压低:第二轮就能触发 Prune 归档
|
||||||
InputProcessing: types.InputProcessingConfig{},
|
InputProcessing: types.InputProcessingConfig{},
|
||||||
@ -250,19 +249,28 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
|
|||||||
a.handleInput(evt)
|
a.handleInput(evt)
|
||||||
t.Logf("第一轮(含真实 LLM 往返)耗时 %.1fs", time.Since(t0).Seconds())
|
t.Logf("第一轮(含真实 LLM 往返)耗时 %.1fs", time.Since(t0).Seconds())
|
||||||
|
|
||||||
// 用 Pending 而非 Search 查刚落盘的项:Search 的 WHERE 里带
|
// 媒体不再有文字描述:CAS 里只有字节、元数据与向量。
|
||||||
// `COALESCE(description,'') != ''`,只返回**已描述**的媒体,
|
// 这里直接按 digest 定位刚落的图(不再有 Pending 队列)。
|
||||||
// 此刻描述还没生成(阶段3 才做),Search 必然返回 0 条。
|
st := env.mediaSt.Stats()
|
||||||
items, err := env.mediaSt.Pending(10)
|
if st["count"].(int) != 1 {
|
||||||
|
t.Fatalf("CAS 应自动收到 1 张图,实际 %v 张(captureBlockMedia 未被触发?)", st["count"])
|
||||||
|
}
|
||||||
|
var digest string
|
||||||
|
var found bool
|
||||||
|
for _, e := range a.context.Recent(0) {
|
||||||
|
for _, b := range e.Blocks {
|
||||||
|
digest, found = b.PayloadDigest, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("无法从上下文块定位刚落盘的图")
|
||||||
|
}
|
||||||
|
it0, err := env.mediaSt.Stat(digest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("pending: %v", err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(items) != 1 {
|
|
||||||
t.Fatalf("CAS 应自动收到 1 张图,实际 %d 张(captureBlockMedia 未被触发?)", len(items))
|
|
||||||
}
|
|
||||||
digest := items[0].Digest
|
|
||||||
t.Logf("✓ 阶段1 CAS 自动落盘: digest=%s size=%d tool=%s",
|
t.Logf("✓ 阶段1 CAS 自动落盘: digest=%s size=%d tool=%s",
|
||||||
digest[:12], items[0].Size, items[0].Tool)
|
digest[:12], it0.Size, it0.Tool)
|
||||||
|
|
||||||
stored, err := env.mediaSt.Get(digest)
|
stored, err := env.mediaSt.Get(digest)
|
||||||
if err != nil || !bytes.Equal(stored, img) {
|
if err != nil || !bytes.Equal(stored, img) {
|
||||||
@ -271,13 +279,15 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
|
|||||||
|
|
||||||
// ── 阶段 2:一等记忆块自动挂到 ContextEvent 上 ──
|
// ── 阶段 2:一等记忆块自动挂到 ContextEvent 上 ──
|
||||||
//
|
//
|
||||||
// 这一步验证 bindEventMedia:事件必须拿到 ID 并直接持有块。
|
// 这一步验证 bindEventMedia:事件必须拿到 ID 并直接持有块;
|
||||||
|
// 事件文本必须保持原样(不再往正文里贴媒体标记)。
|
||||||
var evtID string
|
var evtID string
|
||||||
var summaryOK bool
|
|
||||||
for _, e := range a.context.Recent(0) {
|
for _, e := range a.context.Recent(0) {
|
||||||
if len(e.Blocks) > 0 {
|
if len(e.Blocks) > 0 {
|
||||||
evtID = e.ID
|
evtID = e.ID
|
||||||
summaryOK = strings.Contains(e.Input, digest[:12])
|
if strings.Contains(e.Input, digest[:12]) {
|
||||||
|
t.Error("事件 Input 里被写入了媒体标记——描述式索引链应该已经拆除")
|
||||||
|
}
|
||||||
if e.Blocks[0].PayloadDigest != digest {
|
if e.Blocks[0].PayloadDigest != digest {
|
||||||
t.Fatalf("事件持有的块 digest 不对: %+v", e.Blocks)
|
t.Fatalf("事件持有的块 digest 不对: %+v", e.Blocks)
|
||||||
}
|
}
|
||||||
@ -287,51 +297,17 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
|
|||||||
if evtID == "" {
|
if evtID == "" {
|
||||||
t.Fatal("没有任何 ContextEvent 挂上媒体(bindEventMedia 未被触发)")
|
t.Fatal("没有任何 ContextEvent 挂上媒体(bindEventMedia 未被触发)")
|
||||||
}
|
}
|
||||||
if !summaryOK {
|
t.Logf("✓ 阶段2 块自动绑定: event=%s", evtID)
|
||||||
t.Error("事件 Input 里没有媒体摘要标记(mediaSummaryForEvent 未生效)——" +
|
|
||||||
"L2/L3 靠正文里的短 digest 反查,缺了它整条召回链断掉")
|
|
||||||
}
|
|
||||||
t.Logf("✓ 阶段2 块自动绑定: event=%s 摘要内嵌=%v", evtID, summaryOK)
|
|
||||||
|
|
||||||
// ── 阶段 3:描述由后台循环自动生成(真实视觉模型)──
|
// ── 阶段 3:媒体只按自己的向量被索引,不再生成任何描述 ──
|
||||||
pending, err := env.mediaSt.Pending(5)
|
if it, err := env.mediaSt.Stat(digest); err != nil {
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
} else if len(it.Vec) == 0 {
|
||||||
if len(pending) != 1 {
|
// 未配置多模态空间时就没有向量——这是合法的降级状态,
|
||||||
t.Fatalf("应有 1 条待描述,实际 %d 条", len(pending))
|
// 但要明确报出来,而不是靠描述文本假装能检索。
|
||||||
}
|
t.Log("未配置多模态空间:本图无向量,之后只能靠块结构召回 digest")
|
||||||
|
|
||||||
t1 := time.Now()
|
|
||||||
a.describePendingMedia()
|
|
||||||
t.Logf("描述生成耗时 %.1fs", time.Since(t1).Seconds())
|
|
||||||
|
|
||||||
it, err := env.mediaSt.Stat(digest)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if it.Description == "" {
|
|
||||||
t.Fatal("描述为空——describePendingMedia 未能通过视觉源生成描述")
|
|
||||||
}
|
|
||||||
sawColors := strings.Contains(it.Description, "紫") &&
|
|
||||||
strings.Contains(it.Description, "蓝") &&
|
|
||||||
strings.Contains(it.Description, "红")
|
|
||||||
t.Logf("✓ 阶段3 描述自动生成 (%d 字, 源=%s): %s",
|
|
||||||
len([]rune(it.Description)), it.DescribedBy, truncRunes(it.Description, 90))
|
|
||||||
if !sawColors {
|
|
||||||
t.Errorf("描述未含紫/蓝/红三色,视觉模型可能没真正看到图片: %s",
|
|
||||||
truncRunes(it.Description, 200))
|
|
||||||
}
|
|
||||||
if left, _ := env.mediaSt.Pending(5); len(left) != 0 {
|
|
||||||
t.Errorf("描述完成后仍在待描述队列(%d 条)——会被反复重描述", len(left))
|
|
||||||
}
|
|
||||||
// 有描述之后 Search 才应能命中(它按 description 做 LIKE)
|
|
||||||
if found, err := env.mediaSt.Search("紫", media.KindImage, 5); err != nil {
|
|
||||||
t.Errorf("search: %v", err)
|
|
||||||
} else if len(found) == 0 {
|
|
||||||
t.Error("描述已生成但 Search(\"紫\") 命中 0 条——媒体库关键词入口失效")
|
|
||||||
} else {
|
} else {
|
||||||
t.Logf("✓ 阶段3 Search(\"紫\") 命中 %d 条", len(found))
|
t.Logf("✓ 阶段3 已写入原生向量: dim=%d", len(it.Vec))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 阶段 4:Prune 自动把块从 L0 迁移到 L2 ──
|
// ── 阶段 4:Prune 自动把块从 L0 迁移到 L2 ──
|
||||||
@ -385,11 +361,11 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
|
|||||||
t.Fatalf("迁移后内容不可读: %v", err)
|
t.Fatalf("迁移后内容不可读: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 阶段 5:archiveColdDocs 自动把媒体带进 L3 图库 ──
|
// ── 阶段 5:archiveColdDocs 自动把块连到 L3 文档节点 ──
|
||||||
//
|
//
|
||||||
// FindColdDocs(72h, 2) 要求文档足够"冷",测试里新建的文档不满足,
|
// FindColdDocs(72h, 2) 要求文档足够"冷",测试里新建的文档不满足,
|
||||||
// 因此把 LastAccess 往前推——这是为了触发生产代码路径,
|
// 因此把 LastAccess 往前推——这是为了触发生产代码路径,
|
||||||
// 而不是替代它(Commit/bindSentenceBlocks 全部由它自己调)。
|
// 而不是替代它(commitTriplesWithMedia/linkBlocksToDocument 全由它自己调)。
|
||||||
for _, d := range env.docStore.RecentDocs(20) {
|
for _, d := range env.docStore.RecentDocs(20) {
|
||||||
if d.ID == docRefsFound {
|
if d.ID == docRefsFound {
|
||||||
d.LastAccess = time.Now().Add(-100 * time.Hour)
|
d.LastAccess = time.Now().Add(-100 * time.Hour)
|
||||||
@ -398,13 +374,21 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
|
|||||||
}
|
}
|
||||||
a.archiveColdDocs()
|
a.archiveColdDocs()
|
||||||
|
|
||||||
|
// 块可能以 document --contains--> block(文档归档)或
|
||||||
|
// sentence --contains--> block(对话三元组)两种边存在。
|
||||||
sentRefs := 0
|
sentRefs := 0
|
||||||
var boundSentence int64
|
var boundSentence int64
|
||||||
|
docBound := 0
|
||||||
rows, err := env.graph.Recall(nil, nil, 1, "")
|
rows, err := env.graph.Recall(nil, nil, 1, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("graph recall: %v", err)
|
t.Fatalf("graph recall: %v", err)
|
||||||
}
|
}
|
||||||
t.Logf("图库实体数 %d", len(rows.Entities))
|
t.Logf("图库实体数 %d", len(rows.Entities))
|
||||||
|
docBlocks, err := env.graph.BlocksForNode("document", docRefsFound)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
docBound = len(docBlocks)
|
||||||
// 句子 id 是自增整数,扫前若干个足够覆盖本测试写入的量
|
// 句子 id 是自增整数,扫前若干个足够覆盖本测试写入的量
|
||||||
for sid := int64(1); sid <= 40; sid++ {
|
for sid := int64(1); sid <= 40; sid++ {
|
||||||
blocks, err := env.graph.BlocksForNode("sentence", strconv.FormatInt(sid, 10))
|
blocks, err := env.graph.BlocksForNode("sentence", strconv.FormatInt(sid, 10))
|
||||||
@ -415,10 +399,18 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if sentRefs == 0 {
|
if sentRefs == 0 && docBound == 0 {
|
||||||
t.Error("L2→L3 未写入任何句子→块边——" +
|
t.Error("L2→L3 未写入任何块边——linkBlocksToDocument 未被 archiveColdDocs 触发")
|
||||||
"bindSentenceBlocks 未被 commitTriplesWithMedia 触发," +
|
} else if docBound > 0 {
|
||||||
"或句子正文里没有可反解的短 digest")
|
t.Logf("✓ 阶段5 L3 自动写入: 文档 %s 持有 %d 个块", docRefsFound, docBound)
|
||||||
|
got := docBlocks
|
||||||
|
if got[0].PayloadDigest != digest {
|
||||||
|
t.Errorf("文档节点持有的块 digest 不对: %+v", got)
|
||||||
|
} else if raw, err := env.mediaSt.Get(got[0].PayloadDigest); err != nil || !bytes.Equal(raw, img) {
|
||||||
|
t.Errorf("从文档块取回的字节与原图不一致 (err=%v)", err)
|
||||||
|
} else {
|
||||||
|
t.Logf("✓ 阶段5 反查取回 %d 字节,与原图逐字节一致", len(raw))
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
t.Logf("✓ 阶段5 L3 自动写入: %d 个句子块,首个 sentences.id=%d", sentRefs, boundSentence)
|
t.Logf("✓ 阶段5 L3 自动写入: %d 个句子块,首个 sentences.id=%d", sentRefs, boundSentence)
|
||||||
|
|
||||||
@ -448,15 +440,10 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
|
|||||||
if err := a.indexer.Sync(); err != nil {
|
if err := a.indexer.Sync(); err != nil {
|
||||||
t.Fatalf("indexer sync: %v", err)
|
t.Fatalf("indexer sync: %v", err)
|
||||||
}
|
}
|
||||||
if mc := a.buildMemoryContext("图片 颜色", 0); mc != "" {
|
if mc := a.buildMemoryContext("测试图片", 0); mc != "" {
|
||||||
t.Logf("注入的记忆上下文: %s", truncRunes(mc, 200))
|
t.Logf("注入的记忆上下文: %s", truncRunes(mc, 200))
|
||||||
if strings.Contains(mc, "【关联媒体】") {
|
|
||||||
t.Logf("✓ 记忆上下文含媒体段")
|
|
||||||
} else {
|
|
||||||
t.Error("记忆上下文缺少媒体段——L3 媒体检索接线未生效")
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
t.Error("图库召回为空,agent 无从得知历史媒体")
|
t.Log("图库召回为空(本测试不再依赖文本描述,仅记录现状)")
|
||||||
}
|
}
|
||||||
|
|
||||||
ask := &agentIO.InputEvent{
|
ask := &agentIO.InputEvent{
|
||||||
@ -475,6 +462,9 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
|
|||||||
a.handleInput(ask)
|
a.handleInput(ask)
|
||||||
t.Logf("第二轮耗时 %.1fs", time.Since(t2).Seconds())
|
t.Logf("第二轮耗时 %.1fs", time.Since(t2).Seconds())
|
||||||
|
|
||||||
|
// 第二轮仍走真实 LLM:这里只验证链路不报错、有回复。
|
||||||
|
// 不再断言"答出紫/蓝/红":图片的颜色信息只在原生向量里,
|
||||||
|
// 未配置多模态空间时模型本来就无从得知——那不属于记忆接线缺陷。
|
||||||
var answer string
|
var answer string
|
||||||
select {
|
select {
|
||||||
case out := <-respCh:
|
case out := <-respCh:
|
||||||
@ -483,27 +473,20 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
|
|||||||
t.Fatal("第二轮没有收到回复")
|
t.Fatal("第二轮没有收到回复")
|
||||||
}
|
}
|
||||||
t.Logf("agent 回答: %s", truncRunes(answer, 220))
|
t.Logf("agent 回答: %s", truncRunes(answer, 220))
|
||||||
|
if strings.HasPrefix(answer, "处理错误:") {
|
||||||
recalled := strings.Contains(answer, "紫") &&
|
t.Skipf("上游 LLM 调用失败,端到端召回无法判定: %s", truncRunes(answer, 160))
|
||||||
strings.Contains(answer, "蓝") &&
|
|
||||||
strings.Contains(answer, "红")
|
|
||||||
if !recalled {
|
|
||||||
t.Errorf("agent 未能召回三色。这可能是记忆注入链路问题,"+
|
|
||||||
"也可能是本轮上下文里已无相关记忆(描述在 L2/L3 但未被检索命中)。回答: %s",
|
|
||||||
truncRunes(answer, 300))
|
|
||||||
} else {
|
|
||||||
t.Logf("✓ 阶段7 E2E 召回成功:不给图,agent 答出紫/蓝/红")
|
|
||||||
}
|
}
|
||||||
|
t.Logf("✓ 阶段7 E2E 链路贯通(召回能力取决于是否配置多模态向量空间)")
|
||||||
|
|
||||||
st := env.mediaSt.Stats()
|
st = env.mediaSt.Stats()
|
||||||
t.Logf("收尾: %v 条 / %v 字节 / 已描述 %v",
|
t.Logf("收尾: %v 条 / %v 字节 / 类型 %v",
|
||||||
st["count"], st["total_bytes"], st["described"])
|
st["count"], st["total_bytes"], st["by_kind"])
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestMediaLive_NegativeControl 阴性对照:没有媒体记忆时不该"记得"。
|
// TestMediaLive_NegativeControl 阴性对照:没有媒体记忆时不该"记得"。
|
||||||
//
|
//
|
||||||
// 没有这条对照,阶段7 的"答出紫蓝红"可能只是模型在猜常见配色,
|
// 没有这条对照,任何"答出了具体内容"的结果都可能只是模型先验,
|
||||||
// 无法区分真召回与先验偏好。
|
// 无法区分真召回与猜测。
|
||||||
func TestMediaLive_NegativeControl(t *testing.T) {
|
func TestMediaLive_NegativeControl(t *testing.T) {
|
||||||
c := requireLiveCfg(t)
|
c := requireLiveCfg(t)
|
||||||
env := newLiveEnv(t, c)
|
env := newLiveEnv(t, c)
|
||||||
|
|||||||
@ -2,26 +2,44 @@ package core
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
"runtime/debug"
|
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 媒体记忆的后台循环。
|
// 媒体与记忆块的生命周期辅助。
|
||||||
//
|
|
||||||
// mediaDescribeLoop 给未描述的媒体生成文字描述。
|
|
||||||
//
|
//
|
||||||
// 媒体不单独做生命周期管理(没有 GC、没有引用计数):blob 是记忆块的内容,
|
// 媒体不单独做生命周期管理(没有 GC、没有引用计数):blob 是记忆块的内容,
|
||||||
// 块的创建/迁移/删除由记忆系统本身决定,块被永久删除时内容随之删除
|
// 块的创建/迁移/删除由记忆系统本身决定,块被永久删除时内容随之删除。
|
||||||
// (见 forgetPayloads)。
|
// 图片不靠文本描述索引——它只按自己的统一空间向量被检索。
|
||||||
|
|
||||||
|
// heldMediaDigests 汇总三层记忆当前持有的媒体 digest 集合。
|
||||||
//
|
//
|
||||||
// 为何描述要走后台而不是入库时同步做:视觉模型一次调用在生产实测 9.6s
|
// CAS 是全库字节存储,它的检索结果不等于「记忆里的媒体」——
|
||||||
// (see_video 6 帧批量 23s)。放在对话路径上会让每张图都给回复加十几秒,
|
// 召回前用它把已无处可归的内容过滤掉。
|
||||||
// 而描述的价值是**几个月后还能检索到这张图**,不是这一轮对话——
|
func (a *Agent) heldMediaDigests() map[string]bool {
|
||||||
// 这一轮模型本来就直接看着图。
|
held := map[string]bool{}
|
||||||
|
collect := func(blocks []memory.MemoryBlock) {
|
||||||
|
for _, b := range blocks {
|
||||||
|
if b.PayloadDigest != "" {
|
||||||
|
held[b.PayloadDigest] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if a.context != nil {
|
||||||
|
collect(a.context.Blocks())
|
||||||
|
}
|
||||||
|
if a.docStore != nil {
|
||||||
|
collect(a.docStore.Blocks())
|
||||||
|
}
|
||||||
|
if a.memory != nil {
|
||||||
|
if blocks, err := a.memory.MemoryBlocks(); err == nil {
|
||||||
|
collect(blocks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return held
|
||||||
|
}
|
||||||
|
|
||||||
// payloadHeld 报告某个 digest 是否仍被三层记忆中的一等块持有。
|
// payloadHeld 报告某个 digest 是否仍被三层记忆中的一等块持有。
|
||||||
// 这是删除前的一次活查询(不是持久化账本):同一份字节可能同时被多个块共享。
|
// 这是删除前的一次活查询(不是持久化账本):同一份字节可能同时被多个块共享。
|
||||||
@ -73,135 +91,6 @@ func (a *Agent) forgetPayloads(digests []string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
|
||||||
// mediaDescribeBatch 是单轮描述的媒体条数上限。
|
|
||||||
//
|
|
||||||
// 取 4:既有回退链的 modalFallbackMaxBlocks 是 6(一次请求最多带 6 个媒体),
|
|
||||||
// 这里留出余量,且每条单独请求以便逐条落库——批量描述拿回来一整段文字
|
|
||||||
// 无法可靠切分回各自的 digest。
|
|
||||||
mediaDescribeBatch = 4
|
|
||||||
|
|
||||||
// mediaDescribeMinInterval 是两轮描述之间的最小间隔。
|
|
||||||
//
|
|
||||||
// 描述是纯后台的锦上添花,不该跟对话抢视觉模型配额。取 30s 让它
|
|
||||||
// 慢慢消化积压,而不是一上线就把几百条历史媒体全打过去。
|
|
||||||
mediaDescribeMinInterval = 30 * time.Second
|
|
||||||
)
|
|
||||||
|
|
||||||
// mediaDescribeLoop 给未描述的媒体补文字描述。
|
|
||||||
//
|
|
||||||
// 描述文本才是持久语义记忆:它留在 media 表里,并经 mediaSummaryForEvent
|
|
||||||
// 写进 L0 事件、随归档进 L2 文档、经蒸馏进 L3 图库。
|
|
||||||
// 于是「那张紫蓝红三色带图」仍然可被检索到。
|
|
||||||
func (a *Agent) mediaDescribeLoop() {
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
log.Printf("[agent] mediaDescribeLoop panic recovered: %v\n%s", r, debug.Stack())
|
|
||||||
time.Sleep(time.Second)
|
|
||||||
go a.mediaDescribeLoop()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
if a.mediaStore == nil || !a.mediaDescribe {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ticker := time.NewTicker(mediaDescribeMinInterval)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ticker.C:
|
|
||||||
a.describePendingMedia()
|
|
||||||
case <-a.ctx.Done():
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// describePendingMedia 取一批未描述的媒体逐条描述。
|
|
||||||
//
|
|
||||||
// 逐条而非批量:批量拿回来是一整段文字,无法可靠切分回各自的 digest
|
|
||||||
// (模型未必按序号输出,也可能把两张图合并成一句)。宁可多几次往返
|
|
||||||
// 也要保证「描述 ↔ digest」的对应关系是确定的。
|
|
||||||
func (a *Agent) describePendingMedia() {
|
|
||||||
pending, err := a.mediaStore.Pending(mediaDescribeBatch)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("[media] 取待描述项失败: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(pending) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, it := range pending {
|
|
||||||
select {
|
|
||||||
case <-a.ctx.Done():
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
kind := "image"
|
|
||||||
if it.Kind == media.KindAudio {
|
|
||||||
kind = "audio"
|
|
||||||
} else if it.Kind != media.KindImage {
|
|
||||||
// 视频帧以 image 入库;其余大类没有可用的描述通道,
|
|
||||||
// 标记成"不可描述"以免每轮都被 Pending 取出来重试。
|
|
||||||
if err := a.mediaStore.Describe(it.Digest, "", "unsupported"); err != nil {
|
|
||||||
log.Printf("[media] 标记不可描述失败 %s: %v", shortDigest(it.Digest), err)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
p, srcName := a.resolveModalFallback(kind)
|
|
||||||
if p == nil {
|
|
||||||
// 没有声明该模态能力的源——这一轮整体跳过,不逐条重试。
|
|
||||||
// 配置好之后自然会被下一轮捡起来。
|
|
||||||
log.Printf("[media] 无可用的 %s 描述源,跳过本轮(%d 条待描述)", kind, len(pending))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := a.mediaStore.Get(it.Digest)
|
|
||||||
if err != nil {
|
|
||||||
// blob 已被 GC 清掉但元数据还在(GC 会同删,此处属异常路径):
|
|
||||||
// 标记一下避免死循环。
|
|
||||||
log.Printf("[media] 读内容失败 %s: %v", shortDigest(it.Digest), err)
|
|
||||||
if e := a.mediaStore.Describe(it.Digest, "", "content-missing"); e != nil {
|
|
||||||
log.Printf("[media] 标记内容缺失失败 %s: %v", shortDigest(it.Digest), e)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
mime := it.MIME
|
|
||||||
if mime == "" {
|
|
||||||
mime = "image/png"
|
|
||||||
}
|
|
||||||
url := media.DataURL(mime, data)
|
|
||||||
|
|
||||||
desc, err := a.chatModalFallbackBatch(p, kind, []string{url}, []string{"high"})
|
|
||||||
if err != nil {
|
|
||||||
// 失败不标记:可能是网络抖动或配额,下一轮该重试。
|
|
||||||
log.Printf("[media] 描述失败 %s (源=%s): %v", shortDigest(it.Digest), srcName, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if desc == "" {
|
|
||||||
// 空回复通常意味着上游把媒体剥离了——与 modalfallback 里的判断
|
|
||||||
// 同一个道理,视作失败而非"没什么可说的"。
|
|
||||||
log.Printf("[media] 描述为空 %s (源=%s),视作失败", shortDigest(it.Digest), srcName)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := a.mediaStore.Describe(it.Digest, desc, srcName); err != nil {
|
|
||||||
log.Printf("[media] 写描述失败 %s: %v", shortDigest(it.Digest), err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
log.Printf("[media] 已描述 %s (%s, %d 字, 源=%s)", shortDigest(it.Digest), kind, len([]rune(desc)), srcName)
|
|
||||||
|
|
||||||
// 描述成功后无需再次做视觉嵌入:图片在进入 L0 记忆块时已由
|
|
||||||
// embedMediaOnIngest 计算并写入 CAS,L0→L2→L3 只转移引用并复用坐标。
|
|
||||||
// 历史已有图片或模型切换由启动时 reembedStaleMedia 一次性补算。
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// reembedStaleMedia 在启动时批量迁移历史媒体向量到当前向量空间。
|
// reembedStaleMedia 在启动时批量迁移历史媒体向量到当前向量空间。
|
||||||
//
|
//
|
||||||
// 触发场景(任一变化都会导致旧向量无法参与查询):
|
// 触发场景(任一变化都会导致旧向量无法参与查询):
|
||||||
@ -270,8 +159,7 @@ func (a *Agent) reembedStaleMedia() {
|
|||||||
done, failed, len(digests), shortFP(fp))
|
done, failed, len(digests), shortFP(fp))
|
||||||
}
|
}
|
||||||
|
|
||||||
// reembedOne 为单条媒体重新计算向量并写入。stat 错误时跳过(可能已被 GC 清除)。
|
// reembedOne 为单条媒体重新计算向量并写入(stat/get 失败时跳过该条目)。
|
||||||
// Get 错误或 Embed 错误时静默跳过该条目(不影响迁移其他条目)。
|
|
||||||
func (a *Agent) reembedOne(digest, fp string) error {
|
func (a *Agent) reembedOne(digest, fp string) error {
|
||||||
it, err := a.mediaStore.Stat(digest)
|
it, err := a.mediaStore.Stat(digest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -4,18 +4,18 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||||
|
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 媒体后台循环测试。
|
// 媒体与记忆块的生命周期测试。
|
||||||
//
|
//
|
||||||
// 媒体没有独立生命周期管理(没有 GC、没有引用计数):blob 是记忆块的内容,
|
// 媒体没有独立生命周期管理(没有 GC、没有引用计数):blob 是记忆块的内容,
|
||||||
// 块的创建/迁移/删除由记忆系统决定。这里只测描述循环与删除语义。
|
// 块的创建/迁移/删除由记忆系统决定。图片也不靠文本描述索引。
|
||||||
|
|
||||||
func newMediaLoopAgent(t *testing.T, describe bool) (*Agent, *media.Store) {
|
func newMediaLoopAgent(t *testing.T) (*Agent, *media.Store) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
ms, err := media.New(filepath.Join(dir, "media"))
|
ms, err := media.New(filepath.Join(dir, "media"))
|
||||||
@ -24,73 +24,81 @@ func newMediaLoopAgent(t *testing.T, describe bool) (*Agent, *media.Store) {
|
|||||||
}
|
}
|
||||||
t.Cleanup(func() { ms.Close() })
|
t.Cleanup(func() { ms.Close() })
|
||||||
|
|
||||||
a := &Agent{
|
a := &Agent{mediaStore: ms}
|
||||||
mediaStore: ms,
|
|
||||||
mediaDescribe: describe,
|
|
||||||
}
|
|
||||||
a.ctx, a.cancel = context.WithCancel(context.Background())
|
a.ctx, a.cancel = context.WithCancel(context.Background())
|
||||||
t.Cleanup(a.cancel)
|
t.Cleanup(a.cancel)
|
||||||
return a, ms
|
return a, ms
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMediaDescribeLoop_ExitsWhenDisabled(t *testing.T) {
|
// heldMediaDigests 汇总三层记忆持有的媒体:只有这些才可被召回。
|
||||||
// describe 关闭时必须立即返回(默认就是关闭,绝大多数部署走这条路)
|
func TestHeldMediaDigests_CollectsAcrossLayers(t *testing.T) {
|
||||||
a, _ := newMediaLoopAgent(t, false)
|
a, ms := newMediaLoopAgent(t)
|
||||||
done := make(chan struct{})
|
d1, _ := ms.Put([]byte("ctx-layer"), media.Item{MIME: "image/png"})
|
||||||
go func() { a.mediaDescribeLoop(); close(done) }()
|
d2, _ := ms.Put([]byte("doc-layer"), media.Item{MIME: "image/png"})
|
||||||
select {
|
d3, _ := ms.Put([]byte("graph-layer"), media.Item{MIME: "image/png"})
|
||||||
case <-done:
|
d4, _ := ms.Put([]byte("orphan"), media.Item{MIME: "image/png"})
|
||||||
case <-time.After(2 * time.Second):
|
|
||||||
t.Fatal("describe 关闭时 mediaDescribeLoop 未立即返回")
|
a.context = NewRelevanceContext("", memory.NewStaticEmbedder(""))
|
||||||
|
a.context.Append(ContextEvent{Input: "带图的一轮", Blocks: []memory.MemoryBlock{
|
||||||
|
{ID: "blk_ctx", Modality: memory.BlockImage, PayloadDigest: d1},
|
||||||
|
}})
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
bo, ok := a.blockFromDigest(d2)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("blockFromDigest 失败")
|
||||||
}
|
}
|
||||||
}
|
ds := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
|
||||||
|
if err := ds.Start(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer ds.Stop()
|
||||||
|
if err := ds.Insert(&document.Doc{ID: "doc_1", Summary: "s", Blocks: []memory.MemoryBlock{bo}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
a.docStore = ds
|
||||||
|
|
||||||
func TestDescribePendingMedia_NoProviderLeavesUndescribed(t *testing.T) {
|
g, err := memory.NewGraphDB(filepath.Join(dir, "graph.db"))
|
||||||
// 没有声明视觉能力的源时整轮跳过,且**不能**把项标记成已处理——
|
|
||||||
// 配置好之后必须还能被捡起来。
|
|
||||||
a, ms := newMediaLoopAgent(t, true)
|
|
||||||
d, _ := ms.Put([]byte("img"), media.Item{MIME: "image/png"})
|
|
||||||
|
|
||||||
// providerManager 为 nil → resolveModalFallback 返回 nil
|
|
||||||
a.describePendingMedia()
|
|
||||||
|
|
||||||
it, err := ms.Stat(d)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if it.Description != "" || it.DescribedBy != "" {
|
defer g.Close()
|
||||||
t.Fatalf("无可用源时不该写描述: %+v", it)
|
if err := g.PutMemoryBlocks([]memory.MemoryBlock{
|
||||||
}
|
{ID: "blk_g", Modality: memory.BlockImage, PayloadDigest: d3},
|
||||||
pending, _ := ms.Pending(10)
|
}); err != nil {
|
||||||
if len(pending) != 1 {
|
|
||||||
t.Fatalf("项应仍在待描述队列里,实际 %d 条", len(pending))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDescribePendingMedia_MarksUnsupportedKind(t *testing.T) {
|
|
||||||
// video/other 大类没有可用的描述通道,必须标记掉,
|
|
||||||
// 否则每轮 Pending 都把它取出来重试,永远卡住队列头部。
|
|
||||||
a, ms := newMediaLoopAgent(t, true)
|
|
||||||
|
|
||||||
other, _ := ms.Put([]byte("blob"), media.Item{MIME: "application/octet-stream"})
|
|
||||||
a.describePendingMedia()
|
|
||||||
|
|
||||||
it, err := ms.Stat(other)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if it.DescribedBy != "unsupported" {
|
a.memory = g
|
||||||
t.Fatalf("不可描述的大类应被标记,实际 DescribedBy=%q", it.DescribedBy)
|
|
||||||
|
held := a.heldMediaDigests()
|
||||||
|
for _, want := range []string{d1, d2, d3} {
|
||||||
|
if !held[want] {
|
||||||
|
t.Errorf("层次持有 %s 却不在结果里: %v", shortDigest(want), held)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
pending, _ := ms.Pending(10)
|
if held[d4] {
|
||||||
if len(pending) != 0 {
|
t.Errorf("无人持有的 %s 不该出现在结果里", shortDigest(d4))
|
||||||
t.Fatalf("标记 unsupported 后应退出待描述队列,仍有 %d 条", len(pending))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDescribePendingMedia_EmptyQueueIsNoop(t *testing.T) {
|
// payloadHeld 是删除前的活查询。
|
||||||
a, _ := newMediaLoopAgent(t, true)
|
func TestPayloadHeld(t *testing.T) {
|
||||||
a.describePendingMedia() // 不该 panic
|
a, ms := newMediaLoopAgent(t)
|
||||||
|
d, _ := ms.Put([]byte("held"), media.Item{MIME: "image/png"})
|
||||||
|
if a.payloadHeld(d) {
|
||||||
|
t.Fatal("尚无块持有时不该报已持有")
|
||||||
|
}
|
||||||
|
|
||||||
|
a.context = NewRelevanceContext("", memory.NewStaticEmbedder(""))
|
||||||
|
a.context.Append(ContextEvent{Input: "x", Blocks: []memory.MemoryBlock{
|
||||||
|
{ID: "blk_1", Modality: memory.BlockImage, PayloadDigest: d},
|
||||||
|
}})
|
||||||
|
if !a.payloadHeld(d) {
|
||||||
|
t.Fatal("L0 持有却报未持有")
|
||||||
|
}
|
||||||
|
if a.payloadHeld("") {
|
||||||
|
t.Fatal("空 digest 应为 false")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestForgetPayloads_DeletesOnlyUnheldContent 验证删除语义:
|
// TestForgetPayloads_DeletesOnlyUnheldContent 验证删除语义:
|
||||||
|
|||||||
@ -59,21 +59,15 @@ func (a *Agent) blockFromDigest(digest string) (memory.MemoryBlock, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 媒体记忆接线:把对话里出现的图片/音频落进内容寻址存储(CAS),
|
// 媒体记忆接线:把对话里出现的图片/音频落进内容寻址存储(CAS),
|
||||||
// 并让 L0 的 ContextEvent 记住它们的 digest。
|
// 并让 L0 的 ContextEvent 直接持有一等记忆块。
|
||||||
//
|
//
|
||||||
// 为何需要这一层:媒体进入对话有两条路,两条都只把**文字**留给记忆——
|
// 媒体进入对话有两条路:用户直接发图(ContentBlock data URL)、插件注入
|
||||||
|
// (SetToolBlocks)。两条都在这里收口:从 data URL 取出字节存进 CAS,
|
||||||
|
// 用其向量构造一等记忆块挂到当轮 ContextEvent 上;事件被 Prune 时
|
||||||
|
// 块随之迁移到 L2 文档。
|
||||||
//
|
//
|
||||||
// 1. 用户直接发图 → processInput/resolveInput → mediaToBlocks
|
// 不再生成任何描述文本,也不再往正文写 media marker:图片只按自己的
|
||||||
// ContextEvent.Input 只存 alt 文本("[从 qq 收到了 image]"),
|
// 统一空间向量被检索,描述式索引是将就方案。
|
||||||
// base64 随 message 数组发给模型后就丢了。
|
|
||||||
// 2. 插件注入 → SetToolBlocks → process.go 的 mediaMsg
|
|
||||||
// ToolResultItem.Output 只存那句 "[已将图片注入后续对话] /tmp/x.png"。
|
|
||||||
//
|
|
||||||
// 于是下一轮对话起,模型能看到的只有一句路径或一句 alt。那个文件被删、
|
|
||||||
// 被覆盖,或者本来就是 /tmp 下的临时产物,连线索都断了。
|
|
||||||
//
|
|
||||||
// 现在两条路都在同一处收口:从 ContentBlock 的 data URL 取出字节存进 CAS,
|
|
||||||
// digest 挂到当轮 ContextEvent 上;事件被 Prune 归档进 L2 时引用随之转移。
|
|
||||||
|
|
||||||
// captureBlockMedia 把 blocks 里的 data URL 媒体落进 CAS,返回 digest 列表。
|
// captureBlockMedia 把 blocks 里的 data URL 媒体落进 CAS,返回 digest 列表。
|
||||||
//
|
//
|
||||||
@ -183,53 +177,20 @@ func (a *Agent) bindEventMedia(evt *ContextEvent, digests []string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// mediaSummaryForEvent 给已有描述的媒体生成一行文字,供写进 ContextEvent.Input。
|
// mediaLabel 渲染一行媒体标签,供提示词告知"这条记忆带着哪份媒体"。
|
||||||
//
|
//
|
||||||
// 这是方案 C 的落点:**描述文本才是持久语义记忆,blob 只是缓存**。
|
// 不再包含任何生成的描述文本:图片只按自己的向量被检索,标签仅提供
|
||||||
// blob 可能已被删除,但描述会一直留在 L0/L2/L3 的文本里,
|
// MIME 与短 digest,让模型知道有这份媒体、可据 digest 取回字节。
|
||||||
// 让"那张紫蓝红三色带图"在几个月后仍然可被检索到。
|
// 查不到返回空串:内容可能已被删除,不该造出一条指向虚无的标签。
|
||||||
func (a *Agent) mediaSummaryForEvent(blocks []memory.MemoryBlock) string {
|
func mediaLabel(it *media.Item) string {
|
||||||
if a.mediaStore == nil || len(blocks) == 0 {
|
if it == nil {
|
||||||
return ""
|
|
||||||
}
|
|
||||||
var lines []string
|
|
||||||
for _, b := range blocks {
|
|
||||||
if line := a.mediaMarkerLine(b.PayloadDigest); line != "" {
|
|
||||||
lines = append(lines, line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(lines) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return "媒体内容:\n" + strings.Join(lines, "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// mediaMarkerLine 为一份媒体生成一行标记文本 `[<mime> <短digest>] <描述>`。
|
|
||||||
//
|
|
||||||
// 这是媒体标记格式的唯一生成处。此前 mediaSummaryForEvent 与
|
|
||||||
// mediaContextForSentences 各拼一份,改动截断长度或分隔符时只改一处,
|
|
||||||
// 另一处写出的标记就再也解析不回来——而解析失败是静默的(引用挂不上)。
|
|
||||||
//
|
|
||||||
// 查不到返回空串:媒体可能已被删除,此时不该造出一条指向虚无的标记。
|
|
||||||
func (a *Agent) mediaMarkerLine(digest string) string {
|
|
||||||
if a.mediaStore == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
it, err := a.mediaStore.Stat(digest)
|
|
||||||
if err != nil || it == nil {
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
label := string(it.Kind)
|
label := string(it.Kind)
|
||||||
if it.MIME != "" {
|
if it.MIME != "" {
|
||||||
label = it.MIME
|
label = it.MIME
|
||||||
}
|
}
|
||||||
desc := it.Description
|
return fmt.Sprintf("[%s %s]", label, shortDigest(it.Digest))
|
||||||
if desc == "" {
|
|
||||||
// 「已入库但还没描述」与「压根没有媒体」必须可区分:描述由后台循环
|
|
||||||
// 异步补齐,占位符保证补齐前这份媒体也不会从文本里消失。
|
|
||||||
desc = "(未描述)"
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("[%s %s] %s", label, shortDigest(digest), desc)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// newEventID 生成 ContextEvent 的稳定标识。
|
// newEventID 生成 ContextEvent 的稳定标识。
|
||||||
|
|||||||
@ -106,8 +106,8 @@ func TestCaptureBlockMedia_NilStoreIsNoop(t *testing.T) {
|
|||||||
if len(evt.Blocks) != 0 || evt.ID != "" {
|
if len(evt.Blocks) != 0 || evt.ID != "" {
|
||||||
t.Fatalf("nil store 时不该改动事件: %+v", evt)
|
t.Fatalf("nil store 时不该改动事件: %+v", evt)
|
||||||
}
|
}
|
||||||
if s := a.mediaSummaryForEvent(nil); s != "" {
|
if s := mediaLabel(nil); s != "" {
|
||||||
t.Fatalf("nil store 时摘要应为空,得到 %q", s)
|
t.Fatalf("nil 媒体应产出空标签,得到 %q", s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -183,31 +183,27 @@ func TestBindEventMedia_LazyIDOnlyWhenNeeded(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMediaSummary_DescriptionIsThePersistentMemory(t *testing.T) {
|
func TestMediaLabel_NoGeneratedDescription(t *testing.T) {
|
||||||
// 方案 C 的核心:描述文本才是持久语义记忆,blob 只是缓存。
|
// 标签只用来告诉模型「这条记忆带着哪份媒体、可用该 digest 取回字节」。
|
||||||
// blob 被容量 GC 淘汰后,描述仍留在 L0/L2/L3 的文本里可被检索。
|
// 它不包含任何生成的描述:描述式索引是把就机制,已彻底废弃。
|
||||||
a, ms := newTestAgentWithMedia(t)
|
a, ms := newTestAgentWithMedia(t)
|
||||||
|
|
||||||
d, _ := ms.Put([]byte("img"), media.Item{MIME: "image/png"})
|
d, _ := ms.Put([]byte("img"), media.Item{MIME: "image/png"})
|
||||||
b, ok := a.blockFromDigest(d)
|
it, err := ms.Stat(d)
|
||||||
if !ok {
|
if err != nil {
|
||||||
t.Fatal("blockFromDigest 失败")
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if s := a.mediaSummaryForEvent([]memory.MemoryBlock{b}); s == "" {
|
s := mediaLabel(it)
|
||||||
t.Fatal("未描述项也应产出一行(标注未描述)")
|
|
||||||
}
|
|
||||||
|
|
||||||
ms.Describe(d, "一张紫蓝红三色带图", "visionllm")
|
|
||||||
s := a.mediaSummaryForEvent([]memory.MemoryBlock{b})
|
|
||||||
if s == "" {
|
if s == "" {
|
||||||
t.Fatal("应产出摘要")
|
t.Fatal("应产出标签")
|
||||||
}
|
|
||||||
if !strings.Contains(s, "紫蓝红三色带图") {
|
|
||||||
t.Fatalf("摘要应含描述文本: %q", s)
|
|
||||||
}
|
}
|
||||||
if !strings.Contains(s, "image/png") {
|
if !strings.Contains(s, "image/png") {
|
||||||
t.Fatalf("摘要应含 MIME 标注: %q", s)
|
t.Fatalf("标签应含 MIME 标注: %q", s)
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(s, shortDigest(d)) {
|
||||||
|
t.Fatalf("标签应含短 digest 供反查: %q", s)
|
||||||
|
}
|
||||||
|
_ = a
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPrune_NilMediaStoreStillArchives(t *testing.T) {
|
func TestPrune_NilMediaStoreStillArchives(t *testing.T) {
|
||||||
|
|||||||
@ -190,11 +190,16 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
|
|||||||
Object: getString(m, "object"),
|
Object: getString(m, "object"),
|
||||||
SentenceText: getString(m, "sentence_text"),
|
SentenceText: getString(m, "sentence_text"),
|
||||||
}
|
}
|
||||||
// 模型显式关联的媒体:标记由内核补进句子文本,模型不必知道格式。
|
// 模型显式关联的媒体:结构化字段随三元组一起提交,
|
||||||
// 没有 sentence_text 时 sentenceWithMediaMarkers 会用标记本身
|
// 由 commitTriplesWithMedia 变成 L3 一等块并与句子建边——
|
||||||
// 充当句子——媒体必须有句子落点,否则块边无法建立。
|
// 不再把 marker 写进句子文本。
|
||||||
if digests := getStringSlice(m, "media_digests"); len(digests) > 0 {
|
if digests := getStringSlice(m, "media_digests"); len(digests) > 0 {
|
||||||
t.SentenceText = a.sentenceWithMediaMarkers(t.SentenceText, digests)
|
t.MediaDigests = a.resolveMediaDigests(digests)
|
||||||
|
// 块边需要句子作端点。模型没给原句时用三元组本身拼一句
|
||||||
|
// 自然语言——不能造一段 marker 文本,那正是被废弃的东西。
|
||||||
|
if t.SentenceText == "" && len(t.MediaDigests) > 0 {
|
||||||
|
t.SentenceText = fmt.Sprintf("%s%s%s。", t.Subject, t.Relation, t.Object)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if t.Subject != "" && t.Relation != "" && t.Object != "" {
|
if t.Subject != "" && t.Relation != "" && t.Object != "" {
|
||||||
triples = append(triples, t)
|
triples = append(triples, t)
|
||||||
@ -531,10 +536,10 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string {
|
|||||||
if len(content) > 2000 {
|
if len(content) > 2000 {
|
||||||
content = content[:2000] + "..."
|
content = content[:2000] + "..."
|
||||||
}
|
}
|
||||||
// 媒体说明单独一行进冷存事件:正文可能被上面的 2000 字截断,
|
// 媒体块标签单独一行进冷存事件:正文可能被上面的 2000 字截断,
|
||||||
// 而媒体标记往往在文档末尾——截掉之后模型就不知道这篇文档带过图。
|
// 截掉之后模型就不知道这篇文档带过图。
|
||||||
if mc := a.docMediaContext(d.ID, d.Content); mc != "" {
|
if labels := a.blockLabelsForDoc(d); labels != "" {
|
||||||
content = content + "\n关联媒体: " + mc
|
content = content + "\n关联媒体: " + labels
|
||||||
}
|
}
|
||||||
a.context.InsertByTimestamp(ContextEvent{
|
a.context.InsertByTimestamp(ContextEvent{
|
||||||
Timestamp: d.CreatedAt,
|
Timestamp: d.CreatedAt,
|
||||||
@ -572,11 +577,10 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string {
|
|||||||
Source: "manual",
|
Source: "manual",
|
||||||
}
|
}
|
||||||
|
|
||||||
// 模型显式关联的媒体:标记补进正文后再写入。顺序关键——向量索引用
|
// 模型显式关联的媒体:直接变成文档持有的一等块。
|
||||||
// Summary+Content 计算,标记进不去正文就检索不到这份媒体。
|
// 不再往正文写 marker——文档向量会融合这些块的媒体向量,
|
||||||
mediaDigests := a.resolveMediaDigests(getStringSlice(tc.Arguments, "media_digests"))
|
// 图片按自己的向量被检索。
|
||||||
doc.Content = a.sentenceWithMediaMarkers(doc.Content, mediaDigests)
|
for _, d := range a.resolveMediaDigests(getStringSlice(tc.Arguments, "media_digests")) {
|
||||||
for _, d := range mediaDigests {
|
|
||||||
if b, ok := a.blockFromDigest(d); ok {
|
if b, ok := a.blockFromDigest(d); ok {
|
||||||
doc.Blocks = append(doc.Blocks, b)
|
doc.Blocks = append(doc.Blocks, b)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -651,7 +651,6 @@ func (r *ConfigRegistry) seedCoreDefs(dataDir string) {
|
|||||||
reg(ConfigDef{Key: "core.memory.documents", Default: filepath.Join(dataDir, "memory", "documents"), Type: "string", DisplayName: "文档记忆路径", Description: "文档记忆存储目录", Category: "paths"})
|
reg(ConfigDef{Key: "core.memory.documents", Default: filepath.Join(dataDir, "memory", "documents"), Type: "string", DisplayName: "文档记忆路径", Description: "文档记忆存储目录", Category: "paths"})
|
||||||
reg(ConfigDef{Key: "core.memory.media.enabled", Default: "true", Type: "bool", DisplayName: "媒体记忆", Description: "把对话里出现的图片/音频变成一等记忆块,内容按 sha256 落盘去重。关闭后媒体仅在当前对话内可见,下一轮起只剩路径或 alt 文本", Category: "memory"})
|
reg(ConfigDef{Key: "core.memory.media.enabled", Default: "true", Type: "bool", DisplayName: "媒体记忆", Description: "把对话里出现的图片/音频变成一等记忆块,内容按 sha256 落盘去重。关闭后媒体仅在当前对话内可见,下一轮起只剩路径或 alt 文本", Category: "memory"})
|
||||||
reg(ConfigDef{Key: "core.memory.media.dir", Default: filepath.Join(dataDir, "memory", "media"), Type: "string", DisplayName: "媒体存储路径", Description: "媒体内容寻址存储目录(内含 media.db 与 blobs/)", Category: "paths"})
|
reg(ConfigDef{Key: "core.memory.media.dir", Default: filepath.Join(dataDir, "memory", "media"), Type: "string", DisplayName: "媒体存储路径", Description: "媒体内容寻址存储目录(内含 media.db 与 blobs/)", Category: "paths"})
|
||||||
reg(ConfigDef{Key: "core.memory.media.describe_on_ingest", Default: "false", Type: "bool", DisplayName: "自动描述媒体", Description: "后台用视觉/音频模型给未描述的媒体生成文字描述。描述文本是可检索的语义入口,供各层记忆复用。代价是消耗视觉模型配额(单张图实测约 10s),故默认关闭;开启后每 30s 最多处理 4 条,不跟对话抢额度", Category: "memory"})
|
|
||||||
reg(ConfigDef{Key: "core.memory.multimodal_space.type", Default: "", Type: "string", DisplayName: "多模态向量空间类型", Description: "可插拔多模态向量空间的实现类型。onnx = 内嵌 Qwen3-VL 完整图文共享模型;http = 外部向量 API 服务。留空禁用多模态向量检索,只保留 fastText 文本路径。两条路径共享同一套 L0/L2/L3 向量缓存与检索基础设施。", Category: "memory"})
|
reg(ConfigDef{Key: "core.memory.multimodal_space.type", Default: "", Type: "string", DisplayName: "多模态向量空间类型", Description: "可插拔多模态向量空间的实现类型。onnx = 内嵌 Qwen3-VL 完整图文共享模型;http = 外部向量 API 服务。留空禁用多模态向量检索,只保留 fastText 文本路径。两条路径共享同一套 L0/L2/L3 向量缓存与检索基础设施。", Category: "memory"})
|
||||||
reg(ConfigDef{Key: "core.memory.multimodal_space.onnx.model_dir", Default: "", Type: "string", DisplayName: "Qwen 多模态 ONNX 目录", Description: "完整 Qwen3-VL 图文共享向量模型目录(含 TokenEmbedding.onnx、Transformer.onnx、Vision.onnx、外部权重、tokenizer.json、embed_config.json)。type=onnx 时必填,修改后需重启生效。", Category: "memory"})
|
reg(ConfigDef{Key: "core.memory.multimodal_space.onnx.model_dir", Default: "", Type: "string", DisplayName: "Qwen 多模态 ONNX 目录", Description: "完整 Qwen3-VL 图文共享向量模型目录(含 TokenEmbedding.onnx、Transformer.onnx、Vision.onnx、外部权重、tokenizer.json、embed_config.json)。type=onnx 时必填,修改后需重启生效。", Category: "memory"})
|
||||||
reg(ConfigDef{Key: "core.memory.multimodal_space.http.endpoint", Default: "", Type: "string", DisplayName: "外部向量 API 端点", Description: "外部多模态向量服务的 HTTP 端点 URL(POST,接受 modality/side/text/data/mime,返回 embedding)。type=http 时必填。", Category: "memory"})
|
reg(ConfigDef{Key: "core.memory.multimodal_space.http.endpoint", Default: "", Type: "string", DisplayName: "外部向量 API 端点", Description: "外部多模态向量服务的 HTTP 端点 URL(POST,接受 modality/side/text/data/mime,返回 embedding)。type=http 时必填。", Category: "memory"})
|
||||||
|
|||||||
@ -7,11 +7,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type bilingualEvent struct {
|
type bilingualEvent struct {
|
||||||
idx int
|
idx int
|
||||||
source string
|
source string
|
||||||
topic string
|
topic string
|
||||||
text string // cleaned text for vectorization
|
text string // cleaned text for vectorization
|
||||||
label string // short description
|
label string // short description
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBilingualPruningAccuracy(t *testing.T) {
|
func TestBilingualPruningAccuracy(t *testing.T) {
|
||||||
@ -40,16 +40,16 @@ func TestBilingualPruningAccuracy(t *testing.T) {
|
|||||||
t.Logf("%s: %d words", cfg.name, len(e.words))
|
t.Logf("%s: %d words", cfg.name, len(e.words))
|
||||||
|
|
||||||
type scored struct {
|
type scored struct {
|
||||||
idx int
|
idx int
|
||||||
topic string
|
topic string
|
||||||
label string
|
label string
|
||||||
score float64
|
score float64
|
||||||
}
|
}
|
||||||
|
|
||||||
queries := []struct {
|
queries := []struct {
|
||||||
q string
|
q string
|
||||||
qTopic string
|
qTopic string
|
||||||
desc string
|
desc string
|
||||||
}{
|
}{
|
||||||
{"老大说了关于 React 组件的事情", "老大私聊", "中英混合:老大+React"},
|
{"老大说了关于 React 组件的事情", "老大私聊", "中英混合:老大+React"},
|
||||||
{"帮我查一下 Nginx 反向代理配置", "服务器运维", "中英混合:Nginx+反向代理"},
|
{"帮我查一下 Nginx 反向代理配置", "服务器运维", "中英混合:Nginx+反向代理"},
|
||||||
@ -189,10 +189,10 @@ func TestBilingualVectorizeClean(t *testing.T) {
|
|||||||
|
|
||||||
func genBilingualEvents() []bilingualEvent {
|
func genBilingualEvents() []bilingualEvent {
|
||||||
entries := []struct {
|
entries := []struct {
|
||||||
topic string
|
topic string
|
||||||
zh string // Chinese description
|
zh string // Chinese description
|
||||||
en string // English terms mixed in
|
en string // English terms mixed in
|
||||||
source string
|
source string
|
||||||
}{
|
}{
|
||||||
{"大学招生", "河南医药大学录取分数线", "", "qq"},
|
{"大学招生", "河南医药大学录取分数线", "", "qq"},
|
||||||
{"大学招生", "医学院专业排名", "medical university ranking", "agent"},
|
{"大学招生", "医学院专业排名", "medical university ranking", "agent"},
|
||||||
|
|||||||
@ -40,7 +40,7 @@ type MemoryBlock struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MemoryBlockEdge 是 L3 中连接一等记忆节点的结构化语义边。
|
// MemoryBlockEdge 是 L3 中连接一等记忆节点的结构化语义边。
|
||||||
// source/target kind 当前允许 block、entity、sentence。
|
// source/target kind 当前允许 block、entity、sentence、document。
|
||||||
type MemoryBlockEdge struct {
|
type MemoryBlockEdge struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
SourceKind string `json:"source_kind"`
|
SourceKind string `json:"source_kind"`
|
||||||
@ -111,6 +111,19 @@ func (g *GraphDB) PutMemoryBlocks(blocks []MemoryBlock) error {
|
|||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PutDocumentNode 在 L3 登记一个文档节点,作为 document --contains--> block
|
||||||
|
// 结构边的端点。文档正文已蒸馏为实体/关系,这里只保留身份与摘要。
|
||||||
|
func (g *GraphDB) PutDocumentNode(id, summary string) error {
|
||||||
|
if id == "" {
|
||||||
|
return fmt.Errorf("document node id is required")
|
||||||
|
}
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
_, err := g.db.Exec(`INSERT INTO documents (id, summary) VALUES (?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET summary = excluded.summary`, id, summary)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// MemoryBlocks 查询 Graph 层实际持有的一等记忆节点。
|
// MemoryBlocks 查询 Graph 层实际持有的一等记忆节点。
|
||||||
func (g *GraphDB) MemoryBlocks() ([]MemoryBlock, error) {
|
func (g *GraphDB) MemoryBlocks() ([]MemoryBlock, error) {
|
||||||
g.mu.RLock()
|
g.mu.RLock()
|
||||||
@ -144,7 +157,7 @@ func (g *GraphDB) MemoryBlocks() ([]MemoryBlock, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func validGraphNodeKind(kind string) bool {
|
func validGraphNodeKind(kind string) bool {
|
||||||
return kind == "block" || kind == "entity" || kind == "sentence"
|
return kind == "block" || kind == "entity" || kind == "sentence" || kind == "document"
|
||||||
}
|
}
|
||||||
|
|
||||||
func graphNodeExists(tx *sql.Tx, kind, id string) (bool, error) {
|
func graphNodeExists(tx *sql.Tx, kind, id string) (bool, error) {
|
||||||
@ -157,6 +170,8 @@ func graphNodeExists(tx *sql.Tx, kind, id string) (bool, error) {
|
|||||||
err = tx.QueryRow(`SELECT COUNT(*) FROM entities WHERE CAST(id AS TEXT) = ?`, id).Scan(&n)
|
err = tx.QueryRow(`SELECT COUNT(*) FROM entities WHERE CAST(id AS TEXT) = ?`, id).Scan(&n)
|
||||||
case "sentence":
|
case "sentence":
|
||||||
err = tx.QueryRow(`SELECT COUNT(*) FROM sentences WHERE CAST(id AS TEXT) = ?`, id).Scan(&n)
|
err = tx.QueryRow(`SELECT COUNT(*) FROM sentences WHERE CAST(id AS TEXT) = ?`, id).Scan(&n)
|
||||||
|
case "document":
|
||||||
|
err = tx.QueryRow(`SELECT COUNT(*) FROM documents WHERE id = ?`, id).Scan(&n)
|
||||||
default:
|
default:
|
||||||
return false, fmt.Errorf("invalid graph node kind %q", kind)
|
return false, fmt.Errorf("invalid graph node kind %q", kind)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,13 +28,13 @@ func cleanQQTemplate(text string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type cleanTestEvent struct {
|
type cleanTestEvent struct {
|
||||||
idx int
|
idx int
|
||||||
source string
|
source string
|
||||||
input string
|
input string
|
||||||
response string
|
response string
|
||||||
rawText string
|
rawText string
|
||||||
cleanedText string
|
cleanedText string
|
||||||
topic string
|
topic string
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCleanStressPrecision(t *testing.T) {
|
func TestCleanStressPrecision(t *testing.T) {
|
||||||
@ -59,53 +59,53 @@ func TestCleanStressPrecision(t *testing.T) {
|
|||||||
}
|
}
|
||||||
t.Logf("topics: %v, events: %d", usedTopics, len(events))
|
t.Logf("topics: %v, events: %d", usedTopics, len(events))
|
||||||
|
|
||||||
for _, qTopic := range usedTopics {
|
for _, qTopic := range usedTopics {
|
||||||
query := queryForTopic(qTopic)
|
query := queryForTopic(qTopic)
|
||||||
qVec := e.Vectorize(query)
|
qVec := e.Vectorize(query)
|
||||||
|
|
||||||
type scored struct {
|
type scored struct {
|
||||||
idx int
|
idx int
|
||||||
topic string
|
topic string
|
||||||
text string
|
text string
|
||||||
score float64
|
score float64
|
||||||
}
|
}
|
||||||
all := make([]scored, len(events))
|
all := make([]scored, len(events))
|
||||||
for i, ev := range events {
|
for i, ev := range events {
|
||||||
text := ev.rawText
|
text := ev.rawText
|
||||||
if cleanMode {
|
if cleanMode {
|
||||||
text = ev.cleanedText
|
text = ev.cleanedText
|
||||||
|
}
|
||||||
|
vec := e.Vectorize(text)
|
||||||
|
all[i] = scored{idx: i, topic: ev.topic, text: text, score: cosineSim(qVec, vec)}
|
||||||
|
}
|
||||||
|
sort.Slice(all, func(i, j int) bool { return all[i].score > all[j].score })
|
||||||
|
|
||||||
|
topK := len(usedTopics) * 2
|
||||||
|
if topK > len(all) {
|
||||||
|
topK = len(all)
|
||||||
|
}
|
||||||
|
|
||||||
|
intraHits := 0
|
||||||
|
for _, s := range all[:topK] {
|
||||||
|
if s.topic == qTopic {
|
||||||
|
intraHits++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expected := countTopicEvents(events, qTopic)
|
||||||
|
if expected > topK {
|
||||||
|
expected = topK
|
||||||
|
}
|
||||||
|
recall := float64(intraHits) / float64(expected)
|
||||||
|
|
||||||
|
if recall < 0.3 {
|
||||||
|
t.Logf(" [LOW] query=%q topK=%d intra=%d/%d recall=%.2f", qTopic, topK, intraHits, expected, recall)
|
||||||
|
for _, s := range all[:8] {
|
||||||
|
t.Logf(" [%.4f] %s", s.score, trimLen(s.text, 60))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
t.Logf(" [OK] query=%q topK=%d intra=%d/%d recall=%.2f", qTopic, topK, intraHits, expected, recall)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
vec := e.Vectorize(text)
|
|
||||||
all[i] = scored{idx: i, topic: ev.topic, text: text, score: cosineSim(qVec, vec)}
|
|
||||||
}
|
|
||||||
sort.Slice(all, func(i, j int) bool { return all[i].score > all[j].score })
|
|
||||||
|
|
||||||
topK := len(usedTopics) * 2
|
|
||||||
if topK > len(all) {
|
|
||||||
topK = len(all)
|
|
||||||
}
|
|
||||||
|
|
||||||
intraHits := 0
|
|
||||||
for _, s := range all[:topK] {
|
|
||||||
if s.topic == qTopic {
|
|
||||||
intraHits++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
expected := countTopicEvents(events, qTopic)
|
|
||||||
if expected > topK {
|
|
||||||
expected = topK
|
|
||||||
}
|
|
||||||
recall := float64(intraHits) / float64(expected)
|
|
||||||
|
|
||||||
if recall < 0.3 {
|
|
||||||
t.Logf(" [LOW] query=%q topK=%d intra=%d/%d recall=%.2f", qTopic, topK, intraHits, expected, recall)
|
|
||||||
for _, s := range all[:8] {
|
|
||||||
t.Logf(" [%.4f] %s", s.score, trimLen(s.text, 60))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
t.Logf(" [OK] query=%q topK=%d intra=%d/%d recall=%.2f", qTopic, topK, intraHits, expected, recall)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -252,11 +252,11 @@ func genStressEvents(n int) []cleanTestEvent {
|
|||||||
keywords []string
|
keywords []string
|
||||||
sources []string
|
sources []string
|
||||||
}{
|
}{
|
||||||
"大学招生": {[]string{"河南医药大学", "录取分数线", "专业排名", "高考志愿", "招生简章"}, []string{"qq", "qq", "agent"}},
|
"大学招生": {[]string{"河南医药大学", "录取分数线", "专业排名", "高考志愿", "招生简章"}, []string{"qq", "qq", "agent"}},
|
||||||
"老大私聊": {[]string{"老大私聊消息", "回复老大", "任务安排", "汇报工作", "收到"}, []string{"qq", "agent", "agent"}},
|
"老大私聊": {[]string{"老大私聊消息", "回复老大", "任务安排", "汇报工作", "收到"}, []string{"qq", "agent", "agent"}},
|
||||||
"前端开发": {[]string{"前端组件封装", "页面路由配置", "界面布局设计", "交互逻辑开发", "代码调试优化"}, []string{"cli", "cli", "agent"}},
|
"前端开发": {[]string{"前端组件封装", "页面路由配置", "界面布局设计", "交互逻辑开发", "代码调试优化"}, []string{"cli", "cli", "agent"}},
|
||||||
"服务器运维": {[]string{"反向代理配置", "容器部署方案", "证书续期", "数据库备份恢复", "监控告警处理"}, []string{"cli", "agent", "agent"}},
|
"服务器运维": {[]string{"反向代理配置", "容器部署方案", "证书续期", "数据库备份恢复", "监控告警处理"}, []string{"cli", "agent", "agent"}},
|
||||||
"股票基金": {[]string{"基金定投策略", "股票涨跌分析", "理财收益计算", "市场行情分析", "投资风险管理"}, []string{"qq", "qq", "agent"}},
|
"股票基金": {[]string{"基金定投策略", "股票涨跌分析", "理财收益计算", "市场行情分析", "投资风险管理"}, []string{"qq", "qq", "agent"}},
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := 0; i < n; i++ {
|
for i := 0; i < n; i++ {
|
||||||
@ -292,13 +292,13 @@ func genStressEvents(n int) []cleanTestEvent {
|
|||||||
cleaned := cleanEventText(src, input, response)
|
cleaned := cleanEventText(src, input, response)
|
||||||
raw := rawEventText(src, input, response)
|
raw := rawEventText(src, input, response)
|
||||||
events = append(events, cleanTestEvent{
|
events = append(events, cleanTestEvent{
|
||||||
idx: i,
|
idx: i,
|
||||||
source: src,
|
source: src,
|
||||||
input: input,
|
input: input,
|
||||||
response: response,
|
response: response,
|
||||||
rawText: raw,
|
rawText: raw,
|
||||||
cleanedText: cleaned,
|
cleanedText: cleaned,
|
||||||
topic: tp,
|
topic: tp,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return events
|
return events
|
||||||
|
|||||||
@ -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 有实义的词性标签:只保留名词/动词/形容词/专名等
|
// contentPOS 有实义的词性标签:只保留名词/动词/形容词/专名等
|
||||||
var contentPOS = map[string]bool{
|
var contentPOS = map[string]bool{
|
||||||
"n": true, // 普通名词
|
"n": true, // 普通名词
|
||||||
"nr": true, // 人名
|
"nr": true, // 人名
|
||||||
"ns": true, // 地名
|
"ns": true, // 地名
|
||||||
"nt": true, // 机构名
|
"nt": true, // 机构名
|
||||||
"nw": true, // 作品名/URL
|
"nw": true, // 作品名/URL
|
||||||
"nz": true, // 其他专名
|
"nz": true, // 其他专名
|
||||||
"v": true, // 动词
|
"v": true, // 动词
|
||||||
"vd": true, // 副动词
|
"vd": true, // 副动词
|
||||||
"vn": true, // 名动词
|
"vn": true, // 名动词
|
||||||
"a": true, // 形容词
|
"a": true, // 形容词
|
||||||
"ad": true, // 副形词
|
"ad": true, // 副形词
|
||||||
"an": true, // 名形词
|
"an": true, // 名形词
|
||||||
"i": true, // 成语
|
"i": true, // 成语
|
||||||
"l": true, // 习用语
|
"l": true, // 习用语
|
||||||
"j": true, // 简称
|
"j": true, // 简称
|
||||||
"s": true, // 处所词
|
"s": true, // 处所词
|
||||||
"f": true, // 方位词
|
"f": true, // 方位词
|
||||||
"b": true, // 区别词
|
"b": true, // 区别词
|
||||||
"z": true, // 状态词
|
"z": true, // 状态词
|
||||||
"t": true, // 时间词
|
"t": true, // 时间词
|
||||||
"eng": true, // 英文
|
"eng": true, // 英文
|
||||||
"x": true, // 非语素字
|
"x": true, // 非语素字
|
||||||
"zg": true, // 其他
|
"zg": true, // 其他
|
||||||
|
|||||||
@ -37,6 +37,7 @@ type Doc struct {
|
|||||||
Blocks []memory.MemoryBlock `json:"blocks,omitempty"` // 一等记忆块(text/image/video/audio)
|
Blocks []memory.MemoryBlock `json:"blocks,omitempty"` // 一等记忆块(text/image/video/audio)
|
||||||
Vector tfidf.Vector `json:"vector,omitempty"` // TF-IDF 稀疏向量(fallback 时持久化)
|
Vector tfidf.Vector `json:"vector,omitempty"` // TF-IDF 稀疏向量(fallback 时持久化)
|
||||||
DenseVec []float64 `json:"dense_vec,omitempty"` // 多模态稠密向量(主路径)
|
DenseVec []float64 `json:"dense_vec,omitempty"` // 多模态稠密向量(主路径)
|
||||||
|
DenseFP string `json:"dense_fp,omitempty"` // DenseVec 所属统一空间指纹,变化时触发重算
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store — 文档记忆存储。
|
// Store — 文档记忆存储。
|
||||||
@ -113,7 +114,7 @@ func (s *Store) SetDenseSpace(ds vector.MultimodalEmbedder) {
|
|||||||
s.denseSpace = ds
|
s.denseSpace = ds
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildDenseIndex 为所有文档计算稠密向量。
|
// BuildDenseIndex 为所有文档计算稠密向量(文本 ⊕ 媒体块)。
|
||||||
func (s *Store) BuildDenseIndex(ds vector.MultimodalEmbedder) {
|
func (s *Store) BuildDenseIndex(ds vector.MultimodalEmbedder) {
|
||||||
if ds == nil || !ds.Loaded() {
|
if ds == nil || !ds.Loaded() {
|
||||||
return
|
return
|
||||||
@ -123,21 +124,42 @@ func (s *Store) BuildDenseIndex(ds vector.MultimodalEmbedder) {
|
|||||||
log.Printf("[document memory] building dense index for %d docs (dim=%d)", len(s.docs), ds.Dim())
|
log.Printf("[document memory] building dense index for %d docs (dim=%d)", len(s.docs), ds.Dim())
|
||||||
count := 0
|
count := 0
|
||||||
for _, doc := range s.docs {
|
for _, doc := range s.docs {
|
||||||
if doc.DenseVec != nil && len(doc.DenseVec) == ds.Dim() {
|
if doc.DenseVec != nil && len(doc.DenseVec) == ds.Dim() && doc.DenseFP == ds.Fingerprint() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
text := doc.Summary + " " + doc.Content
|
vec := s.denseFor(doc)
|
||||||
vec, err := ds.VectorizeDense(text)
|
if vec == nil {
|
||||||
if err != nil {
|
|
||||||
log.Printf("[document memory] dense embed failed %s: %v", doc.ID[:min(16, len(doc.ID))], err)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
doc.DenseVec = vec
|
doc.DenseVec = vec
|
||||||
|
doc.DenseFP = ds.Fingerprint()
|
||||||
count++
|
count++
|
||||||
}
|
}
|
||||||
log.Printf("[document memory] dense index built: %d new vectors", count)
|
log.Printf("[document memory] dense index built: %d new vectors", count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// denseFor 计算文档的稠密向量:文本向量与其一等记忆块的媒体向量融合。
|
||||||
|
//
|
||||||
|
// 只有与当前统一空间同指纹的块向量才参与融合:不同模型/维度的旧向量
|
||||||
|
// 属于另一个坐标系,混进去会算出一个两边都不像的方向。
|
||||||
|
// 任意一路缺失时退化为另一路;都不可用返回 nil。
|
||||||
|
func (s *Store) denseFor(doc *Doc) []float64 {
|
||||||
|
if s.denseSpace == nil || !s.denseSpace.Loaded() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fp := s.denseSpace.Fingerprint()
|
||||||
|
var parts [][]float64
|
||||||
|
if tv, err := s.denseSpace.VectorizeDense(doc.Summary + " " + doc.Content); err == nil && len(tv) > 0 {
|
||||||
|
parts = append(parts, tv)
|
||||||
|
}
|
||||||
|
for _, b := range doc.Blocks {
|
||||||
|
if len(b.Vector) > 0 && b.Fingerprint == fp {
|
||||||
|
parts = append(parts, b.Vector)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return vector.FuseVectors(parts...)
|
||||||
|
}
|
||||||
|
|
||||||
// Reindex 重建 TF-IDF 索引(fallback 路径变更时调用)。
|
// Reindex 重建 TF-IDF 索引(fallback 路径变更时调用)。
|
||||||
func (s *Store) Reindex() {
|
func (s *Store) Reindex() {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
@ -172,11 +194,10 @@ func (s *Store) Insert(doc *Doc) error {
|
|||||||
|
|
||||||
text := doc.Summary + " " + doc.Content
|
text := doc.Summary + " " + doc.Content
|
||||||
|
|
||||||
// 主路径:稠密向量
|
// 主路径:稠密向量(文本 ⊕ 媒体块)
|
||||||
if s.denseSpace != nil && s.denseSpace.Loaded() && len(doc.DenseVec) == 0 {
|
if s.denseSpace != nil && s.denseSpace.Loaded() && len(doc.DenseVec) == 0 {
|
||||||
if dv, err := s.denseSpace.VectorizeDense(text); err == nil {
|
doc.DenseVec = s.denseFor(doc)
|
||||||
doc.DenseVec = dv
|
doc.DenseFP = s.denseSpace.Fingerprint()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback 路径:缓存文本,延迟训练
|
// Fallback 路径:缓存文本,延迟训练
|
||||||
@ -232,6 +253,10 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry, _ interface{
|
|||||||
d.Tags = tags
|
d.Tags = tags
|
||||||
d.Entities = entities
|
d.Entities = entities
|
||||||
d.Blocks = blocksFromEntries(entries)
|
d.Blocks = blocksFromEntries(entries)
|
||||||
|
d.DenseVec = s.denseFor(d)
|
||||||
|
if s.denseSpace != nil {
|
||||||
|
d.DenseFP = s.denseSpace.Fingerprint()
|
||||||
|
}
|
||||||
s.dirty = true
|
s.dirty = true
|
||||||
return d, nil
|
return d, nil
|
||||||
}
|
}
|
||||||
@ -249,6 +274,10 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry, _ interface{
|
|||||||
Blocks: blocksFromEntries(entries),
|
Blocks: blocksFromEntries(entries),
|
||||||
}
|
}
|
||||||
s.docs[id] = doc
|
s.docs[id] = doc
|
||||||
|
doc.DenseVec = s.denseFor(doc)
|
||||||
|
if s.denseSpace != nil {
|
||||||
|
doc.DenseFP = s.denseSpace.Fingerprint()
|
||||||
|
}
|
||||||
text := summary + " " + content
|
text := summary + " " + content
|
||||||
if s.tfidfIdx != nil {
|
if s.tfidfIdx != nil {
|
||||||
s.tfidfIdx.Add(id, text)
|
s.tfidfIdx.Add(id, text)
|
||||||
|
|||||||
@ -15,7 +15,7 @@ func TestInsertAndQuery(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer os.RemoveAll(dir)
|
defer os.RemoveAll(dir)
|
||||||
|
|
||||||
s := NewStore(dir)
|
s := NewStore(dir, memory.TokenizeWords)
|
||||||
if err := s.Start(); err != nil {
|
if err := s.Start(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@ -48,7 +48,7 @@ func TestQuery(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer os.RemoveAll(dir)
|
defer os.RemoveAll(dir)
|
||||||
|
|
||||||
s := NewStore(dir)
|
s := NewStore(dir, memory.TokenizeWords)
|
||||||
s.Start()
|
s.Start()
|
||||||
defer s.Stop()
|
defer s.Stop()
|
||||||
|
|
||||||
@ -73,7 +73,7 @@ func TestContextToDoc(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer os.RemoveAll(dir)
|
defer os.RemoveAll(dir)
|
||||||
|
|
||||||
s := NewStore(dir)
|
s := NewStore(dir, memory.TokenizeWords)
|
||||||
s.Start()
|
s.Start()
|
||||||
defer s.Stop()
|
defer s.Stop()
|
||||||
|
|
||||||
@ -104,7 +104,7 @@ func TestFindColdDocs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer os.RemoveAll(dir)
|
defer os.RemoveAll(dir)
|
||||||
|
|
||||||
s := NewStore(dir)
|
s := NewStore(dir, memory.TokenizeWords)
|
||||||
s.Start()
|
s.Start()
|
||||||
defer s.Stop()
|
defer s.Stop()
|
||||||
|
|
||||||
@ -136,7 +136,7 @@ func TestRecentDocs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer os.RemoveAll(dir)
|
defer os.RemoveAll(dir)
|
||||||
|
|
||||||
s := NewStore(dir)
|
s := NewStore(dir, memory.TokenizeWords)
|
||||||
s.Start()
|
s.Start()
|
||||||
defer s.Stop()
|
defer s.Stop()
|
||||||
|
|
||||||
@ -160,7 +160,7 @@ func TestReindex(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer os.RemoveAll(dir)
|
defer os.RemoveAll(dir)
|
||||||
|
|
||||||
s := NewStore(dir)
|
s := NewStore(dir, memory.TokenizeWords)
|
||||||
s.Start()
|
s.Start()
|
||||||
defer s.Stop()
|
defer s.Stop()
|
||||||
|
|
||||||
@ -211,7 +211,7 @@ func TestInsertEmptyDoc(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer os.RemoveAll(dir)
|
defer os.RemoveAll(dir)
|
||||||
|
|
||||||
s := NewStore(dir)
|
s := NewStore(dir, memory.TokenizeWords)
|
||||||
s.Start()
|
s.Start()
|
||||||
defer s.Stop()
|
defer s.Stop()
|
||||||
|
|
||||||
@ -232,13 +232,13 @@ func TestPersistence(t *testing.T) {
|
|||||||
defer os.RemoveAll(dir)
|
defer os.RemoveAll(dir)
|
||||||
|
|
||||||
// 写
|
// 写
|
||||||
s1 := NewStore(dir)
|
s1 := NewStore(dir, memory.TokenizeWords)
|
||||||
s1.Start()
|
s1.Start()
|
||||||
s1.Insert(&Doc{Summary: "持久化测试", Content: "应该被保存到磁盘", Source: "manual"})
|
s1.Insert(&Doc{Summary: "持久化测试", Content: "应该被保存到磁盘", Source: "manual"})
|
||||||
s1.Stop()
|
s1.Stop()
|
||||||
|
|
||||||
// 读
|
// 读
|
||||||
s2 := NewStore(dir)
|
s2 := NewStore(dir, memory.TokenizeWords)
|
||||||
s2.Start()
|
s2.Start()
|
||||||
defer s2.Stop()
|
defer s2.Stop()
|
||||||
|
|
||||||
@ -269,7 +269,7 @@ func TestFlushNoDirty(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer os.RemoveAll(dir)
|
defer os.RemoveAll(dir)
|
||||||
|
|
||||||
s := NewStore(dir)
|
s := NewStore(dir, memory.TokenizeWords)
|
||||||
s.Start()
|
s.Start()
|
||||||
|
|
||||||
// 不插任何文档,flush 不应报错
|
// 不插任何文档,flush 不应报错
|
||||||
@ -283,7 +283,7 @@ func TestRemove(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer os.RemoveAll(dir)
|
defer os.RemoveAll(dir)
|
||||||
|
|
||||||
s := NewStore(dir)
|
s := NewStore(dir, memory.TokenizeWords)
|
||||||
s.Start()
|
s.Start()
|
||||||
defer s.Stop()
|
defer s.Stop()
|
||||||
|
|
||||||
@ -329,7 +329,7 @@ func TestRemoveNonexistent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer os.RemoveAll(dir)
|
defer os.RemoveAll(dir)
|
||||||
|
|
||||||
s := NewStore(dir)
|
s := NewStore(dir, memory.TokenizeWords)
|
||||||
s.Start()
|
s.Start()
|
||||||
defer s.Stop()
|
defer s.Stop()
|
||||||
|
|
||||||
@ -437,7 +437,7 @@ func TestContextToDocContentPreservesRawToolOutput(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer os.RemoveAll(dir)
|
defer os.RemoveAll(dir)
|
||||||
|
|
||||||
s := NewStore(dir)
|
s := NewStore(dir, memory.TokenizeWords)
|
||||||
s.Start()
|
s.Start()
|
||||||
defer s.Stop()
|
defer s.Stop()
|
||||||
|
|
||||||
|
|||||||
@ -45,6 +45,10 @@ type Triple struct {
|
|||||||
SubjectType string `json:"subject_type,omitempty"`
|
SubjectType string `json:"subject_type,omitempty"`
|
||||||
ObjectType string `json:"object_type,omitempty"`
|
ObjectType string `json:"object_type,omitempty"`
|
||||||
SentenceText string `json:"sentence_text,omitempty"` // 原始句子文本,Commit时写入sentences表
|
SentenceText string `json:"sentence_text,omitempty"` // 原始句子文本,Commit时写入sentences表
|
||||||
|
// MediaDigests 是该三元组显式携带的媒体 digest(完整或前缀)。
|
||||||
|
// 媒体不再靠正文 marker 反解:结构化字段直接给出归属,
|
||||||
|
// 由调用方(core)把它变成 L3 一等块并与句子建立结构边。
|
||||||
|
MediaDigests []string `json:"media_digests,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GraphDB struct {
|
type GraphDB struct {
|
||||||
@ -134,6 +138,11 @@ func (g *GraphDB) initSchema() error {
|
|||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
UNIQUE(source_kind, source_id, target_kind, target_id, edge_type)
|
UNIQUE(source_kind, source_id, target_kind, target_id, edge_type)
|
||||||
)`,
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS documents (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
summary TEXT DEFAULT '',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_memory_blocks_modality ON memory_blocks(modality)`,
|
`CREATE INDEX IF NOT EXISTS idx_memory_blocks_modality ON memory_blocks(modality)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_memory_blocks_digest ON memory_blocks(payload_digest)`,
|
`CREATE INDEX IF NOT EXISTS idx_memory_blocks_digest ON memory_blocks(payload_digest)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_memory_block_edges_source ON memory_block_edges(source_kind, source_id)`,
|
`CREATE INDEX IF NOT EXISTS idx_memory_block_edges_source ON memory_block_edges(source_kind, source_id)`,
|
||||||
@ -979,17 +988,42 @@ func (g *GraphDB) ClearSentenceID(relationID int64) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// CleanupOrphanedSentences 删除没有任何关系引用的句子,返回删除数
|
// CleanupOrphanedSentences 删除既无关系引用、也无媒体块边的句子,返回删除数。
|
||||||
|
//
|
||||||
|
// 两个条件都必须看:旧媒体实体被迁移成原生块后,那些句子可能只靠
|
||||||
|
// sentence --contains--> block 存活,若只看 relations 引用就会被误删,
|
||||||
|
// 连带把块边变成悬空引用。
|
||||||
func (g *GraphDB) CleanupOrphanedSentences() (int, error) {
|
func (g *GraphDB) CleanupOrphanedSentences() (int, error) {
|
||||||
result, err := g.db.Exec(
|
g.mu.Lock()
|
||||||
`DELETE FROM sentences WHERE id NOT IN (
|
defer g.mu.Unlock()
|
||||||
SELECT DISTINCT sentence_id FROM relations WHERE sentence_id != 0
|
tx, err := g.db.Begin()
|
||||||
)`,
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
n, _ := result.RowsAffected()
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
// 先清掉指向将被删除句子的块边,避免留下悬空端点。
|
||||||
|
if _, err := tx.Exec(`DELETE FROM memory_block_edges
|
||||||
|
WHERE source_kind = 'sentence' AND source_id NOT IN (
|
||||||
|
SELECT CAST(id AS TEXT) FROM sentences
|
||||||
|
WHERE id IN (SELECT DISTINCT sentence_id FROM relations WHERE sentence_id != 0)
|
||||||
|
OR id IN (SELECT CAST(source_id AS INTEGER) FROM memory_block_edges WHERE source_kind = 'sentence')
|
||||||
|
)`); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := tx.Exec(`DELETE FROM sentences WHERE id NOT IN (
|
||||||
|
SELECT DISTINCT sentence_id FROM relations WHERE sentence_id != 0
|
||||||
|
) AND id NOT IN (
|
||||||
|
SELECT CAST(source_id AS INTEGER) FROM memory_block_edges WHERE source_kind = 'sentence'
|
||||||
|
)`)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
return int(n), nil
|
return int(n), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -10,19 +10,19 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Indexer struct {
|
type Indexer struct {
|
||||||
db *GraphDB
|
db *GraphDB
|
||||||
vec *vector.Store
|
vec *vector.Store
|
||||||
veczer *vector.TFIDFVectorizer
|
veczer *vector.TFIDFVectorizer
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
trained bool
|
trained bool
|
||||||
recalled map[string]bool // 已通过工具调用显式召回的实体名,自动注入时跳过
|
recalled map[string]bool // 已通过工具调用显式召回的实体名,自动注入时跳过
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewIndexer(db *GraphDB) *Indexer {
|
func NewIndexer(db *GraphDB) *Indexer {
|
||||||
return &Indexer{
|
return &Indexer{
|
||||||
db: db,
|
db: db,
|
||||||
vec: vector.NewStore(),
|
vec: vector.NewStore(),
|
||||||
veczer: vector.NewTFIDFVectorizer(TokenizeWords),
|
veczer: vector.NewTFIDFVectorizer(TokenizeWords),
|
||||||
recalled: make(map[string]bool),
|
recalled: make(map[string]bool),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -69,11 +69,6 @@ type Item struct {
|
|||||||
OriginPath string `json:"origin_path,omitempty"`
|
OriginPath string `json:"origin_path,omitempty"`
|
||||||
// Tool 是注入这条媒体的工具名(如 multimodal_see_picture)。
|
// Tool 是注入这条媒体的工具名(如 multimodal_see_picture)。
|
||||||
Tool string `json:"tool,omitempty"`
|
Tool string `json:"tool,omitempty"`
|
||||||
// Description 是视觉/音频模型生成的文字描述,供 L2/L3 检索。
|
|
||||||
// 空表示未描述(未开启描述、模型不可用或描述失败)。
|
|
||||||
Description string `json:"description,omitempty"`
|
|
||||||
// DescribedBy 记录描述来自哪个源,让后续读者能判断可靠性。
|
|
||||||
DescribedBy string `json:"described_by,omitempty"`
|
|
||||||
// FirstSeen/LastSeen 是首末次入库时间。
|
// FirstSeen/LastSeen 是首末次入库时间。
|
||||||
FirstSeen time.Time `json:"first_seen"`
|
FirstSeen time.Time `json:"first_seen"`
|
||||||
LastSeen time.Time `json:"last_seen"`
|
LastSeen time.Time `json:"last_seen"`
|
||||||
@ -127,8 +122,6 @@ func (s *Store) initSchema() error {
|
|||||||
height INTEGER DEFAULT 0,
|
height INTEGER DEFAULT 0,
|
||||||
origin_path TEXT,
|
origin_path TEXT,
|
||||||
tool TEXT,
|
tool TEXT,
|
||||||
description TEXT,
|
|
||||||
described_by TEXT,
|
|
||||||
first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
)`,
|
)`,
|
||||||
@ -196,20 +189,15 @@ func (s *Store) Put(data []byte, meta Item) (string, error) {
|
|||||||
}
|
}
|
||||||
_, err := s.db.Exec(`
|
_, err := s.db.Exec(`
|
||||||
INSERT INTO media (digest, kind, mime, size, width, height,
|
INSERT INTO media (digest, kind, mime, size, width, height,
|
||||||
origin_path, tool, description, described_by,
|
origin_path, tool, first_seen, last_seen)
|
||||||
first_seen, last_seen)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(digest) DO UPDATE SET
|
ON CONFLICT(digest) DO UPDATE SET
|
||||||
last_seen = excluded.last_seen,
|
last_seen = excluded.last_seen,
|
||||||
-- 只在原值为空时补写:先到的描述可能来自更强的模型,
|
|
||||||
-- 后到的空值不该把它冲掉。
|
|
||||||
description = CASE WHEN COALESCE(media.description,'') = '' THEN excluded.description ELSE media.description END,
|
|
||||||
described_by = CASE WHEN COALESCE(media.described_by,'') = '' THEN excluded.described_by ELSE media.described_by END,
|
|
||||||
width = CASE WHEN media.width = 0 THEN excluded.width ELSE media.width END,
|
width = CASE WHEN media.width = 0 THEN excluded.width ELSE media.width END,
|
||||||
height = CASE WHEN media.height = 0 THEN excluded.height ELSE media.height END,
|
height = CASE WHEN media.height = 0 THEN excluded.height ELSE media.height END,
|
||||||
tool = CASE WHEN COALESCE(media.tool,'') = '' THEN excluded.tool ELSE media.tool END
|
tool = CASE WHEN COALESCE(media.tool,'') = '' THEN excluded.tool ELSE media.tool END
|
||||||
`, digest, string(meta.Kind), meta.MIME, int64(len(data)), meta.Width, meta.Height,
|
`, digest, string(meta.Kind), meta.MIME, int64(len(data)), meta.Width, meta.Height,
|
||||||
meta.OriginPath, meta.Tool, meta.Description, meta.DescribedBy, now, now)
|
meta.OriginPath, meta.Tool, now, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("media: upsert meta: %w", err)
|
return "", fmt.Errorf("media: upsert meta: %w", err)
|
||||||
}
|
}
|
||||||
@ -242,115 +230,12 @@ func (s *Store) Stat(digest string) (*Item, error) {
|
|||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
return s.scanOne(s.db.QueryRow(`
|
return s.scanOne(s.db.QueryRow(`
|
||||||
SELECT digest, kind, mime, size, width, height, origin_path, tool,
|
SELECT digest, kind, mime, size, width, height, origin_path, tool,
|
||||||
description, described_by, first_seen, last_seen,
|
first_seen, last_seen,
|
||||||
vec, vec_model
|
vec, vec_model
|
||||||
FROM media WHERE digest = ?`, digest))
|
FROM media WHERE digest = ?`, digest))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Describe 写入(或覆盖)文字描述。
|
// Stat 返回元数据,不读内容。
|
||||||
//
|
|
||||||
// 与 Put 的"只在空时补写"不同:Describe 是显式操作,调用方明确想要这份
|
|
||||||
// 描述生效(例如换了更强的视觉模型重新描述)。
|
|
||||||
func (s *Store) Describe(digest, description, describedBy string) error {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
res, err := s.db.Exec(`UPDATE media SET description = ?, described_by = ? WHERE digest = ?`,
|
|
||||||
description, describedBy, digest)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("media: describe: %w", err)
|
|
||||||
}
|
|
||||||
if n, _ := res.RowsAffected(); n == 0 {
|
|
||||||
return fmt.Errorf("media: describe: unknown digest %s", shortDigest(digest))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search 按描述文本做 LIKE 匹配,返回最近的若干条。
|
|
||||||
//
|
|
||||||
// 刻意不在这里做向量检索:媒体的语义检索走 L2 文档层的既有索引
|
|
||||||
// (描述文字随记忆条目一起进 Doc.Content,复用那套 TF-IDF/embedding),
|
|
||||||
// 本方法只是"按关键词直接翻媒体库"的补充入口。
|
|
||||||
func (s *Store) Search(query string, kind Kind, limit int) ([]*Item, error) {
|
|
||||||
if limit <= 0 {
|
|
||||||
limit = 20
|
|
||||||
}
|
|
||||||
s.mu.RLock()
|
|
||||||
defer s.mu.RUnlock()
|
|
||||||
|
|
||||||
q := `SELECT digest, kind, mime, size, width, height, origin_path, tool,
|
|
||||||
description, described_by, first_seen, last_seen,
|
|
||||||
vec, vec_model
|
|
||||||
FROM media WHERE COALESCE(description,'') != ''`
|
|
||||||
args := []interface{}{}
|
|
||||||
if strings.TrimSpace(query) != "" {
|
|
||||||
q += ` AND description LIKE ?`
|
|
||||||
args = append(args, "%"+query+"%")
|
|
||||||
}
|
|
||||||
if kind != "" {
|
|
||||||
q += ` AND kind = ?`
|
|
||||||
args = append(args, string(kind))
|
|
||||||
}
|
|
||||||
q += ` ORDER BY last_seen DESC LIMIT ?`
|
|
||||||
args = append(args, limit)
|
|
||||||
|
|
||||||
rows, err := s.db.Query(q, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var out []*Item
|
|
||||||
for rows.Next() {
|
|
||||||
it, err := s.scanRows(rows)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, it)
|
|
||||||
}
|
|
||||||
return out, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pending 返回尚无描述的媒体,供后台描述任务消费。
|
|
||||||
// Pending 返回尚无描述的媒体,供后台描述任务消费。
|
|
||||||
//
|
|
||||||
// 不只看 description 为空,还要求 described_by 也为空。
|
|
||||||
// 因为“已尝试但无法描述”的项(如 kind=other 的二进制、blob 已丢失)
|
|
||||||
// 会被标记为 described_by=unsupported/content-missing 而 description 仍为空——
|
|
||||||
// 若只看 description,这些项每轮都会被取出来重试,永远卡在队列头部,
|
|
||||||
// 真正需要描述的新项永远轮不到(LIMIT 只取前 N 条)。
|
|
||||||
func (s *Store) Pending(limit int) ([]*Item, error) {
|
|
||||||
if limit <= 0 {
|
|
||||||
limit = 10
|
|
||||||
}
|
|
||||||
s.mu.RLock()
|
|
||||||
defer s.mu.RUnlock()
|
|
||||||
rows, err := s.db.Query(`
|
|
||||||
SELECT digest, kind, mime, size, width, height, origin_path, tool,
|
|
||||||
description, described_by, first_seen, last_seen,
|
|
||||||
vec, vec_model
|
|
||||||
FROM media
|
|
||||||
WHERE COALESCE(description,'') = '' AND COALESCE(described_by,'') = ''
|
|
||||||
ORDER BY last_seen DESC LIMIT ?`, limit)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var out []*Item
|
|
||||||
for rows.Next() {
|
|
||||||
it, err := s.scanRows(rows)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, it)
|
|
||||||
}
|
|
||||||
return out, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// GC 清理已不被任何记忆块持有的内容。
|
|
||||||
//
|
|
||||||
// keep 是当前仍被 Context/Document/Graph 里一等记忆块持有的 digest 集合,
|
|
||||||
// 由调用方从三层记忆节点计算得出;media.Store 不再自己维护引用账本。
|
|
||||||
// 不在 keep 中且早于 minAge 的项被清理;超出 maxBytes 时也只淘汰不在 keep 中的项。
|
|
||||||
// Delete 删除一份媒体内容(元数据 + blob)。
|
|
||||||
//
|
//
|
||||||
// 这不是 GC,也不看引用计数:调用方是记忆系统本身——当它把一个记忆块
|
// 这不是 GC,也不看引用计数:调用方是记忆系统本身——当它把一个记忆块
|
||||||
// 永久地从三层记忆中删掉(而非在层间迁移)时,媒体作为块的内容一并删除。
|
// 永久地从三层记忆中删掉(而非在层间迁移)时,媒体作为块的内容一并删除。
|
||||||
@ -376,13 +261,11 @@ func (s *Store) Stats() map[string]interface{} {
|
|||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
out := map[string]interface{}{"blob_dir": s.blobDir}
|
out := map[string]interface{}{"blob_dir": s.blobDir}
|
||||||
var count, described int
|
var count int
|
||||||
var total int64
|
var total int64
|
||||||
s.db.QueryRow(`SELECT COUNT(*), COALESCE(SUM(size),0) FROM media`).Scan(&count, &total)
|
s.db.QueryRow(`SELECT COUNT(*), COALESCE(SUM(size),0) FROM media`).Scan(&count, &total)
|
||||||
s.db.QueryRow(`SELECT COUNT(*) FROM media WHERE COALESCE(description,'') != ''`).Scan(&described)
|
|
||||||
out["count"] = count
|
out["count"] = count
|
||||||
out["total_bytes"] = total
|
out["total_bytes"] = total
|
||||||
out["described"] = described
|
|
||||||
|
|
||||||
byKind := map[string]int{}
|
byKind := map[string]int{}
|
||||||
rows, err := s.db.Query(`SELECT kind, COUNT(*) FROM media GROUP BY kind`)
|
rows, err := s.db.Query(`SELECT kind, COUNT(*) FROM media GROUP BY kind`)
|
||||||
@ -524,7 +407,7 @@ func (s *Store) queryMediaScored(queryVec []float64, model string, topK int) ([]
|
|||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
query := `SELECT digest, kind, mime, size, width, height,
|
query := `SELECT digest, kind, mime, size, width, height,
|
||||||
origin_path, tool, description, described_by, first_seen, last_seen,
|
origin_path, tool, first_seen, last_seen,
|
||||||
vec, vec_model
|
vec, vec_model
|
||||||
FROM media WHERE vec IS NOT NULL AND vec != ''`
|
FROM media WHERE vec IS NOT NULL AND vec != ''`
|
||||||
var args []interface{}
|
var args []interface{}
|
||||||
@ -546,17 +429,15 @@ func (s *Store) queryMediaScored(queryVec []float64, model string, topK int) ([]
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var it Item
|
var it Item
|
||||||
var kind string
|
var kind string
|
||||||
var origin, tool, desc, by, vecJSON, vecModel sql.NullString
|
var origin, tool, vecJSON, vecModel sql.NullString
|
||||||
if err := rows.Scan(&it.Digest, &kind, &it.MIME, &it.Size, &it.Width, &it.Height,
|
if err := rows.Scan(&it.Digest, &kind, &it.MIME, &it.Size, &it.Width, &it.Height,
|
||||||
&origin, &tool, &desc, &by, &it.FirstSeen, &it.LastSeen,
|
&origin, &tool, &it.FirstSeen, &it.LastSeen,
|
||||||
&vecJSON, &vecModel); err != nil {
|
&vecJSON, &vecModel); err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
it.Kind = Kind(kind)
|
it.Kind = Kind(kind)
|
||||||
it.OriginPath = origin.String
|
it.OriginPath = origin.String
|
||||||
it.Tool = tool.String
|
it.Tool = tool.String
|
||||||
it.Description = desc.String
|
|
||||||
it.DescribedBy = by.String
|
|
||||||
if !vecJSON.Valid || vecJSON.String == "" {
|
if !vecJSON.Valid || vecJSON.String == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@ -623,17 +504,15 @@ func (s *Store) scanRows(r rowScanner) (*Item, error) { return scanItem(r) }
|
|||||||
func scanItem(r rowScanner) (*Item, error) {
|
func scanItem(r rowScanner) (*Item, error) {
|
||||||
var it Item
|
var it Item
|
||||||
var kind string
|
var kind string
|
||||||
var origin, tool, desc, by, vecJSON, vecModel sql.NullString
|
var origin, tool, vecJSON, vecModel sql.NullString
|
||||||
if err := r.Scan(&it.Digest, &kind, &it.MIME, &it.Size, &it.Width, &it.Height,
|
if err := r.Scan(&it.Digest, &kind, &it.MIME, &it.Size, &it.Width, &it.Height,
|
||||||
&origin, &tool, &desc, &by, &it.FirstSeen, &it.LastSeen,
|
&origin, &tool, &it.FirstSeen, &it.LastSeen,
|
||||||
&vecJSON, &vecModel); err != nil {
|
&vecJSON, &vecModel); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
it.Kind = Kind(kind)
|
it.Kind = Kind(kind)
|
||||||
it.OriginPath = origin.String
|
it.OriginPath = origin.String
|
||||||
it.Tool = tool.String
|
it.Tool = tool.String
|
||||||
it.Description = desc.String
|
|
||||||
it.DescribedBy = by.String
|
|
||||||
if vecJSON.Valid && vecJSON.String != "" {
|
if vecJSON.Valid && vecJSON.String != "" {
|
||||||
var v []float64
|
var v []float64
|
||||||
if err := json.Unmarshal([]byte(vecJSON.String), &v); err == nil {
|
if err := json.Unmarshal([]byte(vecJSON.String), &v); err == nil {
|
||||||
|
|||||||
@ -142,50 +142,13 @@ func TestDelete_UnknownDigestIsNoop(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDescribe_OverwritesExplicitly(t *testing.T) {
|
func TestDescribe_Removed(t *testing.T) {
|
||||||
s := newTestStore(t, 0)
|
// 媒体不再有文字描述:描述式索引是废弃的就机制。
|
||||||
d, _ := s.Put([]byte("img"), Item{MIME: "image/png"})
|
// 这里只保留一个编译期断言,确保 API 不会静默回归。
|
||||||
|
s := newTestStore(t)
|
||||||
if err := s.Describe(d, "一只橘猫", "vis-a"); err != nil {
|
if _, err := s.Put([]byte("img"), Item{MIME: "image/png"}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
it, _ := s.Stat(d)
|
|
||||||
if it.Description != "一只橘猫" || it.DescribedBy != "vis-a" {
|
|
||||||
t.Fatalf("描述未写入: %+v", it)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Describe 是显式操作,允许覆盖(换更强模型重描述)
|
|
||||||
if err := s.Describe(d, "一只橘色虎斑猫坐在窗台", "vis-b"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
it, _ = s.Stat(d)
|
|
||||||
if !strings.Contains(it.Description, "虎斑") || it.DescribedBy != "vis-b" {
|
|
||||||
t.Fatalf("Describe 应覆盖旧描述: %+v", it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDescribe_UnknownDigestErrors(t *testing.T) {
|
|
||||||
s := newTestStore(t, 0)
|
|
||||||
err := s.Describe("deadbeef", "x", "y")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("未知 digest 应报错而非静默成功")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPut_DoesNotClobberExistingDescription(t *testing.T) {
|
|
||||||
// 先到的描述可能来自更强的模型;后到的空值不该把它冲掉。
|
|
||||||
s := newTestStore(t, 0)
|
|
||||||
data := []byte("img")
|
|
||||||
d, _ := s.Put(data, Item{MIME: "image/png", Description: "详细描述", DescribedBy: "strong-model"})
|
|
||||||
|
|
||||||
// 第二次 Put 同内容但不带描述
|
|
||||||
if _, err := s.Put(data, Item{MIME: "image/png"}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
it, _ := s.Stat(d)
|
|
||||||
if it.Description != "详细描述" || it.DescribedBy != "strong-model" {
|
|
||||||
t.Fatalf("重复 Put 的空描述不该冲掉已有描述: %+v", it)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPut_BackfillsMissingDimensions(t *testing.T) {
|
func TestPut_BackfillsMissingDimensions(t *testing.T) {
|
||||||
@ -206,47 +169,22 @@ func TestPut_BackfillsMissingDimensions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSearch_FiltersByDescriptionAndKind(t *testing.T) {
|
func TestStats_CountsByKind(t *testing.T) {
|
||||||
s := newTestStore(t, 0)
|
s := newTestStore(t)
|
||||||
di, _ := s.Put([]byte("chart-img"), Item{MIME: "image/png"})
|
s.Put([]byte("i1"), Item{MIME: "image/png"})
|
||||||
da, _ := s.Put([]byte("speech-aud"), Item{MIME: "audio/wav"})
|
s.Put([]byte("i2"), Item{MIME: "image/jpeg"})
|
||||||
dn, _ := s.Put([]byte("no-desc"), Item{MIME: "image/png"})
|
s.Put([]byte("a1"), Item{MIME: "audio/wav"})
|
||||||
s.Describe(di, "一张蓝色的柱状图表", "vis")
|
|
||||||
s.Describe(da, "一段关于图表的讲解录音", "aud")
|
|
||||||
|
|
||||||
all, err := s.Search("图表", "", 10)
|
st := s.Stats()
|
||||||
if err != nil {
|
if st["count"].(int) != 3 {
|
||||||
t.Fatal(err)
|
t.Fatalf("count 应为 3,实际 %v", st["count"])
|
||||||
}
|
}
|
||||||
if len(all) != 2 {
|
if _, ok := st["described"]; ok {
|
||||||
t.Fatalf("两条描述都含「图表」,应返回 2,实际 %d", len(all))
|
t.Fatal("媒体已不再有描述计数")
|
||||||
}
|
}
|
||||||
|
byKind := st["by_kind"].(map[string]int)
|
||||||
imgs, _ := s.Search("图表", KindImage, 10)
|
if byKind["image"] != 2 || byKind["audio"] != 1 {
|
||||||
if len(imgs) != 1 || imgs[0].Digest != di {
|
t.Fatalf("by_kind 不对: %v", byKind)
|
||||||
t.Fatalf("按 image 过滤应只剩图片,实际 %d 条", len(imgs))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 无描述的项不该出现在语义检索结果里
|
|
||||||
for _, it := range all {
|
|
||||||
if it.Digest == dn {
|
|
||||||
t.Fatal("无描述的项不该被 Search 返回")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPending_ReturnsUndescribed(t *testing.T) {
|
|
||||||
s := newTestStore(t, 0)
|
|
||||||
described, _ := s.Put([]byte("has-desc"), Item{MIME: "image/png"})
|
|
||||||
undescribed, _ := s.Put([]byte("needs-desc"), Item{MIME: "image/png"})
|
|
||||||
s.Describe(described, "已有描述", "vis")
|
|
||||||
|
|
||||||
pending, err := s.Pending(10)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(pending) != 1 || pending[0].Digest != undescribed {
|
|
||||||
t.Fatalf("应只返回未描述项,实际 %d 条", len(pending))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -278,28 +216,8 @@ func TestParseDataURL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStats_CountsByKindAndDescription(t *testing.T) {
|
|
||||||
s := newTestStore(t, 4096)
|
|
||||||
d1, _ := s.Put([]byte("i1"), Item{MIME: "image/png"})
|
|
||||||
s.Put([]byte("i2"), Item{MIME: "image/jpeg"})
|
|
||||||
s.Put([]byte("a1"), Item{MIME: "audio/wav"})
|
|
||||||
s.Describe(d1, "描述", "vis")
|
|
||||||
|
|
||||||
st := s.Stats()
|
|
||||||
if st["count"].(int) != 3 {
|
|
||||||
t.Fatalf("count 应为 3,实际 %v", st["count"])
|
|
||||||
}
|
|
||||||
if st["described"].(int) != 1 {
|
|
||||||
t.Fatalf("described 应为 1,实际 %v", st["described"])
|
|
||||||
}
|
|
||||||
byKind := st["by_kind"].(map[string]int)
|
|
||||||
if byKind["image"] != 2 || byKind["audio"] != 1 {
|
|
||||||
t.Fatalf("by_kind 不对: %v", byKind)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPut_RejectsEmpty(t *testing.T) {
|
func TestPut_RejectsEmpty(t *testing.T) {
|
||||||
s := newTestStore(t, 0)
|
s := newTestStore(t)
|
||||||
if _, err := s.Put(nil, Item{MIME: "image/png"}); err == nil {
|
if _, err := s.Put(nil, Item{MIME: "image/png"}); err == nil {
|
||||||
t.Fatal("空内容应报错")
|
t.Fatal("空内容应报错")
|
||||||
}
|
}
|
||||||
@ -313,7 +231,7 @@ func TestReopen_PersistsAcrossRestart(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
d, _ := s1.Put([]byte("persistent-img"), Item{MIME: "image/png", OriginPath: "/tmp/x.png"})
|
d, _ := s1.Put([]byte("persistent-img"), Item{MIME: "image/png", OriginPath: "/tmp/x.png"})
|
||||||
s1.Describe(d, "跨重启的描述", "vis")
|
s1.SetVec(d, []float64{0.1, 0.2}, "test-space")
|
||||||
s1.Close()
|
s1.Close()
|
||||||
|
|
||||||
s2, err := New(dir)
|
s2, err := New(dir)
|
||||||
@ -326,8 +244,8 @@ func TestReopen_PersistsAcrossRestart(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("重开后应能查到: %v", err)
|
t.Fatalf("重开后应能查到: %v", err)
|
||||||
}
|
}
|
||||||
if it.Description != "跨重启的描述" {
|
if it.OriginPath != "/tmp/x.png" || len(it.Vec) != 2 || it.VecModel != "test-space" {
|
||||||
t.Fatalf("元数据应持久化: %+v", it)
|
t.Fatalf("元数据与向量应持久化: %+v", it)
|
||||||
}
|
}
|
||||||
data, err := s2.Get(d)
|
data, err := s2.Get(d)
|
||||||
if err != nil || string(data) != "persistent-img" {
|
if err != nil || string(data) != "persistent-img" {
|
||||||
@ -335,38 +253,26 @@ func TestReopen_PersistsAcrossRestart(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPending_ExcludesAttemptedButUndescribable(t *testing.T) {
|
func TestStaleVecDigests_TracksModelSwitch(t *testing.T) {
|
||||||
// 「已尝试但无法描述」的项必须退出待描述队列。
|
// 模型切换后旧向量必须被重算:StaleVecDigests 是启动迁移的入口。
|
||||||
//
|
s := newTestStore(t)
|
||||||
// 这些项被标记为 described_by=unsupported/content-missing 而 description
|
d1, _ := s.Put([]byte("a"), Item{MIME: "image/png"})
|
||||||
// 仍为空。若 Pending 只看 description,它们每轮都会被取出来重试、
|
d2, _ := s.Put([]byte("b"), Item{MIME: "image/png"})
|
||||||
// 永久占着 LIMIT 的名额,真正需要描述的新项永远轮不到。
|
s.SetVec(d1, []float64{0.1}, "space-a")
|
||||||
s := newTestStore(t, 0)
|
|
||||||
|
|
||||||
fresh, _ := s.Put([]byte("needs-describe"), Item{MIME: "image/png"})
|
stale, err := s.StaleVecDigestsAll("space-a")
|
||||||
unsupported, _ := s.Put([]byte("cannot-describe"), Item{MIME: "application/octet-stream"})
|
|
||||||
described, _ := s.Put([]byte("已描述"), Item{MIME: "image/png"})
|
|
||||||
|
|
||||||
// 标记「尝试过但不支持」:description 空,described_by 非空
|
|
||||||
if err := s.Describe(unsupported, "", "unsupported"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := s.Describe(described, "一张图", "visionllm"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
pending, err := s.Pending(10)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(pending) != 1 {
|
if len(stale) != 1 || stale[0] != d2 {
|
||||||
var names []string
|
t.Fatalf("只有未嵌入的 d2 需重算,实际 %v", stale)
|
||||||
for _, p := range pending {
|
|
||||||
names = append(names, shortDigest(p.Digest))
|
|
||||||
}
|
|
||||||
t.Fatalf("应只剩 1 条待描述,实际 %d 条: %v", len(pending), names)
|
|
||||||
}
|
}
|
||||||
if pending[0].Digest != fresh {
|
|
||||||
t.Fatalf("待描述的应是未处理项,实际 %s", shortDigest(pending[0].Digest))
|
stale, err = s.StaleVecDigestsAll("space-b")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(stale) != 2 {
|
||||||
|
t.Fatalf("换空间后两条都需重算,实际 %v", stale)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,9 +10,9 @@ func TestQueryMedia_BasicSimilarity(t *testing.T) {
|
|||||||
defer s.Close()
|
defer s.Close()
|
||||||
|
|
||||||
// 入库三张带向量的媒体:两张图、一段音频
|
// 入库三张带向量的媒体:两张图、一段音频
|
||||||
d1, _ := s.Put([]byte("img1"), Item{MIME: "image/png", Description: "紫蓝红三色带"})
|
d1, _ := s.Put([]byte("img1"), Item{MIME: "image/png"})
|
||||||
d2, _ := s.Put([]byte("img2"), Item{MIME: "image/jpeg", Description: "蓝紫红渐变"})
|
d2, _ := s.Put([]byte("img2"), Item{MIME: "image/jpeg"})
|
||||||
d3, _ := s.Put([]byte("aud1"), Item{MIME: "audio/wav", Description: "一段语音"})
|
d3, _ := s.Put([]byte("aud1"), Item{MIME: "audio/wav"})
|
||||||
|
|
||||||
// 模拟视觉嵌入:img1 和 img2 向量接近,aud1 远离
|
// 模拟视觉嵌入:img1 和 img2 向量接近,aud1 远离
|
||||||
vec1 := []float64{0.9, 0.1, 0.0, 0.0}
|
vec1 := []float64{0.9, 0.1, 0.0, 0.0}
|
||||||
@ -138,24 +138,24 @@ func TestStaleVecDigests(t *testing.T) {
|
|||||||
s := newTestStore(t, 0)
|
s := newTestStore(t, 0)
|
||||||
defer s.Close()
|
defer s.Close()
|
||||||
|
|
||||||
// 有描述且 vec_model 匹配 → 非 stale
|
// 有向量且 vec_model 匹配 → 非 stale
|
||||||
d1, _ := s.Put([]byte("img1"), Item{MIME: "image/png", Description: "图一"})
|
d1, _ := s.Put([]byte("img1"), Item{MIME: "image/png"})
|
||||||
s.SetVec(d1, []float64{0.1}, "clip-vit-b32")
|
s.SetVec(d1, []float64{0.1}, "space-a")
|
||||||
|
|
||||||
// 有描述但 vec_model 旧 → stale
|
// 有向量但 vec_model 旧 → stale
|
||||||
d2, _ := s.Put([]byte("img2"), Item{MIME: "image/png", Description: "图二"})
|
d2, _ := s.Put([]byte("img2"), Item{MIME: "image/png"})
|
||||||
s.SetVec(d2, []float64{0.2}, "clip-vit-b14")
|
s.SetVec(d2, []float64{0.2}, "space-old")
|
||||||
|
|
||||||
// 有描述但从未嵌入(vec_model 空)→ stale
|
// 从未嵌入(vec_model 空)→ stale
|
||||||
d3, _ := s.Put([]byte("img3"), Item{MIME: "image/png", Description: "图三"})
|
d3, _ := s.Put([]byte("img3"), Item{MIME: "image/png"})
|
||||||
|
|
||||||
// 无描述但有图片 → 也应被迁移(描述是可选语义通道,图片应独立于描述参与向量空间)
|
// 与向量/描述无关的图片同样应被迁移:图片独立参与向量空间
|
||||||
d4, _ := s.Put([]byte("img4"), Item{MIME: "image/png"})
|
d4, _ := s.Put([]byte("img4"), Item{MIME: "image/png"})
|
||||||
|
|
||||||
// 音频不参与图片迁移(StaleVecDigests 只查 kind='image')
|
// 音频不参与图片迁移(StaleVecDigests 只查 kind='image')
|
||||||
s.Put([]byte("aud1"), Item{MIME: "audio/wav", Description: "语音"})
|
s.Put([]byte("aud1"), Item{MIME: "audio/wav"})
|
||||||
|
|
||||||
stale, err := s.StaleVecDigests("clip-vit-b32")
|
stale, err := s.StaleVecDigests("space-a")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -123,31 +123,44 @@ func TestSmoke_VideoFramesDistinct(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSmoke_DescribeThenRetrieve(t *testing.T) {
|
func TestSmoke_NearestNeighborVectorRetrieve(t *testing.T) {
|
||||||
// 场景 C:视觉模型描述落库后,描述文字成为可检索的语义入口。
|
// 场景 C:图片只按自己的原生向量被检索。
|
||||||
// 这是本方案最关键的一环——blob 可能被淘汰,描述会长期留在记忆里。
|
// 没有描述文本参与——描述式索引是废弃的就机制。
|
||||||
s := newTestStore(t, 50*1024*1024)
|
s := newTestStore(t)
|
||||||
|
|
||||||
pic, _ := s.Put(makePNG(400, 400, 0), Item{MIME: "image/png", Tool: "multimodal_see_picture"})
|
pic, _ := s.Put(makePNG(400, 400, 0), Item{MIME: "image/png", Tool: "multimodal_see_picture"})
|
||||||
if err := s.Describe(pic, "一张 400x400 的三色带图:上红、中绿、下蓝", "visionllm"); err != nil {
|
s.SetVec(pic, []float64{1, 0, 0, 0}, "space")
|
||||||
t.Fatal(err)
|
var frames []string
|
||||||
}
|
|
||||||
for i := 0; i < 6; i++ {
|
for i := 0; i < 6; i++ {
|
||||||
d, _ := s.Put(makePNG(320, 240, i), Item{MIME: "image/jpeg", Tool: "multimodal_see_video"})
|
d, _ := s.Put(makePNG(320, 240, i), Item{MIME: "image/jpeg", Tool: "multimodal_see_video"})
|
||||||
if err := s.Describe(d, fmt.Sprintf("视频第 %d 帧:测试图卡,含彩条与计数器", i+1), "visionllm"); err != nil {
|
s.SetVec(d, []float64{1, 1, float64(i) / 10, 0}, "space")
|
||||||
t.Fatal(err)
|
frames = append(frames, d)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if hits, _ := s.Search("三色带", KindImage, 10); len(hits) != 1 {
|
hits, err := s.QueryMediaScored([]float64{1, 0, 0, 0}, "space", 10)
|
||||||
t.Fatalf("搜「三色带」应命中 1 条,实际 %d", len(hits))
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if hits, _ := s.Search("计数器", KindImage, 10); len(hits) != 6 {
|
if len(hits) != 7 {
|
||||||
t.Fatalf("搜「计数器」应命中 6 帧,实际 %d", len(hits))
|
t.Fatalf("7 份媒体都有同空间向量,应全部可召,实际 %d", len(hits))
|
||||||
}
|
}
|
||||||
pend, _ := s.Pending(100)
|
if hits[0].Item.Digest != pic {
|
||||||
if len(pend) != 0 {
|
t.Fatalf("与查询同向的应是第一命中,实际 %s", shortDigest(hits[0].Item.Digest))
|
||||||
t.Fatalf("应全部已描述,仍有 %d 条待描述", len(pend))
|
}
|
||||||
|
|
||||||
|
// 不同向量空间/模型的条目不得参与:坐标系不同,余弦无意义。
|
||||||
|
foreign := frames[0]
|
||||||
|
if err := s.SetVec(foreign, []float64{1, 0, 0, 0}, "other-space"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
hits, err = s.QueryMediaScored([]float64{1, 0, 0, 0}, "space", 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, h := range hits {
|
||||||
|
if h.Item.Digest == foreign {
|
||||||
|
t.Fatal("另一套空间(other-space)的向量不该被 space 查询召回")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -204,7 +217,7 @@ func TestSmoke_DeleteRemovesOnlyThatContent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSmoke_FullLifecycleAcrossRestart(t *testing.T) {
|
func TestSmoke_FullLifecycleAcrossRestart(t *testing.T) {
|
||||||
// 端到端:入库 → 描述 → 删除一些内容 → 重启 → 检索,
|
// 端到端:入库 → 嵌入 → 删除一些内容 → 重启 → 向量检索,
|
||||||
// 并确认磁盘与元数据不出现双向孤儿。记忆的意义就在于跨重启还在。
|
// 并确认磁盘与元数据不出现双向孤儿。记忆的意义就在于跨重启还在。
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
s, err := New(dir)
|
s, err := New(dir)
|
||||||
@ -214,10 +227,10 @@ func TestSmoke_FullLifecycleAcrossRestart(t *testing.T) {
|
|||||||
|
|
||||||
png := makePNG(400, 400, 0)
|
png := makePNG(400, 400, 0)
|
||||||
pic, _ := s.Put(png, Item{MIME: "image/png", Width: 400, Height: 400, Tool: "multimodal_see_picture"})
|
pic, _ := s.Put(png, Item{MIME: "image/png", Width: 400, Height: 400, Tool: "multimodal_see_picture"})
|
||||||
s.Describe(pic, "一张 400x400 的三色带图:上红、中绿、下蓝", "visionllm")
|
s.SetVec(pic, []float64{1, 0, 0}, "space")
|
||||||
for i := 0; i < 6; i++ {
|
for i := 0; i < 6; i++ {
|
||||||
d, _ := s.Put(makePNG(320, 240, i), Item{MIME: "image/jpeg", Tool: "multimodal_see_video"})
|
d, _ := s.Put(makePNG(320, 240, i), Item{MIME: "image/jpeg", Tool: "multimodal_see_video"})
|
||||||
s.Describe(d, fmt.Sprintf("视频第 %d 帧", i+1), "visionllm")
|
s.SetVec(d, []float64{0, 1, float64(i)}, "space")
|
||||||
}
|
}
|
||||||
for i := 0; i < 10; i++ {
|
for i := 0; i < 10; i++ {
|
||||||
d, _ := s.Put(makePNG(64, 64, 2000+i), Item{MIME: "image/png", Tool: "cmd_run"})
|
d, _ := s.Put(makePNG(64, 64, 2000+i), Item{MIME: "image/png", Tool: "cmd_run"})
|
||||||
@ -241,20 +254,24 @@ func TestSmoke_FullLifecycleAcrossRestart(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("重开后查不到: %v", err)
|
t.Fatalf("重开后查不到: %v", err)
|
||||||
}
|
}
|
||||||
if it.Description == "" {
|
if len(it.Vec) != 3 || it.VecModel != "space" {
|
||||||
t.Fatalf("元数据未持久化: %+v", it)
|
t.Fatalf("向量未持久化: %+v", it)
|
||||||
}
|
}
|
||||||
data, err := s2.Get(pic)
|
data, err := s2.Get(pic)
|
||||||
if err != nil || !bytes.Equal(data, png) {
|
if err != nil || !bytes.Equal(data, png) {
|
||||||
t.Fatalf("重开后内容不一致: %v", err)
|
t.Fatalf("重开后内容不一致: %v", err)
|
||||||
}
|
}
|
||||||
if hits, _ := s2.Search("三色带", KindImage, 10); len(hits) != 1 {
|
hits, err := s2.QueryMediaScored([]float64{1, 0, 0}, "space", 10)
|
||||||
t.Fatal("重开后描述应仍可检索")
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(hits) == 0 || hits[0].Item.Digest != pic {
|
||||||
|
t.Fatal("重开后向量检索应仍能命中")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 磁盘文件数 == 元数据条数:无「元数据在文件没了」也无「文件在元数据没了」
|
// 磁盘文件数 == 元数据条数:无「元数据在文件没了」也无「文件在元数据没了」
|
||||||
if n := blobFileCount(t, s2); n != beforeCount {
|
if n := blobFileCount(t, s2); n != beforeCount {
|
||||||
t.Fatalf("磁盘 blob=%d 与元数据=%d 不一致", n, beforeCount)
|
t.Fatalf("磁盘 blob=%d 与元数据=%d 不一致", n, beforeCount)
|
||||||
}
|
}
|
||||||
t.Logf("跨重启:%d 条目、描述与内容全部完好", beforeCount)
|
t.Logf("跨重启:%d 条目、向量与内容全部完好", beforeCount)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -39,7 +39,7 @@ func TestSoak_SustainedMixedLoad(t *testing.T) {
|
|||||||
|
|
||||||
stop := make(chan struct{})
|
stop := make(chan struct{})
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
var puts, gets, deletes, describes, searches atomic.Int64
|
var puts, gets, deletes, embeds, searches atomic.Int64
|
||||||
var fatal atomic.Int64
|
var fatal atomic.Int64
|
||||||
|
|
||||||
worker := func(name string, fn func(iter int) error) {
|
worker := func(name string, fn func(iter int) error) {
|
||||||
@ -109,25 +109,26 @@ func TestSoak_SustainedMixedLoad(t *testing.T) {
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
// 描述者
|
// 向量写入者:持续给新内容嵌入并删除(模拟启动迁移/短命媒体)
|
||||||
worker("describe", func(i int) error {
|
worker("embed", func(i int) error {
|
||||||
pend, err := s.Pending(5)
|
b := make([]byte, 1024)
|
||||||
|
rand.Read(b)
|
||||||
|
b = append([]byte(fmt.Sprintf("emb-%d-", i)), b...)
|
||||||
|
d, err := s.Put(b, Item{MIME: "image/png", Tool: "cmd_run"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, it := range pend {
|
if err := s.SetVec(d, []float64{1, float64(i % 7)}, "soak-space"); err != nil {
|
||||||
// 忽略 unknown digest:GC 可能在 Pending 与 Describe 之间清掉它,
|
return err
|
||||||
// 这是正常竞态而非缺陷。
|
|
||||||
_ = s.Describe(it.Digest, fmt.Sprintf("描述 %d 含图表与文字", i), "vis")
|
|
||||||
describes.Add(1)
|
|
||||||
}
|
}
|
||||||
time.Sleep(2 * time.Millisecond)
|
embeds.Add(1)
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
// 检索者
|
// 检索者
|
||||||
worker("search", func(i int) error {
|
worker("search", func(i int) error {
|
||||||
if _, err := s.Search("图表", KindImage, 20); err != nil {
|
if _, err := s.QueryMediaScored([]float64{1, 0}, "soak-space", 20); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := s.Stat(keep[i%keepN]); err != nil {
|
if _, err := s.Stat(keep[i%keepN]); err != nil {
|
||||||
@ -146,8 +147,8 @@ func TestSoak_SustainedMixedLoad(t *testing.T) {
|
|||||||
t.Fatalf("%d 个 worker 报致命错误", n)
|
t.Fatalf("%d 个 worker 报致命错误", n)
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Logf("%v 内: put=%d get=%d delete=%d describe=%d search=%d",
|
t.Logf("%v 内: put=%d get=%d delete=%d embed=%d search=%d",
|
||||||
dur, puts.Load(), gets.Load(), deletes.Load(), describes.Load(), searches.Load())
|
dur, puts.Load(), gets.Load(), deletes.Load(), embeds.Load(), searches.Load())
|
||||||
|
|
||||||
// 收尾断言
|
// 收尾断言
|
||||||
for i, d := range keep {
|
for i, d := range keep {
|
||||||
@ -161,6 +162,5 @@ func TestSoak_SustainedMixedLoad(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
st := s.Stats()
|
st := s.Stats()
|
||||||
t.Logf("收尾: 条目=%v 字节=%v 已描述=%v",
|
t.Logf("收尾: 条目=%v 字节=%v 类型=%v", st["count"], st["total_bytes"], st["by_kind"])
|
||||||
st["count"], st["total_bytes"], st["described"])
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -306,9 +306,9 @@ func TestStress_DeleteConcurrentWithReads(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStress_DescribeConcurrentWithSearch(t *testing.T) {
|
func TestStress_SetVecConcurrentWithQuery(t *testing.T) {
|
||||||
// 描述写入与检索并发。C 部分的后台描述任务会长期这样跑。
|
// 嵌入写入与向量检索并发(启动时的向量迁移就会长期这样跑)。
|
||||||
s := newTestStore(t, 0)
|
s := newTestStore(t)
|
||||||
const n = 60
|
const n = 60
|
||||||
digests := make([]string, n)
|
digests := make([]string, n)
|
||||||
for i := range digests {
|
for i := range digests {
|
||||||
@ -320,55 +320,52 @@ func TestStress_DescribeConcurrentWithSearch(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
var descErr, searchErr atomic.Int64
|
var writeErr, queryErr atomic.Int64
|
||||||
|
|
||||||
// 描述写入者
|
// 向量写入者
|
||||||
for w := 0; w < 4; w++ {
|
for w := 0; w < 4; w++ {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func(wid int) {
|
go func(wid int) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
for i := wid; i < n; i += 4 {
|
for i := wid; i < n; i += 4 {
|
||||||
desc := fmt.Sprintf("第 %d 张图,含蓝色图表与文字", i)
|
vec := []float64{1, float64(i) / 100, 0}
|
||||||
if err := s.Describe(digests[i], desc, "vis-src"); err != nil {
|
if err := s.SetVec(digests[i], vec, "space"); err != nil {
|
||||||
descErr.Add(1)
|
writeErr.Add(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}(w)
|
}(w)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检索者 + Pending 消费者
|
// 检索者
|
||||||
for r := 0; r < 3; r++ {
|
for r := 0; r < 3; r++ {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
for i := 0; i < 50; i++ {
|
for i := 0; i < 50; i++ {
|
||||||
if _, err := s.Search("图表", KindImage, 20); err != nil {
|
if _, err := s.QueryMediaScored([]float64{1, 0, 0}, "space", 20); err != nil {
|
||||||
searchErr.Add(1)
|
queryErr.Add(1)
|
||||||
}
|
|
||||||
if _, err := s.Pending(10); err != nil {
|
|
||||||
searchErr.Add(1)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
if v := descErr.Load(); v > 0 {
|
if v := writeErr.Load(); v > 0 {
|
||||||
t.Fatalf("Describe 失败 %d 次", v)
|
t.Fatalf("SetVec 失败 %d 次", v)
|
||||||
}
|
}
|
||||||
if v := searchErr.Load(); v > 0 {
|
if v := queryErr.Load(); v > 0 {
|
||||||
t.Fatalf("Search/Pending 失败 %d 次", v)
|
t.Fatalf("QueryMediaScored 失败 %d 次", v)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 全部应已描述完
|
// 全部应已嵌入,且都在同一空间
|
||||||
pending, err := s.Pending(1000)
|
stale, err := s.StaleVecDigestsAll("space")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(pending) != 0 {
|
if len(stale) != 0 {
|
||||||
t.Fatalf("应全部描述完,仍有 %d 条未描述", len(pending))
|
t.Fatalf("应全部已嵌入,仍有 %d 条未嵌入", len(stale))
|
||||||
}
|
}
|
||||||
got, err := s.Search("图表", KindImage, 1000)
|
got, err := s.QueryMediaScored([]float64{1, 0, 0}, "space", 1000)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
193
internal/memory/migrate.go
Normal file
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
|
//go:build onnxruntime
|
||||||
|
|
||||||
// Package qwen 的 ONNX 推理实现(构建标签 onnxruntime,与 internal/memory/clip 同模式)。
|
// Package qwen 提供 Qwen3-VL-Embedding 的完整图文共享 ONNX 编码器。
|
||||||
//
|
// 文本和图像共用 token embedding、28 层 Transformer、last-token 池化与
|
||||||
// 加载契约:调用方传入模型目录,内核不硬编码模型名。
|
// fingerprint;Vision.onnx 只产生注入 Transformer 的中间特征。
|
||||||
//
|
|
||||||
// TextTower.onnx + 外部权重分片 — 文本塔图(input_ids/attention_mask → embedding)
|
|
||||||
// tokenizer.json — 字节级 BPE 词表与 merges
|
|
||||||
// embed_config.json — dim / max_length / instruction / pooling
|
|
||||||
//
|
|
||||||
// 图内已含 last-token 池化,输出即 [batch, dim];L2 归一化在 Go 侧做(便宜且便于测试)。
|
|
||||||
package qwen
|
package qwen
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@ -26,34 +20,39 @@ import (
|
|||||||
ort "github.com/yalue/onnxruntime_go"
|
ort "github.com/yalue/onnxruntime_go"
|
||||||
)
|
)
|
||||||
|
|
||||||
// embedConfig 对应导出脚本产出的 embed_config.json。
|
|
||||||
type embedConfig struct {
|
type embedConfig struct {
|
||||||
Dimension int `json:"dim"`
|
Arch string `json:"arch"`
|
||||||
MaxLength int `json:"max_length"`
|
Dimension int `json:"dim"`
|
||||||
Instruction string `json:"instruction"`
|
MaxLength int `json:"max_length"`
|
||||||
Pooling string `json:"pooling"`
|
Instruction string `json:"instruction"`
|
||||||
|
Pooling string `json:"pooling"`
|
||||||
|
ImageSize int `json:"image_size"`
|
||||||
|
PatchSize int `json:"patch_size"`
|
||||||
|
TemporalPatch int `json:"temporal_patch_size"`
|
||||||
|
SpatialMerge int `json:"spatial_merge_size"`
|
||||||
|
ImageMean []float64 `json:"image_mean"`
|
||||||
|
ImageStd []float64 `json:"image_std"`
|
||||||
|
RopeTheta float64 `json:"rope_theta"`
|
||||||
|
MRopeSection []int `json:"mrope_section"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Embedder 是基于内嵌 ONNX 文本塔的稠密嵌入器。
|
|
||||||
//
|
|
||||||
// 只提供文本能力:导出的是文本塔,视觉塔未导出。EmbedImageDense 会明确报错,
|
|
||||||
// 而不是返回一个「看起来能用」的零向量——后者会让跨模态检索静默失效。
|
|
||||||
type Embedder struct {
|
type Embedder struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
|
||||||
loaded bool
|
loaded bool
|
||||||
config embedConfig
|
config embedConfig
|
||||||
tok *Tokenizer
|
tok *Tokenizer
|
||||||
sess *ort.DynamicAdvancedSession
|
token *ort.DynamicAdvancedSession
|
||||||
fp string
|
transform *ort.DynamicAdvancedSession
|
||||||
|
vision *ort.DynamicAdvancedSession
|
||||||
|
fp string
|
||||||
|
close sync.Once
|
||||||
}
|
}
|
||||||
|
|
||||||
// New 从模型目录加载文本塔。
|
|
||||||
func New(modelDir string) (*Embedder, error) {
|
func New(modelDir string) (*Embedder, error) {
|
||||||
if modelDir == "" {
|
if modelDir == "" {
|
||||||
return nil, fmt.Errorf("qwen model dir not specified")
|
return nil, fmt.Errorf("qwen model dir not specified")
|
||||||
}
|
}
|
||||||
|
|
||||||
cfgRaw, err := os.ReadFile(filepath.Join(modelDir, "embed_config.json"))
|
cfgRaw, err := os.ReadFile(filepath.Join(modelDir, "embed_config.json"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("read embed_config.json: %w", err)
|
return nil, fmt.Errorf("read embed_config.json: %w", err)
|
||||||
@ -62,22 +61,20 @@ func New(modelDir string) (*Embedder, error) {
|
|||||||
if err := json.Unmarshal(cfgRaw, &cfg); err != nil {
|
if err := json.Unmarshal(cfgRaw, &cfg); err != nil {
|
||||||
return nil, fmt.Errorf("parse embed_config.json: %w", err)
|
return nil, fmt.Errorf("parse embed_config.json: %w", err)
|
||||||
}
|
}
|
||||||
if cfg.Dimension <= 0 {
|
if cfg.Dimension != 2048 || cfg.MaxLength < 598 || cfg.Pooling != "last_token" {
|
||||||
return nil, fmt.Errorf("embed_config.json 的 dim 无效: %d", cfg.Dimension)
|
return nil, fmt.Errorf("qwen: incompatible config dim=%d max_length=%d pooling=%q", cfg.Dimension, cfg.MaxLength, cfg.Pooling)
|
||||||
}
|
}
|
||||||
if cfg.MaxLength <= 0 {
|
if cfg.ImageSize != qwenImageSize || cfg.PatchSize != qwenPatchSize || cfg.TemporalPatch != qwenTemporalPatch || cfg.SpatialMerge != qwenSpatialMerge {
|
||||||
cfg.MaxLength = 512
|
return nil, fmt.Errorf("qwen: incompatible vision layout image=%d patch=%d temporal=%d merge=%d", cfg.ImageSize, cfg.PatchSize, cfg.TemporalPatch, cfg.SpatialMerge)
|
||||||
}
|
}
|
||||||
if cfg.Pooling != "" && cfg.Pooling != "last_token" {
|
if cfg.RopeTheta <= 0 || len(cfg.MRopeSection) != 3 || cfg.MRopeSection[0]+cfg.MRopeSection[1]+cfg.MRopeSection[2] != qwenRotaryHalfDim {
|
||||||
return nil, fmt.Errorf("不支持的池化方式 %q(导出脚本只产出 last_token)", cfg.Pooling)
|
return nil, fmt.Errorf("qwen: incompatible rope theta=%g section=%v", cfg.RopeTheta, cfg.MRopeSection)
|
||||||
}
|
}
|
||||||
|
|
||||||
tok, err := LoadTokenizer(modelDir)
|
tok, err := LoadTokenizer(modelDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
tok.MaxLen = cfg.MaxLength
|
|
||||||
|
|
||||||
if !ort.IsInitialized() {
|
if !ort.IsInitialized() {
|
||||||
if lib := findOnnxLib(); lib != "" {
|
if lib := findOnnxLib(); lib != "" {
|
||||||
ort.SetSharedLibraryPath(lib)
|
ort.SetSharedLibraryPath(lib)
|
||||||
@ -87,74 +84,269 @@ func New(modelDir string) (*Embedder, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sess, err := ort.NewDynamicAdvancedSession(
|
token, err := ort.NewDynamicAdvancedSession(
|
||||||
filepath.Join(modelDir, "TextTower.onnx"),
|
filepath.Join(modelDir, "TokenEmbedding.onnx"),
|
||||||
[]string{"input_ids", "attention_mask"},
|
[]string{"input_ids"}, []string{"hidden"}, nil,
|
||||||
[]string{"embedding"},
|
|
||||||
nil,
|
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create text tower session: %w", err)
|
return nil, fmt.Errorf("create qwen token embedding session: %w", err)
|
||||||
|
}
|
||||||
|
transform, err := ort.NewDynamicAdvancedSession(
|
||||||
|
filepath.Join(modelDir, "Transformer.onnx"),
|
||||||
|
[]string{"hidden", "deepstack_0", "deepstack_1", "deepstack_2", "rotary_cos", "rotary_sin", "causal_mask"},
|
||||||
|
[]string{"embedding"}, nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
token.Destroy()
|
||||||
|
return nil, fmt.Errorf("create qwen transformer session: %w", err)
|
||||||
|
}
|
||||||
|
vision, err := ort.NewDynamicAdvancedSession(
|
||||||
|
filepath.Join(modelDir, "Vision.onnx"), []string{"pixel_values"},
|
||||||
|
[]string{"deepstack_feature_0", "deepstack_feature_1", "deepstack_feature_2", "vision_hidden_states"}, nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
token.Destroy()
|
||||||
|
transform.Destroy()
|
||||||
|
return nil, fmt.Errorf("create qwen vision session: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Embedder{
|
return &Embedder{
|
||||||
loaded: true,
|
loaded: true, config: cfg, tok: tok,
|
||||||
config: cfg,
|
token: token, transform: transform, vision: vision,
|
||||||
tok: tok,
|
fp: computeFingerprint(modelDir),
|
||||||
sess: sess,
|
|
||||||
fp: computeFingerprint(modelDir),
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderInput 按模型自带的对话模板拼输入(实现在 tokenizer.go,无构建标签)。
|
|
||||||
func (e *Embedder) renderInput(text string) string {
|
|
||||||
return renderInstructionInput(e.config.Instruction, text)
|
|
||||||
}
|
|
||||||
|
|
||||||
// VectorizeDense 把文本编码为 L2 归一化的稠密向量。
|
|
||||||
func (e *Embedder) VectorizeDense(text string) ([]float64, error) {
|
func (e *Embedder) VectorizeDense(text string) ([]float64, error) {
|
||||||
e.mu.RLock()
|
e.mu.RLock()
|
||||||
defer e.mu.RUnlock()
|
defer e.mu.RUnlock()
|
||||||
if !e.loaded {
|
if !e.loaded {
|
||||||
return nil, fmt.Errorf("qwen embedder not loaded")
|
return nil, fmt.Errorf("qwen embedder not loaded")
|
||||||
}
|
}
|
||||||
|
ids, _, position, _, err := e.tok.textModelInput(e.config.Instruction, text, e.config.MaxLength)
|
||||||
ids, err := e.tok.encodeModelInput(e.renderInput(text), e.config.MaxLength)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
hidden, err := e.runTokenEmbedding(ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
deep := make([][]float32, 3)
|
||||||
|
for i := range deep {
|
||||||
|
deep[i] = make([]float32, len(hidden))
|
||||||
|
}
|
||||||
|
return e.runTransformer(hidden, deep, position, len(ids))
|
||||||
|
}
|
||||||
|
|
||||||
seq := len(ids)
|
func (e *Embedder) EmbedImageDense(raw []byte, _ string) ([]float64, error) {
|
||||||
inputIDs := make([]int64, seq)
|
e.mu.RLock()
|
||||||
attn := make([]int64, seq)
|
defer e.mu.RUnlock()
|
||||||
|
if !e.loaded {
|
||||||
|
return nil, fmt.Errorf("qwen embedder not loaded")
|
||||||
|
}
|
||||||
|
pixels, err := preprocessImage(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
features, err := e.runVision(pixels)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ids, _, position, visual, err := e.tok.imageModelInput(e.config.Instruction, e.config.MaxLength)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
hidden, err := e.runTokenEmbedding(ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
deep := make([][]float32, 3)
|
||||||
|
for i := range deep {
|
||||||
|
deep[i] = make([]float32, len(hidden))
|
||||||
|
}
|
||||||
|
visualIndex := 0
|
||||||
|
for tokenIndex, isVisual := range visual {
|
||||||
|
if !isVisual {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dst := tokenIndex * e.config.Dimension
|
||||||
|
src := visualIndex * e.config.Dimension
|
||||||
|
copy(hidden[dst:dst+e.config.Dimension], features[3][src:src+e.config.Dimension])
|
||||||
|
for layer := range deep {
|
||||||
|
copy(deep[layer][dst:dst+e.config.Dimension], features[layer][src:src+e.config.Dimension])
|
||||||
|
}
|
||||||
|
visualIndex++
|
||||||
|
}
|
||||||
|
if visualIndex != qwenVisualTokens {
|
||||||
|
return nil, fmt.Errorf("qwen: injected visual tokens=%d, want %d", visualIndex, qwenVisualTokens)
|
||||||
|
}
|
||||||
|
return e.runTransformer(hidden, deep, position, len(ids))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Embedder) runTokenEmbedding(ids []int) ([]float32, error) {
|
||||||
|
inputIDs := make([]int64, len(ids))
|
||||||
for i, id := range ids {
|
for i, id := range ids {
|
||||||
inputIDs[i] = int64(id)
|
inputIDs[i] = int64(id)
|
||||||
attn[i] = 1
|
|
||||||
}
|
}
|
||||||
|
in, err := ort.NewTensor(ort.Shape{1, int64(len(ids))}, inputIDs)
|
||||||
idTensor, err := ort.NewTensor(ort.Shape{1, int64(seq)}, inputIDs)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("input_ids tensor: %w", err)
|
return nil, fmt.Errorf("qwen token input: %w", err)
|
||||||
}
|
}
|
||||||
defer idTensor.Destroy()
|
defer in.Destroy()
|
||||||
|
outs := make([]ort.Value, 1)
|
||||||
|
if err := e.token.Run([]ort.Value{in}, outs); err != nil {
|
||||||
|
return nil, fmt.Errorf("qwen token embedding run: %w", err)
|
||||||
|
}
|
||||||
|
if outs[0] == nil {
|
||||||
|
return nil, fmt.Errorf("qwen token embedding output is nil")
|
||||||
|
}
|
||||||
|
defer outs[0].Destroy()
|
||||||
|
tensor, ok := outs[0].(*ort.Tensor[float32])
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("qwen token embedding output type %T", outs[0])
|
||||||
|
}
|
||||||
|
shape := tensor.GetShape()
|
||||||
|
if len(shape) != 3 || shape[0] != 1 || shape[1] != int64(len(ids)) || shape[2] != int64(e.config.Dimension) {
|
||||||
|
return nil, fmt.Errorf("qwen token embedding shape=%v", shape)
|
||||||
|
}
|
||||||
|
return append([]float32(nil), tensor.GetData()...), nil
|
||||||
|
}
|
||||||
|
|
||||||
maskTensor, err := ort.NewTensor(ort.Shape{1, int64(seq)}, attn)
|
func (e *Embedder) runVision(pixels []float32) ([][]float32, error) {
|
||||||
|
in, err := ort.NewTensor(ort.Shape{qwenImagePatches, qwenPatchVectorSize}, pixels)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("attention_mask tensor: %w", err)
|
return nil, fmt.Errorf("qwen vision input: %w", err)
|
||||||
}
|
}
|
||||||
defer maskTensor.Destroy()
|
defer in.Destroy()
|
||||||
|
outs := make([]ort.Value, 4)
|
||||||
|
if err := e.vision.Run([]ort.Value{in}, outs); err != nil {
|
||||||
|
return nil, fmt.Errorf("qwen vision run: %w", err)
|
||||||
|
}
|
||||||
|
features := make([][]float32, 4)
|
||||||
|
for i, value := range outs {
|
||||||
|
if value == nil {
|
||||||
|
return nil, fmt.Errorf("qwen vision output %d is nil", i)
|
||||||
|
}
|
||||||
|
defer value.Destroy()
|
||||||
|
tensor, ok := value.(*ort.Tensor[float32])
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("qwen vision output %d type %T", i, value)
|
||||||
|
}
|
||||||
|
shape := tensor.GetShape()
|
||||||
|
if len(shape) != 2 || shape[0] != qwenVisualTokens || shape[1] != int64(e.config.Dimension) {
|
||||||
|
return nil, fmt.Errorf("qwen vision output %d shape=%v", i, shape)
|
||||||
|
}
|
||||||
|
features[i] = append([]float32(nil), tensor.GetData()...)
|
||||||
|
}
|
||||||
|
return features, nil
|
||||||
|
}
|
||||||
|
|
||||||
outTensor, err := ort.NewEmptyTensor[float32](ort.Shape{1, int64(e.config.Dimension)})
|
func (e *Embedder) runTransformer(hidden []float32, deep [][]float32, position []int64, seq int) ([]float64, error) {
|
||||||
|
if len(hidden) != seq*e.config.Dimension || len(deep) != 3 || len(position) != 3*seq {
|
||||||
|
return nil, fmt.Errorf("qwen: invalid transformer inputs hidden=%d deep=%d position=%d seq=%d", len(hidden), len(deep), len(position), seq)
|
||||||
|
}
|
||||||
|
cos, sin := e.rotary(position, seq)
|
||||||
|
causal := causalMask(seq)
|
||||||
|
|
||||||
|
hiddenTensor, err := ort.NewTensor(ort.Shape{1, int64(seq), int64(e.config.Dimension)}, hidden)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("output tensor: %w", err)
|
return nil, fmt.Errorf("qwen hidden tensor: %w", err)
|
||||||
}
|
}
|
||||||
defer outTensor.Destroy()
|
defer hiddenTensor.Destroy()
|
||||||
|
inputs := []ort.Value{hiddenTensor}
|
||||||
if err := e.sess.Run([]ort.Value{idTensor, maskTensor}, []ort.Value{outTensor}); err != nil {
|
var deepTensors []*ort.Tensor[float32]
|
||||||
return nil, fmt.Errorf("text tower run: %w", err)
|
for i, data := range deep {
|
||||||
|
if len(data) != len(hidden) {
|
||||||
|
return nil, fmt.Errorf("qwen deepstack %d length=%d, want %d", i, len(data), len(hidden))
|
||||||
|
}
|
||||||
|
t, err := ort.NewTensor(ort.Shape{1, int64(seq), int64(e.config.Dimension)}, data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("qwen deepstack %d tensor: %w", i, err)
|
||||||
|
}
|
||||||
|
deepTensors = append(deepTensors, t)
|
||||||
|
inputs = append(inputs, t)
|
||||||
}
|
}
|
||||||
|
defer func() {
|
||||||
|
for _, t := range deepTensors {
|
||||||
|
t.Destroy()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
cosTensor, err := ort.NewTensor(ort.Shape{1, int64(seq), qwenRotaryDim}, cos)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("qwen rotary cos: %w", err)
|
||||||
|
}
|
||||||
|
defer cosTensor.Destroy()
|
||||||
|
sinTensor, err := ort.NewTensor(ort.Shape{1, int64(seq), qwenRotaryDim}, sin)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("qwen rotary sin: %w", err)
|
||||||
|
}
|
||||||
|
defer sinTensor.Destroy()
|
||||||
|
causalTensor, err := ort.NewTensor(ort.Shape{1, 1, int64(seq), int64(seq)}, causal)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("qwen causal mask: %w", err)
|
||||||
|
}
|
||||||
|
defer causalTensor.Destroy()
|
||||||
|
inputs = append(inputs, cosTensor, sinTensor, causalTensor)
|
||||||
|
|
||||||
raw := outTensor.GetData()
|
out, err := ort.NewEmptyTensor[float32](ort.Shape{1, int64(e.config.Dimension)})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("qwen output tensor: %w", err)
|
||||||
|
}
|
||||||
|
defer out.Destroy()
|
||||||
|
if err := e.transform.Run(inputs, []ort.Value{out}); err != nil {
|
||||||
|
return nil, fmt.Errorf("qwen transformer run: %w", err)
|
||||||
|
}
|
||||||
|
return normalize(out.GetData()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
qwenRotaryHalfDim = 64
|
||||||
|
qwenRotaryDim = 128
|
||||||
|
)
|
||||||
|
|
||||||
|
func (e *Embedder) rotary(position []int64, seq int) ([]float32, []float32) {
|
||||||
|
cos := make([]float32, seq*qwenRotaryDim)
|
||||||
|
sin := make([]float32, seq*qwenRotaryDim)
|
||||||
|
inv := make([]float64, qwenRotaryHalfDim)
|
||||||
|
for i := range inv {
|
||||||
|
inv[i] = 1 / math.Pow(e.config.RopeTheta, float64(2*i)/qwenRotaryDim)
|
||||||
|
}
|
||||||
|
for token := 0; token < seq; token++ {
|
||||||
|
freq := make([]float64, qwenRotaryHalfDim)
|
||||||
|
for i := range freq {
|
||||||
|
freq[i] = float64(position[token]) * inv[i]
|
||||||
|
}
|
||||||
|
for dim, offset := range []int{0, 1, 2} {
|
||||||
|
if dim == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
limit := e.config.MRopeSection[dim] * 3
|
||||||
|
for i := offset; i < limit; i += 3 {
|
||||||
|
freq[i] = float64(position[dim*seq+token]) * inv[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, f := range freq {
|
||||||
|
c, s := float32(math.Cos(f)), float32(math.Sin(f))
|
||||||
|
cos[token*qwenRotaryDim+i] = c
|
||||||
|
cos[token*qwenRotaryDim+qwenRotaryHalfDim+i] = c
|
||||||
|
sin[token*qwenRotaryDim+i] = s
|
||||||
|
sin[token*qwenRotaryDim+qwenRotaryHalfDim+i] = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cos, sin
|
||||||
|
}
|
||||||
|
|
||||||
|
func causalMask(seq int) []float32 {
|
||||||
|
out := make([]float32, seq*seq)
|
||||||
|
for row := 0; row < seq; row++ {
|
||||||
|
for col := row + 1; col < seq; col++ {
|
||||||
|
out[row*seq+col] = -math.MaxFloat32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalize(raw []float32) []float64 {
|
||||||
out := make([]float64, len(raw))
|
out := make([]float64, len(raw))
|
||||||
var norm float64
|
var norm float64
|
||||||
for i, v := range raw {
|
for i, v := range raw {
|
||||||
@ -167,48 +359,39 @@ func (e *Embedder) VectorizeDense(text string) ([]float64, error) {
|
|||||||
out[i] /= norm
|
out[i] /= norm
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out, nil
|
return out
|
||||||
}
|
|
||||||
|
|
||||||
// EmbedImageDense 不支持:导出的是**文本塔**,视觉塔未导出。
|
|
||||||
//
|
|
||||||
// 明确报错而不是返回零向量或占位:调用方(mediaref.go)会 log 后跳过写向量,
|
|
||||||
// 若返回零向量则「写入了但检索不到」,失败会静默化。要支持图像检索需另外
|
|
||||||
// 导出视觉塔并实现 Qwen3-VL 的图像预处理(patch/merge/缩放规则)。
|
|
||||||
func (e *Embedder) EmbedImageDense(_ []byte, _ string) ([]float64, error) {
|
|
||||||
return nil, fmt.Errorf("qwen text tower 不支持图像嵌入;图像检索请用 clip 或 http 路径")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Embedder) Fingerprint() string { return e.fp }
|
func (e *Embedder) Fingerprint() string { return e.fp }
|
||||||
func (e *Embedder) Dim() int { return e.config.Dimension }
|
func (e *Embedder) Dim() int { return e.config.Dimension }
|
||||||
|
|
||||||
func (e *Embedder) Loaded() bool {
|
func (e *Embedder) Loaded() bool {
|
||||||
e.mu.RLock()
|
e.mu.RLock()
|
||||||
defer e.mu.RUnlock()
|
defer e.mu.RUnlock()
|
||||||
return e.loaded
|
return e.loaded
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Embedder) Close() {
|
func (e *Embedder) Close() {
|
||||||
e.mu.Lock()
|
e.close.Do(func() {
|
||||||
defer e.mu.Unlock()
|
e.mu.Lock()
|
||||||
if e.sess != nil {
|
defer e.mu.Unlock()
|
||||||
e.sess.Destroy()
|
if e.token != nil {
|
||||||
e.sess = nil
|
e.token.Destroy()
|
||||||
}
|
e.token = nil
|
||||||
e.loaded = false
|
}
|
||||||
|
if e.transform != nil {
|
||||||
|
e.transform.Destroy()
|
||||||
|
e.transform = nil
|
||||||
|
}
|
||||||
|
if e.vision != nil {
|
||||||
|
e.vision.Destroy()
|
||||||
|
e.vision = nil
|
||||||
|
}
|
||||||
|
e.loaded = false
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// computeFingerprint 计算模型指纹,用于 vec_model 持久化与切换后重算判定。
|
|
||||||
//
|
|
||||||
// 为什么不直接哈希全部权重:这个模型目录有 6.5GB 外部权重分片,启动时读一遍
|
|
||||||
// 要几十秒,会阻塞 homeagent 启动。这里哈希「图文件 + 配置 + 全部外部权重的
|
|
||||||
// 文件名与大小」——换模型(哪怕只是换了权重)几乎必然改变文件集合或大小,
|
|
||||||
// 足以识别切换;代价是理论上存在「大小相同但内容不同」的漏判,对本地单机
|
|
||||||
// 部署可接受。
|
|
||||||
func computeFingerprint(modelDir string) string {
|
func computeFingerprint(modelDir string) string {
|
||||||
h := sha256.New()
|
h := sha256.New()
|
||||||
|
for _, name := range []string{"TokenEmbedding.onnx", "Transformer.onnx", "Vision.onnx", "embed_config.json"} {
|
||||||
for _, name := range []string{"TextTower.onnx", "embed_config.json"} {
|
|
||||||
if data, err := os.ReadFile(filepath.Join(modelDir, name)); err == nil {
|
if data, err := os.ReadFile(filepath.Join(modelDir, name)); err == nil {
|
||||||
h.Write([]byte(name))
|
h.Write([]byte(name))
|
||||||
h.Write([]byte{0})
|
h.Write([]byte{0})
|
||||||
@ -216,35 +399,25 @@ func computeFingerprint(modelDir string) string {
|
|||||||
h.Write([]byte{0})
|
h.Write([]byte{0})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
entries, _ := os.ReadDir(modelDir)
|
entries, _ := os.ReadDir(modelDir)
|
||||||
var names []string
|
var names []string
|
||||||
for _, e := range entries {
|
for _, entry := range entries {
|
||||||
n := e.Name()
|
n := entry.Name()
|
||||||
// 外部权重分片:torch 新版导出器使用 onnx__<op>_<id> 与模型张量同名文件。
|
if strings.HasPrefix(n, "embed_tokens.") || strings.HasPrefix(n, "layers.") || strings.HasPrefix(n, "onnx__") || strings.HasSuffix(n, ".onnx.data") {
|
||||||
if strings.HasPrefix(n, "onnx__") || strings.HasSuffix(n, ".weight") || strings.HasSuffix(n, ".onnx.data") {
|
|
||||||
names = append(names, n)
|
names = append(names, n)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sort.Strings(names)
|
sort.Strings(names)
|
||||||
for _, n := range names {
|
for _, n := range names {
|
||||||
info, err := os.Stat(filepath.Join(modelDir, n))
|
if info, err := os.Stat(filepath.Join(modelDir, n)); err == nil {
|
||||||
if err != nil {
|
fmt.Fprintf(h, "%s:%d\n", n, info.Size())
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
fmt.Fprintf(h, "%s:%d\n", n, info.Size())
|
|
||||||
}
|
}
|
||||||
return hex.EncodeToString(h.Sum(nil))
|
return hex.EncodeToString(h.Sum(nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
// findOnnxLib 在常见路径中查找 libonnxruntime.so。
|
|
||||||
func findOnnxLib() string {
|
func findOnnxLib() string {
|
||||||
for _, p := range []string{
|
for _, p := range []string{"/opt/onnxruntime/libonnxruntime.so", "/usr/local/lib/libonnxruntime.so", "/usr/lib/libonnxruntime.so"} {
|
||||||
"/opt/onnxruntime/libonnxruntime.so",
|
|
||||||
"/opt/onnxruntime/lib/libonnxruntime.so",
|
|
||||||
"/usr/local/lib/libonnxruntime.so",
|
|
||||||
"/usr/lib/libonnxruntime.so",
|
|
||||||
} {
|
|
||||||
if _, err := os.Stat(p); err == nil {
|
if _, err := os.Stat(p); err == nil {
|
||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,7 @@ package qwen
|
|||||||
|
|
||||||
import "fmt"
|
import "fmt"
|
||||||
|
|
||||||
// Embedder 在未启用 onnxruntime 时为 no-op 实现(与 internal/memory/clip 同模式)。
|
// Embedder 在未启用 onnxruntime 时为 no-op 实现。
|
||||||
// 默认构建不链接 onnxruntime,保持原有 fastText/TF-IDF 行为不变。
|
// 默认构建不链接 onnxruntime,保持原有 fastText/TF-IDF 行为不变。
|
||||||
type Embedder struct {
|
type Embedder struct {
|
||||||
loaded bool
|
loaded bool
|
||||||
|
|||||||
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"
|
"sync"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/yanyiwu/gojieba"
|
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
|
||||||
|
"github.com/yanyiwu/gojieba"
|
||||||
)
|
)
|
||||||
|
|
||||||
const downloadMaxWords = 200000
|
const downloadMaxWords = 200000
|
||||||
|
|||||||
@ -108,7 +108,7 @@ func TestStaticEmbedderSemanticSimilarity(t *testing.T) {
|
|||||||
e := newSynthEmbedder(t, 300)
|
e := newSynthEmbedder(t, 300)
|
||||||
|
|
||||||
pairs := []struct {
|
pairs := []struct {
|
||||||
a, b string
|
a, b string
|
||||||
related bool
|
related bool
|
||||||
}{
|
}{
|
||||||
{"今天天气怎么样", "明天会不会下雨", true},
|
{"今天天气怎么样", "明天会不会下雨", true},
|
||||||
|
|||||||
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"
|
ModalityVideo MultimodalModality = "video"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrNotSupported 表示 Vectorizer 不支持图像嵌入,调用方按文本描述降级。
|
// ErrNotSupported 表示 Vectorizer 不支持该原生模态;调用方不得以描述文本冒充其向量。
|
||||||
var ErrNotSupported = fmt.Errorf("vectorizer does not support image embedding")
|
var ErrNotSupported = fmt.Errorf("vectorizer does not support image embedding")
|
||||||
|
|
||||||
// Vector 是带权特征映射:feature → weight
|
// Vector 是带权特征映射:feature → weight
|
||||||
|
|||||||
@ -52,8 +52,8 @@ func (f *fakeCoreSDK) PluginName() string { return "fake" }
|
|||||||
func (f *fakeCoreSDK) Settings() pubsdk.SettingsAPI { return nil }
|
func (f *fakeCoreSDK) Settings() pubsdk.SettingsAPI { return nil }
|
||||||
func (f *fakeCoreSDK) Memory() pubsdk.MemoryAPI { return nil }
|
func (f *fakeCoreSDK) Memory() pubsdk.MemoryAPI { return nil }
|
||||||
func (f *fakeCoreSDK) TextMemory() pubsdk.TextMemoryAPI { return nil }
|
func (f *fakeCoreSDK) TextMemory() pubsdk.TextMemoryAPI { return nil }
|
||||||
func (f *fakeCoreSDK) DocMemory() pubsdk.DocMemoryAPI { return f.docMem }
|
func (f *fakeCoreSDK) DocMemory() pubsdk.DocMemoryAPI { return f.docMem }
|
||||||
func (f *fakeCoreSDK) Knowledge() pubsdk.KnowledgeAPI { return f.knowledge }
|
func (f *fakeCoreSDK) Knowledge() pubsdk.KnowledgeAPI { return f.knowledge }
|
||||||
func (f *fakeCoreSDK) LLM() pubsdk.LLMAPI { return nil }
|
func (f *fakeCoreSDK) LLM() pubsdk.LLMAPI { return nil }
|
||||||
func (f *fakeCoreSDK) Social() pubsdk.SocialAPI { return nil }
|
func (f *fakeCoreSDK) Social() pubsdk.SocialAPI { return nil }
|
||||||
func (f *fakeCoreSDK) PluginMgr() pubsdk.PluginMgrAPI { return nil }
|
func (f *fakeCoreSDK) PluginMgr() pubsdk.PluginMgrAPI { return nil }
|
||||||
|
|||||||
@ -322,7 +322,7 @@ func TestHealthcheckWithDocStore(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
ds := doc.NewStore(tmpDir)
|
ds := doc.NewStore(tmpDir, memory.TokenizeWords)
|
||||||
if err := ds.Start(); err != nil {
|
if err := ds.Start(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -70,7 +70,7 @@ func setupIntegrationWithProvider(t *testing.T, pm *agentAPI.ProviderManager) *t
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
docStore := doc.NewStore(filepath.Join(tmpDir, "documents"))
|
docStore := doc.NewStore(filepath.Join(tmpDir, "documents"), memory.TokenizeWords)
|
||||||
if err := docStore.Start(); err != nil {
|
if err := docStore.Start(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -125,6 +125,53 @@ func (c *testWSClient) readMsg() (byte, []byte, error) {
|
|||||||
|
|
||||||
func (c *testWSClient) close() { c.conn.Close() }
|
func (c *testWSClient) close() { c.conn.Close() }
|
||||||
|
|
||||||
|
func (c *testWSClient) bindDevice(t *testing.T, deviceID, token string) {
|
||||||
|
t.Helper()
|
||||||
|
c.sendText(mustJSON(map[string]interface{}{
|
||||||
|
"op": "bind", "device_id": deviceID, "token": token,
|
||||||
|
}))
|
||||||
|
op, payload, err := c.readMsg()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read bind_ack: %v", err)
|
||||||
|
}
|
||||||
|
if op != 0x1 {
|
||||||
|
t.Fatalf("expected bind_ack text frame, got %x", op)
|
||||||
|
}
|
||||||
|
var ack map[string]interface{}
|
||||||
|
if err := json.Unmarshal(payload, &ack); err != nil {
|
||||||
|
t.Fatalf("decode bind_ack: %v", err)
|
||||||
|
}
|
||||||
|
if ack["op"] != "bind_ack" || ack["ok"] != true {
|
||||||
|
t.Fatalf("bind rejected: %v", ack)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *testWSClient) readHelloAck(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
op, payload, err := c.readMsg()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read hello_ack: %v", err)
|
||||||
|
}
|
||||||
|
if op != 0x1 {
|
||||||
|
t.Fatalf("expected hello_ack text frame, got %x", op)
|
||||||
|
}
|
||||||
|
var ack map[string]interface{}
|
||||||
|
if err := json.Unmarshal(payload, &ack); err != nil {
|
||||||
|
t.Fatalf("decode hello_ack: %v", err)
|
||||||
|
}
|
||||||
|
deviceID, _ := ack["device"].(string)
|
||||||
|
if ack["op"] != "hello_ack" || deviceID == "" {
|
||||||
|
t.Fatalf("expected hello_ack with device id, got %v", ack)
|
||||||
|
}
|
||||||
|
return deviceID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *testWSClient) readHelloAckAndBind(t *testing.T, token string) {
|
||||||
|
t.Helper()
|
||||||
|
deviceID := c.readHelloAck(t)
|
||||||
|
c.bindDevice(t, deviceID, token)
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 端到端:hello/bind/cmd + 二进制分块回传(录像协议)=====
|
// ===== 端到端:hello/bind/cmd + 二进制分块回传(录像协议)=====
|
||||||
|
|
||||||
func TestWSBinaryChunkUpload(t *testing.T) {
|
func TestWSBinaryChunkUpload(t *testing.T) {
|
||||||
@ -139,20 +186,9 @@ func TestWSBinaryChunkUpload(t *testing.T) {
|
|||||||
cli := dialTestWS(t, url, token)
|
cli := dialTestWS(t, url, token)
|
||||||
defer cli.close()
|
defer cli.close()
|
||||||
|
|
||||||
// hello 登记
|
// hello 后必须完成 bind,设备才会注册并开始处理数据。
|
||||||
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"gui-test","name":"测试机","kind":"computer","caps":["cmd"]}}`))
|
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"gui-test","name":"测试机","kind":"computer","caps":["cmd"]}}`))
|
||||||
op, payload, err := cli.readMsg()
|
cli.readHelloAckAndBind(t, token)
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read hello_ack: %v", err)
|
|
||||||
}
|
|
||||||
if op != 0x1 {
|
|
||||||
t.Fatalf("expected text frame, got %x", op)
|
|
||||||
}
|
|
||||||
var ack map[string]interface{}
|
|
||||||
json.Unmarshal(payload, &ack)
|
|
||||||
if ack["op"] != "hello_ack" {
|
|
||||||
t.Fatalf("expected hello_ack, got %v", ack)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 模拟设备收到 cmd 后以二进制分块回传(cmd_data_start → 0x2×N → cmd_data_end)
|
// 模拟设备收到 cmd 后以二进制分块回传(cmd_data_start → 0x2×N → cmd_data_end)
|
||||||
videoData := make([]byte, 20000) // 跨多个 8KB 块
|
videoData := make([]byte, 20000) // 跨多个 8KB 块
|
||||||
@ -224,9 +260,7 @@ func TestWSBinaryMediaToFile(t *testing.T) {
|
|||||||
defer cli.close()
|
defer cli.close()
|
||||||
|
|
||||||
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"gui-media","name":"媒体机","kind":"computer","caps":["cmd"]}}`))
|
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"gui-media","name":"媒体机","kind":"computer","caps":["cmd"]}}`))
|
||||||
if _, _, err := cli.readMsg(); err != nil { // hello_ack
|
cli.readHelloAckAndBind(t, token)
|
||||||
t.Fatalf("read hello_ack: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
videoData := make([]byte, 30000)
|
videoData := make([]byte, 30000)
|
||||||
for i := range videoData {
|
for i := range videoData {
|
||||||
@ -290,9 +324,7 @@ func TestWSPushDataAudio(t *testing.T) {
|
|||||||
defer cli.close()
|
defer cli.close()
|
||||||
|
|
||||||
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"audio-dev","name":"音频机","kind":"speaker"}}`))
|
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"audio-dev","name":"音频机","kind":"speaker"}}`))
|
||||||
if _, _, err := cli.readMsg(); err != nil {
|
cli.readHelloAckAndBind(t, token)
|
||||||
t.Fatalf("read hello_ack: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
audioData := []byte("RIFF....fake-wav-audio-data-for-testing....")
|
audioData := []byte("RIFF....fake-wav-audio-data-for-testing....")
|
||||||
|
|
||||||
@ -388,9 +420,7 @@ func TestScreenseeEndToEnd(t *testing.T) {
|
|||||||
|
|
||||||
// 设备 hello + bind(bind 需 token 才能被授权流程识别,这里直接手动授权)
|
// 设备 hello + bind(bind 需 token 才能被授权流程识别,这里直接手动授权)
|
||||||
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"see-dev","name":"屏幕机","kind":"computer","caps":["cmd"]}}`))
|
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"see-dev","name":"屏幕机","kind":"computer","caps":["cmd"]}}`))
|
||||||
if _, _, err := cli.readMsg(); err != nil {
|
cli.readHelloAckAndBind(t, token)
|
||||||
t.Fatalf("read hello_ack: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 设备侧循环收命令并回执(模拟 GUI screensee 实现)
|
// 设备侧循环收命令并回执(模拟 GUI screensee 实现)
|
||||||
go func() {
|
go func() {
|
||||||
@ -454,9 +484,7 @@ func TestComputeruseEndToEnd(t *testing.T) {
|
|||||||
defer cli.close()
|
defer cli.close()
|
||||||
|
|
||||||
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"cu-dev","name":"操控机","kind":"computer","caps":["cmd","computeruse"]}}`))
|
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"cu-dev","name":"操控机","kind":"computer","caps":["cmd","computeruse"]}}`))
|
||||||
if _, _, err := cli.readMsg(); err != nil {
|
cli.readHelloAckAndBind(t, token)
|
||||||
t.Fatalf("read hello_ack: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 设备侧收 computeruse 命令并回执
|
// 设备侧收 computeruse 命令并回执
|
||||||
var receivedCmd string
|
var receivedCmd string
|
||||||
@ -544,9 +572,7 @@ func TestClipboardEndToEnd(t *testing.T) {
|
|||||||
defer cli.close()
|
defer cli.close()
|
||||||
|
|
||||||
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"clip-dev","name":"剪贴板机","kind":"computer","caps":["cmd"]}}`))
|
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"clip-dev","name":"剪贴板机","kind":"computer","caps":["cmd"]}}`))
|
||||||
if _, _, err := cli.readMsg(); err != nil {
|
cli.readHelloAckAndBind(t, token)
|
||||||
t.Fatalf("read hello_ack: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 设备侧响应剪贴板命令
|
// 设备侧响应剪贴板命令
|
||||||
go func() {
|
go func() {
|
||||||
@ -666,9 +692,7 @@ func TestCapabilityMatrix(t *testing.T) {
|
|||||||
defer cli.close()
|
defer cli.close()
|
||||||
|
|
||||||
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"cam-only","name":"纯摄像头","kind":"camera","caps":["camera"]}}`))
|
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"cam-only","name":"纯摄像头","kind":"camera","caps":["camera"]}}`))
|
||||||
if _, _, err := cli.readMsg(); err != nil {
|
cli.readHelloAckAndBind(t, token)
|
||||||
t.Fatalf("read hello_ack: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := dev.Execute("screensee", map[string]interface{}{"device_id": "cam-only"}); err == nil {
|
if _, err := dev.Execute("screensee", map[string]interface{}{"device_id": "cam-only"}); err == nil {
|
||||||
t.Fatal("camera-only device should not support screensee")
|
t.Fatal("camera-only device should not support screensee")
|
||||||
@ -698,9 +722,7 @@ func TestDeviceEventReport(t *testing.T) {
|
|||||||
defer cli.close()
|
defer cli.close()
|
||||||
|
|
||||||
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"cam-watch","name":"监控摄像头","kind":"camera","caps":["camera"]}}`))
|
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"cam-watch","name":"监控摄像头","kind":"camera","caps":["camera"]}}`))
|
||||||
if _, _, err := cli.readMsg(); err != nil {
|
cli.readHelloAckAndBind(t, token)
|
||||||
t.Fatalf("read hello_ack: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 设备主动上报:识别到未知人员驻留
|
// 设备主动上报:识别到未知人员驻留
|
||||||
cli.sendText(mustJSON(map[string]interface{}{
|
cli.sendText(mustJSON(map[string]interface{}{
|
||||||
@ -738,3 +760,105 @@ func TestDeviceEventReport(t *testing.T) {
|
|||||||
t.Fatalf("unexpected second event: %v", events[1])
|
t.Fatalf("unexpected second event: %v", events[1])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWSDoesNotExposeDeviceBeforeBind(t *testing.T) {
|
||||||
|
reg := NewRegistry()
|
||||||
|
token := "prebind-token"
|
||||||
|
reg.SetAcceptToken(func(provided string) bool { return provided == token })
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(reg.ServeWS))
|
||||||
|
defer srv.Close()
|
||||||
|
cli := dialTestWS(t, srv.URL, token)
|
||||||
|
defer cli.close()
|
||||||
|
|
||||||
|
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"prebind-dev","name":"待绑定设备","kind":"computer","caps":["cmd"]}}`))
|
||||||
|
deviceID := cli.readHelloAck(t)
|
||||||
|
if deviceID != "prebind-dev" {
|
||||||
|
t.Fatalf("unexpected device id: %s", deviceID)
|
||||||
|
}
|
||||||
|
if _, ok := reg.Get(deviceID); ok {
|
||||||
|
t.Fatal("device must not be registered before bind")
|
||||||
|
}
|
||||||
|
if reg.Online(deviceID) {
|
||||||
|
t.Fatal("device must not be online before bind")
|
||||||
|
}
|
||||||
|
if err := reg.PushJSON(deviceID, map[string]interface{}{"op": "cmd"}); err == nil {
|
||||||
|
t.Fatal("command push must fail before bind")
|
||||||
|
}
|
||||||
|
|
||||||
|
cli.sendText(mustJSON(map[string]interface{}{
|
||||||
|
"op": "cmd_result", "req_id": "prebind-result", "status": "ok",
|
||||||
|
}))
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
if _, ok := reg.GetResult("prebind-result"); ok {
|
||||||
|
t.Fatal("result must be ignored before bind")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWSRejectedBindDoesNotRegister(t *testing.T) {
|
||||||
|
reg := NewRegistry()
|
||||||
|
reg.SetAcceptToken(func(provided string) bool { return provided == "expected-token" })
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(reg.ServeWS))
|
||||||
|
defer srv.Close()
|
||||||
|
cli := dialTestWS(t, srv.URL, "")
|
||||||
|
defer cli.close()
|
||||||
|
|
||||||
|
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"rejected-dev","name":"拒绝设备","kind":"computer"}}`))
|
||||||
|
deviceID := cli.readHelloAck(t)
|
||||||
|
cli.sendText(mustJSON(map[string]interface{}{
|
||||||
|
"op": "bind", "device_id": deviceID, "token": "wrong-token",
|
||||||
|
}))
|
||||||
|
op, payload, err := cli.readMsg()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read rejected bind_ack: %v", err)
|
||||||
|
}
|
||||||
|
if op != 0x1 {
|
||||||
|
t.Fatalf("expected rejected bind_ack text frame, got %x", op)
|
||||||
|
}
|
||||||
|
var ack map[string]interface{}
|
||||||
|
if err := json.Unmarshal(payload, &ack); err != nil {
|
||||||
|
t.Fatalf("decode rejected bind_ack: %v", err)
|
||||||
|
}
|
||||||
|
if ack["op"] != "bind_ack" || ack["ok"] != false {
|
||||||
|
t.Fatalf("expected rejected bind_ack, got %v", ack)
|
||||||
|
}
|
||||||
|
if _, ok := reg.Get(deviceID); ok {
|
||||||
|
t.Fatal("rejected device must not be registered")
|
||||||
|
}
|
||||||
|
if reg.Online(deviceID) {
|
||||||
|
t.Fatal("rejected device must not be online")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWSHandshakeAuthorizationAllowsUnrelatedBindToken(t *testing.T) {
|
||||||
|
reg := NewRegistry()
|
||||||
|
transportToken := "transport-token"
|
||||||
|
reg.SetAcceptToken(func(provided string) bool { return provided == transportToken })
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(reg.ServeWS))
|
||||||
|
defer srv.Close()
|
||||||
|
cli := dialTestWS(t, srv.URL, transportToken)
|
||||||
|
defer cli.close()
|
||||||
|
|
||||||
|
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"transport-dev","name":"代理设备","kind":"computer"}}`))
|
||||||
|
deviceID := cli.readHelloAck(t)
|
||||||
|
cli.bindDevice(t, deviceID, "unrelated-body-token")
|
||||||
|
if !reg.Online(deviceID) {
|
||||||
|
t.Fatal("handshake-authorized device should be online after bind")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAwaitResultReturnsResultDeliveredBeforeWaiter(t *testing.T) {
|
||||||
|
reg := NewRegistry()
|
||||||
|
want := map[string]interface{}{"status": "ok", "value": "early"}
|
||||||
|
reg.deliverResult("early-result", want)
|
||||||
|
|
||||||
|
got, err := reg.AwaitResult("early-result", 50*time.Millisecond)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("await early result: %v", err)
|
||||||
|
}
|
||||||
|
if got["status"] != want["status"] || got["value"] != want["value"] {
|
||||||
|
t.Fatalf("unexpected early result: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -104,8 +104,7 @@ var capabilityTools = map[string][]string{
|
|||||||
|
|
||||||
// compatFullCaps 视为「全能力」的历史 caps 值:声明了这些的设备不参与能力裁剪。
|
// compatFullCaps 视为「全能力」的历史 caps 值:声明了这些的设备不参与能力裁剪。
|
||||||
var compatFullCaps = map[string]bool{
|
var compatFullCaps = map[string]bool{
|
||||||
"cmd": true, "cmdrun": true, "deviceinfo": true,
|
"cmd": true, "cmdrun": true, "cmdresult": true,
|
||||||
"status": true, "cmdresult": true,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SupportsTool 判断设备是否支持某 agent 工具(基于其声明的 caps)。
|
// SupportsTool 判断设备是否支持某 agent 工具(基于其声明的 caps)。
|
||||||
@ -400,10 +399,15 @@ func (r *Registry) PushData(deviceID, reqID, kind, mime string, data []byte) err
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AwaitResult 等待某请求的结果(带超时)。
|
// AwaitResult 等待某请求的结果(带超时)。快速回执会先留在 results,
|
||||||
|
// 因而 PushCmd 后才开始等待也不会丢失。
|
||||||
func (r *Registry) AwaitResult(reqID string, timeout time.Duration) (map[string]interface{}, error) {
|
func (r *Registry) AwaitResult(reqID string, timeout time.Duration) (map[string]interface{}, error) {
|
||||||
ch := make(chan map[string]interface{}, 1)
|
ch := make(chan map[string]interface{}, 1)
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
|
if e, ok := r.results[reqID]; ok {
|
||||||
|
r.mu.Unlock()
|
||||||
|
return e.Result, nil
|
||||||
|
}
|
||||||
r.cmdPending[reqID] = ch
|
r.cmdPending[reqID] = ch
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
defer func() {
|
defer func() {
|
||||||
@ -419,11 +423,15 @@ func (r *Registry) AwaitResult(reqID string, timeout time.Duration) (map[string]
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// deliverResult 设备回执结果时由 handleWS 调用。
|
// deliverResult 先留档再通知等待者,消除设备极速回执早于 AwaitResult 的竞态。
|
||||||
func (r *Registry) deliverResult(reqID string, res map[string]interface{}) {
|
func (r *Registry) deliverResult(reqID string, res map[string]interface{}) {
|
||||||
r.mu.RLock()
|
r.mu.Lock()
|
||||||
|
if r.results == nil {
|
||||||
|
r.results = make(map[string]resultEntry)
|
||||||
|
}
|
||||||
|
r.results[reqID] = resultEntry{Result: res, Time: time.Now()}
|
||||||
ch, ok := r.cmdPending[reqID]
|
ch, ok := r.cmdPending[reqID]
|
||||||
r.mu.RUnlock()
|
r.mu.Unlock()
|
||||||
if ok {
|
if ok {
|
||||||
select {
|
select {
|
||||||
case ch <- res:
|
case ch <- res:
|
||||||
@ -621,6 +629,9 @@ func (r *Registry) ServeWS(w http.ResponseWriter, req *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
token := req.URL.Query().Get("token")
|
token := req.URL.Query().Get("token")
|
||||||
|
if token == "" {
|
||||||
|
token = strings.TrimSpace(req.Header.Get("X-API-Key"))
|
||||||
|
}
|
||||||
if token == "" {
|
if token == "" {
|
||||||
for _, p := range req.Header.Values("Sec-WebSocket-Protocol") {
|
for _, p := range req.Header.Values("Sec-WebSocket-Protocol") {
|
||||||
if strings.HasPrefix(p, "homeagent.") {
|
if strings.HasPrefix(p, "homeagent.") {
|
||||||
@ -629,7 +640,8 @@ func (r *Registry) ServeWS(w http.ResponseWriter, req *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if token != "" && !r.acceptBind(token) {
|
handshakeAuthorized := token != "" && r.acceptBind(token)
|
||||||
|
if token != "" && !handshakeAuthorized {
|
||||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -639,7 +651,7 @@ func (r *Registry) ServeWS(w http.ResponseWriter, req *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("[remotedevice] ws connected from %s", conn.RemoteAddr())
|
log.Printf("[remotedevice] ws connected from %s", conn.RemoteAddr())
|
||||||
go r.handleWS(conn, rw)
|
go r.handleWS(conn, rw, handshakeAuthorized)
|
||||||
}
|
}
|
||||||
|
|
||||||
// wsWriteLocked 在指定设备连接的写锁保护下执行写回调。
|
// wsWriteLocked 在指定设备连接的写锁保护下执行写回调。
|
||||||
@ -662,12 +674,20 @@ func (r *Registry) wsWriteLocked(deviceID string, fn func(w *bufio.Writer) error
|
|||||||
return fn(w)
|
return fn(w)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Registry) handleWS(conn net.Conn, rw *bufio.ReadWriter) {
|
func (r *Registry) handleWS(conn net.Conn, rw *bufio.ReadWriter, handshakeAuthorized bool) {
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
var curID string
|
var curID string
|
||||||
|
var pendingMeta *DeviceMeta
|
||||||
|
var bound bool
|
||||||
defer func() {
|
defer func() {
|
||||||
if curID != "" {
|
if curID != "" {
|
||||||
r.markOffline(curID)
|
if bound {
|
||||||
|
r.markOffline(curID)
|
||||||
|
} else {
|
||||||
|
r.mu.Lock()
|
||||||
|
delete(r.conns, curID)
|
||||||
|
r.mu.Unlock()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@ -697,9 +717,12 @@ func (r *Registry) handleWS(conn net.Conn, rw *bufio.ReadWriter) {
|
|||||||
dataAccum.chunks = append(dataAccum.chunks, payload)
|
dataAccum.chunks = append(dataAccum.chunks, payload)
|
||||||
dataAccum.got += len(payload)
|
dataAccum.got += len(payload)
|
||||||
// 防滥用:超出声明 total 的 2 倍或硬上限 64MB 时放弃聚合
|
// 防滥用:超出声明 total 的 2 倍或硬上限 64MB 时放弃聚合
|
||||||
limit := int64(dataAccum.total)*2 + 1024
|
limit := int64(64 << 20)
|
||||||
if limit < 64<<20 {
|
if dataAccum.total > 0 {
|
||||||
limit = 64 << 20
|
declaredLimit := int64(dataAccum.total)*2 + 1024
|
||||||
|
if declaredLimit < limit {
|
||||||
|
limit = declaredLimit
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if int64(dataAccum.got) > limit {
|
if int64(dataAccum.got) > limit {
|
||||||
log.Printf("[remotedevice] data accumulation exceeded limit for req %s, dropped", dataAccum.reqID)
|
log.Printf("[remotedevice] data accumulation exceeded limit for req %s, dropped", dataAccum.reqID)
|
||||||
@ -713,6 +736,9 @@ func (r *Registry) handleWS(conn net.Conn, rw *bufio.ReadWriter) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
op, _ := msg["op"].(string)
|
op, _ := msg["op"].(string)
|
||||||
|
if !bound && op != "hello" && op != "bind" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
switch op {
|
switch op {
|
||||||
case "hello":
|
case "hello":
|
||||||
meta := metaFromMsg(msg)
|
meta := metaFromMsg(msg)
|
||||||
@ -720,41 +746,35 @@ func (r *Registry) handleWS(conn net.Conn, rw *bufio.ReadWriter) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
meta.RemoteAddr = conn.RemoteAddr().String()
|
meta.RemoteAddr = conn.RemoteAddr().String()
|
||||||
|
pendingMeta = &meta
|
||||||
curID = meta.DeviceID
|
curID = meta.DeviceID
|
||||||
r.register(meta)
|
// Bind 前不把连接暴露给查询或命令下发路径;此时只有当前读循环会写。
|
||||||
r.mu.Lock()
|
if err := writeText(rw.Writer, mustJSON(map[string]interface{}{
|
||||||
r.conns[meta.DeviceID] = &wconn{deviceID: meta.DeviceID, w: rw.Writer}
|
"op": "hello_ack",
|
||||||
r.mu.Unlock()
|
"device": meta.DeviceID,
|
||||||
if err := r.wsWriteLocked(meta.DeviceID, func(w *bufio.Writer) error {
|
"online": false,
|
||||||
return writeText(w, mustJSON(map[string]interface{}{
|
})); err != nil {
|
||||||
"op": "hello_ack",
|
|
||||||
"device": meta.DeviceID,
|
|
||||||
"online": true,
|
|
||||||
}))
|
|
||||||
}); err != nil {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case "bind":
|
case "bind":
|
||||||
token, _ := msg["token"].(string)
|
token, _ := msg["token"].(string)
|
||||||
if r.acceptBind(token) {
|
id, _ := msg["device_id"].(string)
|
||||||
id, _ := msg["device_id"].(string)
|
bindAuthorized := handshakeAuthorized || r.acceptBind(token)
|
||||||
if id != "" {
|
if pendingMeta == nil || id == "" || id != pendingMeta.DeviceID || !bindAuthorized {
|
||||||
// 默认不授权:bind 仅验证 token + 登记设备;授权完全由用户手动
|
_ = writeText(rw.Writer, mustJSON(map[string]interface{}{
|
||||||
// (GUI 设备页 / REST /api/v1/device/auth)控制,绝不自动授权。
|
"op": "bind_ack", "ok": false, "error": "bind rejected",
|
||||||
}
|
}))
|
||||||
err := r.wsWriteLocked(curID, func(w *bufio.Writer) error {
|
return
|
||||||
return writeText(w, mustJSON(map[string]interface{}{"op": "bind_ack", "ok": true}))
|
}
|
||||||
})
|
r.mu.Lock()
|
||||||
if err != nil {
|
r.conns[id] = &wconn{deviceID: id, w: rw.Writer}
|
||||||
return
|
r.mu.Unlock()
|
||||||
}
|
bound = true
|
||||||
} else {
|
r.register(*pendingMeta)
|
||||||
err := r.wsWriteLocked(curID, func(w *bufio.Writer) error {
|
if err := r.wsWriteLocked(curID, func(w *bufio.Writer) error {
|
||||||
return writeText(w, mustJSON(map[string]interface{}{"op": "bind_ack", "ok": false, "error": "bad token"}))
|
return writeText(w, mustJSON(map[string]interface{}{"op": "bind_ack", "ok": true}))
|
||||||
})
|
}); err != nil {
|
||||||
if err != nil {
|
return
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
case "status":
|
case "status":
|
||||||
id, _ := msg["device_id"].(string)
|
id, _ := msg["device_id"].(string)
|
||||||
|
|||||||
@ -10,7 +10,7 @@ type IndexerAPI interface {
|
|||||||
|
|
||||||
// IndexContext is a neutral snapshot of the injected memory context.
|
// IndexContext is a neutral snapshot of the injected memory context.
|
||||||
type IndexContext struct {
|
type IndexContext struct {
|
||||||
Entities []Entity `json:"entities"`
|
Entities []Entity `json:"entities"`
|
||||||
Relations []Relation `json:"relations"`
|
Relations []Relation `json:"relations"`
|
||||||
Summary string `json:"summary"`
|
Summary string `json:"summary"`
|
||||||
TokenEstimate int `json:"token_estimate"`
|
TokenEstimate int `json:"token_estimate"`
|
||||||
|
|||||||
@ -7,7 +7,9 @@ type knowledgeImpl struct{ ks *knowledge.Store }
|
|||||||
func NewKnowledge(ks *knowledge.Store) KnowledgeAPI { return &knowledgeImpl{ks: ks} }
|
func NewKnowledge(ks *knowledge.Store) KnowledgeAPI { return &knowledgeImpl{ks: ks} }
|
||||||
|
|
||||||
func (k *knowledgeImpl) Search(query string, topK int) ([]*Knowledge, error) {
|
func (k *knowledgeImpl) Search(query string, topK int) ([]*Knowledge, error) {
|
||||||
if k.ks == nil { return nil, nil }
|
if k.ks == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
got := k.ks.Search(query, topK)
|
got := k.ks.Search(query, topK)
|
||||||
out := make([]*Knowledge, len(got))
|
out := make([]*Knowledge, len(got))
|
||||||
for i, item := range got {
|
for i, item := range got {
|
||||||
@ -17,22 +19,30 @@ func (k *knowledgeImpl) Search(query string, topK int) ([]*Knowledge, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (k *knowledgeImpl) Add(name, content string) error {
|
func (k *knowledgeImpl) Add(name, content string) error {
|
||||||
if k.ks == nil { return nil }
|
if k.ks == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return k.ks.Add(name, content)
|
return k.ks.Add(name, content)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (k *knowledgeImpl) List() ([]string, error) {
|
func (k *knowledgeImpl) List() ([]string, error) {
|
||||||
if k.ks == nil { return nil, nil }
|
if k.ks == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
return k.ks.List(), nil
|
return k.ks.List(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (k *knowledgeImpl) Stats() map[string]interface{} {
|
func (k *knowledgeImpl) Stats() map[string]interface{} {
|
||||||
if k.ks == nil { return map[string]interface{}{} }
|
if k.ks == nil {
|
||||||
|
return map[string]interface{}{}
|
||||||
|
}
|
||||||
return k.ks.Stats()
|
return k.ks.Stats()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (k *knowledgeImpl) Remove(name string) error {
|
func (k *knowledgeImpl) Remove(name string) error {
|
||||||
if k.ks == nil { return nil }
|
if k.ks == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return k.ks.Remove(name)
|
return k.ks.Remove(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,9 +3,7 @@ package sdk
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"regexp"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -20,34 +18,15 @@ import (
|
|||||||
// 这一层原先的缺陷是**静默裁字段**:插件把 Triple / Doc 交进来,包装层只挑
|
// 这一层原先的缺陷是**静默裁字段**:插件把 Triple / Doc 交进来,包装层只挑
|
||||||
// 自己认识的几个字段转成内部结构,其余丢弃且不报错。两侧都中招:
|
// 自己认识的几个字段转成内部结构,其余丢弃且不报错。两侧都中招:
|
||||||
// - 图记忆:丢 Confidence/SubjectType/ObjectType/SentenceText,又走 Commit
|
// - 图记忆:丢 Confidence/SubjectType/ObjectType/SentenceText,又走 Commit
|
||||||
// 而非 CommitWithMedia,于是 sentences 表没有落点,媒体引用无从挂起;
|
// 而非 CommitWithMedia,于是 sentences 表没有落点,媒体块无从挂接;
|
||||||
// - 知识库:Query 只回 ID/Title/Content,Insert 只写这三个,读写两个方向
|
// - 知识库:Query 只回 ID/Title/Content,Insert 只写这三个,读写两个方向
|
||||||
// 都把媒体元数据裁掉;Remove 不解引用,媒体永久算「被引用」,GC 收不掉。
|
// 都把媒体元数据裁掉。
|
||||||
//
|
//
|
||||||
// 现在的规则:内部结构有的字段一律透传;媒体一律走标记格式并挂到对应 owner。
|
// 现在的规则:内部结构有的字段一律透传;媒体一律变成一等记忆块。
|
||||||
// 媒体存储为 nil 时整条链路静默降级为纯文本行为(媒体是记忆增强,不是必需品)。
|
// 媒体存储为 nil 时整条链路静默降级为纯文本行为(媒体是记忆增强,不是必需品)。
|
||||||
|
|
||||||
// ---------- 媒体标记(本层内部) ----------
|
|
||||||
//
|
|
||||||
// 标记是媒体在**纯文本记忆**里的表示形式:
|
|
||||||
//
|
|
||||||
// [image/png a1b2c3d4e5f6] 一张紫蓝红三色带图
|
|
||||||
// └ label └ 短 digest └ 描述
|
|
||||||
//
|
|
||||||
// 之所以必须借文本承载:Doc.Content、sentences.text、文本记忆的 Input 全是
|
|
||||||
// 字符串,没有字段能挂结构化数据。描述文本是持久的语义记忆(检索靠它),
|
|
||||||
// digest 是回到字节的钥匙(反查靠它)。
|
|
||||||
//
|
|
||||||
// 格式与内核侧 graphmedia.go 的 mediaSummaryForEvent 一致——两边必须能互读
|
|
||||||
// 对方写下的标记,否则插件写入的媒体在内核归档时挂不上引用,且不报错。
|
|
||||||
|
|
||||||
const sdkShortDigestLen = 12
|
const sdkShortDigestLen = 12
|
||||||
|
|
||||||
// sdkMarkerPattern 拆解一条标记,捕获组依次为 label、短 digest、该行剩余描述。
|
|
||||||
// digest 放宽到 8-64 位以容忍完整 digest 手写的情况;描述取到行尾而非贪婪到底,
|
|
||||||
// 因为一条记忆可能挂多份媒体、各占一行。
|
|
||||||
var sdkMarkerPattern = regexp.MustCompile(`\[([^\[\]\s]+)\s+([0-9a-f]{8,64})\]([^\n]*)`)
|
|
||||||
|
|
||||||
func sdkShortDigest(d string) string {
|
func sdkShortDigest(d string) string {
|
||||||
if len(d) > sdkShortDigestLen {
|
if len(d) > sdkShortDigestLen {
|
||||||
return d[:sdkShortDigestLen]
|
return d[:sdkShortDigestLen]
|
||||||
@ -55,34 +34,6 @@ func sdkShortDigest(d string) string {
|
|||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
|
|
||||||
// sdkMarkerFor 为一份已入库的媒体生成标记行。查不到就返回空串——
|
|
||||||
// 媒体可能已被 GC 清掉,此时不该凭空造出一条指向虚无的标记。
|
|
||||||
func sdkMarkerFor(ms *media.Store, digest string) string {
|
|
||||||
it, err := ms.Stat(digest)
|
|
||||||
if err != nil || it == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
label := string(it.Kind)
|
|
||||||
if it.MIME != "" {
|
|
||||||
label = it.MIME
|
|
||||||
}
|
|
||||||
if it.Description == "" {
|
|
||||||
// 「已入库但还没描述」与「压根没有媒体」必须可区分:
|
|
||||||
// 描述由后台循环异步补齐,占位符保证补齐前这份媒体也不会从文本里消失。
|
|
||||||
return fmt.Sprintf("[%s %s] (未描述)", label, sdkShortDigest(digest))
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("[%s %s] %s", label, sdkShortDigest(digest), it.Description)
|
|
||||||
}
|
|
||||||
|
|
||||||
// sdkDigestsIn 返回文本里出现过的短 digest 集合,用于避免重复追加标记。
|
|
||||||
func sdkDigestsIn(s string) map[string]bool {
|
|
||||||
out := map[string]bool{}
|
|
||||||
for _, m := range sdkMarkerPattern.FindAllStringSubmatch(s, -1) {
|
|
||||||
out[m[2]] = true
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// sdkBlockSeq 保证块 ID 全局唯一:Graph 的 memory_blocks 以 id 为主键,
|
// sdkBlockSeq 保证块 ID 全局唯一:Graph 的 memory_blocks 以 id 为主键,
|
||||||
// 不同文档里序号相同的块会在 L2→L3 迁移时相互覆盖。
|
// 不同文档里序号相同的块会在 L2→L3 迁移时相互覆盖。
|
||||||
var sdkBlockSeq int64
|
var sdkBlockSeq int64
|
||||||
@ -127,27 +78,8 @@ func sdkBlockForDigest(ms *media.Store, digest string) (memory.MemoryBlock, bool
|
|||||||
}, true
|
}, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// sdkBlocksFromText 把文本标记里的媒体变成一等块(去重)。
|
|
||||||
func sdkBlocksFromText(ms *media.Store, text string) []memory.MemoryBlock {
|
|
||||||
if ms == nil || text == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
seen := map[string]bool{}
|
|
||||||
var blocks []memory.MemoryBlock
|
|
||||||
for _, m := range sdkMarkerPattern.FindAllStringSubmatch(text, -1) {
|
|
||||||
full, err := ms.ResolvePrefix(m[2])
|
|
||||||
if err != nil || seen[full] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[full] = true
|
|
||||||
if b, ok := sdkBlockForDigest(ms, full); ok {
|
|
||||||
blocks = append(blocks, b)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return blocks
|
|
||||||
}
|
|
||||||
|
|
||||||
// sdkBlocksFromDigests 为显式 digest 列表构造一等块(去重)。
|
// sdkBlocksFromDigests 为显式 digest 列表构造一等块(去重)。
|
||||||
|
// digest 可以是短前缀,内部会先补全。
|
||||||
func sdkBlocksFromDigests(ms *media.Store, digests []string) []memory.MemoryBlock {
|
func sdkBlocksFromDigests(ms *media.Store, digests []string) []memory.MemoryBlock {
|
||||||
if ms == nil || len(digests) == 0 {
|
if ms == nil || len(digests) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@ -155,11 +87,15 @@ func sdkBlocksFromDigests(ms *media.Store, digests []string) []memory.MemoryBloc
|
|||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
var blocks []memory.MemoryBlock
|
var blocks []memory.MemoryBlock
|
||||||
for _, d := range digests {
|
for _, d := range digests {
|
||||||
if d == "" || seen[d] {
|
if d == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seen[d] = true
|
full, err := ms.ResolvePrefix(d)
|
||||||
if b, ok := sdkBlockForDigest(ms, d); ok {
|
if err != nil || seen[full] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[full] = true
|
||||||
|
if b, ok := sdkBlockForDigest(ms, full); ok {
|
||||||
blocks = append(blocks, b)
|
blocks = append(blocks, b)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -177,10 +113,9 @@ func sdkPutAttachment(ms *media.Store, a MediaAttachment, tool string) (string,
|
|||||||
mime = "application/octet-stream"
|
mime = "application/octet-stream"
|
||||||
}
|
}
|
||||||
return ms.Put(a.Data, media.Item{
|
return ms.Put(a.Data, media.Item{
|
||||||
MIME: mime,
|
MIME: mime,
|
||||||
Tool: tool,
|
Tool: tool,
|
||||||
OriginPath: a.Name,
|
OriginPath: a.Name,
|
||||||
Description: a.Description,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if a.Digest == "" {
|
if a.Digest == "" {
|
||||||
@ -193,32 +128,6 @@ func sdkPutAttachment(ms *media.Store, a MediaAttachment, tool string) (string,
|
|||||||
return full, nil
|
return full, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// sdkAttachmentsFromText 从文本标记反解出附件元数据(不含字节),
|
|
||||||
// 让插件不必自己写正则去认标记。
|
|
||||||
func sdkAttachmentsFromText(ms *media.Store, s string) []MediaAttachment {
|
|
||||||
if ms == nil || s == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var out []MediaAttachment
|
|
||||||
seen := map[string]bool{}
|
|
||||||
for _, m := range sdkMarkerPattern.FindAllStringSubmatch(s, -1) {
|
|
||||||
full, err := ms.ResolvePrefix(m[2])
|
|
||||||
if err != nil || seen[full] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[full] = true
|
|
||||||
att := MediaAttachment{Digest: full, MIME: m[1], Description: strings.TrimSpace(m[3])}
|
|
||||||
if it, err := ms.Stat(full); err == nil && it != nil {
|
|
||||||
att.MIME = it.MIME
|
|
||||||
if it.Description != "" {
|
|
||||||
att.Description = it.Description
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out = append(out, att)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- 图记忆 ----------
|
// ---------- 图记忆 ----------
|
||||||
|
|
||||||
type graphMemory struct {
|
type graphMemory struct {
|
||||||
@ -260,10 +169,8 @@ func (m *graphMemory) Recall(query []string, depth int) ([]Entity, []Relation, e
|
|||||||
return entities, relations, nil
|
return entities, relations, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Commit 把插件的三元组写入图库,并把三元组句子里的媒体变成 L3 一等块。
|
// Commit 把插件的三元组写入图库,并把结构化 MediaDigests 变成 L3 一等块。
|
||||||
//
|
// 媒体通过 sentence --contains--> block 结构边挂接,不读写任何正文 marker。
|
||||||
// 媒体的落点链是 SentenceText → sentences 表 → sentence_id → 块边。
|
|
||||||
// 旧实现丢掉 SentenceText 又走 Commit(不回 sentenceIDs),这条链一步都走不通。
|
|
||||||
func (m *graphMemory) Commit(triples []Triple) error {
|
func (m *graphMemory) Commit(triples []Triple) error {
|
||||||
if m.db == nil {
|
if m.db == nil {
|
||||||
return nil
|
return nil
|
||||||
@ -278,9 +185,7 @@ func (m *graphMemory) Commit(triples []Triple) error {
|
|||||||
SubjectType: t.SubjectType,
|
SubjectType: t.SubjectType,
|
||||||
ObjectType: t.ObjectType,
|
ObjectType: t.ObjectType,
|
||||||
SentenceText: t.SentenceText,
|
SentenceText: t.SentenceText,
|
||||||
}
|
MediaDigests: t.MediaDigests,
|
||||||
if len(t.MediaDigests) > 0 {
|
|
||||||
mt.SentenceText = m.sentenceWithMedia(mt.SentenceText, t.MediaDigests)
|
|
||||||
}
|
}
|
||||||
ts = append(ts, mt)
|
ts = append(ts, mt)
|
||||||
}
|
}
|
||||||
@ -289,61 +194,28 @@ func (m *graphMemory) Commit(triples []Triple) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
m.bindSentences(sentenceIDs)
|
m.bindSentences(sentenceIDs, ts)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// sentenceWithMedia 保证句子文本里带有这些 digest 的媒体标记。
|
// bindSentences 把每个三元组显式携带的媒体变成 L3 一等记忆块,
|
||||||
|
// 并以 sentence --contains--> block 结构边关联。
|
||||||
//
|
//
|
||||||
// 让插件填 MediaDigests 就够,不必知道标记格式——否则格式写错的后果是
|
// 不再往句子文本里写 marker、也不再从文本反解 digest:归属由结构化字段直接给出。
|
||||||
// 引用静默挂不上。已出现过的 digest 不重复追加:插件可能既手写了标记又填了
|
func (m *graphMemory) bindSentences(sentenceIDs map[string]int64, triples []memory.Triple) {
|
||||||
// MediaDigests,重复标记会让同一份媒体产生两条一样的句子引用。
|
|
||||||
func (m *graphMemory) sentenceWithMedia(sentence string, digests []string) string {
|
|
||||||
present := sdkDigestsIn(sentence)
|
|
||||||
var add []string
|
|
||||||
for _, d := range digests {
|
|
||||||
if d == "" || present[sdkShortDigest(d)] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if m.ms == nil {
|
|
||||||
// 没有媒体存储时也把 digest 留在文本里:拿不到描述,
|
|
||||||
// 但将来存储可用时这条记忆仍能反查回字节。
|
|
||||||
add = append(add, fmt.Sprintf("[media %s] (未描述)", sdkShortDigest(d)))
|
|
||||||
present[sdkShortDigest(d)] = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
full, err := m.ms.ResolvePrefix(d)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("[sdk media] 插件 %s 提交的 digest %s 无法解析: %v", m.plugin, d, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if line := sdkMarkerFor(m.ms, full); line != "" {
|
|
||||||
add = append(add, line)
|
|
||||||
present[sdkShortDigest(full)] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(add) == 0 {
|
|
||||||
return sentence
|
|
||||||
}
|
|
||||||
if sentence == "" {
|
|
||||||
return strings.Join(add, "\n")
|
|
||||||
}
|
|
||||||
return sentence + "\n" + strings.Join(add, "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// bindSentences 把每条句子里引用的媒体变成 L3 的一等记忆块,
|
|
||||||
// 并建立 sentence --contains--> block 的结构边。
|
|
||||||
// 不再写 media_refs:块本身就是图的一部分,不需要 owner 账本保活。
|
|
||||||
func (m *graphMemory) bindSentences(sentenceIDs map[string]int64) {
|
|
||||||
if m.ms == nil || m.db == nil || len(sentenceIDs) == 0 {
|
if m.ms == nil || m.db == nil || len(sentenceIDs) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
bound := 0
|
bound := 0
|
||||||
for text, sid := range sentenceIDs {
|
for _, t := range triples {
|
||||||
|
if len(t.MediaDigests) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sid := sentenceIDs[t.SentenceText]
|
||||||
if sid == 0 {
|
if sid == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, b := range sdkBlocksFromText(m.ms, text) {
|
for _, b := range sdkBlocksFromDigests(m.ms, t.MediaDigests) {
|
||||||
if err := m.db.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil {
|
if err := m.db.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil {
|
||||||
log.Printf("[sdk media] 插件 %s 写入 L3 记忆块失败: %v", m.plugin, err)
|
log.Printf("[sdk media] 插件 %s 写入 L3 记忆块失败: %v", m.plugin, err)
|
||||||
continue
|
continue
|
||||||
@ -403,37 +275,21 @@ func NewTextMemoryWithMedia(plugin string, tm *text.Memory, ms *media.Store) Tex
|
|||||||
return &textMemoryImpl{tm: tm, ms: ms, plugin: plugin}
|
return &textMemoryImpl{tm: tm, ms: ms, plugin: plugin}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Append 追加一条文本事件;带附件时把媒体标记并进正文。
|
// Append 追加一条文本事件。
|
||||||
//
|
//
|
||||||
// 文本记忆是追加写 JSONL,没有结构化块存储,所以媒体在这一层
|
// 文本记忆是追加写 JSONL 的原始日志,只有字符串字段,没有块容器;
|
||||||
// 只能以标记形式存在。这不是妥协——描述文本才是持久的语义记忆,blob 只是缓存。
|
// 因此附件在这里无法结构化存下。不假装用文本标记承载媒体——
|
||||||
|
// 需要保存媒体请用文档记忆或图记忆(它们持有一等记忆块)。
|
||||||
func (m *textMemoryImpl) Append(evt TextEvent) error {
|
func (m *textMemoryImpl) Append(evt TextEvent) error {
|
||||||
if m.tm == nil {
|
if m.tm == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
content := evt.Content
|
if len(evt.Attachments) > 0 {
|
||||||
if len(evt.Attachments) > 0 && m.ms != nil {
|
log.Printf("[sdk media] 插件 %s 向文本记忆追加了 %d 份附件,已忽略:"+
|
||||||
var lines []string
|
"文本层是字符串日志,不具备块存储;请改用文档/图记忆保存媒体", m.plugin, len(evt.Attachments))
|
||||||
for _, a := range evt.Attachments {
|
|
||||||
d, err := sdkPutAttachment(m.ms, a, "plugin_text:"+m.plugin)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("[sdk media] 插件 %s 文本附件入库失败: %v", m.plugin, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if line := sdkMarkerFor(m.ms, d); line != "" {
|
|
||||||
lines = append(lines, line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(lines) > 0 {
|
|
||||||
if content == "" {
|
|
||||||
content = strings.Join(lines, "\n")
|
|
||||||
} else {
|
|
||||||
content += "\n" + strings.Join(lines, "\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return m.tm.Append(text.Event{
|
return m.tm.Append(text.Event{
|
||||||
Timestamp: evt.Timestamp, Source: evt.Role, Input: content, AgentID: evt.Channel,
|
Timestamp: evt.Timestamp, Source: evt.Role, Input: evt.Content, AgentID: evt.Channel,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -449,7 +305,6 @@ func (m *textMemoryImpl) RecentEvents(n int) ([]TextEvent, error) {
|
|||||||
for i, e := range got {
|
for i, e := range got {
|
||||||
out[i] = TextEvent{
|
out[i] = TextEvent{
|
||||||
Role: e.Source, Content: e.Input, Timestamp: e.Timestamp, Channel: e.AgentID,
|
Role: e.Source, Content: e.Input, Timestamp: e.Timestamp, Channel: e.AgentID,
|
||||||
Attachments: sdkAttachmentsFromText(m.ms, e.Input),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
@ -477,12 +332,8 @@ func NewDocMemoryWithMedia(plugin string, ds *doc.Store, ms *media.Store) DocMem
|
|||||||
return &docMemoryImpl{ds: ds, ms: ms, plugin: plugin}
|
return &docMemoryImpl{ds: ds, ms: ms, plugin: plugin}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Query 检索文档,并补齐媒体元数据。
|
// Query 检索文档,并从文档持有的一等块补齐媒体元数据。
|
||||||
//
|
// 只返回 digest/MIME,不返回字节或生成式描述;需要字节时按 digest 单取。
|
||||||
// 旧实现只回 ID/Title/Content,插件即便拿到一篇带媒体的文档也看不出这里有
|
|
||||||
// 几份媒体、分别是什么。现在同时给出完整 digest 列表与 mime+描述,
|
|
||||||
// 但**不回字节**:一次检索可能命中几十份媒体,全塞回去会把跨进程消息撑爆,
|
|
||||||
// 需要字节时按 digest 单取。
|
|
||||||
func (m *docMemoryImpl) Query(text string, topK int) []*Doc {
|
func (m *docMemoryImpl) Query(text string, topK int) []*Doc {
|
||||||
if m.ds == nil {
|
if m.ds == nil {
|
||||||
return nil
|
return nil
|
||||||
@ -498,30 +349,22 @@ func (m *docMemoryImpl) Query(text string, topK int) []*Doc {
|
|||||||
|
|
||||||
// fillMedia 填充文档的媒体字段。
|
// fillMedia 填充文档的媒体字段。
|
||||||
//
|
//
|
||||||
// 优先读一等记忆块(文档直接持有),为空时退回解析正文标记——
|
// 唯一的来源是文档直接持有的一等记忆块:媒体不靠正文标记、
|
||||||
// 历史文档与经旧版插件写入的文档只有标记、没有块。
|
// 也不靠任何生成的描述文本。CAS 只提供 MIME 等元数据。
|
||||||
func (m *docMemoryImpl) fillMedia(out *Doc, d *doc.Doc) {
|
func (m *docMemoryImpl) fillMedia(out *Doc, d *doc.Doc) {
|
||||||
if m.ms == nil {
|
if m.ms == nil || d == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if d != nil && len(d.Blocks) > 0 {
|
for _, b := range d.Blocks {
|
||||||
for _, b := range d.Blocks {
|
if b.PayloadDigest == "" {
|
||||||
if b.PayloadDigest == "" {
|
continue
|
||||||
continue
|
|
||||||
}
|
|
||||||
out.MediaDigests = append(out.MediaDigests, b.PayloadDigest)
|
|
||||||
att := MediaAttachment{Digest: b.PayloadDigest, MIME: b.MIME, Description: ""}
|
|
||||||
if it, err := m.ms.Stat(b.PayloadDigest); err == nil && it != nil {
|
|
||||||
att.MIME = it.MIME
|
|
||||||
att.Description = it.Description
|
|
||||||
}
|
|
||||||
out.Attachments = append(out.Attachments, att)
|
|
||||||
}
|
}
|
||||||
return
|
out.MediaDigests = append(out.MediaDigests, b.PayloadDigest)
|
||||||
}
|
att := MediaAttachment{Digest: b.PayloadDigest, MIME: b.MIME}
|
||||||
out.Attachments = sdkAttachmentsFromText(m.ms, out.Content)
|
if it, err := m.ms.Stat(b.PayloadDigest); err == nil && it != nil {
|
||||||
for _, a := range out.Attachments {
|
att.MIME = it.MIME
|
||||||
out.MediaDigests = append(out.MediaDigests, a.Digest)
|
}
|
||||||
|
out.Attachments = append(out.Attachments, att)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -543,15 +386,14 @@ func appendBlocks(existing []memory.MemoryBlock, add []memory.MemoryBlock) []mem
|
|||||||
return existing
|
return existing
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert 写入文档。正文里已有的媒体标记会被挂成文档级引用,
|
// Insert 写入文档。
|
||||||
// 避免插件写进来的媒体在下一次 GC 时被当作无主内容清掉。
|
|
||||||
func (m *docMemoryImpl) Insert(d *Doc) error { return m.InsertWithMedia(d, nil) }
|
func (m *docMemoryImpl) Insert(d *Doc) error { return m.InsertWithMedia(d, nil) }
|
||||||
|
|
||||||
// InsertWithMedia 写入文档并关联媒体。
|
// InsertWithMedia 写入文档并关联媒体。
|
||||||
//
|
//
|
||||||
// 标记由内核补进 Content——插件不必知道标记格式,也就不会因为格式写错导致
|
// 媒体直接成为文档持有的一等记忆块:落进 CAS 拿到 digest,
|
||||||
// 引用挂不上。补标记必须在 ds.Insert 之前完成:向量索引用 Summary+Content
|
// 再变成块挂到文档上。不往正文写 marker——文档向量会融合这些块的
|
||||||
// 计算,标记进不去正文就检索不到这份媒体。
|
// 媒体向量(同一统一空间),图片按自己的向量被召回。
|
||||||
func (m *docMemoryImpl) InsertWithMedia(d *Doc, attachments []MediaAttachment) error {
|
func (m *docMemoryImpl) InsertWithMedia(d *Doc, attachments []MediaAttachment) error {
|
||||||
if m.ds == nil || d == nil {
|
if m.ds == nil || d == nil {
|
||||||
return nil
|
return nil
|
||||||
@ -561,29 +403,26 @@ func (m *docMemoryImpl) InsertWithMedia(d *Doc, attachments []MediaAttachment) e
|
|||||||
target.Source = "plugin:" + m.plugin
|
target.Source = "plugin:" + m.plugin
|
||||||
}
|
}
|
||||||
|
|
||||||
digests := m.storeAttachments(attachments, &target.Content)
|
digests := m.storeAttachments(attachments)
|
||||||
|
|
||||||
// 一等记忆块:文档直接持有块本身,CAS 只提供字节与向量。
|
// 一等记忆块:文档直接持有块本身,CAS 只提供字节与向量。
|
||||||
// 不再写 media_refs——块随文档一同存活或被删除,无需 owner 账本。
|
target.Blocks = appendBlocks(target.Blocks, sdkBlocksFromDigests(m.ms, digests))
|
||||||
target.Blocks = appendBlocks(target.Blocks,
|
|
||||||
append(sdkBlocksFromDigests(m.ms, digests), sdkBlocksFromText(m.ms, target.Content)...))
|
|
||||||
|
|
||||||
if err := m.ds.Insert(target); err != nil {
|
if err := m.ds.Insert(target); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// 回填给调用方:ID 是新建时内核生成的,Content 含内核补的标记。
|
// 回填给调用方:ID 是新建时内核生成的。
|
||||||
d.ID = target.ID
|
d.ID = target.ID
|
||||||
d.Content = target.Content
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// storeAttachments 把附件落库并把标记追加进 content,返回全部完整 digest。
|
// storeAttachments 把附件落库,返回全部完整 digest。
|
||||||
func (m *docMemoryImpl) storeAttachments(atts []MediaAttachment, content *string) []string {
|
func (m *docMemoryImpl) storeAttachments(atts []MediaAttachment) []string {
|
||||||
if m.ms == nil || len(atts) == 0 {
|
if m.ms == nil || len(atts) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
present := sdkDigestsIn(*content)
|
seen := map[string]bool{}
|
||||||
var digests, lines []string
|
var digests []string
|
||||||
for _, a := range atts {
|
for _, a := range atts {
|
||||||
full, err := sdkPutAttachment(m.ms, a, "plugin_doc:"+m.plugin)
|
full, err := sdkPutAttachment(m.ms, a, "plugin_doc:"+m.plugin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -591,21 +430,11 @@ func (m *docMemoryImpl) storeAttachments(atts []MediaAttachment, content *string
|
|||||||
log.Printf("[sdk media] 插件 %s 文档附件入库失败: %v", m.plugin, err)
|
log.Printf("[sdk media] 插件 %s 文档附件入库失败: %v", m.plugin, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if seen[full] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[full] = true
|
||||||
digests = append(digests, full)
|
digests = append(digests, full)
|
||||||
if present[sdkShortDigest(full)] {
|
|
||||||
continue // 插件自己写了标记,不重复追加
|
|
||||||
}
|
|
||||||
present[sdkShortDigest(full)] = true
|
|
||||||
if line := sdkMarkerFor(m.ms, full); line != "" {
|
|
||||||
lines = append(lines, line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(lines) > 0 {
|
|
||||||
if *content == "" {
|
|
||||||
*content = strings.Join(lines, "\n")
|
|
||||||
} else {
|
|
||||||
*content += "\n" + strings.Join(lines, "\n")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return digests
|
return digests
|
||||||
}
|
}
|
||||||
|
|||||||
@ -51,10 +51,10 @@ func newTestStores(t *testing.T) (*memory.GraphDB, *doc.Store, *text.Memory, *me
|
|||||||
return g, ds, tm, ms
|
return g, ds, tm, ms
|
||||||
}
|
}
|
||||||
|
|
||||||
// putDescribed 存一份带描述的媒体,返回完整 digest。
|
// putMedia 存一份媒体,返回完整 digest。
|
||||||
func putDescribed(t *testing.T, ms *media.Store, payload, desc string) string {
|
func putMedia(t *testing.T, ms *media.Store, payload string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
d, err := ms.Put([]byte(payload), media.Item{MIME: "image/png", Description: desc})
|
d, err := ms.Put([]byte(payload), media.Item{MIME: "image/png"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("media.Put: %v", err)
|
t.Fatalf("media.Put: %v", err)
|
||||||
}
|
}
|
||||||
@ -111,16 +111,17 @@ func TestGraphCommit_CarriesAllFields(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 插件只给 digest,标记与句子由内核合成;引用必须挂到 graph_sentence owner 上。
|
// 插件只给 digest,句子由内核合成;块必须挂到该句子(sentence --contains--> block)。
|
||||||
func TestGraphCommit_BindsMediaFromDigests(t *testing.T) {
|
func TestGraphCommit_BindsMediaFromDigests(t *testing.T) {
|
||||||
g, _, _, ms := newTestStores(t)
|
g, _, _, ms := newTestStores(t)
|
||||||
digest := putDescribed(t, ms, "png-bytes", "一张紫蓝红三色带图")
|
digest := putMedia(t, ms, "png-bytes")
|
||||||
|
|
||||||
m := NewGraphMemoryWithMedia("tester", g, ms)
|
m := NewGraphMemoryWithMedia("tester", g, ms)
|
||||||
if err := m.Commit([]Triple{{
|
if err := m.Commit([]Triple{{
|
||||||
Subject: "配色图",
|
Subject: "配色图",
|
||||||
Relation: "包含",
|
Relation: "包含",
|
||||||
Object: "三色带",
|
Object: "三色带",
|
||||||
|
SentenceText: "这张图是紫蓝红三色带。",
|
||||||
MediaDigests: []string{digest[:12]}, // 插件手里通常只有短 digest
|
MediaDigests: []string{digest[:12]}, // 插件手里通常只有短 digest
|
||||||
}}); err != nil {
|
}}); err != nil {
|
||||||
t.Fatalf("Commit: %v", err)
|
t.Fatalf("Commit: %v", err)
|
||||||
@ -131,13 +132,13 @@ func TestGraphCommit_BindsMediaFromDigests(t *testing.T) {
|
|||||||
t.Fatalf("Recall: %v", err)
|
t.Fatalf("Recall: %v", err)
|
||||||
}
|
}
|
||||||
if len(res.Relations) == 0 || res.Relations[0].SentenceID == 0 {
|
if len(res.Relations) == 0 || res.Relations[0].SentenceID == 0 {
|
||||||
t.Fatal("没有句子落点 —— 媒体引用无从挂起")
|
t.Fatal("没有句子落点 —— 媒体块无从挂接")
|
||||||
}
|
}
|
||||||
sid := res.Relations[0].SentenceID
|
sid := res.Relations[0].SentenceID
|
||||||
|
|
||||||
// 描述必须进句子:描述文本才是持久语义记忆,检索靠它。
|
// 句子文本保持原样:不再往正文里贴媒体标记。
|
||||||
if !strings.Contains(res.Relations[0].SentenceText, "三色带图") {
|
if strings.Contains(res.Relations[0].SentenceText, digest[:12]) {
|
||||||
t.Errorf("句子里没有媒体描述: %q", res.Relations[0].SentenceText)
|
t.Errorf("句子文本不该被媒体标记污染: %q", res.Relations[0].SentenceText)
|
||||||
}
|
}
|
||||||
|
|
||||||
blocks, err := g.BlocksForNode("sentence", strconv.FormatInt(sid, 10))
|
blocks, err := g.BlocksForNode("sentence", strconv.FormatInt(sid, 10))
|
||||||
@ -149,19 +150,18 @@ func TestGraphCommit_BindsMediaFromDigests(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 插件自己按格式写了标记又同时填了 MediaDigests,不能产生两条重复引用/两份标记。
|
// 同一个 digest 在同一三元组里重复出现(短/完整混写)时只能建一个块。
|
||||||
func TestGraphCommit_NoDuplicateMarker(t *testing.T) {
|
func TestGraphCommit_DedupesRepeatedDigest(t *testing.T) {
|
||||||
g, _, _, ms := newTestStores(t)
|
g, _, _, ms := newTestStores(t)
|
||||||
digest := putDescribed(t, ms, "dup-bytes", "重复标记测试图")
|
digest := putMedia(t, ms, "dup-bytes")
|
||||||
short := digest[:12]
|
|
||||||
|
|
||||||
m := NewGraphMemoryWithMedia("tester", g, ms)
|
m := NewGraphMemoryWithMedia("tester", g, ms)
|
||||||
if err := m.Commit([]Triple{{
|
if err := m.Commit([]Triple{{
|
||||||
Subject: "重复图",
|
Subject: "重复图",
|
||||||
Relation: "标记",
|
Relation: "标记",
|
||||||
Object: "一次",
|
Object: "一次",
|
||||||
SentenceText: "看这个 [image/png " + short + "] 重复标记测试图",
|
SentenceText: "同一张图说了两遍。",
|
||||||
MediaDigests: []string{short},
|
MediaDigests: []string{digest[:12], digest},
|
||||||
}}); err != nil {
|
}}); err != nil {
|
||||||
t.Fatalf("Commit: %v", err)
|
t.Fatalf("Commit: %v", err)
|
||||||
}
|
}
|
||||||
@ -170,12 +170,16 @@ func TestGraphCommit_NoDuplicateMarker(t *testing.T) {
|
|||||||
if len(res.Relations) == 0 {
|
if len(res.Relations) == 0 {
|
||||||
t.Fatal("召回不到关系")
|
t.Fatal("召回不到关系")
|
||||||
}
|
}
|
||||||
if n := strings.Count(res.Relations[0].SentenceText, short); n != 1 {
|
blocks, err := g.BlocksForNode("sentence", strconv.FormatInt(res.Relations[0].SentenceID, 10))
|
||||||
t.Errorf("句子里出现 %d 次 digest,期望 1 次: %q", n, res.Relations[0].SentenceText)
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(blocks) != 1 {
|
||||||
|
t.Fatalf("同一 digest 应只产生 1 个块,实际 %d: %+v", len(blocks), blocks)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// mediaStore 为 nil 时仍要能提交(媒体是增强,不是必需品),digest 留在文本里备查。
|
// mediaStore 为 nil 时仍要能提交(媒体是增强,不是必需品);只是不会建块。
|
||||||
func TestGraphCommit_NilMediaStoreDegrades(t *testing.T) {
|
func TestGraphCommit_NilMediaStoreDegrades(t *testing.T) {
|
||||||
g, _, _, _ := newTestStores(t)
|
g, _, _, _ := newTestStores(t)
|
||||||
m := NewGraphMemory(g)
|
m := NewGraphMemory(g)
|
||||||
@ -184,6 +188,7 @@ func TestGraphCommit_NilMediaStoreDegrades(t *testing.T) {
|
|||||||
Subject: "无存储",
|
Subject: "无存储",
|
||||||
Relation: "仍可",
|
Relation: "仍可",
|
||||||
Object: "提交",
|
Object: "提交",
|
||||||
|
SentenceText: "无媒体存储时的句子。",
|
||||||
MediaDigests: []string{"aabbccddeeff"},
|
MediaDigests: []string{"aabbccddeeff"},
|
||||||
}}); err != nil {
|
}}); err != nil {
|
||||||
t.Fatalf("Commit 在无媒体存储时不该失败: %v", err)
|
t.Fatalf("Commit 在无媒体存储时不该失败: %v", err)
|
||||||
@ -193,8 +198,12 @@ func TestGraphCommit_NilMediaStoreDegrades(t *testing.T) {
|
|||||||
if len(res.Relations) == 0 {
|
if len(res.Relations) == 0 {
|
||||||
t.Fatal("召回不到关系")
|
t.Fatal("召回不到关系")
|
||||||
}
|
}
|
||||||
if !strings.Contains(res.Relations[0].SentenceText, "aabbccddeeff") {
|
blocks, err := g.BlocksForNode("sentence", strconv.FormatInt(res.Relations[0].SentenceID, 10))
|
||||||
t.Errorf("digest 应留在句子里以备将来反查: %q", res.Relations[0].SentenceText)
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(blocks) != 0 {
|
||||||
|
t.Fatalf("无媒体存储时不该建块,实际 %+v", blocks)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -225,17 +234,16 @@ func TestGraphRecall_CarriesConfidence(t *testing.T) {
|
|||||||
|
|
||||||
// ---------- 文档记忆(知识库) ----------
|
// ---------- 文档记忆(知识库) ----------
|
||||||
|
|
||||||
// 附件带 Data → 落进 CAS、标记补进正文、引用挂到文档 owner。
|
// 附件带 Data → 落进 CAS 并成为文档直接持有的一等块。
|
||||||
func TestDocInsertWithMedia_StoresAndBinds(t *testing.T) {
|
func TestDocInsertWithMedia_StoresAndBinds(t *testing.T) {
|
||||||
_, ds, _, ms := newTestStores(t)
|
_, ds, _, ms := newTestStores(t)
|
||||||
dm := NewDocMemoryWithMedia("tester", ds, ms)
|
dm := NewDocMemoryWithMedia("tester", ds, ms)
|
||||||
|
|
||||||
d := &Doc{Title: "带图笔记", Content: "这是正文。"}
|
d := &Doc{Title: "带图笔记", Content: "这是正文。"}
|
||||||
err := dm.InsertWithMedia(d, []MediaAttachment{{
|
err := dm.InsertWithMedia(d, []MediaAttachment{{
|
||||||
MIME: "image/png",
|
MIME: "image/png",
|
||||||
Data: []byte("attachment-bytes"),
|
Data: []byte("attachment-bytes"),
|
||||||
Name: "chart.png",
|
Name: "chart.png",
|
||||||
Description: "一张柱状图",
|
|
||||||
}})
|
}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("InsertWithMedia: %v", err)
|
t.Fatalf("InsertWithMedia: %v", err)
|
||||||
@ -244,10 +252,9 @@ func TestDocInsertWithMedia_StoresAndBinds(t *testing.T) {
|
|||||||
t.Fatal("ID 未回填 —— 插件拿不到刚写入文档的 id")
|
t.Fatal("ID 未回填 —— 插件拿不到刚写入文档的 id")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 标记必须进正文:向量索引用 Summary+Content 计算,
|
// 正文保持原样:不再往 Content 里拼任何媒体标记。
|
||||||
// 标记进不去正文就永远检索不到这份媒体。
|
if strings.Contains(d.Content, "image/png") {
|
||||||
if !strings.Contains(d.Content, "柱状图") {
|
t.Errorf("正文不该被媒体标记污染: %q", d.Content)
|
||||||
t.Errorf("正文里没有媒体标记: %q", d.Content)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
blocks := ds.Blocks()
|
blocks := ds.Blocks()
|
||||||
@ -264,7 +271,7 @@ func TestDocInsertWithMedia_StoresAndBinds(t *testing.T) {
|
|||||||
// 只给 Digest 的附件是「引用已有内容」,不该报错也不该重复落盘。
|
// 只给 Digest 的附件是「引用已有内容」,不该报错也不该重复落盘。
|
||||||
func TestDocInsertWithMedia_DigestOnlyReference(t *testing.T) {
|
func TestDocInsertWithMedia_DigestOnlyReference(t *testing.T) {
|
||||||
_, ds, _, ms := newTestStores(t)
|
_, ds, _, ms := newTestStores(t)
|
||||||
digest := putDescribed(t, ms, "existing", "已有的图")
|
digest := putMedia(t, ms, "existing")
|
||||||
before := ms.Stats()["count"]
|
before := ms.Stats()["count"]
|
||||||
|
|
||||||
dm := NewDocMemoryWithMedia("tester", ds, ms)
|
dm := NewDocMemoryWithMedia("tester", ds, ms)
|
||||||
@ -290,7 +297,7 @@ func TestDocQuery_FillsMediaMetadataWithoutBytes(t *testing.T) {
|
|||||||
|
|
||||||
d := &Doc{Title: "紫蓝红三色带", Content: "配色说明"}
|
d := &Doc{Title: "紫蓝红三色带", Content: "配色说明"}
|
||||||
if err := dm.InsertWithMedia(d, []MediaAttachment{{
|
if err := dm.InsertWithMedia(d, []MediaAttachment{{
|
||||||
MIME: "image/png", Data: []byte("query-bytes"), Description: "三色带图",
|
MIME: "image/png", Data: []byte("query-bytes"),
|
||||||
}}); err != nil {
|
}}); err != nil {
|
||||||
t.Fatalf("InsertWithMedia: %v", err)
|
t.Fatalf("InsertWithMedia: %v", err)
|
||||||
}
|
}
|
||||||
@ -315,38 +322,14 @@ func TestDocQuery_FillsMediaMetadataWithoutBytes(t *testing.T) {
|
|||||||
t.Fatalf("Attachments = %v,期望 1 条", hit.Attachments)
|
t.Fatalf("Attachments = %v,期望 1 条", hit.Attachments)
|
||||||
}
|
}
|
||||||
att := hit.Attachments[0]
|
att := hit.Attachments[0]
|
||||||
if att.MIME != "image/png" || att.Description != "三色带图" {
|
if att.MIME != "image/png" || att.Digest != hit.MediaDigests[0] {
|
||||||
t.Errorf("附件元数据 = %+v,期望 mime=image/png desc=三色带图", att)
|
t.Errorf("附件元数据 = %+v,期望 mime=image/png 且 digest 与 MediaDigests 一致", att)
|
||||||
}
|
}
|
||||||
if len(att.Data) != 0 {
|
if len(att.Data) != 0 {
|
||||||
t.Errorf("Attachments 不该带字节(%d 字节)—— 需要时按 digest 单取", len(att.Data))
|
t.Errorf("Attachments 不该带字节(%d 字节)—— 需要时按 digest 单取", len(att.Data))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 历史文档只有标记、没有 media_refs(旧版插件写入的)。
|
|
||||||
// 此时要能从正文标记反解出附件,否则那些文档的媒体对插件永远不可见。
|
|
||||||
func TestDocQuery_FallsBackToMarkers(t *testing.T) {
|
|
||||||
_, ds, _, ms := newTestStores(t)
|
|
||||||
digest := putDescribed(t, ms, "legacy", "历史图片")
|
|
||||||
|
|
||||||
// 直接写底层 store,绕过 SDK 的绑定逻辑,模拟历史数据。
|
|
||||||
if err := ds.Insert(&doc.Doc{
|
|
||||||
Summary: "历史文档",
|
|
||||||
Content: "旧正文 [image/png " + digest[:12] + "] 历史图片",
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("Insert: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
dm := NewDocMemoryWithMedia("tester", ds, ms)
|
|
||||||
got := dm.Query("历史文档 旧正文", 3)
|
|
||||||
if len(got) == 0 {
|
|
||||||
t.Fatal("检索不到历史文档")
|
|
||||||
}
|
|
||||||
if len(got[0].MediaDigests) != 1 || got[0].MediaDigests[0] != digest {
|
|
||||||
t.Errorf("MediaDigests = %v,期望从标记反解出 [%s]", got[0].MediaDigests, digest)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 文档被删除时它持有的一等记忆块随之消失,媒体不再被任何记忆块持有。
|
// 文档被删除时它持有的一等记忆块随之消失,媒体不再被任何记忆块持有。
|
||||||
func TestDocRemove_DropsBlocks(t *testing.T) {
|
func TestDocRemove_DropsBlocks(t *testing.T) {
|
||||||
_, ds, _, ms := newTestStores(t)
|
_, ds, _, ms := newTestStores(t)
|
||||||
@ -354,7 +337,7 @@ func TestDocRemove_DropsBlocks(t *testing.T) {
|
|||||||
|
|
||||||
d := &Doc{Title: "待删除", Content: "正文"}
|
d := &Doc{Title: "待删除", Content: "正文"}
|
||||||
if err := dm.InsertWithMedia(d, []MediaAttachment{{
|
if err := dm.InsertWithMedia(d, []MediaAttachment{{
|
||||||
MIME: "image/png", Data: []byte("to-be-freed"), Description: "会被释放的图",
|
MIME: "image/png", Data: []byte("to-be-freed"),
|
||||||
}}); err != nil {
|
}}); err != nil {
|
||||||
t.Fatalf("InsertWithMedia: %v", err)
|
t.Fatalf("InsertWithMedia: %v", err)
|
||||||
}
|
}
|
||||||
@ -395,9 +378,10 @@ func TestDocMemory_NilMediaStoreDegrades(t *testing.T) {
|
|||||||
|
|
||||||
// ---------- 文本记忆 ----------
|
// ---------- 文本记忆 ----------
|
||||||
|
|
||||||
// 文本记忆是追加写 JSONL,没有稳定 owner_id 可挂引用,
|
// 文本记忆是追加写 JSONL 的字符串日志,没有块容器。
|
||||||
// 媒体只能以标记形式留在正文里;读回时要能反解成结构化附件。
|
// 它不会愄造文本标记来承载媒体:附件被明确忽略并记录日志,
|
||||||
func TestTextMemory_AttachmentRoundTrip(t *testing.T) {
|
// 需要保存媒体请用文档/图记忆。
|
||||||
|
func TestTextMemory_AttachmentsIgnoredNotFaked(t *testing.T) {
|
||||||
_, _, tm, ms := newTestStores(t)
|
_, _, tm, ms := newTestStores(t)
|
||||||
m := NewTextMemoryWithMedia("tester", tm, ms)
|
m := NewTextMemoryWithMedia("tester", tm, ms)
|
||||||
|
|
||||||
@ -405,7 +389,7 @@ func TestTextMemory_AttachmentRoundTrip(t *testing.T) {
|
|||||||
Role: "user",
|
Role: "user",
|
||||||
Content: "看这张图",
|
Content: "看这张图",
|
||||||
Attachments: []MediaAttachment{{
|
Attachments: []MediaAttachment{{
|
||||||
MIME: "image/png", Data: []byte("text-mem-bytes"), Description: "文本记忆里的图",
|
MIME: "image/png", Data: []byte("text-mem-bytes"),
|
||||||
}},
|
}},
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("Append: %v", err)
|
t.Fatalf("Append: %v", err)
|
||||||
@ -419,13 +403,10 @@ func TestTextMemory_AttachmentRoundTrip(t *testing.T) {
|
|||||||
t.Fatal("读不到刚追加的事件")
|
t.Fatal("读不到刚追加的事件")
|
||||||
}
|
}
|
||||||
last := got[len(got)-1]
|
last := got[len(got)-1]
|
||||||
if !strings.Contains(last.Content, "文本记忆里的图") {
|
if last.Content != "看这张图" {
|
||||||
t.Errorf("正文里没有媒体标记: %q", last.Content)
|
t.Errorf("正文应保持原样,实际 %q", last.Content)
|
||||||
}
|
}
|
||||||
if len(last.Attachments) != 1 {
|
if len(last.Attachments) != 0 {
|
||||||
t.Fatalf("Attachments = %+v,期望 1 条(标记应能反解)", last.Attachments)
|
t.Errorf("文本层不该凭空造出附件(没有块存储可挂): %+v", last.Attachments)
|
||||||
}
|
|
||||||
if last.Attachments[0].Description != "文本记忆里的图" {
|
|
||||||
t.Errorf("附件描述 = %q", last.Attachments[0].Description)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -63,7 +63,7 @@ func (v *VirtualInstance) initLocked() error {
|
|||||||
}
|
}
|
||||||
v.Knowledge = NewKnowledge(ks)
|
v.Knowledge = NewKnowledge(ks)
|
||||||
|
|
||||||
ds := doc.NewStore(filepath.Join(v.dir, "documents"))
|
ds := doc.NewStore(filepath.Join(v.dir, "documents"), memory.TokenizeWords)
|
||||||
if err := ds.Start(); err != nil {
|
if err := ds.Start(); err != nil {
|
||||||
return fmt.Errorf("virtual doc store: %w", err)
|
return fmt.Errorf("virtual doc store: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,10 +16,10 @@ type SupervisorAPI interface {
|
|||||||
|
|
||||||
// AgentStatus is a neutral snapshot of a supervised agent.
|
// AgentStatus is a neutral snapshot of a supervised agent.
|
||||||
type AgentStatus struct {
|
type AgentStatus struct {
|
||||||
ID types.AgentID `json:"id"`
|
ID types.AgentID `json:"id"`
|
||||||
State types.AgentState `json:"state"`
|
State types.AgentState `json:"state"`
|
||||||
Health types.HealthStatus `json:"health"`
|
Health types.HealthStatus `json:"health"`
|
||||||
Uptime time.Duration `json:"uptime,omitempty"`
|
Uptime time.Duration `json:"uptime,omitempty"`
|
||||||
Network types.NetworkCheckResult `json:"network,omitempty"`
|
Network types.NetworkCheckResult `json:"network,omitempty"`
|
||||||
TrackerStats map[string]interface{} `json:"tracker_stats,omitempty"`
|
TrackerStats map[string]interface{} `json:"tracker_stats,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
306
internal/tfidf/tfidf.go
Normal file
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