Files
HomeAgent/internal/memory/scene.go
JianFeeeee 758ec11832 fix(memory): 图整备覆盖 scenes + 证据桶按桶清(R3/R5)
R3:图整理心跳只查 entities/relations,scenes 完全没有整备路径。
Recall(nil,nil,1,"") 的全量路径只 SELECT 这两张表
(graph.go:609/631),于是同一场面的双胞胎键从建库起无人发现:
auto:chan:qq+part:morning 累积到 strength=271 / 6 features / 0 refs,
孪生的 auto:chan:qq_part:morning 持有 210 refs 却 0 features
(聚类只读 scene_features,所以它永远不被看见)。

新增 GraphDB.DedupeScenes:归一化后同名的场景合成一个——强度相加、
特征取并集(权重取大)、引用全部重定向,存活者保留 id 最小行,
跨 origin 也合。接到 mergeLoop 尾部。

为什么不塞进 detectEntityMerge 的双重循环:
- 实体是全库两两 bigram + LLM 裁决(1 万实体实测 5000 万次配对、
  ~224GB 瞬时分配每轮,是独立问题);
- 场景的判重口径是**归一化后是否同名**——同名即同一场面,键相同
  本身就是证据,不需要 LLM 裁决。而「像不像」是 EnterScene 聚类的
  职责,不是这里的事。
只做同键合并、不做相似度合并:把 chan:qq 与 chan:webui 合并是危险
的,去重不是「把像的一律合并」。

生产库副本实测(sqlite3 备份式复制到 /tmp,未碰生产):
65 个场景 → 合并 8 组 → 57 个;
auto:chan:qq_part:morning 的 refs/rel/ent 一条没丢,strength 1 → 272。

R5:createSceneLocked 新场景成立时执行的是
`DELETE FROM situation_evidence`(全表清),而证据表是多通道共用的
计数桶。后果不是「多清一点」:qq 的场景一长出来,就把 mc/webui/cli
尚未攒够 minSceneEvidence=2 的证据抹掉,它们的计数被反复清零,
于是**永远**攒不到 2 次。判据实测:6 个通道各来 3 次,只长出 2 个场景。
改为 `DELETE ... WHERE label = ?`,只清本指纹那个桶。

判据:scene_dedupe_test.go 8 例(含「不同场面不得被合并」与幂等)、
scene_evidence_test.go 3 例。均先红后绿。修 R5 时差点栽:桶键是
sig.Label(2) 本身、不带 auto: 前缀(base 才是带前缀的场景键),
第一版删错对象会「一条没删却看起来通过」,用探针实测真实桶键后改正。

记忆 8 包 + agent/core 全绿,8 包齐全、无 FAIL/panic/race。
2026-09-26 20:16:11 +08:00

726 lines
24 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 (
"database/sql"
"fmt"
"sort"
"strings"
"time"
"unicode"
)
// ──────────────────────────────────────────────
// 场景式关联召回
//
// 词法与向量召回都建立在「字面/语义相似」上,而带**条件**的记忆天生不吃这一套:
//
// QQ回复禁用Markdown格式 --规定--> 纯文本不用Markdown
// 老大 --偏好--> QQ回复禁用Markdown格式
//
// 这些规则约束的是「回 QQ 消息这个场面」,而不是某个话题。用户措辞里没出现
// 「QQ」「Markdown」时它们召不回来;而用户只说了「QQ」时,词法路又会把上百个
// 含 QQ 的实体按建表顺序排前面,把规则本体挤出注入预算(实测:输入「QQ回复格式」
// 命中 148 个实体,规则排第 32,注入只取前 5 —— 规则根本没进去)。
//
// 场景引用把「触发条件」变成一等索引:节点记住自己属于哪个场面,
// 场面重现时按场景直接取回,与措辞无关。
//
// 场景键的形态是**分层字符串**,用 `/` 分隔,由宽到窄:
//
// chan:qq 通道级(在 QQ 上收发消息)
// chan:qq/peer:group_1027993713 再窄一层(具体群)
// tool:qq_get_message 工具级(取回消息正文这一步)
// chan:doc/src:qq 文档归档的来源
//
// 召回按**前缀**匹配:当前场景 `chan:qq` 会取回它自己以及所有更窄的场景
// (`chan:qq/...`)——越窄的场景越具体,不该被漏掉;反向不成立。
// ──────────────────────────────────────────────
// maxSceneKeyLen 是场景键的长度上限。场景键要进索引、要参与前缀比较,
// 过长只说明有人把正文塞进了键里(那就该用句子/实体,而不是场景)。
const maxSceneKeyLen = 96
// NormalizeSceneKey 规范化场景键:按 `/` 分层、每层小写、层内空白与非法字符
// 归一成 `_`(连续多个只留一个)。
//
// 为什么要归一:场景键是**索引键**,`chan:QQ` 与 `chan:qq` 必须是同一个场景,
// 否则同一条规则会因为写入时大小写不同而分裂成两个召不齐的场景。
// 为什么空白不算层级分隔符:来源名里天然带空格(如「老大2026-09-04 12:27
// QQ私聊图片」),把它当层级会把一个平面名字拆成三层伪层级。
// 归一结果为空(全是非法字符)时返回空串,调用方应视为「没有场景」。
func NormalizeSceneKey(key string) string {
key = strings.TrimSpace(key)
if key == "" {
return ""
}
key = strings.ToLower(key)
parts := strings.Split(key, "/")
segs := make([]string, 0, len(parts))
for _, p := range parts {
seg := normalizeSceneSegment(p)
if seg != "" {
segs = append(segs, seg)
}
}
out := strings.Join(segs, "/")
if len(out) > maxSceneKeyLen {
out = strings.TrimRight(out[:maxSceneKeyLen], "/")
}
return out
}
// normalizeSceneSegment 归一单层场景:小写、空白与非法字符 → `_`(压缩连续)。
func normalizeSceneSegment(seg string) string {
var b strings.Builder
lastUnderscore := false
for _, r := range seg {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r >= '\u4e00' && r <= '\u9fff',
r == ':' || r == '-' || r == '.':
b.WriteRune(r)
lastUnderscore = false
case r == '_' || unicode.IsSpace(r):
if !lastUnderscore && b.Len() > 0 {
b.WriteRune('_')
lastUnderscore = true
}
default:
// 其它字符(标点、表情)归一成 `_` 而不是静默丢弃:
// 「A/B」与「A_B」是两个不同的来源,不能塌成一个场景。
if !lastUnderscore && b.Len() > 0 {
b.WriteRune('_')
lastUnderscore = true
}
}
}
return strings.Trim(b.String(), "_")
}
// ChannelScene 由输入/输出通道名构造场景键(`qq` → `chan:qq`)。
func ChannelScene(source string) string {
s := NormalizeSceneKey(source)
if s == "" {
return ""
}
return "chan:" + s
}
// ToolScene 由工具名构造场景键(`qq_get_message` → `tool:qq_get_message`)。
func ToolScene(tool string) string {
s := NormalizeSceneKey(tool)
if s == "" {
return ""
}
return "tool:" + s
}
// SceneStat 是单个场景的规模摘要(供 introspection / 运维观察)。
type SceneStat struct {
Key string `json:"key"`
Refs int `json:"refs"`
Relations int `json:"relations"`
Entities int `json:"entities"`
// Strength 是该场景被重现(强化)的次数;Features 是它长出的特征数。
// 两者一起说明「这个场景是不是真的在涌现」,而不是被一次性写出来的。
Strength int `json:"strength"`
Features int `json:"features"`
// Origin 是这条场景来自哪条路:declared(主动声明)或 emergent(被动涌现)。
Origin string `json:"origin"`
UpdatedAt time.Time `json:"updated_at"`
}
// SceneRecall 是一次场景召回的产物。
type SceneRecall struct {
Scenes []string `json:"scenes"`
Relations []Relation `json:"relations"`
Entities []Entity `json:"entities"`
// Blocks 是该场景下的一等记忆块(图/音/文)。
//
// 为什么场景要能取回块:块是流水线里最细的子项目,而「那场对话里发过来的
// 那张图」只记住名字是没用的——场面重现时要把块本身带回来。
Blocks []MemoryBlock `json:"blocks,omitempty"`
// Documents 是该场景下的 L3 文档节点 id(文档的场景由来源派生,见 TagSceneDocument)。
Documents []string `json:"documents,omitempty"`
}
// tagSceneTx 在事务内把「关系 + 实体」挂到场景上(幂等 upsert)。
//
// weight 取关系的置信度:场景内的记忆也要能排序,置信度是目前唯一现成的
// 质量信号。重复写入同一节点只刷新 weight 与时间,不产生重复引用。
func tagSceneTx(tx *sql.Tx, sceneKey string, relationID int64, entityIDs []int64, weight float64) error {
key := NormalizeSceneKey(sceneKey)
if key == "" {
return nil
}
if relationID != 0 {
if err := tagSceneRefTx(tx, key, "relation", relationID, "", weight); err != nil {
return err
}
}
for _, eid := range entityIDs {
if eid != 0 {
if err := tagSceneRefTx(tx, key, "entity", eid, "", weight); err != nil {
return err
}
}
}
return nil
}
// tagSceneRefTx 在事务内把一个节点挂到场景上(幂等 upsert)。
//
// kind ∈ relation | entity | block | document。textID 供非数值主键的节点使用
// (块与文档的 id 是字符串),数值型节点传 0 并用 id。
//
// 为什么 weight 取 MAX 而不是覆盖:场景内的记忆也要能排序,置信度是目前唯一
// 现成的质量信号;同一节点被低置信度的重复写入命中的,不该把它从场景前排挤下去。
func tagSceneRefTx(tx *sql.Tx, sceneKey, kind string, id int64, textID string, weight float64) error {
key := NormalizeSceneKey(sceneKey)
if kind == "" || (id == 0 && textID == "") {
return nil
}
if key == "" {
return nil
}
if _, err := tx.Exec(
`INSERT INTO scenes (key) VALUES (?)
ON CONFLICT(key) DO UPDATE SET updated_at = CURRENT_TIMESTAMP`, key); err != nil {
return fmt.Errorf("upsert scene %q: %w", key, err)
}
var sceneID int64
if err := tx.QueryRow(`SELECT id FROM scenes WHERE key = ?`, key).Scan(&sceneID); err != nil {
return fmt.Errorf("select scene %q: %w", key, err)
}
if weight <= 0 {
weight = 1.0
}
if _, err := tx.Exec(
`INSERT INTO scene_refs (scene_id, kind, ref_id, ref_text, weight) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(scene_id, kind, ref_id, ref_text)
DO UPDATE SET weight = MAX(weight, excluded.weight), decayed_at = CURRENT_TIMESTAMP`,
sceneID, kind, id, textID, weight); err != nil {
return fmt.Errorf("upsert scene ref %s/%d%s: %w", kind, id, textID, err)
}
return nil
}
// TagScene 给一批已有的关系补挂场景(存量记忆的场景标注入口)。
//
// 为什么需要「事后标注」:场景是后引入的维度,此前写下的规则(那批 QQ 规则
// 就是典型)没有任何场景引用,不补挂就永远吃不到场景召回。
func (g *GraphDB) TagScene(sceneKey string, relationIDs []int64) (int, error) {
g.mu.Lock()
defer g.mu.Unlock()
key := NormalizeSceneKey(sceneKey)
if key == "" || len(relationIDs) == 0 {
return 0, nil
}
tx, err := g.db.Begin()
if err != nil {
return 0, err
}
defer tx.Rollback()
n := 0
for _, rid := range relationIDs {
var sourceID, targetID int64
var confidence float64
if err := tx.QueryRow(
`SELECT source_id, target_id, confidence FROM relations WHERE id = ?`, rid,
).Scan(&sourceID, &targetID, &confidence); err != nil {
if err == sql.ErrNoRows {
continue
}
return n, err
}
if err := tagSceneTx(tx, key, rid, []int64{sourceID, targetID}, confidence); err != nil {
return n, err
}
n++
}
if err := tx.Commit(); err != nil {
return 0, err
}
return n, nil
}
// TagSceneByEntityGlob 把「任一端实体名匹配 GLOB pattern」的活跃关系标进场景,
// 返回标注的关系数。dryRun 时只统计。
//
// 这是**存量引导**用的窄口子:pattern 由调用方显式给出,不做任何自动猜测
// ——猜错的代价是把无关记忆钉死在某个场景上,之后每次进入该场景都会被注入,
// 比漏标更难发现。
//
// 为什么用 GLOB 而不是 LIKE:LIKE 对 ASCII **不区分大小写**,于是 `%QQ%`
// 会把对象里带 `/home/newqqagent` 的路径类记忆(生产数据目录、email-mcp、
// dify-ops技能路径…实测 7 条)一起卷进「QQ 场景」。GLOB 区分大小写,
// `*QQ*` 只命中真正写作 QQ 的那些名字。
func (g *GraphDB) TagSceneByEntityGlob(sceneKey, pattern string, dryRun bool) (int, error) {
key := NormalizeSceneKey(sceneKey)
if key == "" || pattern == "" {
return 0, nil
}
g.mu.Lock()
defer g.mu.Unlock()
rows, err := g.db.Query(
`SELECT r.id, r.source_id, r.target_id, r.confidence
FROM relations r
JOIN entities e1 ON r.source_id = e1.id
JOIN entities e2 ON r.target_id = e2.id
WHERE r.status = 'active' AND (e1.name GLOB ? OR e2.name GLOB ?)`,
pattern, pattern)
if err != nil {
return 0, err
}
type cand struct {
relID int64
sourceID, target int64
confidence float64
}
var cands []cand
for rows.Next() {
var c cand
if err := rows.Scan(&c.relID, &c.sourceID, &c.target, &c.confidence); err != nil {
rows.Close()
return 0, err
}
cands = append(cands, c)
}
rows.Close()
if err := rows.Err(); err != nil {
return 0, err
}
if dryRun {
return len(cands), nil
}
tx, err := g.db.Begin()
if err != nil {
return 0, err
}
defer tx.Rollback()
for _, c := range cands {
if err := tagSceneTx(tx, key, c.relID, []int64{c.sourceID, c.target}, c.confidence); err != nil {
return 0, err
}
}
if err := tx.Commit(); err != nil {
return 0, err
}
return len(cands), nil
}
// RecallByScene 按当前场景取回被钉在该场景上的记忆(前缀匹配,越窄越算命中)。
//
// 排序:weight(=写入时置信度)降序 → 关系时间降序。取回的是**关系全文**
// (含 relation_type 与 JOIN 出的原句),不只是实体名——带条件的规则本体
// 长在关系上,只给名字等于没召回。
//
// limit 同时约束关系数与实体数,避免一个场景把注入预算吃光。
func (g *GraphDB) RecallByScene(scenes []string, limit int) (*SceneRecall, error) {
out := &SceneRecall{}
var keys []string
seen := make(map[string]bool)
for _, s := range scenes {
k := NormalizeSceneKey(s)
if k == "" || seen[k] {
continue
}
seen[k] = true
keys = append(keys, k)
}
if len(keys) == 0 {
return out, nil
}
out.Scenes = keys
if limit <= 0 {
limit = 8
}
g.mu.RLock()
defer g.mu.RUnlock()
// 前缀条件:(key = ? OR key LIKE ? || '/%'),用 '/' 兜底防止
// `chan:qq` 误吞 `chan:qq2` 这种同前缀但不同层的场景。
conds := make([]string, 0, len(keys))
args := make([]interface{}, 0, len(keys)*2)
for _, k := range keys {
conds = append(conds, `(s.key = ? OR s.key LIKE ? || '/%')`)
args = append(args, k, k)
}
where := "(" + strings.Join(conds, " OR ") + ")"
relQuery := `SELECT r.id, r.source_id, r.target_id, e1.name, e2.name,
r.relation_type, r.confidence, r.status, r.session_id,
r.turn_id, r.created_at, COALESCE(r.date_bucket, ''),
COALESCE(r.sentence_id, 0), COALESCE(sn.text, ''),
MAX(sr.weight) AS w
FROM scene_refs sr
JOIN scenes s ON sr.scene_id = s.id
JOIN relations r ON sr.kind = 'relation' AND sr.ref_id = r.id
JOIN entities e1 ON r.source_id = e1.id
JOIN entities e2 ON r.target_id = e2.id
LEFT JOIN sentences sn ON r.sentence_id = sn.id
WHERE ` + where + ` AND r.status = 'active'
GROUP BY r.id
ORDER BY w DESC, r.updated_at DESC, r.id DESC
LIMIT ?`
relArgs := append(append([]interface{}{}, args...), limit)
rows, err := g.db.Query(relQuery, relArgs...)
if err != nil {
return nil, err
}
for rows.Next() {
var rel Relation
var w float64
if err := rows.Scan(&rel.ID, &rel.SourceID, &rel.TargetID, &rel.SourceName, &rel.TargetName,
&rel.RelationType, &rel.Confidence, &rel.Status, &rel.SessionID,
&rel.TurnID, &rel.CreatedAt, &rel.DateBucket, &rel.SentenceID, &rel.SentenceText, &w); err != nil {
rows.Close()
return nil, err
}
out.Relations = append(out.Relations, rel)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
entQuery := `SELECT e.id, e.name, e.type, e.mention_count, e.created_at, e.updated_at, MAX(sr.weight) AS w
FROM scene_refs sr
JOIN scenes s ON sr.scene_id = s.id
JOIN entities e ON sr.kind = 'entity' AND sr.ref_id = e.id
WHERE ` + where + `
GROUP BY e.id
ORDER BY w DESC, e.mention_count DESC, e.id DESC
LIMIT ?`
entArgs := append(append([]interface{}{}, args...), limit)
erows, err := g.db.Query(entQuery, entArgs...)
if err != nil {
return nil, err
}
for erows.Next() {
var e Entity
var w float64
if err := erows.Scan(&e.ID, &e.Name, &e.Type, &e.MentionCount, &e.CreatedAt, &e.UpdatedAt, &w); err != nil {
erows.Close()
return nil, err
}
out.Entities = append(out.Entities, e)
}
erows.Close()
if err := erows.Err(); err != nil {
return nil, err
}
blockQuery := `SELECT b.id, b.modality, b.text_content, b.payload_digest, b.mime,
b.size, b.width, b.height, b.fingerprint, b.source, b.tool,
COALESCE(b.scene, ''), b.created_at, b.updated_at, MAX(sr.weight) AS w
FROM scene_refs sr
JOIN scenes s ON sr.scene_id = s.id
JOIN memory_blocks b ON sr.kind = 'block' AND b.id = sr.ref_text
WHERE ` + where + `
GROUP BY b.id
ORDER BY w DESC, b.created_at DESC
LIMIT ?`
blockArgs := append(append([]interface{}{}, args...), limit)
brows, err := g.db.Query(blockQuery, blockArgs...)
if err != nil {
return nil, err
}
defer brows.Close()
for brows.Next() {
var b MemoryBlock
var w float64
if err := brows.Scan(&b.ID, &b.Modality, &b.Text, &b.PayloadDigest, &b.MIME,
&b.Size, &b.Width, &b.Height, &b.Fingerprint, &b.Source, &b.Tool,
&b.Scene, &b.CreatedAt, &b.UpdatedAt, &w); err != nil {
return nil, err
}
out.Blocks = append(out.Blocks, b)
}
if err := brows.Err(); err != nil {
return nil, err
}
docQuery := `SELECT sr.ref_text, MAX(sr.weight) AS w
FROM scene_refs sr JOIN scenes s ON sr.scene_id = s.id
WHERE ` + where + ` AND sr.kind = 'document'
GROUP BY sr.ref_text ORDER BY w DESC LIMIT ?`
docArgs := append(append([]interface{}{}, args...), limit)
drows, err := g.db.Query(docQuery, docArgs...)
if err != nil {
return nil, err
}
defer drows.Close()
for drows.Next() {
var id string
var w float64
if err := drows.Scan(&id, &w); err != nil {
return nil, err
}
out.Documents = append(out.Documents, id)
}
return out, drows.Err()
}
// SceneStats 返回各场景的规模,按引用数降序。
func (g *GraphDB) SceneStats() ([]SceneStat, error) {
g.mu.RLock()
defer g.mu.RUnlock()
rows, err := g.db.Query(
`SELECT s.key,
COUNT(sr.id),
SUM(CASE WHEN sr.kind = 'relation' THEN 1 ELSE 0 END),
SUM(CASE WHEN sr.kind = 'entity' THEN 1 ELSE 0 END),
COALESCE(s.strength, 1),
(SELECT COUNT(*) FROM scene_features f WHERE f.scene_id = s.id),
COALESCE(s.origin, 'emergent'),
s.updated_at
FROM scenes s LEFT JOIN scene_refs sr ON sr.scene_id = s.id
GROUP BY s.id ORDER BY COUNT(sr.id) DESC, s.key`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []SceneStat
for rows.Next() {
var st SceneStat
var rels, ents sql.NullInt64
if err := rows.Scan(&st.Key, &st.Refs, &rels, &ents, &st.Strength, &st.Features, &st.Origin, &st.UpdatedAt); err != nil {
return nil, err
}
st.Relations = int(rels.Int64)
st.Entities = int(ents.Int64)
out = append(out, st)
}
if err := rows.Err(); err != nil {
return nil, err
}
sort.SliceStable(out, func(i, j int) bool { return out[i].Refs > out[j].Refs })
return out, nil
}
// PurgeStaleSceneRefs 清理指向已不存在节点的场景引用,返回删除数。
//
// 节点被清理(PurgeNoise / PurgeOrphans / memory_delete_entity)时不会级联
// 删 scene_refs(见建表注释),残留引用会让场景看起来很大却召回出空结果,
// 也会让 SceneStats 说谎。这个函数把它们对齐。
func (g *GraphDB) PurgeStaleSceneRefs() (int, error) {
g.mu.Lock()
defer g.mu.Unlock()
return g.purgeStaleSceneRefsLocked()
}
func (g *GraphDB) purgeStaleSceneRefsLocked() (int, error) {
res, err := g.db.Exec(`DELETE FROM scene_refs WHERE
(kind = 'relation' AND ref_id NOT IN (SELECT id FROM relations))
OR (kind = 'entity' AND ref_id NOT IN (SELECT id FROM entities))
OR (kind = 'block' AND ref_text NOT IN (SELECT id FROM memory_blocks))
OR (kind = 'document' AND ref_text NOT IN (SELECT id FROM documents))`)
if err != nil {
return 0, err
}
n, _ := res.RowsAffected()
return int(n), nil
}
// ScenesOfRelation 取回一条关系当前所属的全部场景键。
//
// 用途:memory_edit 是「删旧写新」——旧关系的 scene_refs 会随节点一起失效,
// 新关系若不重新挂上场景,这条记忆就**静默地脱离场景**,此后场面重现也召不回。
func (g *GraphDB) ScenesOfRelation(relationID int64) ([]string, error) {
g.mu.RLock()
defer g.mu.RUnlock()
rows, err := g.db.Query(
`SELECT s.key FROM scene_refs sr JOIN scenes s ON sr.scene_id = s.id
WHERE sr.kind = 'relation' AND sr.ref_id = ?`, relationID)
if err != nil {
return nil, err
}
defer rows.Close()
var keys []string
for rows.Next() {
var k string
if err := rows.Scan(&k); err != nil {
return nil, err
}
keys = append(keys, k)
}
return keys, rows.Err()
}
// TagSceneDocument 把 L3 文档节点挂到场景上(document 层与场景模型兼容)。
//
// 文档的「场景」不由存储字段决定,而由**来源**派生(chan:<source>)——存一份
// 冗余的 Doc.Scene 会随来源改名而说谎,是同一事实的第二份真相。
// 这里只登记「这份文档属于哪些场面」,供 scene 侧枚举与统计。
func (g *GraphDB) TagSceneDocument(sceneKey, docID string) error {
if docID == "" {
return nil
}
key := NormalizeSceneKey(sceneKey)
if key == "" {
return nil
}
g.mu.Lock()
defer g.mu.Unlock()
tx, err := g.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if err := tagSceneRefTx(tx, key, "document", 0, docID, 1.0); err != nil {
return err
}
return tx.Commit()
}
// ──────────────────────────────────────────────
// 场景去重(整备):把「同一个场面的两个键」合成一个
// ──────────────────────────────────────────────
// DedupeScenes 合并归一化后同名的场景,返回合并组数。
//
// 存在的原因:scenes.key 有 UNIQUE 约束,但**归一化口径曾经不统一**——
// 建键路径用 "auto:" + Label(2) 而 Label 拼的是 "+",不过 NormalizeSceneKey;
// 而写侧(effectiveScenes)、读侧(RecallByScene)、声明建键(EnsureScene)
// 三处都过了归一化。于是 "+" 与 "_" 成为两个都合法的主键,UNIQUE 拦不住:
//
// auto:chan:qq+part:morning strength=270 6 features 0 refs
// auto:chan:qq_part:morning strength=1 0 features 201 refs
//
// 两个节点互不可见:聚类只读 scene_features,所以 0-features 的那个
// 永远不被看见;而 0-refs 的那个收不到任何写侧记忆。实测这对双胞胎
// 从建库起累积到 strength=270 都没人发现——因为图整理心跳(mergeLoop)
// 的遍历入口 Recall(nil,nil,1,"") 只查 entities 与 relations,scenes
// 不在其中。
//
// 合并口径:**只有归一化后完全同名才算重复**。相似但不同的场面
// (chan:qq 与 chan:webui)绝不合并——去重不是"把像的一律合并"。
// 场景之间的相似度判定是 EnterScene 的聚类职责,那是另一件事。
//
// 存活规则:保留 id 最小的那一行(先来者),其余并入它。
// 强度相加、特征取并集(权重取大)、引用全部重定向。
// 跨 origin 也合:现网存在 origin='emergent' 却长得像声明键的
// chan:context_archived。
func (g *GraphDB) DedupeScenes() (int, error) {
g.mu.Lock()
defer g.mu.Unlock()
rows, err := g.db.Query(`SELECT id, key FROM scenes ORDER BY id`)
if err != nil {
return 0, err
}
type row struct {
id int64
key string
}
var all []row
for rows.Next() {
var r row
if err := rows.Scan(&r.id, &r.key); err != nil {
rows.Close()
return 0, err
}
all = append(all, r)
}
rows.Close()
if err := rows.Err(); err != nil {
return 0, err
}
if len(all) < 2 {
return 0, nil
}
// 按归一化后的键分组,组内 id 最小者为存活者。
groups := make(map[string][]row)
order := make([]string, 0, len(all))
for _, r := range all {
nk := NormalizeSceneKey(r.key)
if nk == "" {
// 键归一化后为空:无法判定它与谁重复,跳过(不擅自删数据)。
continue
}
if _, seen := groups[nk]; !seen {
order = append(order, nk)
}
groups[nk] = append(groups[nk], r)
}
merged := 0
for _, nk := range order {
gp := groups[nk]
if len(gp) < 2 {
continue
}
keep := gp[0] // ORDER BY id ⇒ 最早创建的那行
tx, err := g.db.Begin()
if err != nil {
return merged, err
}
err = func() error {
for _, dup := range gp[1:] {
// 强度相加。
if _, err := tx.Exec(
`UPDATE scenes SET strength = COALESCE(strength,1) +
COALESCE((SELECT strength FROM scenes WHERE id = ?), 0),
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`, dup.id, keep.id); err != nil {
return err
}
// 特征取并集,权重取大。
if _, err := tx.Exec(
`INSERT INTO scene_features (scene_id, feature, weight)
SELECT ?, feature, weight FROM scene_features WHERE scene_id = ?
ON CONFLICT(scene_id, feature) DO UPDATE
SET weight = MAX(weight, excluded.weight)`,
keep.id, dup.id); err != nil {
return err
}
if _, err := tx.Exec(
`DELETE FROM scene_features WHERE scene_id = ?`, dup.id); err != nil {
return err
}
// 引用重定向。UNIQUE(scene_id,kind,ref_id,ref_text) 会与存活者
// 上的同一条冲突——冲突即同一条记忆,取权重大的那条。
if _, err := tx.Exec(
`INSERT INTO scene_refs (scene_id, kind, ref_id, ref_text, weight)
SELECT ?, kind, ref_id, ref_text, weight FROM scene_refs WHERE scene_id = ?
ON CONFLICT(scene_id, kind, ref_id, ref_text) DO UPDATE
SET weight = MAX(weight, excluded.weight)`,
keep.id, dup.id); err != nil {
return err
}
if _, err := tx.Exec(
`DELETE FROM scene_refs WHERE scene_id = ?`, dup.id); err != nil {
return err
}
if _, err := tx.Exec(`DELETE FROM scenes WHERE id = ?`, dup.id); err != nil {
return err
}
merged++
}
// 存活者的键也归一化,避免下次又认不出自己。
if _, err := tx.Exec(`UPDATE scenes SET key = ? WHERE id = ?`, nk, keep.id); err != nil {
return err
}
return nil
}()
if err != nil {
tx.Rollback()
return merged, err
}
if err := tx.Commit(); err != nil {
return merged, err
}
}
return merged, nil
}