From 9d45cd0277e9ab59276d09fad8c8ce22682cd629 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Tue, 15 Sep 2026 09:03:36 +0800 Subject: [PATCH] =?UTF-8?q?fix(rel):=20=E5=9C=BA=E6=99=AF=E8=B4=AF?= =?UTF-8?q?=E7=A9=BF=E6=B5=81=E6=B0=B4=E7=BA=BF=E5=88=B0=E5=9D=97=E5=B1=82?= =?UTF-8?q?=20+=20=E7=BC=96=E8=BE=91=E4=B8=8D=E5=86=8D=E4=B8=A2=E7=BD=AE?= =?UTF-8?q?=E4=BF=A1=E5=BA=A6/=E5=9C=BA=E6=99=AF=20+=20=E6=9E=84=E5=BB=BA?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E5=B8=A6=20onnxruntime?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三件事,前两件是上一轮热部署暴露/遗留的真缺陷。 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。 --- Makefile | 21 ++- internal/agent/core/distill.go | 2 +- internal/agent/core/graphmedia.go | 18 ++- internal/agent/core/graphmedia_test.go | 8 +- internal/agent/core/toolcall.go | 23 ++- internal/memory/block.go | 26 ++- internal/memory/graph.go | 102 +++++++++++- internal/memory/indexer.go | 41 ++++- internal/memory/scene.go | 130 +++++++++++---- internal/memory/scene_test.go | 214 +++++++++++++++++++++++++ 10 files changed, 532 insertions(+), 53 deletions(-) diff --git a/Makefile b/Makefile index 0b71eb1..17936d8 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,17 @@ -.PHONY: all build build-cli build-gui clean install test run build-static build-linux-arm64 lint fmt +.PHONY: all build build-plain build-cli build-gui clean install test run build-static build-linux-arm64 lint fmt + +# HOMED_TAGS 默认带 onnxruntime:发行版**默认启用**本地向量空间(与 +# deploy/packaging/build.sh 保持一致)。 +# +# 曾经这里是空 tags,实测的后果(2026-09-15 热部署):`make build` 产出的 +# homed 只有 33MB,而 onnxruntime 版是 84MB;启动日志里 +# 「multimodal space active: provider=chineseclip」整行消失,少加载一个插件, +# 静态词向量也退化成 fallback——而打包脚本会直接**拒收**这种二进制 +# (package-linux.sh 检查 `-tags=.*onnxruntime`)。即「本地随手 make build」 +# 与「发行构建」不是同一个东西,部署时无从察觉。 +# 需要极简构建时显式 HOMED_TAGS= 关掉。 +HOMED_TAGS ?= onnxruntime +TAG_ARGS = $(if $(HOMED_TAGS),-tags $(HOMED_TAGS),) BINARY=homed CLI_BINARY=waiter @@ -17,8 +30,10 @@ all: build build-cli build: @mkdir -p $(BUILD_DIR) - CGO_ENABLED=1 $(GO) build -trimpath -installsuffix dynlink -ldflags '$(LDFLAGS)' -o $(BUILD_DIR)/$(BINARY) ./cmd/homed/ - @echo "Built: $(BUILD_DIR)/$(BINARY) ($(VERSION))" + CGO_ENABLED=1 $(GO) build $(TAG_ARGS) -trimpath -installsuffix dynlink -ldflags '$(LDFLAGS)' -o $(BUILD_DIR)/$(BINARY) ./cmd/homed/ + @echo "Built: $(BUILD_DIR)/$(BINARY) ($(VERSION), tags='$(HOMED_TAGS)')" + @go version -m $(BUILD_DIR)/$(BINARY) | grep -q 'onnxruntime' \ + || echo "WARN: 本次构建不含 onnxruntime,本地向量空间不可用(HOMED_TAGS= 显式关掉时才符合预期)" build-cli: @mkdir -p $(BUILD_DIR) diff --git a/internal/agent/core/distill.go b/internal/agent/core/distill.go index 3c53b0d..5cb69da 100644 --- a/internal/agent/core/distill.go +++ b/internal/agent/core/distill.go @@ -207,7 +207,7 @@ func (a *Agent) archiveColdDocs() { // 文档持有的一等块写入 L3,并以 document --contains--> block 边关联; // 块 ID 原样保留(迁移而非重建)。块迁走后删除文档即完成迁移。 if len(doc.Blocks) > 0 { - if bound := a.linkBlocksToDocument(doc.ID, doc.Blocks); bound != len(doc.Blocks) { + if bound := a.linkBlocksToDocument(doc.ID, doc.Blocks, memory.ChannelScene(doc.Source)); bound != len(doc.Blocks) { log.Printf("[agent] doc→graph: %s 块迁移不完整 (%d/%d),保留文档待下轮重试", doc.ID, bound, len(doc.Blocks)) continue diff --git a/internal/agent/core/graphmedia.go b/internal/agent/core/graphmedia.go index 3611f9b..c0792a0 100644 --- a/internal/agent/core/graphmedia.go +++ b/internal/agent/core/graphmedia.go @@ -47,7 +47,7 @@ func (a *Agent) migrateLegacyGraphMedia() { // attachBlocksToSentence 把一组 digest 变成 L3 一等块并挂到句子上。 // seed 允许复用已持有块的 ID(L2→L3 迁移保持块身份不变)。 -func (a *Agent) attachBlocksToSentence(sentenceID int64, digests []string, seed map[string]memory.MemoryBlock) int { +func (a *Agent) attachBlocksToSentence(sentenceID int64, digests []string, seed map[string]memory.MemoryBlock, scene string) int { if a.mediaStore == nil || a.memory == nil || sentenceID == 0 { return 0 } @@ -64,6 +64,11 @@ func (a *Agent) attachBlocksToSentence(sentenceID int64, digests []string, seed continue } } + // 块继承承载它的三元组的场景:块是流水线里最细的子项目,场景要落到它身上, + // 否则「那场对话里发过来的那张图」在场面重现时永远取不回来。 + if b.Scene == "" { + b.Scene = scene + } if err := a.memory.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil { log.Printf("[media] L3 块写入失败 (%s): %v", shortDigest(full), err) continue @@ -79,7 +84,7 @@ func (a *Agent) attachBlocksToSentence(sentenceID int64, digests []string, seed // linkBlocksToDocument 把文档持有的块写入 L3,并建立 // document --contains--> block 边。块的 ID 原样保留(迁移而非重建)。 -func (a *Agent) linkBlocksToDocument(docID string, blocks []memory.MemoryBlock) int { +func (a *Agent) linkBlocksToDocument(docID string, blocks []memory.MemoryBlock, scene string) int { if a.memory == nil || docID == "" || len(blocks) == 0 { return 0 } @@ -87,6 +92,13 @@ func (a *Agent) linkBlocksToDocument(docID string, blocks []memory.MemoryBlock) log.Printf("[media] 写入 L3 文档节点失败 (%s): %v", docID, err) return 0 } + // 文档层把场景传给块:归档进图库的块属于该文档的来源场面(QQ 归档的图 + // 就该挂在 chan:qq 上),否则 L3 里这批块在场景召回中不可见。 + for i := range blocks { + if blocks[i].Scene == "" { + blocks[i].Scene = scene + } + } if err := a.memory.PutMemoryBlocks(blocks); err != nil { log.Printf("[media] 写入 L3 记忆块失败 (doc %s): %v", docID, err) return 0 @@ -135,7 +147,7 @@ func (a *Agent) commitTriplesWithMedia(triples []memory.Triple, sessionID string if sid == 0 { continue } - blocks += a.attachBlocksToSentence(sid, t.MediaDigests, byDigest) + blocks += a.attachBlocksToSentence(sid, t.MediaDigests, byDigest, t.Scene) } return ec, rc, blocks, nil } diff --git a/internal/agent/core/graphmedia_test.go b/internal/agent/core/graphmedia_test.go index dd61973..9c126c6 100644 --- a/internal/agent/core/graphmedia_test.go +++ b/internal/agent/core/graphmedia_test.go @@ -194,7 +194,7 @@ func TestCommitTriplesWithMedia_RoundTrip(t *testing.T) { func TestAttachBlocksToSentence_SkipsUnresolvable(t *testing.T) { // digest 在库里不存在时必须跳过,不能建一条指向虚无的块边。 a, g, _ := newGraphMediaAgent(t) - if n := a.attachBlocksToSentence(42, []string{"deadbeefdead"}, nil); n != 0 { + if n := a.attachBlocksToSentence(42, []string{"deadbeefdead"}, nil, ""); n != 0 { t.Fatalf("无法补全的 digest 不该建块,实际绑定 %d", n) } blocks, err := g.BlocksForNode("sentence", "42") @@ -208,7 +208,7 @@ func TestAttachBlocksToSentence_SkipsUnresolvable(t *testing.T) { func TestAttachBlocksToSentence_NilStoreNoop(t *testing.T) { a := &Agent{} - if n := a.attachBlocksToSentence(1, []string{"aaaaaaaaaaaa"}, nil); n != 0 { + if n := a.attachBlocksToSentence(1, []string{"aaaaaaaaaaaa"}, nil, ""); n != 0 { t.Fatalf("媒体关闭时应静默无操作,实际 %d", n) } if got, err := a.RecallBlocksForSentence(1); err != nil || got != nil { @@ -234,7 +234,7 @@ func TestAttachBlocksToSentence_ReusesSeedIdentity(t *testing.T) { sid := ids["迁移测试句。"] byDigest := map[string]memory.MemoryBlock{digest: seedBlock} - if n := a.attachBlocksToSentence(sid, []string{digest}, byDigest); n != 1 { + if n := a.attachBlocksToSentence(sid, []string{digest}, byDigest, ""); n != 1 { t.Fatalf("应绑定 1 个块,实际 %d", n) } blocks, err := g.BlocksForNode("sentence", strconv.FormatInt(sid, 10)) @@ -256,7 +256,7 @@ func TestLinkBlocksToDocument_CreatesDocumentNodeEdge(t *testing.T) { t.Fatal("blockFromDigest 失败") } - if n := a.linkBlocksToDocument("doc_42", []memory.MemoryBlock{b}); n != 1 { + if n := a.linkBlocksToDocument("doc_42", []memory.MemoryBlock{b}, ""); n != 1 { t.Fatalf("应建立 1 条文档→块边,实际 %d", n) } blocks, err := g.BlocksForNode("document", "doc_42") diff --git a/internal/agent/core/toolcall.go b/internal/agent/core/toolcall.go index 47b4d7b..86a147c 100644 --- a/internal/agent/core/toolcall.go +++ b/internal/agent/core/toolcall.go @@ -360,6 +360,20 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string { if newObject == "" { newObject = oldObject } + // 编辑前先精确取回旧关系:Purge 是「删旧写新」,中间那一步会把 + // 置信度、场景引用、原句一起丢掉。复审心跳(reviewLoop)正是走这条路, + // 于是每次复审都把置信度重置成默认 1.0、把场景钉死的记忆打散成无场景, + // 而且没有任何日志——这类「静默降级」比报错难查得多。 + var carriedConf float64 + var carriedSentence, carriedScene string + if olds, ferr := a.memory.FindRelations(oldSubject, oldRelation, oldObject); ferr == nil && len(olds) > 0 { + carriedConf = olds[0].Confidence + carriedSentence = olds[0].SentenceText + if keys, serr := a.memory.ScenesOfRelation(olds[0].ID); serr == nil && len(keys) > 0 { + carriedScene = keys[0] + } + } + n, err := a.memory.Purge(map[string]string{ "subject_contains": oldSubject, "relation_type": oldRelation, @@ -369,9 +383,12 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string { return fmt.Sprintf("编辑图记忆失败(删除旧记录): %v", err) } triples := []memory.Triple{{ - Subject: newSubject, - Relation: newRelation, - Object: newObject, + Subject: newSubject, + Relation: newRelation, + Object: newObject, + Confidence: carriedConf, + SentenceText: carriedSentence, + Scene: carriedScene, }} ec, rc, err := a.memory.Commit(triples, string(a.id), 0) if err != nil { diff --git a/internal/memory/block.go b/internal/memory/block.go index 020511c..f4a1fb2 100644 --- a/internal/memory/block.go +++ b/internal/memory/block.go @@ -35,8 +35,15 @@ type MemoryBlock struct { Fingerprint string `json:"fingerprint,omitempty"` Source string `json:"source,omitempty"` Tool string `json:"tool,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + // Scene 是这个块所属的场景键(可空)。 + // + // 块是记忆流水线里最细的「子项目」:一段转写、一张图的描述、一份附件。 + // 场景要贯穿到流水线底,就得从块开始——否则「QQ 那场对话里发过来的那张图」 + // 在场面重现时永远拿不回来。块进 L3 时按 Scene 挂 scene_refs(kind='block'), + // 场景召回即可把它取回(见 GraphDB.RecallByScene)。 + Scene string `json:"scene,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // MemoryBlockEdge 是 L3 中连接一等记忆节点的结构化语义边。 @@ -86,8 +93,8 @@ func (g *GraphDB) PutMemoryBlocks(blocks []MemoryBlock) error { } _, err = tx.Exec(`INSERT INTO memory_blocks ( id, modality, text_content, payload_digest, mime, size, width, height, - vector, fingerprint, source, tool, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + vector, fingerprint, source, tool, scene, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET modality = excluded.modality, text_content = excluded.text_content, @@ -100,13 +107,22 @@ func (g *GraphDB) PutMemoryBlocks(blocks []MemoryBlock) error { fingerprint = excluded.fingerprint, source = excluded.source, tool = excluded.tool, + -- 场景只在本次给了值时才覆盖:块可能先被写入、后被归档路径补挂场景, + -- 反过来「已挂场景的块被一次无场景的重写抹掉」是不可接受的静默降级。 + scene = CASE WHEN excluded.scene != '' THEN excluded.scene ELSE memory_blocks.scene END, updated_at = excluded.updated_at`, block.ID, block.Modality, block.Text, block.PayloadDigest, block.MIME, block.Size, block.Width, block.Height, string(vectorJSON), block.Fingerprint, - block.Source, block.Tool, block.CreatedAt, now) + block.Source, block.Tool, block.Scene, block.CreatedAt, now) if err != nil { return fmt.Errorf("put memory block %s: %w", block.ID, err) } + // 场景引用与块同事务:块写进去了、引用丢了,这个块在场景里就永远取不回。 + if block.Scene != "" { + if err := tagSceneRefTx(tx, block.Scene, "block", 0, block.ID, 1.0); err != nil { + return fmt.Errorf("tag scene for block %s: %w", block.ID, err) + } + } } return tx.Commit() } diff --git a/internal/memory/graph.go b/internal/memory/graph.go index 9d17333..5c08321 100644 --- a/internal/memory/graph.go +++ b/internal/memory/graph.go @@ -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 +} diff --git a/internal/memory/indexer.go b/internal/memory/indexer.go index bda7381..ed46532 100644 --- a/internal/memory/indexer.go +++ b/internal/memory/indexer.go @@ -106,8 +106,9 @@ type InjectedContext struct { // Scenes 是本轮识别出的当前场景;SceneRelations 是被钉在这些场景上的 // 记忆(带 relation_type 与原句)。两者都进注入文本——场景记忆是 // **带条件的规则**,只给实体名等于没召回。 - Scenes []string `json:"scenes,omitempty"` - SceneRelations []Relation `json:"scene_relations,omitempty"` + Scenes []string `json:"scenes,omitempty"` + SceneRelations []Relation `json:"scene_relations,omitempty"` + SceneBlocks []MemoryBlock `json:"scene_blocks,omitempty"` } // BuildContext 不带场景的召回(保持既有行为:词法 + 实体名向量)。 @@ -181,6 +182,7 @@ func (idx *Indexer) BuildContextInScene(userInput string, scenes []string) *Inje if sceneRecall != nil { ctx.Scenes = sceneRecall.Scenes ctx.SceneRelations = sceneRecall.Relations + ctx.SceneBlocks = sceneRecall.Blocks } if len(filtered) > 0 { @@ -251,7 +253,7 @@ func (idx *Indexer) BuildToolPrompt() string { } func (idx *Indexer) FormatContext(ctx *InjectedContext) string { - if ctx == nil || (len(ctx.Entities) == 0 && len(ctx.SceneRelations) == 0) { + if ctx == nil || (len(ctx.Entities) == 0 && len(ctx.SceneRelations) == 0 && len(ctx.SceneBlocks) == 0) { return "" } @@ -274,6 +276,28 @@ func (idx *Indexer) FormatContext(ctx *InjectedContext) string { } b.WriteString("\n") } + // 场景块:块是流水线里最细的子项目(一段转写、一张图的描述)。 + // 只给 id 没用,要给能判断「这是什么」的短文本。 + if len(ctx.SceneBlocks) > 0 { + b.WriteString("场景素材: ") + for i, blk := range ctx.SceneBlocks { + if i >= maxSceneRecallBlocks { + b.WriteString("…") + break + } + if i > 0 { + b.WriteString(" | ") + } + b.WriteString(string(blk.Modality)) + b.WriteString(" ") + if blk.Text != "" { + b.WriteString(truncateRunes(blk.Text, sceneSentenceMaxRunes)) + } else { + b.WriteString(shortBlockDigest(blk.PayloadDigest)) + } + } + b.WriteString("\n") + } b.WriteString("(以上是该场景下的既有约定,请照办)\n") } @@ -316,6 +340,17 @@ const maxSceneRecallRelations = 8 // sceneSentenceMaxRunes 是场景关系后附原句的截断长度。 const sceneSentenceMaxRunes = 60 +// maxSceneRecallBlocks 是场景块在注入文本里的条数上限(同为常驻内容,要封顶)。 +const maxSceneRecallBlocks = 3 + +// shortBlockDigest 取 digest 前 12 位做展示(与 L3 里引用媒体的写法一致)。 +func shortBlockDigest(d string) string { + if len(d) <= 12 { + return d + } + return d[:12] +} + func truncateRunes(s string, max int) string { r := []rune(s) if len(r) <= max { diff --git a/internal/memory/scene.go b/internal/memory/scene.go index 0df958e..4cc4189 100644 --- a/internal/memory/scene.go +++ b/internal/memory/scene.go @@ -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() +} diff --git a/internal/memory/scene_test.go b/internal/memory/scene_test.go index 9a22285..45474ff 100644 --- a/internal/memory/scene_test.go +++ b/internal/memory/scene_test.go @@ -271,3 +271,217 @@ func TestBuildContextInScene(t *testing.T) { t.Errorf("无场景却出现场景块: %q", plain) } } + +// TestFindRelationsAndScenesOfRelation 是 memory_edit「删旧写新」的取数依据: +// 编辑前必须能精确取回旧关系的置信度/原句/场景,否则复审心跳每跑一次就把 +// 置信度重置成 1.0、把场景钉死的记忆打散成无场景,而且没有任何日志。 +func TestFindRelationsAndScenesOfRelation(t *testing.T) { + g := newTestGraph(t) + defer os.Remove(g.dbPath) + defer g.Close() + + if _, _, err := g.Commit([]Triple{ + {Subject: "老大", Relation: "偏好", Object: "QQ回复禁用Markdown格式", Confidence: 0.63, + Scene: "chan:qq", SentenceText: "回QQ消息别用markdown"}, + {Subject: "老大", Relation: "偏好", Object: "早起", Confidence: 0.9}, + }, "main", 0); err != nil { + t.Fatalf("commit: %v", err) + } + + rels, err := g.FindRelations("老大", "偏好", "QQ回复禁用Markdown格式") + if err != nil { + t.Fatalf("FindRelations: %v", err) + } + if len(rels) != 1 { + t.Fatalf("精确查找命中 %d 条,want 1", len(rels)) + } + if rels[0].Confidence != 0.63 || rels[0].SentenceText != "回QQ消息别用markdown" { + t.Errorf("取回的附加信息不对: %+v", rels[0]) + } + scenes, err := g.ScenesOfRelation(rels[0].ID) + if err != nil { + t.Fatalf("ScenesOfRelation: %v", err) + } + if len(scenes) != 1 || scenes[0] != "chan:qq" { + t.Errorf("场景键取回不对: %v", scenes) + } + + // 编辑:删旧写新并把三项带过去 + n, err := g.Purge(map[string]string{ + "subject_contains": "老大", + "relation_type": "偏好", + "target_contains": "QQ回复禁用Markdown格式", + }, "hard") + if err != nil || n != 1 { + t.Fatalf("Purge = %d, %v", n, err) + } + // 旧引用应已被清掉(不然场景里留着召不回的幽灵) + if stale, _ := g.RecallByScene([]string{"chan:qq"}, 8); len(stale.Relations) != 0 { + t.Errorf("Purge 后仍有悬空场景引用: %+v", stale.Relations) + } + if _, _, err := g.Commit([]Triple{{ + Subject: "老大", Relation: "偏好", Object: "禁止Markdown回复", + Confidence: rels[0].Confidence, SentenceText: rels[0].SentenceText, Scene: scenes[0], + }}, "main", 0); err != nil { + t.Fatalf("re-commit: %v", err) + } + + again, _ := g.FindRelations("老大", "偏好", "禁止Markdown回复") + if len(again) != 1 || again[0].Confidence != 0.63 || again[0].SentenceText != "回QQ消息别用markdown" { + t.Errorf("编辑后附加信息丢了: %+v", again) + } + back, _ := g.RecallByScene([]string{"chan:qq"}, 8) + if len(back.Relations) != 1 || back.Relations[0].TargetName != "禁止Markdown回复" { + t.Errorf("编辑后场景没跟上: %+v", back.Relations) + } +} + +// TestSceneBlocks 钉住「场景贯穿到块」:块是流水线里最细的子项目, +// 场景复现时必须能把块本身取回来,而不只是一个名字。 +func TestSceneBlocks(t *testing.T) { + g := newTestGraph(t) + defer os.Remove(g.dbPath) + defer g.Close() + + blocks := []MemoryBlock{ + {ID: "blk_a", Modality: "image", Text: "老大发的排班表截图", PayloadDigest: "aaaa1111bbbb2222", Scene: "chan:qq"}, + {ID: "blk_b", Modality: "text", Text: "无关场面的转写"}, + } + if err := g.PutMemoryBlocks(blocks); err != nil { + t.Fatalf("PutMemoryBlocks: %v", err) + } + + r, err := g.RecallByScene([]string{"chan:qq"}, 8) + if err != nil { + t.Fatalf("RecallByScene: %v", err) + } + if len(r.Blocks) != 1 || r.Blocks[0].ID != "blk_a" { + t.Fatalf("场景块取回不对: %+v", r.Blocks) + } + if r.Blocks[0].Text != "老大发的排班表截图" { + t.Errorf("块的文本没带回来: %+v", r.Blocks[0]) + } + + // 同一场景里的第二个块不能被唯一约束顶掉(这正是 ref_text 参与唯一约束的原因) + if err := g.PutMemoryBlocks([]MemoryBlock{ + {ID: "blk_c", Modality: "audio", Text: "语音转写", Scene: "chan:qq"}, + }); err != nil { + t.Fatalf("PutMemoryBlocks 第二块: %v", err) + } + r, _ = g.RecallByScene([]string{"chan:qq"}, 8) + if len(r.Blocks) != 2 { + t.Errorf("同场景应有两个块,得到 %d: %+v", len(r.Blocks), r.Blocks) + } + + // 无场景重写不得抹掉已挂的场景(静默降级防护) + if err := g.PutMemoryBlocks([]MemoryBlock{ + {ID: "blk_a", Modality: "image", Text: "重新描述", PayloadDigest: "aaaa1111bbbb2222"}, + }); err != nil { + t.Fatalf("重写块: %v", err) + } + var scene string + if err := g.db.QueryRow(`SELECT COALESCE(scene,'') FROM memory_blocks WHERE id='blk_a'`).Scan(&scene); err != nil { + t.Fatal(err) + } + if scene != "chan:qq" { + t.Errorf("无场景重写抹掉了块的场景: %q", scene) + } + // 块被删后引用也要对齐 + if _, err := g.db.Exec(`DELETE FROM memory_blocks WHERE id='blk_c'`); err != nil { + t.Fatal(err) + } + if n, err := g.PurgeStaleSceneRefs(); err != nil || n != 1 { + t.Errorf("悬空块引用应清掉 1 条,得到 %d, %v", n, err) + } +} + +// TestSceneRefsLegacyMigration 模拟「生产库里已存在旧版 scene_refs」的情形: +// 旧唯一约束是 (scene_id, kind, ref_id),不含 ref_text。不重建表的后果是 +// 「同一场景下的第二个块」直接冲突——只在多块场景才暴露。 +func TestSceneRefsLegacyMigration(t *testing.T) { + g := newTestGraph(t) + dbPath := g.dbPath + // 手工降级成旧表结构 + if _, err := g.db.Exec(`DROP TABLE scene_refs`); err != nil { + t.Fatalf("drop: %v", err) + } + if _, err := g.db.Exec(`CREATE TABLE scene_refs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + scene_id INTEGER NOT NULL, + kind TEXT NOT NULL, + ref_id INTEGER NOT NULL, + weight REAL DEFAULT 1.0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(scene_id, kind, ref_id))`); err != nil { + t.Fatalf("recreate legacy: %v", err) + } + // 预置一条旧数据,迁移必须把它带过来 + if _, err := g.db.Exec(`INSERT INTO scenes (key) VALUES ('chan:qq')`); err != nil { + t.Fatal(err) + } + if _, err := g.db.Exec(`INSERT INTO scene_refs (scene_id, kind, ref_id, weight) + SELECT id, 'entity', 7, 0.5 FROM scenes WHERE key='chan:qq'`); err != nil { + t.Fatal(err) + } + g.Close() + + g2, err := NewGraphDB(dbPath) + if err != nil { + t.Fatalf("reopen: %v", err) + } + defer os.Remove(dbPath) + defer g2.Close() + + var legacy int + if err := g2.db.QueryRow(`SELECT COUNT(*) FROM scene_refs WHERE kind='entity' AND ref_id=7`).Scan(&legacy); err != nil { + t.Fatalf("旧数据丢失: %v", err) + } + if legacy != 1 { + t.Errorf("迁移后旧引用应保留 1 条,得到 %d", legacy) + } + // 迁移后必须能容纳同场景多个块 + if err := g2.PutMemoryBlocks([]MemoryBlock{ + {ID: "b1", Modality: "image", Text: "x", Scene: "chan:qq"}, + {ID: "b2", Modality: "image", Text: "y", Scene: "chan:qq"}, + }); err != nil { + t.Fatalf("迁移后仍写不进多块: %v", err) + } + r, err := g2.RecallByScene([]string{"chan:qq"}, 8) + if err != nil { + t.Fatalf("RecallByScene: %v", err) + } + if len(r.Blocks) != 2 { + t.Errorf("迁移后应能取回 2 个块,得到 %d", len(r.Blocks)) + } +} + +// TestFormatContextSceneBlocks 场景素材要出现在注入文本里(只给 id 没用, +// 模型看不出那是什么)。 +func TestFormatContextSceneBlocks(t *testing.T) { + g := newTestGraph(t) + defer os.Remove(g.dbPath) + defer g.Close() + + if _, _, err := g.Commit([]Triple{ + {Subject: "老大", Relation: "偏好", Object: "QQ回复禁用Markdown格式", Confidence: 1.0, Scene: "chan:qq"}, + }, "main", 0); err != nil { + t.Fatal(err) + } + if err := g.PutMemoryBlocks([]MemoryBlock{ + {ID: "blk_x", Modality: "image", Text: "老大发的排班表截图", PayloadDigest: "cccc3333dddd4444", Scene: "chan:qq"}, + }); err != nil { + t.Fatal(err) + } + idx := NewIndexer(g) + if err := idx.Sync(); err != nil { + t.Fatal(err) + } + + text := idx.FormatContext(idx.BuildContextInScene("在吗", []string{"chan:qq"})) + if !strings.Contains(text, "场景素材:") { + t.Fatalf("没有场景素材段: %q", text) + } + if !strings.Contains(text, "老大发的排班表截图") { + t.Errorf("块的文本没注入: %q", text) + } +}