fix(rel): 场景贯穿流水线到块层 + 编辑不再丢置信度/场景 + 构建默认带 onnxruntime

三件事,前两件是上一轮热部署暴露/遗留的真缺陷。

1) 热部署差点静默降级(已修)
   `make build` 之前**不带任何 tags**,而发行构建(deploy/packaging/build.sh)
   默认 HOMED_TAGS=onnxruntime,package-linux.sh 还会直接拒收非 onnxruntime 二进制。
   实测差异:33MB vs 84MB;启动日志里
   「multimodal space active: provider=chineseclip dim=512」整行消失、
   少加载一个插件(chinese-clip/qwen3vl provider 降级)、
   静态词向量退回 fallback。即「随手 make build」与「发行构建」不是同一个东西,
   而部署时无从察觉。
   修:Makefile 的 build 默认 HOMED_TAGS ?= onnxruntime(与打包脚本一致),
   构建后自动校验二进制里有没有 onnxruntime,缺了就打 WARN。
   生产已按此重新构建部署(v1.4.0+hotfix.d98bf51,已核实 provider 行回归)。

2) memory_edit 每跑一次就静默降级一次(新)
   memory_edit 是「按包含匹配 Purge + 写新三元组」,中间那一步把旧关系的
   置信度、原句、**场景引用**全丢了:置信度被重置成默认 1.0,场景钉死的记忆
   被打散成无场景。而关系复审心跳(reviewLoop)走的正是这条路——每轮复审都
   在无声地削记忆质量。
   修:编辑前用 FindRelations 精确取回旧关系,把置信度/原句/场景带到新三元组;
   新增 ScenesOfRelation。Purge(hard/soft)与 PurgeNoise/PurgeOrphans 之后
   统一清理悬空 scene_refs,SceneStats 不再说谎。

3) 场景贯穿流水线到块层(按「rel 应贯穿整条流水线」的设计)
   此前场景只到 relation/entity:块(L0/L3 一等记忆块)没有场景,于是
   「那场 QQ 对话里发过来的那张图」在场面重现时永远取不回来。
   - MemoryBlock.Scene + memory_blocks.scene 列(幂等 ALTER 迁移)。
   - scene_refs 增加 ref_text 承载字符串主键(块/文档 id 不是数值)。
     **不能只 ALTER ADD COLUMN**:唯一约束要从 (scene_id,kind,ref_id) 变成
     含 ref_text 的四元组,而 ALTER 改不了约束——旧约束会让「同场景第 2 个块」
     直接冲突(只在多块场景暴露)。改为按列探测后整表重建并搬运旧数据。
   - PutMemoryBlocks 同事务挂 scene_refs(kind='block');无场景重写不覆盖已有场景
     (否则一次无场景重写就静默抹掉挂载)。
   - RecallByScene 返回块;FormatContext 增「场景素材」段(模态 + 文本/短 digest),
     上限 3 条。
   - 生产者接线:attachBlocksToSentence 让块继承承载它的三元组的场景;
     linkBlocksToDocument 让文档的块继承文档来源场景(QQ 归档的图挂 chan:qq)。

验证:go build/vet 干净,go test -count=1 ./... 全绿。
新增用例:场景块(取回/同场景多块/无场景重写不抹场景/悬空引用清理)、
**旧表结构迁移**(降级成旧 scene_refs 后重开,旧数据保留且多块可写)、
场景素材注入、FindRelations+ScenesOfRelation 编辑搬运闭环。

生产:已重建(-tags onnxruntime)并原子替换 /usr/local/bin/homed + 重启,
35 插件全加载、panic/fatal=0、chineseclip 空间 active。
This commit is contained in:
JianFeeeee
2026-09-15 09:03:36 +08:00
parent d98bf512e1
commit 9d45cd0277
10 changed files with 532 additions and 53 deletions

View File

@ -158,6 +158,7 @@ func (g *GraphDB) initSchema() error {
fingerprint TEXT DEFAULT '',
source TEXT DEFAULT '',
tool TEXT DEFAULT '',
scene TEXT DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`,
@ -196,9 +197,12 @@ func (g *GraphDB) initSchema() error {
scene_id INTEGER NOT NULL,
kind TEXT NOT NULL,
ref_id INTEGER NOT NULL,
-- ref_text 承载非数值主键的节点 id(块/文档的 id 是字符串),
-- 数值型节点(relation/entity)为空串。
ref_text TEXT NOT NULL DEFAULT '',
weight REAL DEFAULT 1.0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(scene_id, kind, ref_id)
UNIQUE(scene_id, kind, ref_id, ref_text)
)`,
`CREATE INDEX IF NOT EXISTS idx_scene_refs_scene ON scene_refs(scene_id, kind)`,
`CREATE INDEX IF NOT EXISTS idx_scene_refs_ref ON scene_refs(kind, ref_id)`,
@ -226,6 +230,40 @@ func (g *GraphDB) initSchema() error {
tx.Exec(`ALTER TABLE relations ADD COLUMN sentence_ref TEXT DEFAULT ''`)
// 迁移2:为新表添加 sentence_id 列(必须放在索引创建之前,否则旧表无此列导致索引创建失败)
tx.Exec(`ALTER TABLE relations ADD COLUMN sentence_id INTEGER DEFAULT 0`)
// 迁移4:记忆块加场景列(旧表已存在时 CREATE TABLE IF NOT EXISTS 不会补列)
tx.Exec(`ALTER TABLE memory_blocks ADD COLUMN scene TEXT DEFAULT ''`)
// 迁移5:场景引用加 ref_text(块/文档的 id 是字符串)。
//
// 不能只 `ALTER TABLE ADD COLUMN`:REF_TEXT 同时参与唯一约束
// (scene_id, kind, ref_id, ref_text),而 ALTER 改不了已有约束。旧约束
// (scene_id, kind, ref_id) 会让「同一场景下的第 2 个块」直接冲突——
// 表现是块写不进场景、且只在有多个块时才出现。
// 因此按需整表重建(表小、操作幂等):判定依据是 ref_text 列是否存在。
if !columnExists(tx, "scene_refs", "ref_text") {
migrate := []string{
`CREATE TABLE scene_refs_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scene_id INTEGER NOT NULL,
kind TEXT NOT NULL,
ref_id INTEGER NOT NULL,
ref_text TEXT NOT NULL DEFAULT '',
weight REAL DEFAULT 1.0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(scene_id, kind, ref_id, ref_text)
)`,
`INSERT INTO scene_refs_new (id, scene_id, kind, ref_id, ref_text, weight, created_at)
SELECT id, scene_id, kind, ref_id, '', weight, created_at FROM scene_refs`,
`DROP TABLE scene_refs`,
`ALTER TABLE scene_refs_new RENAME TO scene_refs`,
`CREATE INDEX IF NOT EXISTS idx_scene_refs_scene ON scene_refs(scene_id, kind)`,
`CREATE INDEX IF NOT EXISTS idx_scene_refs_ref ON scene_refs(kind, ref_id)`,
}
for _, m := range migrate {
if _, err := tx.Exec(m); err != nil {
return fmt.Errorf("migrate scene_refs: %w", err)
}
}
}
// 迁移3:将现有 sentence_ref 数据迁移到 sentences 表
tx.Exec(`INSERT OR IGNORE INTO sentences (text) SELECT DISTINCT sentence_ref FROM relations WHERE sentence_ref != ''`)
tx.Exec(`UPDATE relations SET sentence_id = (SELECT id FROM sentences WHERE text = relations.sentence_ref) WHERE sentence_ref != ''`)
@ -822,6 +860,10 @@ func (g *GraphDB) Purge(criteria map[string]string, mode string) (int, error) {
SELECT DISTINCT source_id FROM relations
UNION SELECT DISTINCT target_id FROM relations)`)
// 关系没了,它的场景引用必须跟着对齐:残留引用会让场景看着很大、
// 召回却是空的(SceneStats 也跟着说谎)。
g.purgeStaleSceneRefsLocked()
return int(n), nil
}
@ -833,6 +875,9 @@ func (g *GraphDB) Purge(criteria map[string]string, mode string) (int, error) {
return 0, err
}
n, _ := result.RowsAffected()
// 软删除也要摘掉场景引用:RecallByScene 只返回 status='active',
// 留着引用只会在场景里挂一条永远召不回的幽灵。
g.purgeStaleSceneRefsLocked()
return int(n), nil
}
@ -1193,3 +1238,58 @@ func placeholders(n int) string {
}
return string(b)
}
// FindRelations 按实体名与关系类型**精确**查找活跃关系(带原句)。
//
// 为什么需要精确查找:memory_edit 走的是「按包含匹配 Purge + 写入新三元组」,
// 中间那一步会把旧关系的附加信息(置信度、场景、原句)一起丢掉。
// 编辑前先精确取回这条关系,才能把这些信息带过去。
func (g *GraphDB) FindRelations(subject, relationType, object string) ([]Relation, error) {
g.mu.RLock()
defer g.mu.RUnlock()
rows, err := g.db.Query(
`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, '')
FROM relations r
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 r.status = 'active' AND e1.name = ? AND r.relation_type = ? AND e2.name = ?
ORDER BY r.id DESC`, subject, relationType, object)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Relation
for rows.Next() {
var rel Relation
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); err != nil {
return nil, err
}
out = append(out, rel)
}
return out, rows.Err()
}
// columnExists 判断表里是否已有某列(SQLite 的 ALTER 无法改约束,只能按列探测后重建)。
func columnExists(tx *sql.Tx, table, column string) bool {
rows, err := tx.Query(`SELECT name FROM pragma_table_info(?)`, table)
if err != nil {
return false
}
defer rows.Close()
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return false
}
if name == column {
return true
}
}
return false
}