Files
HomeAgent/internal/memory/clean_stress_test.go
JianFeeeee 5836c2ce5c 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 等)。
2026-09-11 11:45:24 +08:00

369 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package memory
import (
"fmt"
"regexp"
"sort"
"testing"
)
// cleanQQTemplate 剥离 QQ 工具调用模板与时间戳噪声。
// 仅用于测试——生产环境中由 QQ 外置插件的工具 Cleaner 完成。
func cleanQQTemplate(text string) string {
reQQGroupSuffix := regexp.MustCompile(`通过id\d+使用qq_get_message工具获取消息正文。获取内容后使用 output_send\(channel="qq"\) 回复该群聊content 设为 JSON 字符串:\{[^}]*\}`)
reQQPrivateSuffix := regexp.MustCompile(`通过id\d+使用qq_get_message工具获取消息正文。获取内容后使用 output_send\(channel="qq"\) 回复对方content 设为 JSON 字符串:\{[^}]*\}`)
reQQOldReply := regexp.MustCompile(`通过id\d+使用qq_get_message工具获取消息正文。获取后必须使用[^。]+。`)
reQQOldForbid := regexp.MustCompile(`你只能通过qq_get_message先看消息然后直接用%!s\(MISSING\)send_private_msg回复对方中间的思考过程禁止调用任何其他工具\s*→\s*`)
reQQGeneral := regexp.MustCompile(`通过id\d+使用qq_get_message工具获取消息正文[。,][^。]*?(?:回复|发送消息)`)
reTimestamp := regexp.MustCompile(`\[\d{2}:\d{2}\]\s*`)
reMultiSpace := regexp.MustCompile(`\s+`)
text = reQQGroupSuffix.ReplaceAllString(text, "")
text = reQQPrivateSuffix.ReplaceAllString(text, "")
text = reQQOldReply.ReplaceAllString(text, "")
text = reQQOldForbid.ReplaceAllString(text, "")
text = reQQGeneral.ReplaceAllString(text, "")
text = reTimestamp.ReplaceAllString(text, "")
text = reMultiSpace.ReplaceAllString(text, " ")
return text
}
type cleanTestEvent struct {
idx int
source string
input string
response string
rawText string
cleanedText string
topic string
}
func TestCleanStressPrecision(t *testing.T) {
events := genStressEvents(200)
topics := []string{"大学招生", "老大私聊", "前端开发", "服务器运维", "股票基金"}
e := newSynthEmbedder(t, 300)
if !e.Loaded() {
t.Skip("embedder not loaded")
}
for _, cleanMode := range []bool{true, false} {
t.Run(fmt.Sprintf("去模版=%v", cleanMode), func(t *testing.T) {
usedTopics := make([]string, 0)
for _, tp := range topics {
if hasTopicEvents(events, tp) {
usedTopics = append(usedTopics, tp)
}
}
if len(usedTopics) == 0 {
t.Fatal("no events for any topic")
}
t.Logf("topics: %v, events: %d", usedTopics, len(events))
for _, qTopic := range usedTopics {
query := queryForTopic(qTopic)
qVec := e.Vectorize(query)
type scored struct {
idx int
topic string
text string
score float64
}
all := make([]scored, len(events))
for i, ev := range events {
text := ev.rawText
if cleanMode {
text = ev.cleanedText
}
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)
}
}
})
}
}
func TestCleanStressCrossTopic(t *testing.T) {
events := genStressEvents(200)
e := newSynthEmbedder(t, 300)
if !e.Loaded() {
t.Skip("embedder not loaded")
}
queries := []string{
"河南医药大学录取分数线",
"老大发了什么私聊消息",
"前端组件怎么封装布局",
"服务器部署配置代理备份证书",
"基金定投收益计算",
}
for _, q := range queries {
qVec := e.Vectorize(q)
t.Logf("query: %q", q)
type scored struct {
idx int
topic string
score float64
}
all := make([]scored, len(events))
for i, ev := range events {
all[i] = scored{idx: i, topic: ev.topic, score: cosineSim(qVec, e.Vectorize(ev.cleanedText))}
}
sort.Slice(all, func(i, j int) bool { return all[i].score > all[j].score })
topScores := make(map[string]float64)
for _, s := range all[:10] {
if _, ok := topScores[s.topic]; !ok {
topScores[s.topic] = s.score
}
}
for tp, sc := range topScores {
t.Logf(" [%.4f] %s", sc, tp)
}
}
}
func TestCleanTemplateNoiseSuppression(t *testing.T) {
e := newSynthEmbedder(t, 300)
if !e.Loaded() {
t.Skip("embedder not loaded")
}
noisyInput := `来自小王的扶高升学咨询群群聊消息通过id99使用qq_get_message工具获取消息正文。获取内容后使用 output_send(channel="qq") 回复该群聊content 设为 JSON 字符串:{"content":"你的回复","group_id":979911915}`
cleanInput := `来自小王的(扶高升学咨询群)群聊消息`
query := "扶高升学咨询群"
qClear := e.Vectorize(query)
qNoisy := e.Vectorize(noisyInput)
qClean := e.Vectorize(cleanInput)
n2c := cosineSim(qNoisy, qClean)
n2q := cosineSim(qNoisy, qClear)
c2q := cosineSim(qClean, qClear)
t.Logf("noisy(%q) vs clean(%q) = %.4f", noisyInput[:30], cleanInput, n2c)
t.Logf("noisy vs query(%q) = %.4f", query, n2q)
t.Logf("clean vs query = %.4f", c2q)
if c2q <= n2q {
t.Log("NOTE: clean not better than noisy for this pattern (may have useful info in metadata)")
}
}
func TestCleanVectorConsistency(t *testing.T) {
e := newSynthEmbedder(t, 300)
if !e.Loaded() {
t.Skip("embedder not loaded")
}
templates := []string{
`通过id1使用qq_get_message工具获取消息正文。获取内容后使用 output_send(channel="qq") 回复该群聊content 设为 JSON 字符串:{"content":"你的回复","group_id":1}`,
`通过id2使用qq_get_message工具获取消息正文。获取内容后使用 output_send(channel="qq") 回复对方content 设为 JSON 字符串:{"content":"你的回复","user_id":2}`,
`通过id3使用qq_get_message工具获取消息正文。获取后必须使用qq_send_private_msg工具回复对方不得使用其他非回复工具。`,
`[12:00] agent: 处理完成`,
}
for _, tmpl := range templates {
cleaned := CleanText(tmpl)
t.Logf("template {%q} → {%q} (%d chars)", trimLen(tmpl, 60), cleaned, len(cleaned))
}
pairs := []struct {
a, b string
reason string
}{
{cleanQQGroup("A", "群1"), cleanQQGroup("B", "群1"), "同群不同人"},
{cleanQQGroup("A", "群1"), cleanQQGroup("A", "群2"), "同人不同群"},
{cleanQQPrivate("老大"), cleanQQPrivate("老板"), "私聊不同人"},
{cleanQQGroup("A", "高考群"), cleanQQPrivate("老大"), "群聊vs私聊"},
}
for _, p := range pairs {
va := e.Vectorize(p.a)
vb := e.Vectorize(p.b)
sim := cosineSim(va, vb)
t.Logf("sim(%q, %q) [%s] = %.4f", trimLen(p.a, 40), trimLen(p.b, 40), p.reason, sim)
}
}
func BenchmarkCleanVectorize(b *testing.B) {
e := newSynthEmbedder(b, 300)
if !e.Loaded() {
b.Skip("embedder not loaded")
}
texts := make([]string, 100)
for i := range texts {
texts[i] = fmt.Sprintf(
`【重要!老大消息】来自—/的私聊消息通过id%d使用qq_get_message工具获取消息正文。获取内容后使用 output_send(channel="qq") 回复对方content 设为 JSON 字符串:{"content":"你的回复","user_id":%d}`,
i, 1000+i,
)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
e.VectorizeClean(texts[i%len(texts)])
}
}
// --- test data generators ---
func genStressEvents(n int) []cleanTestEvent {
if n <= 0 {
return nil
}
topics := []string{"大学招生", "老大私聊", "前端开发", "服务器运维", "股票基金"}
events := make([]cleanTestEvent, 0, n)
names := []string{"小明", "小红", "小张", "老王", "老大", "小李", "小王", "小赵"}
groups := []string{"扶高升学咨询群", "前端技术交流", "服务器运维群", "基金定投群", "闲聊群"}
topicContent := map[string]struct {
keywords []string
sources []string
}{
"大学招生": {[]string{"河南医药大学", "录取分数线", "专业排名", "高考志愿", "招生简章"}, []string{"qq", "qq", "agent"}},
"老大私聊": {[]string{"老大私聊消息", "回复老大", "任务安排", "汇报工作", "收到"}, []string{"qq", "agent", "agent"}},
"前端开发": {[]string{"前端组件封装", "页面路由配置", "界面布局设计", "交互逻辑开发", "代码调试优化"}, []string{"cli", "cli", "agent"}},
"服务器运维": {[]string{"反向代理配置", "容器部署方案", "证书续期", "数据库备份恢复", "监控告警处理"}, []string{"cli", "agent", "agent"}},
"股票基金": {[]string{"基金定投策略", "股票涨跌分析", "理财收益计算", "市场行情分析", "投资风险管理"}, []string{"qq", "qq", "agent"}},
}
for i := 0; i < n; i++ {
tp := topics[i%len(topics)]
info := topicContent[tp]
kw := info.keywords[i%len(info.keywords)]
nm := names[i%len(names)]
grp := groups[i%len(groups)]
src := info.sources[i%len(info.sources)]
var input, response string
switch src {
case "qq":
if tp == "老大私聊" {
input = fmt.Sprintf(`【重要!老大消息】来自%s的私聊消息通过id%d使用qq_get_message工具获取消息正文。获取内容后使用 output_send(channel="qq") 回复对方content 设为 JSON 字符串:{"content":"你的回复","user_id":%d}`, nm, i, 1000+i)
if i%3 == 0 {
response = fmt.Sprintf("已回复老大,关于%s", kw)
}
} else {
input = fmt.Sprintf(`来自%s的%s群聊消息通过id%d使用qq_get_message工具获取消息正文。获取内容后使用 output_send(channel="qq") 回复该群聊content 设为 JSON 字符串:{"content":"你的回复","group_id":%d}`, nm, grp, i, 9000+i)
if i%3 == 0 {
response = fmt.Sprintf("已回复%s相关的问题", kw)
}
}
case "agent":
input = fmt.Sprintf(`来自%s的%s消息`, nm, grp)
response = fmt.Sprintf("关于%s我的建议是...已处理完成。", kw)
case "cli":
input = fmt.Sprintf("查询%s的相关信息", kw)
response = fmt.Sprintf("查到了%s的结果", kw)
}
cleaned := cleanEventText(src, input, response)
raw := rawEventText(src, input, response)
events = append(events, cleanTestEvent{
idx: i,
source: src,
input: input,
response: response,
rawText: raw,
cleanedText: cleaned,
topic: tp,
})
}
return events
}
func cleanEventText(source, input, response string) string {
// 先做基础 CleanText去空格/逗号),再做模板噪音清理
switch {
case source == "agent" && response != "":
return cleanQQTemplate(CleanText(response))
case source == "cold_storage":
return cleanQQTemplate(CleanText(input + " " + response))
default:
return cleanQQTemplate(CleanText(input))
}
}
func rawEventText(source, input, response string) string {
if response == "" {
return input
}
return input + " " + response
}
func queryForTopic(topic string) string {
switch topic {
case "大学招生":
return "河南医药大学录取分数线多少"
case "老大私聊":
return "老大刚才说了什么私聊消息"
case "前端开发":
return "前端组件怎么封装布局"
case "服务器运维":
return "服务器部署容器代理配置证书备份监控告警"
case "股票基金":
return "基金定投收益怎么样"
default:
return topic
}
}
func hasTopicEvents(events []cleanTestEvent, topic string) bool {
for _, ev := range events {
if ev.topic == topic {
return true
}
}
return false
}
func countTopicEvents(events []cleanTestEvent, topic string) int {
n := 0
for _, ev := range events {
if ev.topic == topic {
n++
}
}
return n
}
func cleanQQGroup(user, group string) string {
return fmt.Sprintf("来自%s的%s群聊消息", user, group)
}
func cleanQQPrivate(user string) string {
return fmt.Sprintf("【重要!老大消息】来自%s的私聊消息", user)
}