mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 09:28:14 +00:00
记忆系统在 1.1.0 支持了二进制多媒体节点,但那条链路只对**内核自己**开放: 用户在 qq 发图能落进 CAS、能被记忆引用,而插件调 Commit / DocMemory().Insert 交进来的媒体一律无处安放。原因是三层都断着,且**每一层都不报错**。 ## 一、公开 SDK:补上媒体的表达能力(全部新增,无签名变更) - `Triple` += `SentenceText`、`MediaDigests` - `Doc` += `MediaDigests`、`Attachments`;新增 `MediaAttachment` - `TextEvent` += `Attachments` - `DocMemoryAPI` += `InsertWithMedia` - `IOInjector` += `InjectInputMedia` / `InjectInputMediaSync` / `InjectInterruptMedia` - `PluginSDK` 补上一直缺失的 `SetToolBlocks` 包装(接口里有、便捷方法里没有) `MediaAttachment` 一个类型服务两个方向:给 `Data`+`MIME` 是新内容(CAS 按字节 去重),只给 `Digest` 是引用已有内容。读路径**只回元数据不回字节**——一次检索 可能命中几十份媒体,全塞回去会把跨进程消息撑爆。 媒体注入不能搭 `SetToolBlocks` 的车:那个方法只在工具处理函数内部可用,且媒体 要等下一条 tool message 才到模型手上。插件主动发起一轮带媒体的对话、以及中断 注入,需要自己的签名,且媒体在**本轮**就送到模型。 ## 二、内核桥接层:原先在静默裁字段 `internal/sdk/memory_impl.go` 此前只搬自己认识的几个字段,其余丢弃且返回 nil: - 图记忆丢 `Confidence`/`SubjectType`/`ObjectType`/`SentenceText`,又走 `Commit` 而非 `CommitWithMedia`(不回 sentenceIDs)→ 媒体绑定链 `SentenceText → sentences → sentence_id → media_refs` 一步都走不通,插件即便按格式写好标记也永远挂不上; - 知识库 `Query` 只回 ID/Title/Content,`Insert` 只写这三个;`Remove` 不解引用, 于是那些媒体永久处于「被引用」状态,GC 收不掉、磁盘只增不减 (内核的归档路径 `releaseDocMedia` 做了这一步,插件路径漏了同一步)。 规则改为:**内部结构有的字段一律透传**。标记格式处理作为包级私有辅助留在桥接 层自己手里,但必须与内核 `mediaSummaryForEvent` 字节兼容——两边要能互读对方 写下的标记。 标记插入必须在 `ds.Insert` **之前**(向量索引取 `Summary + " " + Content`, 之后补的标记检索不到),引用绑定必须在**之后**(owner_id 是 Insert 生成的 ID)。 ## 三、跨进程链路:不接线就是全体外部插件编译失败 `go test` 直接把这一层拍出来了——`procIO does not implement sdk.IOInjector`。 公开接口加方法后,生成模板不跟上,**每个外部插件都编不过**,是硬失败不是软降级。 六处接线:`protocol.go` 四个 method 常量、`capability.go` 能力归属、 `corehandler.go` 四个分派分支、`proc_core.go` 委托、`proc_main.go.tmpl` 模板侧 实现、以及三个测试替身。 ## 四、统一输入主干:把模态从「函数选择」降级为「字段」 `processTextInput` / `processMediaInput` 合并为 `processInput`。这个分叉是历史 产物而非设计:`processTextInput` 本来就处理媒体(`bindEventMedia` + `mediaSummaryForEvent`,与媒体路径尾部完全相同),`process()` 只看 `stageCtx.Extra["media_blocks"]`、根本不认识 `evt.Type`。模态是输入的**属性**, 不是输入的**种类**。 媒体路径由此获得它一直缺的六项:去重、`no_memory`、通道 `Cleaner`、中断语义、 `_consolidation_` 路由、正确的 `EventRawInput`。 最后一项是个真 bug:媒体路径发布 `"content": evt.Payload`(一个 map),而 `webui/handler.go` 断言 `.(string)` → 断言失败、`content == ""`、提前返回。 **用户发的图从来没出现在 WebUI 聊天记录里。** `media_blocks` 同时接受 `[]agentAPI.ContentBlock` 与 `[]pubsdk.ContentBlock`: 字段一致但 Go 不自动转换,只认一种的后果是另一种被静默丢弃。 ## 五、模型可调用的三个工具 `memory_commit` 的 `sentence_text` **从未暴露给模型**,而它是绑定链上的必经环节; 连同 `media_digests` 一起补进 JSON schema 与工具文档。`doc_commit` 加 `media_digests`。`doc_query` 把关联媒体单独一行附在结果末尾(正文按 2000 字截断, 标记通常就在尾部)。 标记由**内核**生成而非插件/模型拼装:要求调用方知道格式,等于让一个拼写错误 静默切断引用绑定,而全链路无人报错。 ## 六、WebUI 上传走真实媒体链路 图片/音频读回字节拼 data URL 注入 `media_blocks`(8MB 上限,超限退回按路径处理)。 此前只注入一句「文件已保存到 <路径>」,指望模型自己调 `files_read`——但那返回 文本,图片字节对模型永远不可见。附件类型识别扩展到 audio 并在缺 Content-Type 时按扩展名兜底(判错不只是卡片样式问题,图片被当普通文件就进不了视觉链路)。 ## 测试 - `internal/sdk/memory_impl_test.go`(12 例,此前该包**没有任何测试文件**) - `internal/agent/core/inputunify_test.go`(统一主干 + 双静态类型 + 三工具媒体) - `third_party/homeagent-sdk/sdk/stress_test.go`(13 例并发压测) 压测抓到两处**真**竞态(不是理论风险):`PluginSDK` 的 API 字段与 `autoRestart` 无锁,而写方(内核注入 API、插件 `SetAutoRestart`)与读方(插件后台 goroutine 注入、内核 registry 读 `AutoRestart`)天然跨 goroutine。加 `apiMu` 修掉;约定 只在持锁期间取字段值,取完即释放再调用——持锁调用会把 `InjectInputSync` 这类 阻塞到 agent 回复(可达数分钟)的方法与 `SetIOInjector` 串起来,让插件重载卡死。 测试还抓出两个自身缺陷:`bindDocMedia` 把同一份媒体数两次(`AddRef` 幂等所以表 是对的,但日志说「绑定 2 个」而实际 1 条——误导后续排查),以及用单字符实体名 时 `validEntityName` 静默跳过、`Commit` 返回 nil 却什么都没写。 存量插件不需要改一行也不需要重编:新增方法由插件调用、内核实现,不调就不受影响。 17 个 example 插件源码零改动通过类型检查。
435 lines
14 KiB
Go
435 lines
14 KiB
Go
package sdk
|
||
|
||
import (
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"testing"
|
||
|
||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||
doc "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
|
||
)
|
||
|
||
// 插件边界的媒体透传测试。
|
||
//
|
||
// 断言的都是不变量,而不是"函数被调过":
|
||
// 1. 插件填的字段一个都不许丢(旧实现静默裁掉 Confidence/类型/SentenceText);
|
||
// 2. 插件不必知道媒体标记格式,内核负责补;
|
||
// 3. 媒体引用挂到正确的 owner 上,删除时释放;
|
||
// 4. mediaStore 为 nil 时整条链路退化成纯文本,不 panic 不报错。
|
||
|
||
func newTestStores(t *testing.T) (*memory.GraphDB, *doc.Store, *text.Memory, *media.Store) {
|
||
t.Helper()
|
||
dir := t.TempDir()
|
||
|
||
g, err := memory.NewGraphDB(filepath.Join(dir, "graph.db"))
|
||
if err != nil {
|
||
t.Fatalf("NewGraphDB: %v", err)
|
||
}
|
||
t.Cleanup(func() { g.Close() })
|
||
|
||
ds := doc.NewStore(filepath.Join(dir, "documents"))
|
||
if err := ds.Start(); err != nil {
|
||
t.Fatalf("doc store start: %v", err)
|
||
}
|
||
t.Cleanup(func() { ds.Stop() })
|
||
|
||
tm := text.New(filepath.Join(dir, "text"))
|
||
if err := tm.Start(); err != nil {
|
||
t.Fatalf("text memory start: %v", err)
|
||
}
|
||
t.Cleanup(func() { tm.Stop() })
|
||
|
||
ms, err := media.New(filepath.Join(dir, "media"), 0)
|
||
if err != nil {
|
||
t.Fatalf("media.New: %v", err)
|
||
}
|
||
t.Cleanup(func() { ms.Close() })
|
||
|
||
return g, ds, tm, ms
|
||
}
|
||
|
||
// putDescribed 存一份带描述的媒体,返回完整 digest。
|
||
func putDescribed(t *testing.T, ms *media.Store, payload, desc string) string {
|
||
t.Helper()
|
||
d, err := ms.Put([]byte(payload), media.Item{MIME: "image/png", Description: desc})
|
||
if err != nil {
|
||
t.Fatalf("media.Put: %v", err)
|
||
}
|
||
return d
|
||
}
|
||
|
||
// ---------- 图记忆 ----------
|
||
|
||
// 旧实现只搬 Subject/Relation/Object,其余字段静默丢弃:
|
||
// 插件标注的类型全部落成默认 Concept,置信度全成 1.0,SentenceText 直接消失。
|
||
func TestGraphCommit_CarriesAllFields(t *testing.T) {
|
||
g, _, _, ms := newTestStores(t)
|
||
m := NewGraphMemoryWithMedia("tester", g, ms)
|
||
|
||
err := m.Commit([]Triple{{
|
||
Subject: "张三",
|
||
Relation: "养",
|
||
Object: "橘猫",
|
||
Confidence: 0.75,
|
||
SubjectType: "Person",
|
||
ObjectType: "Animal",
|
||
SentenceText: "张三养了一只橘猫。",
|
||
}})
|
||
if err != nil {
|
||
t.Fatalf("Commit: %v", err)
|
||
}
|
||
|
||
res, err := g.Recall([]string{"张三"}, nil, 2, "")
|
||
if err != nil {
|
||
t.Fatalf("Recall: %v", err)
|
||
}
|
||
if len(res.Relations) == 0 {
|
||
t.Fatal("召回不到刚提交的关系")
|
||
}
|
||
r := res.Relations[0]
|
||
if r.Confidence != 0.75 {
|
||
t.Errorf("Confidence = %v,期望 0.75(插件标注的置信度被丢弃)", r.Confidence)
|
||
}
|
||
if r.SentenceText != "张三养了一只橘猫。" {
|
||
t.Errorf("SentenceText = %q,期望原句(丢了它媒体就没有落点)", r.SentenceText)
|
||
}
|
||
|
||
var subjType, objType string
|
||
for _, e := range res.Entities {
|
||
switch e.Name {
|
||
case "张三":
|
||
subjType = e.Type
|
||
case "橘猫":
|
||
objType = e.Type
|
||
}
|
||
}
|
||
if subjType != "Person" || objType != "Animal" {
|
||
t.Errorf("实体类型 = (%q,%q),期望 (Person,Animal)", subjType, objType)
|
||
}
|
||
}
|
||
|
||
// 插件只给 digest,标记与句子由内核合成;引用必须挂到 graph_sentence owner 上。
|
||
func TestGraphCommit_BindsMediaFromDigests(t *testing.T) {
|
||
g, _, _, ms := newTestStores(t)
|
||
digest := putDescribed(t, ms, "png-bytes", "一张紫蓝红三色带图")
|
||
|
||
m := NewGraphMemoryWithMedia("tester", g, ms)
|
||
if err := m.Commit([]Triple{{
|
||
Subject: "配色图",
|
||
Relation: "包含",
|
||
Object: "三色带",
|
||
MediaDigests: []string{digest[:12]}, // 插件手里通常只有短 digest
|
||
}}); err != nil {
|
||
t.Fatalf("Commit: %v", err)
|
||
}
|
||
|
||
res, err := g.Recall([]string{"配色图"}, nil, 2, "")
|
||
if err != nil {
|
||
t.Fatalf("Recall: %v", err)
|
||
}
|
||
if len(res.Relations) == 0 || res.Relations[0].SentenceID == 0 {
|
||
t.Fatal("没有句子落点 —— 媒体引用无从挂起")
|
||
}
|
||
sid := res.Relations[0].SentenceID
|
||
|
||
// 描述必须进句子:描述文本才是持久语义记忆,检索靠它。
|
||
if !strings.Contains(res.Relations[0].SentenceText, "三色带图") {
|
||
t.Errorf("句子里没有媒体描述: %q", res.Relations[0].SentenceText)
|
||
}
|
||
|
||
refs, err := ms.Refs(media.OwnerGraphSentence, strconv.FormatInt(sid, 10))
|
||
if err != nil {
|
||
t.Fatalf("Refs: %v", err)
|
||
}
|
||
if len(refs) != 1 || refs[0] != digest {
|
||
t.Errorf("句子 #%d 的媒体引用 = %v,期望 [%s]", sid, refs, digest)
|
||
}
|
||
}
|
||
|
||
// 插件自己按格式写了标记又同时填了 MediaDigests,不能产生两条重复引用/两份标记。
|
||
func TestGraphCommit_NoDuplicateMarker(t *testing.T) {
|
||
g, _, _, ms := newTestStores(t)
|
||
digest := putDescribed(t, ms, "dup-bytes", "重复标记测试图")
|
||
short := digest[:12]
|
||
|
||
m := NewGraphMemoryWithMedia("tester", g, ms)
|
||
if err := m.Commit([]Triple{{
|
||
Subject: "重复图",
|
||
Relation: "标记",
|
||
Object: "一次",
|
||
SentenceText: "看这个 [image/png " + short + "] 重复标记测试图",
|
||
MediaDigests: []string{short},
|
||
}}); err != nil {
|
||
t.Fatalf("Commit: %v", err)
|
||
}
|
||
|
||
res, _ := g.Recall([]string{"重复图"}, nil, 2, "")
|
||
if len(res.Relations) == 0 {
|
||
t.Fatal("召回不到关系")
|
||
}
|
||
if n := strings.Count(res.Relations[0].SentenceText, short); n != 1 {
|
||
t.Errorf("句子里出现 %d 次 digest,期望 1 次: %q", n, res.Relations[0].SentenceText)
|
||
}
|
||
}
|
||
|
||
// mediaStore 为 nil 时仍要能提交(媒体是增强,不是必需品),digest 留在文本里备查。
|
||
func TestGraphCommit_NilMediaStoreDegrades(t *testing.T) {
|
||
g, _, _, _ := newTestStores(t)
|
||
m := NewGraphMemory(g)
|
||
|
||
if err := m.Commit([]Triple{{
|
||
Subject: "无存储",
|
||
Relation: "仍可",
|
||
Object: "提交",
|
||
MediaDigests: []string{"aabbccddeeff"},
|
||
}}); err != nil {
|
||
t.Fatalf("Commit 在无媒体存储时不该失败: %v", err)
|
||
}
|
||
|
||
res, _ := g.Recall([]string{"无存储"}, nil, 2, "")
|
||
if len(res.Relations) == 0 {
|
||
t.Fatal("召回不到关系")
|
||
}
|
||
if !strings.Contains(res.Relations[0].SentenceText, "aabbccddeeff") {
|
||
t.Errorf("digest 应留在句子里以备将来反查: %q", res.Relations[0].SentenceText)
|
||
}
|
||
}
|
||
|
||
// Recall 必须把置信度带回插件:拿不到它,插件只能把所有召回结果等同看待。
|
||
func TestGraphRecall_CarriesConfidence(t *testing.T) {
|
||
g, _, _, ms := newTestStores(t)
|
||
m := NewGraphMemoryWithMedia("tester", g, ms)
|
||
|
||
// 实体名至少两个字符:validEntityName 会静默跳过单字实体,
|
||
// 那样 Commit 返回 nil 但什么都没写,测试会退化成假阳性。
|
||
if err := m.Commit([]Triple{{
|
||
Subject: "甲方", Relation: "疑似", Object: "乙方", Confidence: 0.3,
|
||
}}); err != nil {
|
||
t.Fatalf("Commit: %v", err)
|
||
}
|
||
|
||
_, rels, err := m.Recall([]string{"甲方"}, 2)
|
||
if err != nil {
|
||
t.Fatalf("Recall: %v", err)
|
||
}
|
||
if len(rels) == 0 {
|
||
t.Fatal("召回为空")
|
||
}
|
||
if rels[0].Confidence != 0.3 {
|
||
t.Errorf("Confidence = %v,期望 0.3", rels[0].Confidence)
|
||
}
|
||
}
|
||
|
||
// ---------- 文档记忆(知识库) ----------
|
||
|
||
// 附件带 Data → 落进 CAS、标记补进正文、引用挂到文档 owner。
|
||
func TestDocInsertWithMedia_StoresAndBinds(t *testing.T) {
|
||
_, ds, _, ms := newTestStores(t)
|
||
dm := NewDocMemoryWithMedia("tester", ds, ms)
|
||
|
||
d := &Doc{Title: "带图笔记", Content: "这是正文。"}
|
||
err := dm.InsertWithMedia(d, []MediaAttachment{{
|
||
MIME: "image/png",
|
||
Data: []byte("attachment-bytes"),
|
||
Name: "chart.png",
|
||
Description: "一张柱状图",
|
||
}})
|
||
if err != nil {
|
||
t.Fatalf("InsertWithMedia: %v", err)
|
||
}
|
||
if d.ID == "" {
|
||
t.Fatal("ID 未回填 —— 插件拿不到刚写入文档的 id")
|
||
}
|
||
|
||
// 标记必须进正文:向量索引用 Summary+Content 计算,
|
||
// 标记进不去正文就永远检索不到这份媒体。
|
||
if !strings.Contains(d.Content, "柱状图") {
|
||
t.Errorf("正文里没有媒体标记: %q", d.Content)
|
||
}
|
||
|
||
refs, err := ms.Refs(media.OwnerDocument, d.ID)
|
||
if err != nil {
|
||
t.Fatalf("Refs: %v", err)
|
||
}
|
||
if len(refs) != 1 {
|
||
t.Fatalf("文档媒体引用 = %v,期望 1 条", refs)
|
||
}
|
||
// 内容可读,说明真的落盘了而不只是记了个 digest。
|
||
got, err := ms.Get(refs[0])
|
||
if err != nil || string(got) != "attachment-bytes" {
|
||
t.Errorf("媒体内容读回失败: %v / %q", err, got)
|
||
}
|
||
}
|
||
|
||
// 只给 Digest 的附件是「引用已有内容」,不该报错也不该重复落盘。
|
||
func TestDocInsertWithMedia_DigestOnlyReference(t *testing.T) {
|
||
_, ds, _, ms := newTestStores(t)
|
||
digest := putDescribed(t, ms, "existing", "已有的图")
|
||
before := ms.Stats()["count"]
|
||
|
||
dm := NewDocMemoryWithMedia("tester", ds, ms)
|
||
d := &Doc{Title: "引用已有", Content: "正文"}
|
||
if err := dm.InsertWithMedia(d, []MediaAttachment{{Digest: digest[:10]}}); err != nil {
|
||
t.Fatalf("InsertWithMedia: %v", err)
|
||
}
|
||
|
||
if after := ms.Stats()["count"]; after != before {
|
||
t.Errorf("媒体条数从 %v 变成 %v —— 引用已有内容不该新增", before, after)
|
||
}
|
||
refs, _ := ms.Refs(media.OwnerDocument, d.ID)
|
||
if len(refs) != 1 || refs[0] != digest {
|
||
t.Errorf("引用 = %v,期望 [%s]", refs, digest)
|
||
}
|
||
}
|
||
|
||
// Query 必须回媒体元数据但**不回字节**:一次检索可能命中几十份媒体,
|
||
// 全塞回插件会把跨进程消息撑爆。
|
||
func TestDocQuery_FillsMediaMetadataWithoutBytes(t *testing.T) {
|
||
_, ds, _, ms := newTestStores(t)
|
||
dm := NewDocMemoryWithMedia("tester", ds, ms)
|
||
|
||
d := &Doc{Title: "紫蓝红三色带", Content: "配色说明"}
|
||
if err := dm.InsertWithMedia(d, []MediaAttachment{{
|
||
MIME: "image/png", Data: []byte("query-bytes"), Description: "三色带图",
|
||
}}); err != nil {
|
||
t.Fatalf("InsertWithMedia: %v", err)
|
||
}
|
||
|
||
got := dm.Query("紫蓝红三色带 配色说明", 3)
|
||
if len(got) == 0 {
|
||
t.Fatal("检索不到刚写入的文档")
|
||
}
|
||
var hit *Doc
|
||
for _, g := range got {
|
||
if g.ID == d.ID {
|
||
hit = g
|
||
}
|
||
}
|
||
if hit == nil {
|
||
t.Fatalf("检索结果里没有目标文档: %+v", got)
|
||
}
|
||
if len(hit.MediaDigests) != 1 {
|
||
t.Errorf("MediaDigests = %v,期望 1 条", hit.MediaDigests)
|
||
}
|
||
if len(hit.Attachments) != 1 {
|
||
t.Fatalf("Attachments = %v,期望 1 条", hit.Attachments)
|
||
}
|
||
att := hit.Attachments[0]
|
||
if att.MIME != "image/png" || att.Description != "三色带图" {
|
||
t.Errorf("附件元数据 = %+v,期望 mime=image/png desc=三色带图", att)
|
||
}
|
||
if len(att.Data) != 0 {
|
||
t.Errorf("Attachments 不该带字节(%d 字节)—— 需要时按 digest 单取", len(att.Data))
|
||
}
|
||
}
|
||
|
||
// 历史文档只有标记、没有 media_refs(旧版插件写入的)。
|
||
// 此时要能从正文标记反解出附件,否则那些文档的媒体对插件永远不可见。
|
||
func TestDocQuery_FallsBackToMarkers(t *testing.T) {
|
||
_, ds, _, ms := newTestStores(t)
|
||
digest := putDescribed(t, ms, "legacy", "历史图片")
|
||
|
||
// 直接写底层 store,绕过 SDK 的绑定逻辑,模拟历史数据。
|
||
if err := ds.Insert(&doc.Doc{
|
||
Summary: "历史文档",
|
||
Content: "旧正文 [image/png " + digest[:12] + "] 历史图片",
|
||
}); err != nil {
|
||
t.Fatalf("Insert: %v", err)
|
||
}
|
||
|
||
dm := NewDocMemoryWithMedia("tester", ds, ms)
|
||
got := dm.Query("历史文档 旧正文", 3)
|
||
if len(got) == 0 {
|
||
t.Fatal("检索不到历史文档")
|
||
}
|
||
if len(got[0].MediaDigests) != 1 || got[0].MediaDigests[0] != digest {
|
||
t.Errorf("MediaDigests = %v,期望从标记反解出 [%s]", got[0].MediaDigests, digest)
|
||
}
|
||
}
|
||
|
||
// 旧实现删文档不解引用 → 媒体永久"被引用",GC 收不掉,磁盘只增不减。
|
||
func TestDocRemove_ReleasesMediaRefs(t *testing.T) {
|
||
_, ds, _, ms := newTestStores(t)
|
||
dm := NewDocMemoryWithMedia("tester", ds, ms)
|
||
|
||
d := &Doc{Title: "待删除", Content: "正文"}
|
||
if err := dm.InsertWithMedia(d, []MediaAttachment{{
|
||
MIME: "image/png", Data: []byte("to-be-freed"), Description: "会被释放的图",
|
||
}}); err != nil {
|
||
t.Fatalf("InsertWithMedia: %v", err)
|
||
}
|
||
if refs, _ := ms.Refs(media.OwnerDocument, d.ID); len(refs) != 1 {
|
||
t.Fatalf("前置条件不成立,引用 = %v", refs)
|
||
}
|
||
|
||
dm.Remove(d.ID)
|
||
|
||
if refs, _ := ms.Refs(media.OwnerDocument, d.ID); len(refs) != 0 {
|
||
t.Errorf("删除文档后仍有 %v 条引用 —— GC 永远收不掉这份媒体", refs)
|
||
}
|
||
}
|
||
|
||
// mediaStore 为 nil 时 Insert/Query/Remove 必须与本特性上线前完全一致。
|
||
func TestDocMemory_NilMediaStoreDegrades(t *testing.T) {
|
||
_, ds, _, _ := newTestStores(t)
|
||
dm := NewDocMemory(ds)
|
||
|
||
d := &Doc{Title: "无媒体存储", Content: "正文照常写入"}
|
||
if err := dm.InsertWithMedia(d, []MediaAttachment{{
|
||
MIME: "image/png", Data: []byte("ignored"),
|
||
}}); err != nil {
|
||
t.Fatalf("无媒体存储时写入不该失败: %v", err)
|
||
}
|
||
if d.ID == "" {
|
||
t.Error("ID 仍应回填")
|
||
}
|
||
got := dm.Query("无媒体存储 正文照常写入", 3)
|
||
if len(got) == 0 {
|
||
t.Fatal("检索不到文档")
|
||
}
|
||
if len(got[0].Attachments) != 0 {
|
||
t.Errorf("无媒体存储时不该有附件: %+v", got[0].Attachments)
|
||
}
|
||
dm.Remove(d.ID) // 不该 panic
|
||
}
|
||
|
||
// ---------- 文本记忆 ----------
|
||
|
||
// 文本记忆是追加写 JSONL,没有稳定 owner_id 可挂引用,
|
||
// 媒体只能以标记形式留在正文里;读回时要能反解成结构化附件。
|
||
func TestTextMemory_AttachmentRoundTrip(t *testing.T) {
|
||
_, _, tm, ms := newTestStores(t)
|
||
m := NewTextMemoryWithMedia("tester", tm, ms)
|
||
|
||
if err := m.Append(TextEvent{
|
||
Role: "user",
|
||
Content: "看这张图",
|
||
Attachments: []MediaAttachment{{
|
||
MIME: "image/png", Data: []byte("text-mem-bytes"), Description: "文本记忆里的图",
|
||
}},
|
||
}); err != nil {
|
||
t.Fatalf("Append: %v", err)
|
||
}
|
||
|
||
got, err := m.RecentEvents(5)
|
||
if err != nil {
|
||
t.Fatalf("RecentEvents: %v", err)
|
||
}
|
||
if len(got) == 0 {
|
||
t.Fatal("读不到刚追加的事件")
|
||
}
|
||
last := got[len(got)-1]
|
||
if !strings.Contains(last.Content, "文本记忆里的图") {
|
||
t.Errorf("正文里没有媒体标记: %q", last.Content)
|
||
}
|
||
if len(last.Attachments) != 1 {
|
||
t.Fatalf("Attachments = %+v,期望 1 条(标记应能反解)", last.Attachments)
|
||
}
|
||
if last.Attachments[0].Description != "文本记忆里的图" {
|
||
t.Errorf("附件描述 = %q", last.Attachments[0].Description)
|
||
}
|
||
}
|