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

@ -129,6 +129,11 @@ type SceneRecall struct {
Scenes []string `json:"scenes"`
Relations []Relation `json:"relations"`
Entities []Entity `json:"entities"`
// Blocks 是该场景下的一等记忆块(图/音/文)。
//
// 为什么场景要能取回块:块是流水线里最细的子项目,而「那场对话里发过来的
// 那张图」只记住名字是没用的——场面重现时要把块本身带回来。
Blocks []MemoryBlock `json:"blocks,omitempty"`
}
// tagSceneTx 在事务内把「关系 + 实体」挂到场景上(幂等 upsert)。
@ -140,6 +145,36 @@ func tagSceneTx(tx *sql.Tx, sceneKey string, relationID int64, entityIDs []int64
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 {
@ -152,34 +187,12 @@ func tagSceneTx(tx *sql.Tx, sceneKey string, relationID int64, entityIDs []int64
if weight <= 0 {
weight = 1.0
}
refs := make([]struct {
kind string
id int64
}, 0, len(entityIDs)+1)
if relationID != 0 {
refs = append(refs, struct {
kind string
id int64
}{"relation", relationID})
}
for _, eid := range entityIDs {
if eid != 0 {
refs = append(refs, struct {
kind string
id int64
}{"entity", eid})
}
}
for _, r := range refs {
if _, err := tx.Exec(
`INSERT INTO scene_refs (scene_id, kind, ref_id, weight) VALUES (?, ?, ?, ?)
ON CONFLICT(scene_id, kind, ref_id)
DO UPDATE SET weight = MAX(weight, excluded.weight)`,
sceneID, r.kind, r.id, weight); err != nil {
return fmt.Errorf("upsert scene ref %s/%d: %w", r.kind, r.id, err)
}
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)`,
sceneID, kind, id, textID, weight); err != nil {
return fmt.Errorf("upsert scene ref %s/%d%s: %w", kind, id, textID, err)
}
return nil
}
@ -391,7 +404,37 @@ func (g *GraphDB) RecallByScene(scenes []string, limit int) (*SceneRecall, error
out.Entities = append(out.Entities, e)
}
erows.Close()
return out, erows.Err()
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)
}
return out, brows.Err()
}
// SceneStats 返回各场景的规模,按引用数降序。
@ -444,10 +487,37 @@ func (g *GraphDB) PurgeStaleSceneRefs() (int, error) {
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 = '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()
}