mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-22 18:08:04 +00:00
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:
21
Makefile
21
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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user