mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 06:47:29 +00:00
记忆层: - MemoryBlock 承载 FrameTS/PageSeq/PageKind/BlockRole/CallID - coldcontext 归档与回填按完整页传递,工具调用参数和配对键不丢失 - 召回先估算总 token,超预算时按全局页相似度标记 inactive - inactive 仅是 context 渲染期状态,不落盘、不进入 coldcontext,Prune 后重置 - Peek/确认/Remove 两阶段召回,失败保留冷上下文副本 - stablemem 记录 last_recalled_at,删块连带清理边 命名: - internal/memory/document → internal/memory/coldcontext - Doc/DocEvent/DocHit → ColdDoc/ColdEvent/ColdHit - ContextToColdContext/RecentColdContexts/FindColdContexts - 保留既有 JSON 标签、doc_ 文件前缀与 doc_count 状态键,兼容存量数据 安全治理: - 工具策略门禁、安全配置、事件总线发布者身份与入站脱敏 - 声明式 SecurityAPI 与 QQ owner/guest profile - 日志保留策略与相关 e2e/回归判据 验证:go build ./...、go vet ./...、go test ./...(62 包); 生产副本 e2e:7013 块迁移后 ID/向量/指纹 0 变化、0 缺失。
390 lines
14 KiB
Go
390 lines
14 KiB
Go
package memory
|
||
|
||
import (
|
||
"fmt"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// ──────────────────────────────────────────────
|
||
// 图记忆「噪音实体」判定与清理
|
||
//
|
||
// 为什么需要这一层:doc→graph 蒸馏在 1cb3e87 从「CutExact 滑窗词链」换成
|
||
// NLP 依存提取器后,落库闸门只剩 validEntityName(长度 2–50、含字母/汉字)。
|
||
// validEntityName 挡的是「不像名字的字符串」,不挡「像名字的常用词」——
|
||
// 于是「结果 / 什么 / 哪个 / 待命 / 报告」这类词被反复写成实体,
|
||
// mention_count 冲到几百,度数却只有 1~2:它们占着热实体位、挤满召回预算,
|
||
// 却不带任何结构。CutExact(去停用词 + validEntityName + 去重)本可以做这层
|
||
// 过滤,但它当年只挂在 doc→graph 上,换提取器时被整条摘掉,如今只剩测试调用。
|
||
//
|
||
// 这里把判定收敛到一处,供三个地方共用:
|
||
// - 自动填充路:coldDocToTriples(冷文档归档)、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) {
|
||
// ★★★ 改扫 memory_blocks(2026-10-04)
|
||
//
|
||
// 旧实现扫 entities 表。旧表退场后返回空 ⇒ PurgeNoise 认定「库里没有噪音」
|
||
// ⇒ 直接 return 0,0,nil —— 而块侧的噪音块一条不少。
|
||
//
|
||
// 这比「删不掉」更糟:用户点清理,报告显示「已清理 0 个噪音」,
|
||
// 看起来是**成功**的。
|
||
//
|
||
// ★ ID 口径:Entity.ID 是 int64,块 ID 是字符串。
|
||
// 这里用**行号**(ROW_NUMBER 不便,改用 OFFSET 累计的序号)占位,
|
||
// 因为 PurgeNoise 真正用来删的是块 ID,不是这个数字。
|
||
// 排序仍然按 created_at(块的),语义上最接近旧的 mention_count ——
|
||
// mention_count 已无处可取,而「先出现的更可能是早期噪音」是同向的。
|
||
rows, err := g.db.Query(
|
||
`SELECT id, text_content, modality, created_at, updated_at
|
||
FROM memory_blocks WHERE text_content != '' ORDER BY created_at ASC, id ASC`)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
var out []Entity
|
||
var seq int
|
||
for rows.Next() {
|
||
var id, text, modality string
|
||
var created, updated time.Time
|
||
if err := rows.Scan(&id, &text, &modality, &created, &updated); err != nil {
|
||
return nil, err
|
||
}
|
||
seq++
|
||
if !IsNoiseEntity(text) {
|
||
continue
|
||
}
|
||
out = append(out, Entity{
|
||
ID: int64(seq), // 占位:PurgeNoise 用块 ID 删,不用它
|
||
Name: text,
|
||
Type: "block",
|
||
MentionCount: 1, // 块无 mention_count;排序时用 created_at
|
||
CreatedAt: created,
|
||
UpdatedAt: updated,
|
||
})
|
||
}
|
||
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 {
|
||
// ★ junk 里的 ID 是占位序号,真正要的是**块 ID**。
|
||
// 块 ID 由内容派生(TripleBlockID / SentenceBlockID),
|
||
// 所以按文本反查而不是信任那个数字。
|
||
blk, err := g.blocksByTextTx(e.Name)
|
||
if err != nil {
|
||
return 0, 0, err
|
||
}
|
||
for _, b := range blk {
|
||
ids = append(ids, b.ID)
|
||
}
|
||
}
|
||
if len(ids) == 0 {
|
||
// ★ 识别到噪音但一个块 ID 都取不到 —— 这是**不一致**,
|
||
// 不能当成「无事可做」静默返回。
|
||
return 0, 0, fmt.Errorf("noise purge: %d junk names matched no blocks",
|
||
len(junk))
|
||
}
|
||
// ★ ph 必须按 **ids** 的长度生成,不能用 len(junk)。
|
||
//
|
||
// junk 是「噪音实体」数,ids 是它们反查到的**块 ID** 数。
|
||
// 两者只在「一个噪音文本对应一个块」时相等 ——
|
||
// 而迁移期的历史数据里同文本块可以重复(blk_ent_a / blk_ent_b)。
|
||
//
|
||
// ★ 用 len(junk) 会让占位符与参数个数不符,
|
||
// SQLite 直接报错(好);
|
||
// ★ 更坏的是个数**恰好相同但内容不同** ——
|
||
// 那就是静默删错块。所以这里按 ids 生成。
|
||
ph := placeholders(len(ids))
|
||
args := append(append([]interface{}{}, ids...), ids...)
|
||
|
||
// 关系数按 DISTINCT id 统计:两端都是噪音的关系不能被算两次。
|
||
var relCount int
|
||
if err := g.db.QueryRow(
|
||
// ★ 改查 memory_block_edges(2026-10-04)
|
||
// 旧表退场后 relations 空 ⇒ relCount 恒 0 ⇒
|
||
// PurgeNoise 的 dry-run 报告会说「0 条关系」而实际要删很多。
|
||
`SELECT COUNT(DISTINCT id) FROM memory_block_edges
|
||
WHERE source_kind='block' AND target_kind='block'
|
||
AND (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()
|
||
|
||
// ★★★ 改删边而不是旧表(2026-10-04)
|
||
//
|
||
// 旧实现删 relations + entities。旧表退场后这段变成**空操作但报成功** ——
|
||
// 而块侧的边还在 active ⇒ 场景召回照样返回那些噪音记忆。
|
||
//
|
||
// 这正是「写入成功但静默失效」的另一种形态:
|
||
// 用户点了「清理噪音」,界面说成功,记忆一条没少。
|
||
//
|
||
// ★ 只删**关系边**,不删结构边(contains):
|
||
// 结构边连的是原句块与媒体块,删了会让块体系出现悬空端点。
|
||
//
|
||
// ★★ 块本身要删(2026-10-04 修正)
|
||
//
|
||
// 我一开始写的是「块不删,孤立块交给 OrphanEntities」——
|
||
// 理由是旧实现也有同一条纪律(「一次改动只做一件事」)。
|
||
// ★★ 但那条纪律针对的是**孤儿块**(边没了、块还在,可能被别人引用),
|
||
// 而 PurgeNoise 删的是**已判定为噪音的块**:
|
||
// 它被删边的后果正是变成孤儿,而留着它等于清理没生效 ——
|
||
// NoiseEntities 会把它们继续报成噪音,PurgeNoise 再跑又报 4 个。
|
||
//
|
||
// 判据「清理后仍有噪音实体」当场抓住了这一点。
|
||
//
|
||
// 只删**文本块**(这些是噪音实体):媒体块、原句块不在 junk 里。
|
||
if _, err := tx.Exec(
|
||
`DELETE FROM memory_block_edges
|
||
WHERE source_kind='block' AND target_kind='block'
|
||
AND edge_type != 'contains'
|
||
AND (source_id IN (`+ph+`) OR target_id IN (`+ph+`))`,
|
||
args...); err != nil {
|
||
return 0, 0, err
|
||
}
|
||
if _, err := tx.Exec(
|
||
`DELETE FROM memory_blocks 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
|
||
}
|
||
// 节点没了,场景引用必须跟着对齐:残留引用会让场景看着大、召回却是空的。
|
||
if _, err := g.purgeStaleSceneRefsLocked(); 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) {
|
||
// ★★★ 改扫 **memory_blocks**(2026-10-04)
|
||
//
|
||
// 旧实现扫 entities 并用 relations 判孤立 —— 而这两张表
|
||
// 在停双写后**都不再增长、不再被写**,于是:
|
||
//
|
||
// ① 扫描结果是迁移时的快照,永远不变
|
||
// ② 新建的块(真正可能孤立的那批)根本不在其中
|
||
// ③ 而它还在排除 source_kind='entity' 的边 ——
|
||
// 那种 kind 在块化之后已经不产生了
|
||
//
|
||
// 实测(判据 TestPurgeOrphans):孤立的块一个都没识别出来。
|
||
//
|
||
// ★ 块侧的口径:
|
||
// · 没有任何**关系边**指向它 → 孤立
|
||
// · 与媒体块/原句块有 contains 结构边 → 不算孤立
|
||
// (那是 sentence/document --contains--> block 体系的一部分,
|
||
// 删了会让结构边悬空)
|
||
rows, err := g.db.Query(
|
||
`SELECT b.id, b.text_content, COALESCE(b.semantic_type,''),
|
||
b.created_at, b.updated_at
|
||
FROM memory_blocks b
|
||
WHERE b.text_content != ''
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM memory_block_edges e
|
||
WHERE (e.source_id = b.id OR e.target_id = b.id)
|
||
AND e.source_kind = 'block' AND e.target_kind = 'block'
|
||
AND e.edge_type != 'contains')
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM memory_block_edges e
|
||
WHERE (e.source_id = b.id OR e.target_id = b.id)
|
||
AND COALESCE(e.session_id,'') = ''
|
||
AND e.edge_type = 'contains')`)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
var out []Entity
|
||
for rows.Next() {
|
||
var e Entity
|
||
var blockID string
|
||
if err := rows.Scan(&blockID, &e.Name, &e.Type,
|
||
&e.CreatedAt, &e.UpdatedAt); err != nil {
|
||
return nil, err
|
||
}
|
||
// ★ ID 退化为序号;blockKey 才是真实块 ID
|
||
// (PurgeOrphans 按 blockKey 删除 —— 见该函数注释)。
|
||
e.ID = int64(len(out) + 1)
|
||
e.blockKey = blockID
|
||
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
|
||
}
|
||
|
||
// ★★ 删的是**块**,不是旧表行(2026-10-04)
|
||
//
|
||
// orphanEntitiesLocked 已改扫 memory_blocks —— 它返回的 Entity.ID
|
||
// 是「本次查询内的序号」,blockKey 才是真实块 ID。
|
||
//
|
||
// 原实现把 e.ID 当旧表行号去 DELETE FROM entities ⇒ 删错对象
|
||
// (而删不到任何行时静默报 0)。
|
||
ids := make([]interface{}, 0, len(orphans))
|
||
for _, e := range orphans {
|
||
if e.blockKey == "" {
|
||
// 没有块 ID = 不是块侧的孤立实体(理论上不该出现)
|
||
continue
|
||
}
|
||
ids = append(ids, e.blockKey)
|
||
}
|
||
if len(ids) == 0 {
|
||
return 0, nil
|
||
}
|
||
if _, err := g.db.Exec(
|
||
`DELETE FROM memory_blocks WHERE id IN (`+placeholders(len(ids))+`)`, ids...); err != nil {
|
||
return 0, err
|
||
}
|
||
if _, err := g.purgeStaleSceneRefsLocked(); err != nil {
|
||
return 0, err
|
||
}
|
||
return len(orphans), nil
|
||
}
|