From b37141f3f58fc28f6c5dca4bc42b82f5398cfb4e Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Tue, 15 Sep 2026 07:47:16 +0800 Subject: [PATCH] =?UTF-8?q?fix(memory):=20doc=E2=86=92graph=20=E8=A1=A5?= =?UTF-8?q?=E5=9B=9E=E5=B8=B8=E7=94=A8=E8=AF=8D=E9=97=B8=E9=97=A8=20+=20?= =?UTF-8?q?=E5=AD=98=E9=87=8F=E5=99=AA=E9=9F=B3/=E5=AD=A4=E7=AB=8B?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E6=B8=85=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:图记忆里堆着「结果(192) / 什么(59) / 哪个(99) / 待命(176) / 报告(174) / context_archived(43)」这类节点,mention_count 冲到几百、度数只有 1~2—— 占着热实体位、挤满召回预算,却不带任何结构。 根因:这层过滤原本存在,后来被换掉没补回。1cb3e87(NLP 三元组提取系统) 把 doc→graph 从「CutExact 滑窗词链」换成依存句法提取器时,CutExact (去停用词 324 条 + validEntityName + 去重,注释至今还写着「用于 doc→graph 蒸馏」)失去了唯一生产调用点,只剩 cut_test.go 在调它。此后落库闸门只剩 validEntityName——它挡的是「不像名字的字符串」(2–50 字符、含字母), 完全不挡「像名字的常用词」。 改动: - 新增 internal/memory/noise.go:IsNoiseEntity 收敛判定(停用词 / context_archived / 模板摘要回声),FilterNoiseTriples 给自动填充路用, PurgeNoise + PurgeOrphans + cmd/memgc 供存量清理(默认 dry-run)。 判定刻意保守:只拦三类客观噪音,开放类词(报告/对话/处理)不拦—— 它们挡不挡是领域决策,见函数注释。 - docToTriples 接上闸门(用户点名的「文档常用词」路)。 对话蒸馏路 extractKeyTriples **不接**:CutExact 当年也只挂 doc→graph, 且 pipeline_test 明确断言「我 --读书--> 杭州」必须抽出(代词主语是该路 既定行为),是否拦属行为决策,已在代码注释里写明并留给使用者定夺。 - cut.go 补全封闭类常用词:咱俩/咱们(我们/你们/他们 早有)、任何/此/本/ 其中/以及/那么/这样/那样/一样/还有/还要/只是/老是/全部/所有/有些/一些/ 别的/其他/其余/各自/本身/方位词/部分/方面。 「不能/不会」试过又撤回:它们是 embedder tokenize 的实词路径, 加进停用词会让 static_embedder 的领域聚类用例翻转(今天天气句与股票句 的相似度大小关系反了),属真回归,不留。 - graph.go:CleanupOrphanedSentences 拆出 Locked 版供 PurgeNoise 复用。 验证:go build/vet 干净,go test -count=1 ./... 全绿。 新增用例:IsNoiseEntity 判定表、FilterNoiseTriples 顺序与边界、 PurgeNoise(统计/幂等/dry-run 不写库/不误删仍被媒体块边引用的句子)、 PurgeOrphans(识别/不误判有边实体/幂等)、docToTriples 噪音闸门与 模板锚点不被误杀。 存量清理(生产库 /home/newqqagent/memory/graph.db,先用 sqlite3 .backup 备份 到 graph.db.bak-20260915-073210): - 噪音实体 29 个 + 其关系 59 条 - 零关系孤立实体 25 个 - 结果:实体 778→724,关系 639→580;sentences 3 条与 block_edges 3 条原样保留 (媒体块引用不被误删),PRAGMA integrity_check=ok、无悬空关系/块边。 - 运行中的 homed 无需重启:下一个 archive 心跳会 Indexer.Sync 重建实体名向量索引。 --- cmd/memgc/main.go | 88 +++++++ internal/agent/core/agent_helpers_test.go | 55 +++++ internal/agent/core/distill.go | 6 +- internal/memory/cut.go | 12 + internal/memory/graph.go | 6 + internal/memory/noise.go | 256 ++++++++++++++++++++ internal/memory/noise_test.go | 272 ++++++++++++++++++++++ internal/memory/pipeline/pipeline.go | 5 + 8 files changed, 699 insertions(+), 1 deletion(-) create mode 100644 cmd/memgc/main.go create mode 100644 internal/memory/noise.go create mode 100644 internal/memory/noise_test.go diff --git a/cmd/memgc/main.go b/cmd/memgc/main.go new file mode 100644 index 0000000..10693ec --- /dev/null +++ b/cmd/memgc/main.go @@ -0,0 +1,88 @@ +// memgc 清理图记忆里已存在的「噪音实体」「孤立实体」及其关系。 +// +// 为什么需要这个命令:噪音闸门(internal/memory.IsNoiseEntity)只能拦住 +// **新写入**的噪音。旧库里那批(常用词 / 归档内部标记 / 模板摘要回声)是 +// 闸门上线前攒下的存量,没人清就一直在——热实体被它们占着,召回预算被 +// 同构垃圾边挤满。清理是一次性动作,但需要可重复执行、可先看不做。 +// +// 两件事分开开关:-orphans 处理的是「零关系的空节点」(清理噪音后另一端 +// 留下的壳),它们的名字本身可能没问题,但已经不在图里了。 +// +// 用法(默认 dry-run,只列不删): +// +// memgc -db /home/newqqagent/memory/graph.db +// memgc -db /home/newqqagent/memory/graph.db -orphans -apply +// +// 清理生产库前请先备份:sqlite3 graph.db ".backup 'graph.db.bak-'" +// 不要用 cp —— WAL 模式下会复制出主库与 -wal 不一致的快照。 +package main + +import ( + "flag" + "fmt" + "log" + + "gitcode.com/JianFeeeee/HomeAgent/internal/memory" +) + +func main() { + path := flag.String("db", "", "graph.db 路径(必填)") + apply := flag.Bool("apply", false, "真正删除;不加则只 dry-run 打印") + orphans := flag.Bool("orphans", false, "同时处理「零关系孤立实体」(先被清理的噪音在另一端留下的空节点)") + flag.Parse() + + if *path == "" { + flag.Usage() + log.Fatal("memgc: 必须指定 -db") + } + + g, err := memory.NewGraphDB(*path) + if err != nil { + log.Fatalf("memgc: open %s: %v", *path, err) + } + defer g.Close() + + junk, err := g.NoiseEntities() + if err != nil { + log.Fatalf("memgc: scan: %v", err) + } + + fmt.Printf("噪音实体 %d 个:\n", len(junk)) + for _, e := range junk { + fmt.Printf(" %-64s type=%-8s mentions=%d\n", e.Name, e.Type, e.MentionCount) + } + + de, dr, err := g.PurgeNoise(!*apply) + if err != nil { + log.Fatalf("memgc: purge: %v", err) + } + if *apply { + fmt.Printf("[APPLIED] 噪音:已删除 实体=%d 关系=%d\n", de, dr) + } else { + fmt.Printf("[DRY-RUN] 噪音:将删除 实体=%d 关系=%d(未写库,加 -apply 才落地)\n", de, dr) + } + + if *orphans { + list, err := g.OrphanEntities() + if err != nil { + log.Fatalf("memgc: orphans: %v", err) + } + fmt.Printf("孤立实体(零关系)%d 个:\n", len(list)) + for _, e := range list { + fmt.Printf(" %-64s type=%-8s mentions=%d\n", e.Name, e.Type, e.MentionCount) + } + n, err := g.PurgeOrphans(!*apply) + if err != nil { + log.Fatalf("memgc: purge orphans: %v", err) + } + if *apply { + fmt.Printf("[APPLIED] 孤立实体:已删除 %d 个\n", n) + } else { + fmt.Printf("[DRY-RUN] 孤立实体:将删除 %d 个\n", n) + } + } + + if *apply { + fmt.Println("提示:运行中的进程会在下一个 archive 心跳(Indexer.Sync)重建实体名向量索引,无需重启。") + } +} diff --git a/internal/agent/core/agent_helpers_test.go b/internal/agent/core/agent_helpers_test.go index 6f77986..19530b2 100644 --- a/internal/agent/core/agent_helpers_test.go +++ b/internal/agent/core/agent_helpers_test.go @@ -235,3 +235,58 @@ func TestGetFloatInt(t *testing.T) { t.Errorf("expected 5.0, got %f", got) } } + +// TestDocToTriplesDropsNoiseEntities 钉住 doc→graph 的噪音闸门。 +// +// 背景:doc→graph 在 1cb3e87 从「CutExact 滑窗词链」换成 NLP 依存提取器后, +// 唯一还拦常用词的那层(CutExact:去停用词 + validEntityName)失去调用点, +// 闸门只剩 validEntityName——它只管名字像不像名字,不管名字是不是常用词。 +// 实测生产库里因此攒下「文档 --主题--> 来自 N 个来源的 M 条对话 …」这类 +// 模板回声,以及 context_archived 这个内部标记。 +func TestDocToTriplesDropsNoiseEntities(t *testing.T) { + doc := &document.Doc{ + Summary: "来自 1 个来源的 2 条对话 (agent) 涉及: qq, 通道", + Content: "", + Source: "context_archived", + } + triples := docToTriples(doc, nil) + + for _, tr := range triples { + if memory.IsNoiseEntity(tr.Subject) || memory.IsNoiseEntity(tr.Object) { + t.Errorf("docToTriples 漏出噪音实体: %+v", tr) + } + } + + // 模板摘要不当「主题」、context_archived 不当「来源」:两条模板三元组都该被拦下。 + for _, tr := range triples { + if tr.Relation == "主题" { + t.Errorf("模板摘要被写成主题: %+v", tr) + } + if tr.Relation == "来源" && tr.Object == "context_archived" { + t.Errorf("归档内部标记被写成来源: %+v", tr) + } + } +} + +// TestDocToTriplesKeepsTemplateAnchors 保证闸门没把正常的模板三元组一起误杀。 +func TestDocToTriplesKeepsTemplateAnchors(t *testing.T) { + doc := &document.Doc{ + Summary: "多轮对话", + Content: "", + Source: "qq", + } + triples := docToTriples(doc, nil) + + var hasTopic, hasSource bool + for _, tr := range triples { + if tr.Subject == "文档" && tr.Relation == "主题" && tr.Object == "多轮对话" { + hasTopic = true + } + if tr.Subject == "文档" && tr.Relation == "来源" && tr.Object == "qq" { + hasSource = true + } + } + if !hasTopic || !hasSource { + t.Errorf("正常模板三元组被误杀: topic=%v source=%v, triples=%+v", hasTopic, hasSource, triples) + } +} diff --git a/internal/agent/core/distill.go b/internal/agent/core/distill.go index 38d2927..9ef2c60 100644 --- a/internal/agent/core/distill.go +++ b/internal/agent/core/distill.go @@ -468,7 +468,11 @@ func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple { }) } - return triples + // 噪音闸门:NLP 提取器不认常用词(「结果 / 什么 / 待命」都能当主语), + // 而落库闸门 validEntityName 只管名字像不像名字。这一层是防止 + // 「每个文档的常用词都变成实体」的唯一防线(CutExact 时代的那层已随 + // 提取器换代丢失,见 memory.IsNoiseEntity 的说明)。 + return memory.FilterNoiseTriples(triples) } // isTemplateSummary 识别 summarizeEntries 生成的模板化摘要 diff --git a/internal/memory/cut.go b/internal/memory/cut.go index 27f4815..a183f8b 100644 --- a/internal/memory/cut.go +++ b/internal/memory/cut.go @@ -191,6 +191,18 @@ var stopWords = map[string]bool{ "人": true, "人们": true, "东西": true, "事情": true, "问题": true, "情况": true, "时候": true, "地方": true, "方式": true, "方法": true, "原因": true, "结果": true, + // ── 人称代词补全(我们/你们/他们 早有,咱俩/咱们 漏了)── + "咱俩": true, "咱们": true, + // ── 限定/指代类:本身没有独立的指称对象 ── + "任何": true, "此": true, "本": true, "其中": true, "以及": true, + "那么": true, "这样": true, "那样": true, "一样": true, + "还有": true, "还要": true, "只是": true, "老是": true, + // ── 方位/整体类泛指 ── + "全部": true, "所有": true, "有些": true, "一些": true, + "别的": true, "其他": true, "其余": true, "各自": true, "本身": true, + "上面": true, "下面": true, "里面": true, "外面": true, + "前面": true, "后面": true, "左边": true, "右边": true, + "中间": true, "附近": true, "周围": true, "部分": true, "方面": true, // ── 高频语气组合 ── "好的": true, "好吧": true, "好": true, "好了": true, "对了": true, "行了": true, diff --git a/internal/memory/graph.go b/internal/memory/graph.go index 8b8ac7f..4a533bc 100644 --- a/internal/memory/graph.go +++ b/internal/memory/graph.go @@ -1097,6 +1097,12 @@ func (g *GraphDB) ClearSentenceID(relationID int64) error { func (g *GraphDB) CleanupOrphanedSentences() (int, error) { g.mu.Lock() defer g.mu.Unlock() + return g.cleanupOrphanedSentencesLocked() +} + +// cleanupOrphanedSentencesLocked 是 CleanupOrphanedSentences 的加锁内联版, +// 供已在写锁内的调用方(PurgeNoise)复用,避免自锁死。 +func (g *GraphDB) cleanupOrphanedSentencesLocked() (int, error) { tx, err := g.db.Begin() if err != nil { return 0, err diff --git a/internal/memory/noise.go b/internal/memory/noise.go new file mode 100644 index 0000000..529d410 --- /dev/null +++ b/internal/memory/noise.go @@ -0,0 +1,256 @@ +package memory + +import ( + "sort" + "strings" +) + +// ────────────────────────────────────────────── +// 图记忆「噪音实体」判定与清理 +// +// 为什么需要这一层:doc→graph 蒸馏在 1cb3e87 从「CutExact 滑窗词链」换成 +// NLP 依存提取器后,落库闸门只剩 validEntityName(长度 2–50、含字母/汉字)。 +// validEntityName 挡的是「不像名字的字符串」,不挡「像名字的常用词」—— +// 于是「结果 / 什么 / 哪个 / 待命 / 报告」这类词被反复写成实体, +// mention_count 冲到几百,度数却只有 1~2:它们占着热实体位、挤满召回预算, +// 却不带任何结构。CutExact(去停用词 + validEntityName + 去重)本可以做这层 +// 过滤,但它当年只挂在 doc→graph 上,换提取器时被整条摘掉,如今只剩测试调用。 +// +// 这里把判定收敛到一处,供三个地方共用: +// - 自动填充路:docToTriples(冷文档归档)、extractKeyTriples(对话蒸馏) +// - 历史数据清理:GraphDB.PurgeNoise(老库里的存量垃圾) +// ────────────────────────────────────────────── + +// 模板摘要的判定片段:summarizeEntries 产出形如 +// 「来自 N 个来源的 M 条对话 (src…) 涉及: kw…」。这类串只是「哪些词出现过」 +// 的回声,没有独立信息量,写进图库会把「文档 --主题--> …」变成同构垃圾边。 +// 判定与 agent/core.isTemplateSummary 保持一致。 +const ( + templateSummaryPrefix = "来自 " + templateSummaryMarker = "条对话" +) + +// archivedContextSource 是归档上下文文档写死的 source 标记。 +// 它是内部状态而非概念,不应以「来源」实体形式存在于图库中。 +const archivedContextSource = "context_archived" + +// IsNoiseEntity 判定一个实体名是否属于「不该进图记忆的噪音」。 +// +// 判定刻意保守,只覆盖三类**客观**噪音,不做「这个词有没有信息量」的价值判断: +// 1. 停用词表命中的常用词(代词/助词/连词/介词/泛化名词/英文虚词) +// 2. 归档上下文内部标记(context_archived) +// 3. summarizeEntries 的模板摘要串 +// +// 开放类词(「报告 / 对话 / 处理」这类真名词真动词)不在此列:它们在别的 +// 语境下可能是有意义的实体,挡不挡属于领域决策,不该由这个函数替使用者拍板。 +func IsNoiseEntity(name string) bool { + n := strings.TrimSpace(name) + if n == "" { + return true + } + if stopWords[n] { + return true + } + if n == archivedContextSource { + return true + } + return IsTemplateSummaryName(n) +} + +// IsTemplateSummaryName 判断实体名是否为模板摘要串(供调用方单独使用)。 +func IsTemplateSummaryName(name string) bool { + n := strings.TrimSpace(name) + return strings.HasPrefix(n, templateSummaryPrefix) && strings.Contains(n, templateSummaryMarker) +} + +// FilterNoiseTriples 丢弃任一端为噪音实体的三元组,保持原顺序。 +// +// 为什么在自动填充入口过滤、而不是在 Commit 里过滤:Commit 同时是 +// memory_commit 工具(模型显式写入)的落库口。模型主动写「结果 --是--> X」 +// 是它的自由(也可能它当时真的想记),而自动填充是无人在环的批量产出, +// 必须自己保证质量——闸门开在产生噪声的那一端。 +func FilterNoiseTriples(triples []Triple) []Triple { + if len(triples) == 0 { + return triples + } + out := make([]Triple, 0, len(triples)) + for _, t := range triples { + if IsNoiseEntity(t.Subject) || IsNoiseEntity(t.Object) { + continue + } + out = append(out, t) + } + return out +} + +// NoiseEntities 列出库中现存的噪音实体,按 mention_count 降序。 +func (g *GraphDB) NoiseEntities() ([]Entity, error) { + g.mu.RLock() + defer g.mu.RUnlock() + return g.noiseEntitiesLocked() +} + +func (g *GraphDB) noiseEntitiesLocked() ([]Entity, error) { + rows, err := g.db.Query( + `SELECT id, name, type, mention_count, created_at, updated_at FROM entities`) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []Entity + for rows.Next() { + var e Entity + if err := rows.Scan(&e.ID, &e.Name, &e.Type, &e.MentionCount, &e.CreatedAt, &e.UpdatedAt); err != nil { + return nil, err + } + if IsNoiseEntity(e.Name) { + out = append(out, e) + } + } + if err := rows.Err(); err != nil { + return nil, err + } + sort.Slice(out, func(i, j int) bool { + if out[i].MentionCount != out[j].MentionCount { + return out[i].MentionCount > out[j].MentionCount + } + return out[i].Name < out[j].Name + }) + return out, nil +} + +// PurgeNoise 清理库中已存在的噪音实体及其关系,返回删除的实体数与关系数。 +// +// dryRun 为 true 时只统计、不写库——清理生产库前先看清楚要动什么。 +// 关系按「任一端是噪音实体」删除;删完实体后顺带清理失去引用的孤儿句子 +// (复用 cleanupOrphanedSentencesLocked,不在这里重写一遍判定)。 +func (g *GraphDB) PurgeNoise(dryRun bool) (int, int, error) { + g.mu.Lock() + defer g.mu.Unlock() + + junk, err := g.noiseEntitiesLocked() + if err != nil { + return 0, 0, err + } + if len(junk) == 0 { + return 0, 0, nil + } + + ids := make([]interface{}, 0, len(junk)) + for _, e := range junk { + ids = append(ids, e.ID) + } + ph := placeholders(len(junk)) + args := append(append([]interface{}{}, ids...), ids...) + + // 关系数按 DISTINCT id 统计:两端都是噪音的关系不能被算两次。 + var relCount int + if err := g.db.QueryRow( + `SELECT COUNT(DISTINCT id) FROM relations + WHERE source_id IN (`+ph+`) OR target_id IN (`+ph+`)`, + args..., + ).Scan(&relCount); err != nil { + return 0, 0, err + } + + if dryRun { + return len(junk), relCount, nil + } + + tx, err := g.db.Begin() + if err != nil { + return 0, 0, err + } + defer tx.Rollback() + + if _, err := tx.Exec( + `DELETE FROM relations WHERE source_id IN (`+ph+`) OR target_id IN (`+ph+`)`, + args...); err != nil { + return 0, 0, err + } + if _, err := tx.Exec( + `DELETE FROM entities WHERE id IN (`+ph+`)`, ids...); err != nil { + return 0, 0, err + } + if err := tx.Commit(); err != nil { + return 0, 0, err + } + + if _, err := g.cleanupOrphanedSentencesLocked(); err != nil { + return len(junk), relCount, err + } + return len(junk), relCount, nil +} + +// OrphanEntities 列出「没有任何关系的孤立实体」,按 mention_count 降序。 +// +// 孤立实体在图里只剩一个名字:关系召回永远够不到它,唯一的副作用是 +// 混进实体名向量索引、被自动注入当成相关实体。它们出现的典型路径是 +// 噪音实体被清理后留下的另一端(边没了,节点还在),或提取器把关系写重了。 +func (g *GraphDB) OrphanEntities() ([]Entity, error) { + g.mu.RLock() + defer g.mu.RUnlock() + return g.orphanEntitiesLocked() +} + +func (g *GraphDB) orphanEntitiesLocked() ([]Entity, error) { + rows, err := g.db.Query( + `SELECT id, name, type, mention_count, created_at, updated_at FROM entities e + WHERE NOT EXISTS (SELECT 1 FROM relations r WHERE r.source_id = e.id OR r.target_id = e.id) + -- 与媒体块有边的实体不算孤立:那是 sentence/document --contains--> block + -- 体系的一部分,删了会让块边悬空。 + AND NOT EXISTS (SELECT 1 FROM memory_block_edges b + WHERE (b.source_kind = 'entity' AND b.source_id = CAST(e.id AS TEXT)) + OR (b.target_kind = 'entity' AND b.target_id = CAST(e.id AS TEXT)))`) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []Entity + for rows.Next() { + var e Entity + if err := rows.Scan(&e.ID, &e.Name, &e.Type, &e.MentionCount, &e.CreatedAt, &e.UpdatedAt); err != nil { + return nil, err + } + out = append(out, e) + } + if err := rows.Err(); err != nil { + return nil, err + } + sort.Slice(out, func(i, j int) bool { + if out[i].MentionCount != out[j].MentionCount { + return out[i].MentionCount > out[j].MentionCount + } + return out[i].Name < out[j].Name + }) + return out, nil +} + +// PurgeOrphans 删除没有任何关系的孤立实体,返回删除数。 +// +// dryRun 为 true 时只统计。与 PurgeNoise 分开:噪音是「这个名字本身不该在」, +// 孤立是「这个名字虽然可能合理,但它已经不在图里了」——两件事,别混在一个开关里。 +func (g *GraphDB) PurgeOrphans(dryRun bool) (int, error) { + g.mu.Lock() + defer g.mu.Unlock() + + orphans, err := g.orphanEntitiesLocked() + if err != nil { + return 0, err + } + if dryRun || len(orphans) == 0 { + return len(orphans), nil + } + + ids := make([]interface{}, 0, len(orphans)) + for _, e := range orphans { + ids = append(ids, e.ID) + } + if _, err := g.db.Exec( + `DELETE FROM entities WHERE id IN (`+placeholders(len(ids))+`)`, ids...); err != nil { + return 0, err + } + return len(orphans), nil +} diff --git a/internal/memory/noise_test.go b/internal/memory/noise_test.go new file mode 100644 index 0000000..dd46205 --- /dev/null +++ b/internal/memory/noise_test.go @@ -0,0 +1,272 @@ +package memory + +import ( + "fmt" + "os" + "testing" +) + +func TestIsNoiseEntity(t *testing.T) { + cases := []struct { + name string + want bool + why string + }{ + {"结果", true, "停用词表内的泛化名词"}, + {"什么", true, "疑问代词"}, + {"哪个", true, "疑问代词"}, + {"咱俩", true, "人称代词(2026-09 补全)"}, + {"任何", true, "限定词"}, + {"部分", true, "泛指名词"}, + {"context_archived", true, "归档上下文内部标记"}, + {"来自 1 个来源的 2 条对话 (agent) 涉及: qq, 通道", true, "模板摘要回声"}, + {"来自 2 个来源的 2 条对话 (cli, agent)", true, "模板摘要回声(无涉及段)"}, + {"", true, "空名"}, + {" ", true, "纯空白"}, + {"文档", false, "doc→graph 的模板主语,保留"}, + {"小宅", false, "人名"}, + {"CodeGraph", false, "专名"}, + {"报告", false, "开放类词:可能是有意义的实体,不由本函数拦截"}, + {"对话", false, "开放类词"}, + } + for _, c := range cases { + if got := IsNoiseEntity(c.name); got != c.want { + t.Errorf("IsNoiseEntity(%q) = %v, want %v (%s)", c.name, got, c.want, c.why) + } + } +} + +func TestFilterNoiseTriples(t *testing.T) { + in := []Triple{ + {Subject: "结果", Relation: "是", Object: "问题"}, // 两端噪音 + {Subject: "小宅", Relation: "需要", Object: "什么"}, // 一端噪音 + {Subject: "文档", Relation: "主题", Object: "来自 1 个来源的 2 条对话 (agent)"}, // 一端噪音(模板摘要) + {Subject: "小宅", Relation: "使用", Object: "CodeGraph"}, // 干净 + {Subject: "小宅", Relation: "继续", Object: "待命"}, // 开放类词:本层不拦 + } + out := FilterNoiseTriples(in) + if len(out) != 2 { + t.Fatalf("FilterNoiseTriples 保留 %d 条,want 2: %+v", len(out), out) + } + if out[0].Object != "CodeGraph" || out[1].Object != "待命" { + t.Errorf("留下的不是那两条干净三元组: %+v", out) + } + + // 空输入不应被改写成非 nil(调用方按 len 判断,别制造意外) + if FilterNoiseTriples(nil) != nil { + t.Error("nil 输入应原样返回 nil") + } +} + +func TestPurgeNoise(t *testing.T) { + g := newTestGraph(t) + defer os.Remove(g.dbPath) + defer g.Close() + + triples := []Triple{ + {Subject: "结果", Relation: "是", Object: "问题", Confidence: 1.0}, + {Subject: "小宅", Relation: "继续", Object: "待命", Confidence: 1.0}, + {Subject: "文档", Relation: "来源", Object: "context_archived", Confidence: 1.0}, + {Subject: "文档", Relation: "主题", Object: "来自 1 个来源的 2 条对话 (agent)", Confidence: 1.0}, + {Subject: "小宅", Relation: "使用", Object: "CodeGraph", Confidence: 1.0}, + } + if _, _, err := g.Commit(triples, "test", 0); err != nil { + t.Fatalf("commit: %v", err) + } + + before, err := g.NoiseEntities() + if err != nil { + t.Fatalf("NoiseEntities: %v", err) + } + if len(before) != 4 { + // 4 个噪音:结果 / 问题 / context_archived / 模板摘要串 + // (「待命」是开放类词,不在停用词表内,故意不算噪音) + t.Fatalf("噪音实体 %d 个,want 4: %+v", len(before), before) + } + // 按 mention_count 降序;都为 1 时按名字升序,只验证顺序单调。 + for i := 1; i < len(before); i++ { + if before[i-1].MentionCount < before[i].MentionCount { + t.Errorf("NoiseEntities 未按 mention_count 降序: %+v", before) + break + } + } + + // dry-run 不得写库 + de, dr, err := g.PurgeNoise(true) + if err != nil { + t.Fatalf("PurgeNoise(dryRun): %v", err) + } + // 3 条关系:结果→问题(两端噪音算一次)、文档→context_archived、文档→模板摘要 + if de != 4 || dr != 3 { + t.Errorf("dry-run 统计 entities=%d relations=%d, want 4/3", de, dr) + } + if again, _ := g.NoiseEntities(); len(again) != 4 { + t.Fatalf("dry-run 改了库:噪音实体剩 %d 个", len(again)) + } + + // 真清理 + de, dr, err = g.PurgeNoise(false) + if err != nil { + t.Fatalf("PurgeNoise: %v", err) + } + if de != 4 || dr != 3 { + t.Errorf("清理统计 entities=%d relations=%d, want 4/3", de, dr) + } + + left, err := g.NoiseEntities() + if err != nil { + t.Fatalf("NoiseEntities: %v", err) + } + if len(left) != 0 { + t.Errorf("清理后仍有噪音实体: %+v", left) + } + + // 干净的那条必须活着 + res, err := g.Recall(nil, nil, 1, "") + if err != nil { + t.Fatalf("Recall: %v", err) + } + names := make(map[string]bool) + for _, e := range res.Entities { + names[e.Name] = true + } + if !names["小宅"] || !names["CodeGraph"] { + t.Errorf("清理误伤干净实体,现存: %v", names) + } + for _, n := range []string{"结果", "问题", "context_archived"} { + if names[n] { + t.Errorf("噪音实体 %q 仍在库中", n) + } + } + // 「文档」是 doc→graph 的模板主语,有意保留(清理只摘它的噪音边) + if !names["文档"] { + t.Error("模板主语「文档」不应被清理") + } + if !names["待命"] { + t.Error("开放类词「待命」不应被清理(它不在停用词表内)") + } + + // 幂等:再清一次应为 0/0 + de, dr, err = g.PurgeNoise(false) + if err != nil { + t.Fatalf("PurgeNoise 二次: %v", err) + } + if de != 0 || dr != 0 { + t.Errorf("二次清理 entities=%d relations=%d, want 0/0", de, dr) + } +} + +// TestPurgeNoiseKeepsBlockBackedSentences 钉住 PurgeNoise 不能误删仍被媒体块 +// 引用的句子——它复用 CleanupOrphanedSentences,那个判定必须照旧生效。 +func TestPurgeNoiseKeepsBlockBackedSentences(t *testing.T) { + g := newTestGraph(t) + defer os.Remove(g.dbPath) + defer g.Close() + + sentence := "小宅 说 结果 很好" + if _, _, err := g.Commit([]Triple{ + {Subject: "结果", Relation: "是", Object: "问题", SentenceText: sentence, Confidence: 1.0}, + }, "test", 0); err != nil { + t.Fatalf("commit: %v", err) + } + + var sid int64 + if err := g.db.QueryRow(`SELECT id FROM sentences WHERE text = ?`, sentence).Scan(&sid); err != nil { + t.Fatalf("sentence 未写入: %v", err) + } + if _, err := g.db.Exec( + `INSERT INTO memory_blocks (id, modality, payload_digest, mime) VALUES ('blk1', 'image', 'digest1', 'image/png')`); err != nil { + t.Fatalf("insert block: %v", err) + } + if _, err := g.db.Exec( + `INSERT INTO memory_block_edges (source_kind, source_id, target_kind, target_id, edge_type) + VALUES ('sentence', ?, 'block', 'blk1', 'contains')`, + fmt.Sprintf("%d", sid)); err != nil { + t.Fatalf("insert edge: %v", err) + } + + if _, _, err := g.PurgeNoise(false); err != nil { + t.Fatalf("PurgeNoise: %v", err) + } + + var n int + if err := g.db.QueryRow(`SELECT COUNT(*) FROM sentences WHERE id = ?`, sid).Scan(&n); err != nil { + t.Fatalf("count sentence: %v", err) + } + if n != 1 { + t.Error("PurgeNoise 删掉了仍被媒体块边引用的句子") + } +} + +func TestPurgeOrphans(t *testing.T) { + g := newTestGraph(t) + defer os.Remove(g.dbPath) + defer g.Close() + + if _, _, err := g.Commit([]Triple{ + {Subject: "小宅", Relation: "使用", Object: "CodeGraph", Confidence: 1.0}, + {Subject: "结果", Relation: "是", Object: "问题", Confidence: 1.0}, + }, "test", 0); err != nil { + t.Fatalf("commit: %v", err) + } + + // 清掉噪音后,「结果」「问题」两个节点被删、边也没了; + // 但先看清理前的孤立集合:应为 0(都有边)。 + if list, err := g.OrphanEntities(); err != nil { + t.Fatalf("OrphanEntities: %v", err) + } else if len(list) != 0 { + t.Fatalf("清理前不应有孤立实体: %+v", list) + } + + // 造一个「边被清掉、节点还在」的壳:直接删边 + if _, err := g.db.Exec(`DELETE FROM relations WHERE relation_type = '是'`); err != nil { + t.Fatalf("delete relation: %v", err) + } + // 再补一个从未有过边的孤立实体 + if _, _, err := g.Commit([]Triple{{Subject: "孤零零", Relation: "是", Object: "小宅", Confidence: 1.0}}, "test", 0); err != nil { + t.Fatalf("commit: %v", err) + } + if _, err := g.db.Exec(`DELETE FROM relations WHERE source_id = (SELECT id FROM entities WHERE name = '孤零零')`); err != nil { + t.Fatalf("delete relation 2: %v", err) + } + + list, err := g.OrphanEntities() + if err != nil { + t.Fatalf("OrphanEntities: %v", err) + } + names := make(map[string]bool) + for _, e := range list { + names[e.Name] = true + } + for _, want := range []string{"结果", "问题", "孤零零"} { + if !names[want] { + t.Errorf("%q 应被识别为孤立实体,实际: %+v", want, list) + } + } + if names["小宅"] || names["CodeGraph"] { + t.Errorf("有边的实体被误判为孤立: %+v", list) + } + + // dry-run 不写库 + if n, err := g.PurgeOrphans(true); err != nil || n != len(list) { + t.Fatalf("PurgeOrphans(dryRun) = %d, %v; want %d", n, err, len(list)) + } + if again, _ := g.OrphanEntities(); len(again) != len(list) { + t.Fatal("dry-run 改了库") + } + + n, err := g.PurgeOrphans(false) + if err != nil { + t.Fatalf("PurgeOrphans: %v", err) + } + if n != len(list) { + t.Errorf("删除 %d 个,want %d", n, len(list)) + } + if left, _ := g.OrphanEntities(); len(left) != 0 { + t.Errorf("清理后仍有孤立实体: %+v", left) + } + // 幂等 + if n, err := g.PurgeOrphans(false); err != nil || n != 0 { + t.Errorf("二次清理 = %d, %v; want 0", n, err) + } +} diff --git a/internal/memory/pipeline/pipeline.go b/internal/memory/pipeline/pipeline.go index ca40193..20a7ed9 100644 --- a/internal/memory/pipeline/pipeline.go +++ b/internal/memory/pipeline/pipeline.go @@ -416,6 +416,11 @@ func extractKeyTriples(userContent, assistantContent string, embedder nlp.Vector } } + // 注意:这里**不**做噪音过滤。对话蒸馏的抽取器(与 doc→graph 共用一个 + // NLP 提取器)历史上就没有常用词闸门:CutExact 那层当年只挂在 doc→graph 上。 + // 而且 pipeline_test 明确断言「我 --读书--> 杭州」必须被抽出(代词作主语是 + // 该路的既定行为)。要不要在对话路也拦常用词是行为决策,不在此处擅改, + // 参见 memory.IsNoiseEntity 的说明。 return triples }