diff --git a/Makefile b/Makefile index 0b71eb1..4f1a5d8 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 sync-client-versions check-client-versions + +# 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,13 +30,15 @@ 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) - CGO_ENABLED=0 $(GO) build -installsuffix dynlink -o $(BUILD_DIR)/$(CLI_BINARY) ./cmd/waiter/ - @echo "Built: $(BUILD_DIR)/$(CLI_BINARY)" + CGO_ENABLED=0 $(GO) build -installsuffix dynlink -ldflags '$(LDFLAGS)' -o $(BUILD_DIR)/$(CLI_BINARY) ./cmd/waiter/ + @echo "Built: $(BUILD_DIR)/$(CLI_BINARY) ($(VERSION))" build-gui: @cd cmd/gui && npm install --production && npx electron-packager . $(GUI_BINARY) --out=../../$(BUILD_DIR) --overwrite --no-sandbox @@ -62,6 +77,14 @@ fmt: lint: $(GO) vet ./... +# 客户端版本与内核版本同步(唯一事实源 internal/meta.Version)。 +# GUI/鸿蒙各有自版本字段,手工改必漂——用脚本拉齐,check 版给门禁用。 +sync-client-versions: + @bash deploy/scripts/sync-client-versions.sh + +check-client-versions: + @bash deploy/scripts/sync-client-versions.sh --check + # lint-full:在 vet 之外跑 golangci-lint(阈值见 .golangci.yml,起步 warn-only)。 # 未安装时给出可执行的安装提示与跳过原因,而不是静默成功。 .PHONY: lint-full diff --git a/README.md b/README.md index 7570ebd..8ed8944 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ flowchart TB end subgraph D[② Document 文件记忆] DS[DocStore JSON+TF-IDF] - Q1[Query 摘要自动注入] -->|【相关记忆文档】| SP + Q1[QueryScored+crossModalMarkdown] -->|【跨模态相关记忆】| SP Q2[doc_query LLM主动召回] -->|Consume+删除源| DS Q2 -->|原始时间戳写入上下文| RC CD[FindColdDocs 72h] -->|docToTriples| G diff --git a/cmd/gui/package.json b/cmd/gui/package.json index b470829..6882d12 100644 --- a/cmd/gui/package.json +++ b/cmd/gui/package.json @@ -1,6 +1,6 @@ { "name": "homeagent-gui", - "version": "1.0.0", + "version": "1.4.0", "author": "JianFeeeee ", "homepage": "https://gitcode.com/JianFeeeee/HomeAgent", "description": "HomeAgent Desktop GUI - Multi-connection management dashboard", diff --git a/cmd/homed/bootstrap.go b/cmd/homed/bootstrap.go index 7a3944f..6f65ef5 100644 --- a/cmd/homed/bootstrap.go +++ b/cmd/homed/bootstrap.go @@ -147,9 +147,6 @@ func initMemoryStack(dataDir string) (*memoryStack, func()) { } else { log.Printf("[homed] graph memory initialized") } - if memDB != nil { - } - memIdx := memory.NewIndexer(memDB) memIdx.Sync() // 启动时立即同步,避免前30分钟空窗 socialStore := social.New(memDB) @@ -160,8 +157,11 @@ func initMemoryStack(dataDir string) (*memoryStack, func()) { BatchSize: 50, }) if memDB != nil { + // 这里**故意不写 defer distiller.Stop()**:本函数在 return 时即触发 + // defer,而 Stop() → cancel() 会让刚启动的 distillLoop 立刻退出, + // 规则蒸馏管线启动即死、10min 心跳从不运行(旧 main() 拆分时的残留)。 + // 停机由调用点注册的 cleanup 负责(见下方返回值)。 distiller.Start() - defer distiller.Stop() } return &memoryStack{db: memDB, indexer: memIdx, social: socialStore, distiller: distiller}, diff --git a/cmd/homed/bootstrap_test.go b/cmd/homed/bootstrap_test.go new file mode 100644 index 0000000..eddc87d --- /dev/null +++ b/cmd/homed/bootstrap_test.go @@ -0,0 +1,42 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +// TestInitMemoryStackKeepsDistillerRunning 锁死启动接线回归: +// initMemoryStack 必须返回一个**仍在运行**的蒸馏器。 +// +// 历史 bug:main() 拆分时函数体内残留一句 `defer distiller.Stop()`, +// 函数一 return 就 cancel 掉刚启动的循环,规则蒸馏 10min 心跳从不运行。 +// 该缺陷不会让任何单测变红——pipeline 的 TestDistillOnce* 直接调 +// distillOnce,绕过了 Start/Stop 接线;只有在这里按「启动阶段函数」的 +// 真实调用方式断言,才照得出来。 +func TestInitMemoryStackKeepsDistillerRunning(t *testing.T) { + dir := t.TempDir() + // NewGraphDB 需要父目录已存在(生产由 dataDir 初始化保证)。 + if err := os.MkdirAll(filepath.Join(dir, "memory"), 0755); err != nil { + t.Fatal(err) + } + st, cleanup := initMemoryStack(dir) + if st == nil || st.distiller == nil { + cleanup() + t.Fatal("initMemoryStack 未返回蒸馏器") + } + if st.db == nil { + cleanup() + t.Skip("图库未初始化,无法验证蒸馏接线") + } + if st.distiller.Stopped() { + cleanup() + t.Fatal("initMemoryStack 返回后蒸馏循环已被停掉(defer Stop 残留?)") + } + + // cleanup 是唯一的停机点:先停蒸馏器、再关图库。 + cleanup() + if !st.distiller.Stopped() { + t.Fatal("cleanup 之后蒸馏器应已停止") + } +} diff --git a/cmd/memgc/main.go b/cmd/memgc/main.go new file mode 100644 index 0000000..eb1de42 --- /dev/null +++ b/cmd/memgc/main.go @@ -0,0 +1,123 @@ +// memgc 清理图记忆里已存在的「噪音实体」「孤立实体」及其关系。 +// +// 为什么需要这个命令:噪音闸门(internal/memory.IsNoiseEntity)只能拦住 +// **新写入**的噪音。旧库里那批(常用词 / 归档内部标记 / 模板摘要回声)是 +// 闸门上线前攒下的存量,没人清就一直在——热实体被它们占着,召回预算被 +// 同构垃圾边挤满。清理是一次性动作,但需要可重复执行、可先看不做。 +// +// 两件事分开开关:-orphans 处理的是「零关系的空节点」(清理噪音后另一端 +// 留下的壳),它们的名字本身可能没问题,但已经不在图里了。 +// +// 用法(默认 dry-run,只列不删): +// +// memgc -db /home/newqqagent/memory/graph.db +// memgc -db /home/newqqagent/memory/graph.db -orphans -apply +// +// 清理生产库前请先备份:sqlite3 graph.db ".backup 'graph.db.bak-'" +// 不要用 cp —— WAL 模式下会复制出主库与 -wal 不一致的快照。 +package main + +import ( + "flag" + "fmt" + "log" + + "gitcode.com/JianFeeeee/HomeAgent/internal/memory" +) + +func main() { + path := flag.String("db", "", "graph.db 路径(必填)") + apply := flag.Bool("apply", false, "真正删除;不加则只 dry-run 打印") + orphans := flag.Bool("orphans", false, "同时处理「零关系孤立实体」(先被清理的噪音在另一端留下的空节点)") + tagScene := flag.String("tag-scene", "", "存量引导:把实体名匹配 -entity-glob 的活跃关系标进该场景键(如 chan:qq)") + entityGlob := flag.String("entity-glob", "", "配合 -tag-scene 的 GLOB 模式(如 *QQ*)。GLOB 区分大小写,避免把 /home/newqqagent 这类路径卷进场景") + sceneStats := flag.Bool("scene-stats", false, "只打印场景规模摘要") + flag.Parse() + + if *path == "" { + flag.Usage() + log.Fatal("memgc: 必须指定 -db") + } + + g, err := memory.NewGraphDB(*path) + if err != nil { + log.Fatalf("memgc: open %s: %v", *path, err) + } + defer g.Close() + + if *sceneStats { + stats, err := g.SceneStats() + if err != nil { + log.Fatalf("memgc: scene stats: %v", err) + } + fmt.Printf("场景 %d 个:\n", len(stats)) + for _, st := range stats { + fmt.Printf(" [%-9s] %-40s refs=%-5d rel=%-5d ent=%-4d strength=%-4d features=%-3d updated=%s\n", + st.Origin, st.Key, st.Refs, st.Relations, st.Entities, st.Strength, st.Features, + st.UpdatedAt.Format("2006-01-02 15:04")) + } + return + } + + // 存量引导:场景是后引入的维度,老库里的规则(那批 QQ 规则就是典型) + // 没有任何场景引用,不补挂就永远吃不到场景召回。 + if *tagScene != "" { + if *entityGlob == "" { + log.Fatal("memgc: -tag-scene 需要配套 -entity-glob(如 '*QQ*');不做自动猜测") + } + n, err := g.TagSceneByEntityGlob(*tagScene, *entityGlob, !*apply) + if err != nil { + log.Fatalf("memgc: tag scene: %v", err) + } + if *apply { + fmt.Printf("[APPLIED] 已把 %d 条关系标进场景 %q\n", n, *tagScene) + } else { + fmt.Printf("[DRY-RUN] 将把 %d 条关系标进场景 %q(未写库)\n", n, *tagScene) + } + return + } + + junk, err := g.NoiseEntities() + if err != nil { + log.Fatalf("memgc: scan: %v", err) + } + + fmt.Printf("噪音实体 %d 个:\n", len(junk)) + for _, e := range junk { + fmt.Printf(" %-64s type=%-8s mentions=%d\n", e.Name, e.Type, e.MentionCount) + } + + de, dr, err := g.PurgeNoise(!*apply) + if err != nil { + log.Fatalf("memgc: purge: %v", err) + } + if *apply { + fmt.Printf("[APPLIED] 噪音:已删除 实体=%d 关系=%d\n", de, dr) + } else { + fmt.Printf("[DRY-RUN] 噪音:将删除 实体=%d 关系=%d(未写库,加 -apply 才落地)\n", de, dr) + } + + if *orphans { + list, err := g.OrphanEntities() + if err != nil { + log.Fatalf("memgc: orphans: %v", err) + } + fmt.Printf("孤立实体(零关系)%d 个:\n", len(list)) + for _, e := range list { + fmt.Printf(" %-64s type=%-8s mentions=%d\n", e.Name, e.Type, e.MentionCount) + } + n, err := g.PurgeOrphans(!*apply) + if err != nil { + log.Fatalf("memgc: purge orphans: %v", err) + } + if *apply { + fmt.Printf("[APPLIED] 孤立实体:已删除 %d 个\n", n) + } else { + fmt.Printf("[DRY-RUN] 孤立实体:将删除 %d 个\n", n) + } + } + + if *apply { + fmt.Println("提示:运行中的进程会在下一个 archive 心跳(Indexer.Sync)重建实体名向量索引,无需重启。") + } +} diff --git a/cmd/ohos/HomeAgent/AppScope/app.json5 b/cmd/ohos/HomeAgent/AppScope/app.json5 index e6d0a76..5a9c380 100644 --- a/cmd/ohos/HomeAgent/AppScope/app.json5 +++ b/cmd/ohos/HomeAgent/AppScope/app.json5 @@ -2,8 +2,8 @@ "app": { "bundleName": "com.example.homeagent", "vendor": "HomeAgent", - "versionCode": 1001001, - "versionName": "1.1.1", + "versionCode": 1004000, + "versionName": "1.4.0", // 分层图标:前景是字形,背景(沉淀色)在 base/ 与 dark/ 各一份,随系统主题切换。 // 直接指向位图会把浅色底烧进图标,深色模式下桌面和启动页都会跳脱。 "icon": "$media:layered_image", diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/common/AppVersion.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/common/AppVersion.ets new file mode 100644 index 0000000..08dc512 --- /dev/null +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/common/AppVersion.ets @@ -0,0 +1,19 @@ +import { bundleManager } from '@kit.AbilityKit'; + +/** + * 应用版本号:从 bundle 元数据读取,而不是在 .ets 里再抄一份。 + * + * AppScope/app.json5 是版本的唯一来源(由 deploy/scripts/sync-client-versions.sh + * 与内核 internal/meta.Version 对齐)。在代码里再写一个字面量就是第二份真相, + * 实测已经漂过:app.json5 写 1.1.1、设备桥上又是一份 1.1.1,而内核早已 1.4.0。 + * 设备桥上报 / deviceinfo 回显的真实安装包版本,应当来自同一个来源。 + */ +export function appVersion(): string { + try { + const info: bundleManager.BundleInfo = + bundleManager.getBundleInfoForSelfSync(bundleManager.BundleFlag.GET_BUNDLE_INFO_DEFAULT); + return info.versionName; + } catch (e) { + return ''; + } +} diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/common/BridgeCaps.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/common/BridgeCaps.ets index e6db188..8c13fb9 100644 --- a/cmd/ohos/HomeAgent/entry/src/main/ets/common/BridgeCaps.ets +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/common/BridgeCaps.ets @@ -5,6 +5,9 @@ import { deviceInfo } from '@kit.BasicServicesKit'; import { textToSpeech } from '@kit.CoreSpeechKit'; import { componentSnapshot } from '@kit.ArkUI'; import { abilityAccessCtrl, common, PermissionRequestResult, Permissions } from '@kit.AbilityKit'; +import { camera, cameraPicker } from '@kit.CameraKit'; +import { fileIo, fileUri } from '@kit.CoreFileKit'; +import { appVersion } from './AppVersion'; // ===== 能力结果 ===== @@ -17,6 +20,7 @@ export const LOCAL_DEVICE_CAPS: string[] = [ 'clipboardsee', 'clipboardsue', 'speakeruse', + 'camerasue', ]; export interface CapResult { @@ -129,6 +133,82 @@ export async function capScreensee(): Promise { } } +// ===== camerasue:系统相机抓拍 ===== +// +// 与桌面/CLI 端的实现路径不同:鸿蒙三方应用不能无界面地直接驱动摄像头 +// (CameraKit 需要预览 surface + CAMERA 权限,且后台采集受限),能拿到 +// “用户正在拍的这一张”的合规路径是系统相机选择器 cameraPicker —— 由系统 +// 相机完成采集,本应用只取回结果文件。语义与桌面端一致:现在给 agent 拍一张。 +// +// 结果落在应用沙箱(saveUri 指向 filesDir),不写系统媒体库,也就不需要 +// READ_IMAGEVIDEO 这类受限权限。 +const CAMERASUE_MAX_B64: number = 950000; + +/** + * camerasue 实现。 + * + * 参数语义与 homeagent-cmdrun 的说明一致:无参数 = 抓拍单张; + * `` = 录 N 秒视频。录像回传暂未接二进制分块(DeviceBridge 已具备 + * sendDataChunked,接线待做)——这里明确拒掉,而不是回一个超长 base64 + * 把模型上下文和 bridge 都撑爆。 + */ +export async function capCamerasue(context: common.UIAbilityContext, + rawArgs: string): Promise { + const raw: string = rawArgs.trim(); + if (raw.length > 0) { + const digits: RegExp = new RegExp('^\\d+$'); + if (digits.test(raw)) { + return errResult('鸿蒙端暂不支持录像回传,请用 camerasue 抓拍单张'); + } + } + + const filePath: string = context.filesDir + '/camerasue_' + Date.now().toString() + '.jpg'; + try { + // cameraPicker 要求 saveUri 指向的文件存在且可写,先建空文件占位 + const f: fileIo.File = fileIo.openSync(filePath, + fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE); + fileIo.closeSync(f); + } catch (e) { + return errResult('无法创建相机输出文件'); + } + + try { + const profile: cameraPicker.PickerProfile = { + cameraPosition: camera.CameraPosition.CAMERA_POSITION_BACK, + saveUri: fileUri.getUriFromPath(filePath), + }; + const res: cameraPicker.PickerResult = + await cameraPicker.pick(context, [cameraPicker.PickerMediaType.PHOTO], profile); + if (res.resultCode !== 0 || res.resultUri.length === 0) { + return errResult('未获取到照片(可能被取消)'); + } + } catch (e) { + return errResult('相机不可用或未授权,请确认应用在前台并允许使用相机'); + } + + let fd: number = -1; + try { + const stat: fileIo.Stat = fileIo.statSync(filePath); + const buf: ArrayBuffer = new ArrayBuffer(stat.size); + const rf: fileIo.File = fileIo.openSync(filePath, fileIo.OpenMode.READ_ONLY); + fd = rf.fd; + fileIo.readSync(fd, buf); + fileIo.closeSync(rf); + fd = -1; + const helper: util.Base64Helper = new util.Base64Helper(); + const b64: string = helper.encodeToStringSync(new Uint8Array(buf)); + if (b64.length > CAMERASUE_MAX_B64) { + return errResult('照片数据过大,请降低分辨率后重试'); + } + return okResult('data:image/jpeg;base64,' + b64); + } catch (e) { + if (fd >= 0) { + try { fileIo.closeSync(fd); } catch (ignore) {} + } + return errResult('照片读取失败,请重试'); + } +} + // ===== clipboardsee / clipboardsue ===== const CLIPBOARD_PERMISSIONS: Array = ['ohos.permission.READ_PASTEBOARD']; @@ -249,7 +329,7 @@ export function capDeviceInfo(deviceId: string, deviceName: string): CapResult { platform: 'OpenHarmony', arch: deviceInfo.abiList, os_release: deviceInfo.osFullName, - version: '1.1.1', + version: appVersion(), cpus: 0, brand: deviceInfo.brand, manufacturer: deviceInfo.manufacture, diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/common/BridgeProtocol.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/common/BridgeProtocol.ets index e50bbdd..6e65a1b 100644 --- a/cmd/ohos/HomeAgent/entry/src/main/ets/common/BridgeProtocol.ets +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/common/BridgeProtocol.ets @@ -10,6 +10,7 @@ */ import { CapResult } from './BridgeCaps'; +import { appVersion } from './AppVersion'; // ===== 协议消息(与 remotedevice 插件对齐)===== @@ -83,7 +84,7 @@ export function bridgeHelloFrame(deviceId: string, name: string, kind: string, platform: 'OpenHarmony', arch: '', os_release: '', - version: '1.1.1', + version: appVersion(), cpus: 0, }; const device: HelloDevice = { diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/common/BridgeRouter.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/common/BridgeRouter.ets index bc02bb7..8adfe92 100644 --- a/cmd/ohos/HomeAgent/entry/src/main/ets/common/BridgeRouter.ets +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/common/BridgeRouter.ets @@ -2,6 +2,7 @@ import { deviceBridge } from './DeviceBridge'; import { CapResult, capScreensee, + capCamerasue, capClipboardSee, capClipboardsue, capSpeakerUse, @@ -84,6 +85,12 @@ async function executeCommand(reqId: string, command: string): Promise { + // 跨页信号:切到设置 Tab,并让设置页直接打开连接二级页 + AppStorage.setOrCreate(K_SETTINGS_SUB, SUB_CONNECTIONS); + AppStorage.setOrCreate(K_REQUESTED_TAB, 3); + }) + } + } + .width('100%') + .height('100%') + .padding({ left: 44, right: 44 }) + .justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Center) + // 自身不吃触摸(空白处仍可滑列表),但子节点(按钮)正常响应 + .hitTestBehavior(HitTestMode.Transparent) + } + // 层1.5:顶栏遮罩(自身撑满并顶部对齐,全链路 hitTest None,触摸完全穿透) PageTopBar({ title: '聊天' }) diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/components/ConnectionsPane.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/components/ConnectionsPane.ets index 4d0279f..3175970 100644 --- a/cmd/ohos/HomeAgent/entry/src/main/ets/components/ConnectionsPane.ets +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/components/ConnectionsPane.ets @@ -13,7 +13,7 @@ import { apiClient } from '../common/ApiClient'; import { connStore } from '../common/ConnStore'; import { restartForegroundBridge } from '../common/DeviceBridgeSession'; import { ConnectionConfig } from '../model/Model'; -import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_MD, RADIUS_SM } from '../common/Constants'; +import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_MD, RADIUS_SM, K_HAS_CONN } from '../common/Constants'; import { SubPageLayer, PlainCard } from './SubPage'; import { common } from '@kit.AbilityKit'; @@ -49,6 +49,11 @@ export struct ConnectionsPane { } } + /** 连接变更后广播状态:聊天空态据此隐藏“去设置连接”入口。 */ + private syncConnFlag(): void { + AppStorage.setOrCreate(K_HAS_CONN, apiClient.hasConnection()); + } + private currentConnName(): string { for (let i = 0; i < this.connections.length; i++) { if (this.connections[i].id === this.currentId) { @@ -64,6 +69,7 @@ export struct ConnectionsPane { if (cur !== null) { apiClient.setConnection(cur); } + this.syncConnFlag(); this.connections = connStore.getConnections(); restartForegroundBridge(getContext(this) as common.UIAbilityContext); this.toast('已切换连接', false); @@ -88,6 +94,7 @@ export struct ConnectionsPane { if (cur !== null) { apiClient.setConnection(cur); } + this.syncConnFlag(); this.connections = connStore.getConnections(); restartForegroundBridge(getContext(this) as common.UIAbilityContext); this.toast('连接已添加', false); @@ -107,6 +114,7 @@ export struct ConnectionsPane { if (cur !== null) { apiClient.setConnection(cur); } + this.syncConnFlag(); this.connections = connStore.getConnections(); restartForegroundBridge(getContext(this) as common.UIAbilityContext); this.toast('连接已更新', false); @@ -151,6 +159,7 @@ export struct ConnectionsPane { } else { apiClient.clearConnection(); } + this.syncConnFlag(); restartForegroundBridge(getContext(this) as common.UIAbilityContext); this.toast('连接已删除', false); }); diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/pages/Index.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/pages/Index.ets index b2143ba..7e2811a 100644 --- a/cmd/ohos/HomeAgent/entry/src/main/ets/pages/Index.ets +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/pages/Index.ets @@ -7,7 +7,7 @@ import { apiClient } from '../common/ApiClient'; import { navBar } from '../common/NavBarController'; import { handleBackPress } from '../common/NavStackRegistry'; import { ConnectionConfig } from '../model/Model'; -import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_MIN_WIDTH, WIDE_NAV_BAR_WIDTH } from '../common/Constants'; +import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_MIN_WIDTH, WIDE_NAV_BAR_WIDTH, K_HAS_CONN, K_REQUESTED_TAB, K_SETTINGS_SUB } from '../common/Constants'; import { ANIM_NORMAL, ANIM_SLOW } from '../common/Constants'; import { MotionBase } from '../components/MotionBase'; import { GradientBackground } from '../components/GradientBackground'; @@ -83,6 +83,8 @@ struct Index { /** 底部手势条高度(vp) */ @State bottomGesture: number = 16; @StorageProp('themeIsDark') @Watch('onThemeChanged') private isDark: boolean = true; + /** 外部请求切换主 Tab(未连接时聊天空态的“去设置连接”用) */ + @StorageProp(K_REQUESTED_TAB) @Watch('onRequestedTab') private requestedTab: number = -1; private swiper: SwiperController = new SwiperController(); private screensueTimer: number = -1; private snapshotBuilder: CustomBuilder = (): void => { }; // 由 @Builder 传入的实际锚点 @@ -101,6 +103,12 @@ struct Index { if (cur !== null) { apiClient.setConnection(cur); } + // 后端连接状态广播:聊天空态根据它决定是否显示“去设置连接”。 + // Index.aboutToAppear 在 EntryAbility 等 connStore.init 之后才跑, + // 所以此处读到的连接状态就是真实的启动态。 + AppStorage.setOrCreate(K_HAS_CONN, apiClient.hasConnection()); + AppStorage.setOrCreate(K_REQUESTED_TAB, -1); + AppStorage.setOrCreate(K_SETTINGS_SUB, ''); // 种子化自定义背景图状态到 AppStorage,GradientBackground 响应读取 const st = connStore.getSettings(); AppStorage.setOrCreate('bgImage', st.bgImage ?? ''); @@ -191,6 +199,23 @@ struct Index { AppStorage.set('currentTab', this.currentTab); } + /** + * 响应外部切 Tab 请求(聊天空态的“去设置连接”)。 + * + * 为什么不能直接改 AppStorage 的 currentTab:Index 的 currentTab 是 + * @State,Swiper.index() 只认它;外部写 AppStorage 不会驱动 Swiper。 + * 所以用独立请求键 + @Watch 把请求转成自己的状态变更。 + */ + private onRequestedTab(): void { + const t: number = this.requestedTab; + AppStorage.set(K_REQUESTED_TAB, -1); + if (t < 0 || t >= this.tabs.length) { + return; + } + this.currentTab = t; + navBar.setVisible(true); + } + private syncSystemBar(): void { const dark: boolean = this.isDark; const bg: string = dark ? '#000000' : '#F1F3F5'; diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/pages/SettingsPage.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/pages/SettingsPage.ets index b1ccef3..fdf66fd 100644 --- a/cmd/ohos/HomeAgent/entry/src/main/ets/pages/SettingsPage.ets +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/pages/SettingsPage.ets @@ -3,7 +3,7 @@ import { userMessage, noConnectionMessage } from '../common/UserError'; import { connStore } from '../common/ConnStore'; import { registerNavStack, unregisterNavStack } from '../common/NavStackRegistry'; import { ConnectionConfig, AppSettings } from '../model/Model'; -import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_NAV_BAR_WIDTH, WIDE_MIN_CONTENT } from '../common/Constants'; +import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_NAV_BAR_WIDTH, WIDE_MIN_CONTENT, K_HAS_CONN, K_SETTINGS_SUB } from '../common/Constants'; import { RADIUS_MD, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants'; import { SubPageLayer, markSubPageOpen, subPageParam } from '../components/SubPage'; import { StatusDetailContent } from '../components/StatusCards'; @@ -42,6 +42,10 @@ export struct SettingsPage { /** 二级页面导航栈:系统返回手势/三键返回直接作用于它 */ private navStack: NavPathStack = new NavPathStack(); + /** 未连接时是否已自动弹过连接页——避免用户关掉后又被 onNavigationModeChange 弹回来 */ + private autoOpenedConn: boolean = false; + /** 外部请求打开某个二级页(聊天空态的“去设置连接”用) */ + @StorageProp(K_SETTINGS_SUB) @Watch('onSubRequest') private subRequest: string = ''; // ===== backend key/value editor state ===== @State sections: SettingsSection[] = []; @@ -86,6 +90,39 @@ export struct SettingsPage { this.connections = connStore.getConnections(); const cur = connStore.getCurrentConnection(); this.currentId = cur !== null ? cur.id : ''; + // 广播连接状态:聊天空态据此显示“去设置连接” + AppStorage.setOrCreate(K_HAS_CONN, apiClient.hasConnection()); + // 可能从聊天空态带着“打开连接页”的请求进来(本页尚未挂载时请求已写入) + const pending: string = AppStorage.get(K_SETTINGS_SUB) ?? ''; + if (pending.length > 0) { + AppStorage.set(K_SETTINGS_SUB, ''); + this.autoOpenedConn = true; + setTimeout(() => { + this.openSub(pending); + }, 0); + } + } + + /** + * 宽屏右栏默认该展示哪一页:没连上就把“后端连接”给出来。 + * + * 为什么不能只靠一级入口行:入口行在列表里,用户很容易略过; + * 而“未连接”恰恰是最需要直接看到连接表单的时刻。窄屏同理, + * 在 onNavigationModeChange(Stack) 里会把连接页直接推到面前。 + */ + private initialSub(): string { + return apiClient.hasConnection() ? SUB_STATUS : SUB_CONNECTIONS; + } + + /** 外部请求打开二级页(“去设置连接”) */ + private onSubRequest(): void { + const id: string = this.subRequest; + if (id.length === 0) { + return; + } + AppStorage.set(K_SETTINGS_SUB, ''); + this.autoOpenedConn = true; + this.openSub(id); } private palette(): ThemePalette { @@ -287,12 +324,17 @@ export struct SettingsPage { if (mode === NavigationMode.Split) { markSubPageOpen(true); if (this.navStack.size() === 0) { - this.openSub(SUB_STATUS); + this.openSub(this.initialSub()); } } else { this.navStack.clear(false); this.activeSub = SUB_NONE; markSubPageOpen(false); + // 窄屏:未配置后端时直接推连接页,保证“设置里一定能找到连后端的入口”。 + if (!this.autoOpenedConn && !apiClient.hasConnection()) { + this.autoOpenedConn = true; + this.openSub(SUB_CONNECTIONS); + } } }) } diff --git a/cmd/waiter/builtin.go b/cmd/waiter/builtin.go index 7a9de6c..dad4f7e 100644 --- a/cmd/waiter/builtin.go +++ b/cmd/waiter/builtin.go @@ -24,7 +24,7 @@ func handleBuiltin(cmd string, cfg *Config, state *State, reconnect func(), out /conn use switch to saved connection /conn del delete saved connection -Server commands (sent to agent): +Server commands (local 与 remote 行为一致): /status system status /kernel kernel status /settings [prefix] list settings @@ -32,6 +32,8 @@ Server commands (sent to agent): /plugin list list installed plugins /plugin install install plugin /plugin remove remove plugin + /plugin disable disable plugin + /plugin enable enable plugin /plugin info plugin details /memory query query graph memory /knowledge list knowledge base @@ -54,6 +56,26 @@ Any other text is sent to the agent directly.`) reconnect() return true + // /stop 与 /interrupt:取消当前生成(可附带一句新指令)。 + // 之前 /help 里写着这条命令,但 handleBuiltin 根本没有对应 case, + // 于是它像普通文本一样被发给了 Agent。 + // 本地交给 CLI 插件(内核优先级 L3),远端走 WebUI 的 chat/interrupt + // (内核优先级 L4)。两条路都是“真中断”,不是发一句话。 + case cmd == "/stop" || cmd == "/interrupt" || + strings.HasPrefix(cmd, "/stop ") || strings.HasPrefix(cmd, "/interrupt "): + msg := stopMessage(cmd) + if rc := state.RemoteConn(); rc != nil { + body := fmt.Sprintf(`{"message":%q}`, msg) + if _, err := rc.DoAPI("POST", "/api/v1/chat/interrupt", body); err != nil { + fmt.Fprintf(out, "interrupt failed: %v\n", err) + } else { + fmt.Fprintln(out, "interrupt sent") + } + } else { + state.Send(cmd) + } + return true + case strings.HasPrefix(cmd, "/connect "): cfg.Socket = strings.TrimSpace(cmd[9:]) cfg.Remote = "" @@ -143,7 +165,7 @@ Any other text is sent to the agent directly.`) rc.DoAPI("PUT", "/api/v1/settings", body) fmt.Fprintln(out, "ok") } else { - state.Send(cmd[1:]) + state.Send(cmd) } return true @@ -152,7 +174,7 @@ Any other text is sent to the agent directly.`) d, _ := rc.DoAPI("GET", "/api/v1/settings", "") printJSON(out, d) } else { - state.Send(cmd[1:]) + state.Send(cmd) } return true @@ -172,7 +194,7 @@ Any other text is sent to the agent directly.`) d, _ := rc.DoAPI("POST", "/api/v1/plugins", body) printJSON(out, d) } else { - state.Send(cmd[1:]) + state.Send(cmd) } return true @@ -182,7 +204,7 @@ Any other text is sent to the agent directly.`) d, _ := rc.DoAPI("DELETE", "/api/v1/plugins/"+name, "") printJSON(out, d) } else { - state.Send(cmd[1:]) + state.Send(cmd) } return true @@ -192,7 +214,27 @@ Any other text is sent to the agent directly.`) d, _ := rc.DoAPI("GET", "/api/v1/plugins/"+name, "") printJSON(out, d) } else { - state.Send(cmd[1:]) + state.Send(cmd) + } + return true + + // disable/enable:本地由 CLI 插件处理,远端走插件管理 REST 动作接口。 + case strings.HasPrefix(cmd, "/plugin disable ") || strings.HasPrefix(cmd, "/plugin enable "): + verb := "disable" + name := strings.TrimSpace(cmd[16:]) + if strings.HasPrefix(cmd, "/plugin enable ") { + verb = "enable" + name = strings.TrimSpace(cmd[15:]) + } + if name == "" { + fmt.Fprintln(out, "usage: /plugin disable|enable ") + return true + } + if rc := state.RemoteConn(); rc != nil { + d, _ := rc.DoAPI("POST", "/api/v1/plugins/"+name+"/"+verb, "") + printJSON(out, d) + } else { + state.Send(cmd) } return true @@ -202,7 +244,7 @@ Any other text is sent to the agent directly.`) d, _ := rc.DoAPI("GET", "/api/v1/memory?query="+q, "") printJSON(out, d) } else { - state.Send(cmd[1:]) + state.Send(cmd) } return true @@ -212,7 +254,7 @@ Any other text is sent to the agent directly.`) d, _ := rc.DoAPI("DELETE", "/api/v1/knowledge/"+name, "") printJSON(out, d) } else { - state.Send(cmd[1:]) + state.Send(cmd) } return true @@ -239,6 +281,16 @@ Any other text is sent to the agent directly.`) } } +// stopMessage 从 /stop 或 /interrupt 行里取出可选的中断附带消息(空串=纯取消)。 +func stopMessage(cmd string) string { + for _, prefix := range []string{"/interrupt", "/stop"} { + if strings.HasPrefix(cmd, prefix) { + return strings.TrimSpace(strings.TrimPrefix(cmd, prefix)) + } + } + return "" +} + func printJSON(out io.Writer, d map[string]interface{}) { if d == nil { fmt.Fprintln(out, "(no data)") diff --git a/cmd/waiter/device.go b/cmd/waiter/device.go index a58c7f1..b59de16 100644 --- a/cmd/waiter/device.go +++ b/cmd/waiter/device.go @@ -15,6 +15,7 @@ import ( "time" "gitcode.com/JianFeeeee/HomeAgent/internal/devicebridge/client" + "gitcode.com/JianFeeeee/HomeAgent/internal/meta" ) // ===== 设备桥管理 ===== @@ -46,6 +47,9 @@ func startDeviceBridge(addr, token string) error { "platform": runtime.GOOS, "arch": runtime.GOARCH, "cpus": runtime.NumCPU(), + // 客户端版本与内核同源(internal/meta),deviceinfo 回显的软件版本 + // 因此与 homed 一致,不再是一个空缺字段。 + "version": meta.Version, } // 确保 gateway URL 格式正确 diff --git a/cmd/waiter/main.go b/cmd/waiter/main.go index 428fa86..000ab74 100644 --- a/cmd/waiter/main.go +++ b/cmd/waiter/main.go @@ -11,6 +11,8 @@ import ( "sync" "syscall" "time" + + "gitcode.com/JianFeeeee/HomeAgent/internal/meta" ) const ( @@ -129,8 +131,15 @@ func main() { daemonMode := flag.Bool("daemon", false, "后台驻留模式:维持 homed 连接 + 设备桥,等待 TUI 实例接入") testCap := flag.String("test-cap", "", "测试本地能力(screensue/speakeruse/screensee/clipboardsee/clipboardsue/computeruse/camerasue),如 --test-cap screensue") testCapArgs := flag.String("test-cap-args", "", "测试能力的参数") + showVersion := flag.Bool("version", false, "打印版本并退出") flag.Parse() + // 版本号直接来自 internal/meta(与 homed 同一事实源,不可能各写一个)。 + if *showVersion { + fmt.Printf("waiter %s (commit %s, built %s)\n", meta.Version, meta.Commit, meta.BuildTime) + return + } + // 本地能力测试模式(无需连接服务器) if *testCap != "" { runCapTest(*testCap, *testCapArgs) @@ -270,9 +279,9 @@ func runLineMode(state *State, cfg *Config, history *History) { addrLabel = cfg.Remote } if colors { - fmt.Printf("%sHomeAgent CLI%s %s(%s://%s)%s\n", colorBold, colorReset, colorDim, modeLabel, addrLabel, colorReset) + fmt.Printf("%sHomeAgent CLI%s %s%s (%s://%s)%s\n", colorBold, colorReset, colorDim, "v"+meta.Version, modeLabel, addrLabel, colorReset) } else { - fmt.Printf("HomeAgent CLI (%s://%s)\n", modeLabel, addrLabel) + fmt.Printf("HomeAgent CLI v%s (%s://%s)\n", meta.Version, modeLabel, addrLabel) } fmt.Println("Type /help for commands.") diff --git a/deploy/scripts/sync-client-versions.sh b/deploy/scripts/sync-client-versions.sh new file mode 100755 index 0000000..fac2601 --- /dev/null +++ b/deploy/scripts/sync-client-versions.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# +# 客户端版本与内核版本同步。 +# +# 为什么要有这个脚本:内核的 internal/meta/meta.go Version 是唯一事实源, +# 而各客户端各有各的版本字段——GUI 在 package.json、鸿蒙在 AppScope/app.json5、 +# waiter 走编译期注入。手工各改各的必然漂移(写这个脚本时的现状:内核 1.4.0、 +# GUI 1.0.0、鸿蒙 1.1.1,三个号互不相干)。 +# +# 用法: +# bash deploy/scripts/sync-client-versions.sh # 同步到内核当前版本 +# bash deploy/scripts/sync-client-versions.sh 1.4.0 # 同步到指定版本 +# bash deploy/scripts/sync-client-versions.sh --check # 只校验,漂移则退出 1 +# +# 同步目标: +# cmd/gui/package.json version +# cmd/ohos/HomeAgent/AppScope/app.json5 versionName + versionCode +# +# waiter 不在此列:它直接引用 internal/meta.Version(同一进程内编译), +# 没有第二份版本字段可漂。 +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" + +CHECK=0 +VERSION="" +for arg in "$@"; do + case "$arg" in + --check) CHECK=1 ;; + *) VERSION="$arg" ;; + esac +done + +# 未显式给版本时,从内核唯一事实源读。 +if [ -z "$VERSION" ]; then + VERSION="$(grep -oE 'Version = "[^"]+"' "$ROOT/internal/meta/meta.go" | head -1 | sed -E 's/.*"([^"]+)".*/\1/')" +fi +[ -n "$VERSION" ] || { echo "sync-client-versions: 无法确定版本号(internal/meta/meta.go 里没找到 Version)" >&2; exit 1; } + +# versionCode 规则:X*1e6 + Y*1e3 + Z。鸿蒙要求 versionCode 单调递增的整数, +# 直接搬 semver 会丢信息,所以用主/次/补丁三段编码(1.4.0 → 1004000)。 +CODE="$(python3 - "$VERSION" <<'PY' +import re, sys +m = re.match(r'^(\d+)\.(\d+)\.(\d+)', sys.argv[1]) +if not m: + sys.exit("sync-client-versions: 版本号必须是 X.Y.Z 形态,得到 %r" % sys.argv[1]) +print(int(m.group(1)) * 1000000 + int(m.group(2)) * 1000 + int(m.group(3))) +PY +)" + +GUI_PKG="$ROOT/cmd/gui/package.json" +OHOS_APP="$ROOT/cmd/ohos/HomeAgent/AppScope/app.json5" + +DRIFT=0 +note() { printf ' %-52s %s\n' "$1" "$2"; } + +# ── GUI ── +gui_cur="$(python3 - "$GUI_PKG" <<'PY' +import json, sys +print(json.load(open(sys.argv[1]))["version"]) +PY +)" +if [ "$gui_cur" != "$VERSION" ]; then + DRIFT=1 + if [ "$CHECK" -eq 1 ]; then + note "cmd/gui/package.json" "$gui_cur → 应为 $VERSION" + else + python3 - "$GUI_PKG" "$VERSION" <<'PY' +import json, sys +p, v = sys.argv[1], sys.argv[2] +d = json.load(open(p)) +d["version"] = v +# indent=2 保留原格式;末尾补换行,避免 diff 噪声 +with open(p, "w") as f: + json.dump(d, f, indent=2, ensure_ascii=False) + f.write("\n") +PY + note "cmd/gui/package.json" "$gui_cur → $VERSION" + fi +fi + +# ── 鸿蒙 ── +ohos_name="$(grep -oE '"versionName"[[:space:]]*:[[:space:]]*"[^"]+"' "$OHOS_APP" | head -1 | sed -E 's/.*"([^"]+)"$/\1/')" +ohos_code="$(grep -oE '"versionCode"[[:space:]]*:[[:space:]]*[0-9]+' "$OHOS_APP" | head -1 | grep -oE '[0-9]+$')" +if [ "$ohos_name" != "$VERSION" ] || [ "$ohos_code" != "$CODE" ]; then + DRIFT=1 + if [ "$CHECK" -eq 1 ]; then + note "cmd/ohos AppScope/app.json5" "$ohos_name/$ohos_code → 应为 $VERSION/$CODE" + else + # app.json5 带注释,不是严格 JSON,用 sed 定点替换两个字段。 + sed -i -E "s/(\"versionCode\"[[:space:]]*:[[:space:]]*)[0-9]+/\1$CODE/" "$OHOS_APP" + sed -i -E "s/(\"versionName\"[[:space:]]*:[[:space:]]*\")[^\"]+/\1$VERSION/" "$OHOS_APP" + note "cmd/ohos AppScope/app.json5" "$ohos_name/$ohos_code → $VERSION/$CODE" + fi +fi + +echo "内核版本: $VERSION (versionCode $CODE)" +if [ "$CHECK" -eq 1 ]; then + if [ "$DRIFT" -eq 1 ]; then + echo "sync-client-versions: 客户端版本与内核不一致(见上);跑 bash deploy/scripts/sync-client-versions.sh 同步" >&2 + exit 1 + fi + echo "sync-client-versions: OK,客户端与内核版本一致" +fi diff --git a/docs/git-branching.md b/docs/git-branching.md index 0fca61d..023cae8 100644 --- a/docs/git-branching.md +++ b/docs/git-branching.md @@ -261,6 +261,11 @@ git switch main && git cherry-pick # 遵守 §三:只 pick,不 merge - alpha/beta tag 的产物**不上现网**(现网是 24/7 服务,预发布通道的存在就是为了不拿它冒险)。 - 涉及 SDK 仓时:主仓 `go.mod` 的 `replace => ./third_party/homeagent-sdk` 指向本地 vendored 副本, 发版前确认 vendored SDK 与 SDK 仓 release tag 一致(**两仓中版本对齐是第一优先级**,见 §七)。 +- **客户端版本必须与内核同步**(GUI / 鸿蒙 / waiter 同一个号,当前皆为 `internal/meta.Version`): + 内核版本是唯一事实源,客户端不得各写一个。拉齐用 `make sync-client-versions`, + 发版前跑 `make check-client-versions` 做漂移门禁。waiter 直接引用 `internal/meta`(无第二份字段); + 鸿蒙的 `versionName/versionCode` 由脚本写 `AppScope/app.json5`,运行时代码从 `bundleManager` 读, + 不再硬编码。 --- diff --git a/internal/agent/core/agent_helpers_test.go b/internal/agent/core/agent_helpers_test.go index 6f77986..19530b2 100644 --- a/internal/agent/core/agent_helpers_test.go +++ b/internal/agent/core/agent_helpers_test.go @@ -235,3 +235,58 @@ func TestGetFloatInt(t *testing.T) { t.Errorf("expected 5.0, got %f", got) } } + +// TestDocToTriplesDropsNoiseEntities 钉住 doc→graph 的噪音闸门。 +// +// 背景:doc→graph 在 1cb3e87 从「CutExact 滑窗词链」换成 NLP 依存提取器后, +// 唯一还拦常用词的那层(CutExact:去停用词 + validEntityName)失去调用点, +// 闸门只剩 validEntityName——它只管名字像不像名字,不管名字是不是常用词。 +// 实测生产库里因此攒下「文档 --主题--> 来自 N 个来源的 M 条对话 …」这类 +// 模板回声,以及 context_archived 这个内部标记。 +func TestDocToTriplesDropsNoiseEntities(t *testing.T) { + doc := &document.Doc{ + Summary: "来自 1 个来源的 2 条对话 (agent) 涉及: qq, 通道", + Content: "", + Source: "context_archived", + } + triples := docToTriples(doc, nil) + + for _, tr := range triples { + if memory.IsNoiseEntity(tr.Subject) || memory.IsNoiseEntity(tr.Object) { + t.Errorf("docToTriples 漏出噪音实体: %+v", tr) + } + } + + // 模板摘要不当「主题」、context_archived 不当「来源」:两条模板三元组都该被拦下。 + for _, tr := range triples { + if tr.Relation == "主题" { + t.Errorf("模板摘要被写成主题: %+v", tr) + } + if tr.Relation == "来源" && tr.Object == "context_archived" { + t.Errorf("归档内部标记被写成来源: %+v", tr) + } + } +} + +// TestDocToTriplesKeepsTemplateAnchors 保证闸门没把正常的模板三元组一起误杀。 +func TestDocToTriplesKeepsTemplateAnchors(t *testing.T) { + doc := &document.Doc{ + Summary: "多轮对话", + Content: "", + Source: "qq", + } + triples := docToTriples(doc, nil) + + var hasTopic, hasSource bool + for _, tr := range triples { + if tr.Subject == "文档" && tr.Relation == "主题" && tr.Object == "多轮对话" { + hasTopic = true + } + if tr.Subject == "文档" && tr.Relation == "来源" && tr.Object == "qq" { + hasSource = true + } + } + if !hasTopic || !hasSource { + t.Errorf("正常模板三元组被误杀: topic=%v source=%v, triples=%+v", hasTopic, hasSource, triples) + } +} diff --git a/internal/agent/core/context.go b/internal/agent/core/context.go index 8a38bb4..ec90426 100644 --- a/internal/agent/core/context.go +++ b/internal/agent/core/context.go @@ -3,6 +3,7 @@ package core import ( "encoding/json" "fmt" + "log" "os" "path/filepath" "sort" @@ -68,10 +69,38 @@ func NewRelevanceContext(savePath string, embedder *memory.StaticEmbedder) *Rele // SetDenseSpace 注入稠密多模态向量空间。配置后 L0 相关性裁剪可用稠密向量 // 余弦(与媒体检索、文档检索共享同一空间),未配置时退化到稀疏词向量。 +// +// 注入时**回填已有事件**的稠密向量。为什么必须回填:NewRelevanceContext 先 +// load()、再 SetDenseSpace,载入时 c.denseSpace 还是 nil,旧事件只算了稀疏 +// 向量;若这里只赋值不回填,Prune 里旧事件因 DenseFP 为空、长度不符而全部 +// 走稀疏余弦,新事件走稠密余弦 —— 同一次排序里两种尺度混排,谁留下谁归档 +// 取决于事件新旧而非相关性。对齐 DocStore.BuildDenseIndex 的做法。 +// +// 注意 DenseVec/DenseFP 刻意不持久化(json:"-"):这是每次启动一次性重算的 +// 缓存,不落盘,因此这里也不需要 Save。 func (c *RelevanceContext) SetDenseSpace(ds vector.MultimodalEmbedder) { c.mu.Lock() defer c.mu.Unlock() c.denseSpace = ds + if ds == nil || !ds.Loaded() { + return + } + fp := ds.Fingerprint() + dim := ds.Dim() + filled := 0 + for _, evt := range c.events { + if evt == nil { + continue + } + if evt.DenseFP == fp && len(evt.DenseVec) == dim { + continue + } + c.computeVector(evt) + filled++ + } + if filled > 0 { + log.Printf("[agent] context dense backfill: %d events", filled) + } } func (c *RelevanceContext) SetToolDefLookup(fn func(name string) *sdk.ToolDef) { diff --git a/internal/agent/core/context_test.go b/internal/agent/core/context_test.go index bbb6f97..dbf996b 100644 --- a/internal/agent/core/context_test.go +++ b/internal/agent/core/context_test.go @@ -2,6 +2,7 @@ package core import ( "os" + "path/filepath" "testing" "time" @@ -237,6 +238,41 @@ func containsStr(s, substr string) bool { return false } +// TestSetDenseSpaceBackfillsExistingEvents 锁死 L0 稠密回填: +// +// NewRelevanceContext 先 load()(此时 denseSpace 仍为 nil,旧事件只算了稀疏 +// 向量),SetDenseSpace 才注入稠密空间。若不回填已有事件,它们的 DenseFP +// 为空、DenseVec 长度不符,Prune 里旧事件走稀疏余弦、新事件走稠密余弦—— +// 同一次排序里混排两种尺度,谁留下只取决于事件新旧。 +func TestSetDenseSpaceBackfillsExistingEvents(t *testing.T) { + path := filepath.Join(t.TempDir(), "context.json") + + // run1:写入事件并落盘(不注入稠密空间)。 + c1 := NewRelevanceContext(path, memory.NewStaticEmbedder("")) + c1.Append(ContextEvent{Timestamp: time.Now(), Source: "user", Input: "昨天的决定"}) + c1.Append(ContextEvent{Timestamp: time.Now(), Source: "agent", Response: "记为待办"}) + if err := c1.Save(); err != nil { + t.Fatal(err) + } + + // run2:模拟重启——load() 发生在 SetDenseSpace 之前。 + c2 := NewRelevanceContext(path, memory.NewStaticEmbedder("")) + if c2.Len() == 0 { + t.Fatal("重启后未读回任何事件") + } + c2.SetDenseSpace(fakeSpace{}) + + events := c2.Recent(c2.Len()) + if len(events) == 0 { + t.Fatal("no events") + } + for i, e := range events { + if e.DenseFP != "fake-space" || len(e.DenseVec) != 2 { + t.Errorf("event %d 未回填稠密向量: fp=%q len=%d", i, e.DenseFP, len(e.DenseVec)) + } + } +} + func splitLines(s string) []string { var lines []string start := 0 diff --git a/internal/agent/core/distill.go b/internal/agent/core/distill.go index 38d2927..03c54b0 100644 --- a/internal/agent/core/distill.go +++ b/internal/agent/core/distill.go @@ -172,6 +172,19 @@ func (a *Agent) archiveColdDocs() { } } + // 场景记忆的「用进废退」:久未重现的关联按半衰期淡出。 + // + // 不做衰减的后果不是"多记一点",而是**注入预算被一次性巧合吃光**—— + // 场景是每轮都要注入的常驻内容,关联只增不减时,越老的库注入越糊。 + // 半衰期取 30 天:比"这个月没做过这类事"更久,避免把季节性的事误删。 + if a.memory != nil { + if n, err := a.memory.DecaySceneRefs(30*24*time.Hour, 0.05); err != nil { + log.Printf("[agent] scene decay error: %v", err) + } else if n > 0 { + log.Printf("[agent] 场景关联衰减:清理 %d 条长期未重现的引用", n) + } + } + if a.docStore != nil { a.docStore.Reindex() } @@ -207,7 +220,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 @@ -423,6 +436,11 @@ func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple { return nil } + // 文档归档的知识是有**来源场面**的:来自 QQ 的对话归档,其三元组就该 + // 钉在 chan:qq 上。这样「又来一条 QQ 消息」时,这批知识靠场景就能取回, + // 不必指望本轮措辞与它们字面重合。 + docScene := memory.ChannelScene(doc.Source) + isArchivedContext := doc.Meta != nil && doc.Meta["is_archived_context"] == "true" // 文档元数据:仅当 summary 合理(非空、非模板化、长度适中)时才写「主题」 @@ -434,6 +452,7 @@ func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple { Object: doc.Summary, ObjectType: "Topic", Confidence: 1.0, + Scene: docScene, }) } @@ -451,6 +470,7 @@ func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple { for _, nt := range result.Triples { mt := nlp.ToMemoryTriple(nt) if mt.Subject != "" && mt.Relation != "" && mt.Object != "" { + mt.Scene = docScene triples = append(triples, mt) } } @@ -465,10 +485,15 @@ func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple { Object: doc.Source, ObjectType: "Source", Confidence: 1.0, + Scene: docScene, }) } - return triples + // 噪音闸门:NLP 提取器不认常用词(「结果 / 什么 / 待命」都能当主语), + // 而落库闸门 validEntityName 只管名字像不像名字。这一层是防止 + // 「每个文档的常用词都变成实体」的唯一防线(CutExact 时代的那层已随 + // 提取器换代丢失,见 memory.IsNoiseEntity 的说明)。 + return memory.FilterNoiseTriples(triples) } // isTemplateSummary 识别 summarizeEntries 生成的模板化摘要 diff --git a/internal/agent/core/eventloop.go b/internal/agent/core/eventloop.go index 48cbbda..e70a084 100644 --- a/internal/agent/core/eventloop.go +++ b/internal/agent/core/eventloop.go @@ -373,20 +373,13 @@ func (a *Agent) emitResponse(evt *agentIO.InputEvent, response string) { // // 查询向量取**清洗后**的输入(通道 Cleaner 的输出),与工具侧同一套语义: // 原始输入里的 ANSI/base64/JSON 包装会把相关性打分带偏,裁掉本该保留的事件。 +// +// 实际执行交由 memoryPass(与召回共用入口、query、预算与审计)。 func (a *Agent) pruneOnInput(evt *agentIO.InputEvent, cleanInput string) int { - if a.context == nil || !a.pruneDeclared(evt) { + if !a.pruneDeclared(evt) { return 0 } - // **动态上下文**是父 agent 专属能力:轻量内核(驻留子)用传统上下文, - // 不做按相关度的裁剪与向 doc 记忆的归档(子也没有 doc 记忆)。 - if a.isLightKernel() { - return 0 - } - topK := a.maxContextSize - 1 - if topK < 1 { - topK = 1 - } - return a.context.Prune(cleanInput, topK, a.docStore) + return a.memoryPass(cleanInput, "input:"+evt.Source, true, false, sceneKeysFor(evt, "")).Archived } // pruneDeclared 判定这次输入是否显式声明了裁剪。 @@ -405,6 +398,29 @@ func (a *Agent) pruneDeclared(evt *agentIO.InputEvent) bool { return false } +// recallDeclared 判定这次输入是否要触发记忆召回(注入)。 +// +// 与 pruneDeclared **正交**:prune 管“踢出去”(归档低相关 L0 事件), +// recall 管“取进来”(把 L2/L3 相关记忆注入本轮)。 +// +// 默认值与 prune 刻意相反:召回是只读增量、日常对话本就需要,所以**默认 auto**; +// 只有显式声明 recall_policy=none(如中断通知的 meta 文本)才关闭。 +// 优先级同 prune:注入点(payload)> 通道(ChannelDef)> 默认 auto。 +func (a *Agent) recallDeclared(evt *agentIO.InputEvent) bool { + if evt == nil { + return true + } + if p, ok := evt.Payload["recall_policy"].(string); ok && p != "" { + return p != pubsdk.RecallPolicyNone + } + if a.io != nil { + if chDef, ok := a.io.GetInputChannelDef(evt.Source); ok && chDef.RecallPolicy != "" { + return chDef.RecallPolicy != pubsdk.RecallPolicyNone + } + } + return true +} + // cleanInputFor 解析这条输入在计算层应当使用的清洗文本。 // // 优先级:注入点声明的 cleaner(payload.cleaner_name,引用某个已注册的通道 diff --git a/internal/agent/core/graphmedia.go b/internal/agent/core/graphmedia.go index e3a40c9..81eb0b9 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,20 @@ func (a *Agent) linkBlocksToDocument(docID string, blocks []memory.MemoryBlock) log.Printf("[media] 写入 L3 文档节点失败 (%s): %v", docID, err) return 0 } + // 文档层与场景模型兼容:文档节点也进场景,好让「这个场面有哪些文档」 + // 可枚举、可统计(场景贯穿流水线的 doc 层落地)。 + if scene != "" { + if err := a.memory.TagSceneDocument(scene, docID); err != nil { + log.Printf("[media] 文档挂场景失败 (%s): %v", docID, err) + } + } + // 文档层把场景传给块:归档进图库的块属于该文档的来源场面(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 +154,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 } @@ -197,6 +216,28 @@ func (a *Agent) mediaContextForRelations(relations []memory.Relation) string { return a.mediaContextForSentences(sentenceIDsFromRelations(relations)) } +// formatRecallRelations 渲染 memory_recall 的关系行,超 max 条截断。 +// +// 带上原始句子(截断到 60 字):三元组只是「A 关系 B」,脱离原句往往看不出 +// 语气、条件与指代——`sentence_text` 的存在意义就是「日后从图谱回到原文」, +// 而 Recall 已经把句子 JOIN 出来了。此前只回显实体名与关系类型,导致模型 +// 填了 sentence_text 也永远拿不回来,这个能力形同虚设。 +func formatRecallRelations(relations []memory.Relation, max int) []string { + var out []string + for i, r := range relations { + if max > 0 && i >= max { + out = append(out, "...更多关系被截断") + break + } + line := fmt.Sprintf("- %s →(%s)→ %s", r.SourceName, r.RelationType, r.TargetName) + if s := strings.TrimSpace(r.SentenceText); s != "" { + line += " 原句: \"" + truncateStr(s, 60) + "\"" + } + out = append(out, line) + } + return out +} + // mediaContextForInjectedEntities 为自动注入路径产出媒体说明。 // // Indexer.BuildContext 刻意不返回关系(只给实体索引以省 token), diff --git a/internal/agent/core/graphmedia_test.go b/internal/agent/core/graphmedia_test.go index c7252ea..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") @@ -376,7 +376,7 @@ func TestBuildMemoryContext_IncludesMediaSection(t *testing.T) { t.Fatalf("indexer sync: %v", err) } - out := a.buildMemoryContext("测试图片", 0) + out := a.buildMemoryContext("测试图片", 0, nil) if out == "" { t.Skip("图库召回未命中(indexer 检索策略所致),无法验证媒体段注入") } @@ -759,3 +759,39 @@ func TestMediaBlocksHeldByDocumentSurviveDeletion(t *testing.T) { t.Fatal("删除后内容应已移除") } } + +// TestFormatRecallRelations_SurfacesSentence 锁死「从图谱回到原文」: +// memory_recall 的关系行必须带上 sentence_text(截断),否则模型按工具 +// schema 填了原始句子也永远取不回,该字段形同虚设。 +func TestFormatRecallRelations_SurfacesSentence(t *testing.T) { + rels := []memory.Relation{ + {SourceName: "张三", RelationType: "喜欢", TargetName: "咖啡", SentenceText: "张三说他每天早上一定要喝一杯手冲咖啡。"}, + {SourceName: "张三", RelationType: "住在", TargetName: "北京"}, // 无原句:不应出现空的原句字段 + } + lines := formatRecallRelations(rels, 10) + if len(lines) != 2 { + t.Fatalf("应渲染 2 行,实际 %d: %v", len(lines), lines) + } + if !strings.Contains(lines[0], "张三 →(喜欢)→ 咖啡") || !strings.Contains(lines[0], "原句:") { + t.Errorf("第一条应带原句,实际 %q", lines[0]) + } + if strings.Contains(lines[1], "原句") { + t.Errorf("无 sentence_text 的关系不应出现原句字段,实际 %q", lines[1]) + } +} + +// TestFormatRecallRelations_Truncates 锁死关系条数上限: +// 超过 max 时截断并明确告知,避免刷屏。 +func TestFormatRecallRelations_Truncates(t *testing.T) { + var rels []memory.Relation + for i := 0; i < 15; i++ { + rels = append(rels, memory.Relation{SourceName: "A", RelationType: "连", TargetName: "B"}) + } + lines := formatRecallRelations(rels, 10) + if len(lines) != 11 { + t.Fatalf("10 条关系 + 1 条截断提示,实际 %d: %v", len(lines), lines) + } + if !strings.Contains(lines[10], "截断") { + t.Errorf("最后一行应为截断提示,实际 %q", lines[10]) + } +} diff --git a/internal/agent/core/inputch.go b/internal/agent/core/inputch.go index dcfbccf..04292a8 100644 --- a/internal/agent/core/inputch.go +++ b/internal/agent/core/inputch.go @@ -139,6 +139,9 @@ func policySuffix(ch agentIO.InputChannel) string { if ch.Def.ContextPolicy != "" && ch.Def.ContextPolicy != "none" { m = append(m, "裁剪:"+ch.Def.ContextPolicy) } + if ch.Def.RecallPolicy != "" && ch.Def.RecallPolicy != "auto" { + m = append(m, "召回:"+ch.Def.RecallPolicy) + } if len(m) == 0 { return "" } diff --git a/internal/agent/core/inputunify_test.go b/internal/agent/core/inputunify_test.go index 3bd25b7..49e4079 100644 --- a/internal/agent/core/inputunify_test.go +++ b/internal/agent/core/inputunify_test.go @@ -450,7 +450,7 @@ func TestToolMemoryCommit_BindsMedia(t *testing.T) { }, }, }, - }) + }, nil) if !strings.Contains(out, "关联") { t.Errorf("返回值应告知模型媒体已关联: %q", out) } @@ -481,7 +481,7 @@ func TestToolMemoryCommit_WithoutMedia(t *testing.T) { map[string]interface{}{"subject": "甲方", "relation": "签署", "object": "合同"}, }, }, - }) + }, nil) if strings.Contains(out, "失败") { t.Errorf("普通提交不该失败: %q", out) } @@ -505,7 +505,7 @@ func TestToolMemoryCommit_CarriesSentenceText(t *testing.T) { }, }, }, - }) + }, nil) res, _ := a.memory.Recall([]string{"李四"}, nil, 2, "") if len(res.Relations) == 0 { t.Fatal("召回为空") @@ -597,7 +597,7 @@ func TestTools_NilMediaStoreDegrades(t *testing.T) { }, }, }, - }) + }, nil) if strings.Contains(out, "失败") { t.Errorf("无媒体存储时提交不该失败: %q", out) } diff --git a/internal/agent/core/lightprofile_test.go b/internal/agent/core/lightprofile_test.go index d6d387c..5eed9df 100644 --- a/internal/agent/core/lightprofile_test.go +++ b/internal/agent/core/lightprofile_test.go @@ -88,7 +88,7 @@ func TestLightProfile_MemoryFaceWiring(t *testing.T) { got := a.executeMemoryTool(agentAPI.ToolCall{ ID: "c1", Name: "memory_recall", Arguments: map[string]interface{}{"query_intent": "主记忆实体,子独有实体"}, - }) + }, nil) if !strings.Contains(got, "主记忆实体") { t.Fatalf("子应看得到主记忆:%s", got) } @@ -132,7 +132,7 @@ func TestLightProfile_OrganizeToolsAbsentAndRefused(t *testing.T) { Arguments: map[string]interface{}{ "name": "任意", "source": "a", "target": "b", "criteria": map[string]interface{}{}, }, - }) + }, nil) if !strings.Contains(got, "轻量内核") { t.Fatalf("%s 在轻量内核里必须明确报不支持,实际 %q", tool, got) } diff --git a/internal/agent/core/medialive_test.go b/internal/agent/core/medialive_test.go index 67838f5..b362bbc 100644 --- a/internal/agent/core/medialive_test.go +++ b/internal/agent/core/medialive_test.go @@ -440,7 +440,7 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) { if err := a.indexer.Sync(); err != nil { t.Fatalf("indexer sync: %v", err) } - if mc := a.buildMemoryContext("测试图片", 0); mc != "" { + if mc := a.buildMemoryContext("测试图片", 0, nil); mc != "" { t.Logf("注入的记忆上下文: %s", truncRunes(mc, 200)) } else { t.Log("图库召回为空(本测试不再依赖文本描述,仅记录现状)") diff --git a/internal/agent/core/memorypass.go b/internal/agent/core/memorypass.go new file mode 100644 index 0000000..a2da104 --- /dev/null +++ b/internal/agent/core/memorypass.go @@ -0,0 +1,260 @@ +package core + +import ( + "log" + "strconv" + "time" + + agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory" +) + +// sceneKeysFor 推导本轮输入的**当前场景**。 +// +// 场景是“这场面正在发生”的机器可读描述,用于把带条件的记忆(规则/约定) +// 取回来。优先级: +// 1. 注入点显式声明(payload.scene)——插件最清楚自己在什么场面里 +// 2. 通道(evt.Source → chan:qq) +// 3. 工具(tool:qq_get_message)——工具输出触发的召回只知道这一步 +// +// 多个场景是**并列命中**(取回任一场景的记忆),不是交集: +// 「在 QQ 上」与「刚取回消息正文」是两个都能独立成立的触发条件。 +func sceneKeysFor(evt *agentIO.InputEvent, toolName string) []string { + var keys []string + seen := make(map[string]bool) + add := func(k string) { + // 显式声明的场景键来自插件,大小写/空白/标点都不可控;归一化后再去重, + // 否则「chan:QQ」与「chan:qq」会变成两个场景,各自只召回一半记忆。 + k = memory.NormalizeSceneKey(k) + if k == "" || seen[k] { + return + } + seen[k] = true + keys = append(keys, k) + } + + if evt != nil && evt.Payload != nil { + switch v := evt.Payload["scene"].(type) { + case string: + add(v) + case []string: + for _, s := range v { + add(s) + } + case []interface{}: + for _, item := range v { + if s, ok := item.(string); ok { + add(s) + } + } + } + } + + if evt != nil { + add(memory.ChannelScene(evt.Source)) + } + if toolName != "" { + add(memory.ToolScene(toolName)) + } + return keys +} + +// memoryPassOut 是一次记忆操作(取进来 / 踢出去)的结果。 +type memoryPassOut struct { + // Archived 是被归档进文档记忆的低相关 L0 事件数(prune 的输出)。 + Archived int + // RecallText 是可注入 prompt 的记忆索引文本(recall 的输出,空串表示无)。 + RecallText string +} + +// memoryPass 是「取进来(召回)」与「踢出去(裁剪)」的**唯一入口**。 +// +// prune 与 recall 是两根正交的声明轴(默认值刻意相反:裁剪是破坏性的、 +// 默认关;召回是只读增量、默认开),但两者都建立在**同一份清洗后的 query** +// 之上。调用点只负责解析声明,这里统一做三件各写一遍就会写歪的事: +// +// 1. 同一 query:prune 与 recall 用同一个查询向量来源,避免「裁错事件、 +// 召回错记忆」——原始内容里的 ANSI/base64/JSON 噪声会把相关性打分带偏。 +// 2. 同一次预算:召回文本按 token 预算截断只做一次(见 recallText)。 +// 3. 同一条审计:谁(trigger)据什么触发了哪种操作都落一条日志, +// 否则又是一个「幕后发生、查不出是谁」的机制(对齐 prune 的设计初衷)。 +// +// 边界:prune 与 recall 目前仍走**各自的相关性空间**(prune 用 L0 事件的 +// 稠密/词向量给已有事件打分,recall 用图 + TF-IDF 实体索引)。真正的 +// 「一次打分」要先统一打分空间(后续步骤);这里统一的是**入口、query、 +// 预算与审计**——这已是「一个过程」的可审计外壳,剩下的差在打分空间。 +func (a *Agent) memoryPass(query, trigger string, prune, recall bool, scenes []string) memoryPassOut { + var out memoryPassOut + if a == nil || (!prune && !recall) { + return out + } + if prune { + out.Archived = a.pruneByQuery(query) + } + if recall && query != "" { + out.RecallText = a.recallTextFor(query, trigger, scenes) + } + if out.Archived > 0 || out.RecallText != "" { + log.Printf("[agent] memory pass (%s): archived=%d recalled=%d chars", + trigger, out.Archived, len(out.RecallText)) + } + return out +} + +// pruneByQuery 按相关性把低相关 L0 事件归档进文档记忆,返回归档数。 +// +// **不做声明判定**——声明已由调用方(pruneOnInput / stepToolAfter)解析, +// 这里只负责执行。放在 memoryPass 内部是为了让裁剪与召回共享入口。 +func (a *Agent) pruneByQuery(query string) int { + if a == nil || a.context == nil { + return 0 + } + // **动态上下文**是父 agent 专属能力:轻量内核(驻留子)用传统上下文, + // 不做按相关度的裁剪与向 doc 记忆的归档(子也没有 doc 记忆)。 + if a.isLightKernel() { + return 0 + } + topK := a.maxContextSize - 1 + if topK < 1 { + topK = 1 + } + return a.context.Prune(query, topK, a.docStore) +} + +// ────────────────────────────────────────────── +// 场面指纹:场景**涌现**的原料 +// +// 场景不是谁声明的,而是从交互流里长出来的。长出来的原料就是每轮可观察的 +// 场面指纹——在哪个通道、跟谁、在做什么、聊什么、什么时段。全部取自运行时 +// 已有量,不需要模型配合,也不需要人工标注。 +// ────────────────────────────────────────────── + +// situationFeaturesFor 采集一轮交互的场面指纹。 +// +// 特征权重由种类决定(见 memory.SituationFeature.Weight):通道与对象是 +// 「同一个场面」最强的同一性信号,工具是行为信号,话题是软信号。 +func situationFeaturesFor(evt *agentIO.InputEvent, cleanInput, tool string) []memory.SituationFeature { + var feats []memory.SituationFeature + if evt != nil { + if evt.Source != "" { + feats = append(feats, memory.SituationFeature{Kind: "chan", Value: evt.Source}) + } + // 对话对象:插件在 payload 里给的群/用户标识(有则用,无则退化为仅有通道) + for _, k := range []string{"peer", "peer_id", "group_id", "user_id", "chat_id"} { + if v, ok := evt.Payload[k]; ok { + if s := payloadString(v); s != "" { + // 群与私聊要能区分:同一 id 在两种场景下不是同一个对象 + kind := "peer" + if k == "group_id" { + kind = "peer_group" + } + feats = append(feats, memory.SituationFeature{Kind: kind, Value: s}) + break + } + } + } + // 时段:弱信号。人的记忆确实带时间气味(「早上那件事」), + // 但它不该主导场面判定,所以权重最低。 + feats = append(feats, memory.SituationFeature{Kind: "part", Value: partOfDay(time.Now())}) + } + if tool != "" { + feats = append(feats, memory.SituationFeature{Kind: "tool", Value: tool}) + } + // 话题:取清洗后输入的内容词做软特征(最多 3 个)。 + if cleanInput != "" { + for i, kw := range memory.ExtractKeywords(memory.CleanText(cleanInput)) { + if i >= 3 { + break + } + feats = append(feats, memory.SituationFeature{Kind: "topic", Value: kw}) + } + } + return feats +} + +// payloadString 从 payload 值里取字符串(可能是 string / float64 / json.Number)。 +func payloadString(v interface{}) string { + switch t := v.(type) { + case string: + return t + case float64: + if t == float64(int64(t)) { + return strconv.FormatInt(int64(t), 10) + } + return strconv.FormatFloat(t, 'f', -1, 64) + case int64: + return strconv.FormatInt(t, 10) + case int: + return strconv.Itoa(t) + default: + return "" + } +} + +// partOfDay 把时刻归成时段(场面指纹里最弱的一维)。 +func partOfDay(t time.Time) string { + switch h := t.Hour(); { + case h < 6: + return "night" + case h < 12: + return "morning" + case h < 18: + return "afternoon" + default: + return "evening" + } +} + +// resolveTurnScenes 解析本轮的场景集合,**同时走主动与被动两条路**: +// +// 主动(声明):注入点/通道/工具声明了"这是哪个场面" → 场景存在化并喂入 +// 本轮指纹(声明场景因此慢慢学会自己认自己) +// 被动(涌现):场面指纹聚类 → 同类指纹重复出现时自己长出场景 +// +// 返回结果的 Primary 用于**写**(优先细粒度的涌现场景,首次交互退到声明场景 +// 兜底),Keys 用于**读**(两条路的并集,去重)。 +// +// 解析会**写库**(场景强化/长出),所以必须一轮一次:多调一次就多给场景记 +// 一次强度,"工具调得多"会被误读成"这个场面更常出现"。 +func (a *Agent) resolveTurnScenes(f *TaskFrame, tool string) memory.TurnScene { + var out memory.TurnScene + if a == nil || a.memory == nil { + return out + } + if f != nil && f.sceneDone { + return f.turnScene + } + + declared := sceneKeysFor(evtOf(f), tool) + feats := situationFeaturesFor(evtOf(f), cleanInputOf(f), tool) + sig := memory.NewSituation(feats...) + + turn, err := a.memory.EnterSceneWithHint(sig, declared) + if err != nil { + log.Printf("[agent] scene enter failed: %v", err) + // 出错时至少把声明场景交给召回,不让整条召回链一起失效 + turn = memory.TurnScene{Keys: declared} + if len(declared) > 0 { + turn.Primary = declared[0] + } + } + if turn.Emergent { + log.Printf("[agent] 场景涌现/命中: %q(指纹 %v)", turn.Primary, sig.Keys()) + } else if len(turn.DeclaredCreated) > 0 { + log.Printf("[agent] 声明场景成立: %v(指纹 %v)", turn.DeclaredCreated, sig.Keys()) + } + if f != nil { + f.turnScene = turn + f.Scene = turn.Primary + f.sceneDone = true + } + return turn +} + +// cleanInputOf 安全取出清洗后输入(f 为 nil 时为空)。 +func cleanInputOf(f *TaskFrame) string { + if f == nil { + return "" + } + return f.CleanInput +} diff --git a/internal/agent/core/memorypass_test.go b/internal/agent/core/memorypass_test.go new file mode 100644 index 0000000..03e4caf --- /dev/null +++ b/internal/agent/core/memorypass_test.go @@ -0,0 +1,107 @@ +package core + +import ( + "path/filepath" + "strings" + "testing" + "time" + + "gitcode.com/JianFeeeee/HomeAgent/internal/memory" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document" +) + +// memoryPass 是「裁剪」与「召回」的唯一入口:两根正交轴,但共用同一份 query。 +// +// 这一组测试锁死三件事: +// 1. 两个策略都不声明时是 no-op(不裁剪、不召回); +// 2. 同时声明时一次调用同时产出「归档数」与「召回文本」; +// 3. 单一策略只产出对应的那一个输出(正交,不互相触发)。 +func TestMemoryPass_NoPolicyIsNoOp(t *testing.T) { + a := &Agent{ + context: newPruneableContext(15), + maxContextSize: 4, + indexer: newTestIndexer(t, "咖啡", "张三"), + } + out := a.memoryPass("咖啡", "test", false, false, nil) + if out.Archived != 0 || out.RecallText != "" { + t.Fatalf("未声明任何策略时不应有任何输出,实际 %+v", out) + } +} + +func TestMemoryPass_PruneAndRecallTogether(t *testing.T) { + a := newMemoryPassAgent(t) + before := a.context.Len() + out := a.memoryPass("咖啡", "tool:test", true, true, nil) + if out.Archived == 0 { + t.Fatal("声明 prune 应归档低相关事件") + } + if a.context.Len() >= before { + t.Fatalf("裁剪后上下文应变短:%d → %d", before, a.context.Len()) + } + if !strings.Contains(out.RecallText, "【记忆索引】") { + t.Fatalf("声明 recall 应产出记忆索引文本,实际 %q", out.RecallText) + } +} + +func TestMemoryPass_PoliciesAreOrthogonal(t *testing.T) { + // 只裁不召回:输出只有归档数。 + onlyPrune := &Agent{ + context: newPruneableContext(15), + maxContextSize: 4, + indexer: newTestIndexer(t, "咖啡", "张三"), + } + if out := onlyPrune.memoryPass("咖啡", "test", true, false, nil); out.RecallText != "" { + t.Fatalf("只声明 prune 不应召回,实际 %q", out.RecallText) + } + // 只召回不裁剪:输出只有召回文本,上下文条数不变。 + onlyRecall := &Agent{ + context: newPruneableContext(15), + maxContextSize: 4, + indexer: newTestIndexer(t, "咖啡", "张三"), + } + before := onlyRecall.context.Len() + out := onlyRecall.memoryPass("咖啡", "test", false, true, nil) + if out.Archived != 0 { + t.Fatalf("只声明 recall 不应裁剪,实际归档 %d", out.Archived) + } + if onlyRecall.context.Len() != before { + t.Fatalf("只声明 recall 不应改变上下文条数:%d → %d", before, onlyRecall.context.Len()) + } +} + +// 输入侧召回必须用**清洗后**的 query(通道 Cleaner 的输出),与裁剪侧一致。 +// 用无关原文 + 命中清洗文本做区分,锁死「用的是 CleanInput 而不是 Input」。 +func TestBuildTaskMemoryContext_UsesCleanInput(t *testing.T) { + a := &Agent{indexer: newTestIndexer(t, "咖啡", "张三")} + + // CleanInput 命中实体、原文完全不相关 → 应召回(证明用了清洗文本)。 + f := &TaskFrame{Evt: nil, CleanInput: "咖啡"} + if got := a.buildTaskMemoryContext(f, "zzz", 0); !strings.Contains(got, "【记忆索引】") { + t.Fatalf("应据清洗后的 query 召回,实际 %q", got) + } + // 清洗为空 → 回退原文;原文无关则不召回。 + f2 := &TaskFrame{CleanInput: ""} + if got := a.buildTaskMemoryContext(f2, "zzz", 0); got != "" { + t.Fatalf("清洗为空且原文无关时不应召回,实际 %q", got) + } +} + +// newMemoryPassAgent 造一个同时能做裁剪与召回的 agent(含 doc 记忆落点)。 +func newMemoryPassAgent(t *testing.T) *Agent { + t.Helper() + return &Agent{ + context: newPruneableContext(15), + maxContextSize: 4, + indexer: newTestIndexer(t, "咖啡", "张三"), + docStore: document.NewStore(filepath.Join(t.TempDir(), "docs"), memory.TokenizeWords), + } +} + +// newPruneableContext 造 n 条可被裁剪的上下文(最近 10 条受保护)。 +func newPruneableContext(n int) *RelevanceContext { + ctx := NewRelevanceContext("", memory.NewStaticEmbedder("")) + for i := 0; i < n; i++ { + ctx.Append(ContextEvent{Timestamp: time.Now(), Source: "user", Input: "事件内容"}) + } + return ctx +} diff --git a/internal/agent/core/process.go b/internal/agent/core/process.go index b78f929..fe6d22b 100644 --- a/internal/agent/core/process.go +++ b/internal/agent/core/process.go @@ -81,6 +81,29 @@ func (a *Agent) toolOutputForQuery(toolName, raw string) string { return raw } +// recallMsgMarker 是工具触发召回时注入的 system 消息前缀。 +// 用它做去重与替换的识别标(与用户/中断的 system 消息区分开)。 +const recallMsgMarker = "【记忆召回】" + +// appendOrReplaceRecall 把一段召回文本作为 system 消息挂到消息末尾。 +// +// 同一任务内多次触发(如模型多次调用 qq_get_message)时**替换**上一条召回, +// 而不是累加:否则召回会线性叠进 prompt,把上下文与 token 预算越挤越紧。 +// 替换位置固定在末尾,不影响 tool/assistant 消息的配对。 +func appendOrReplaceRecall(msgs []agentAPI.Message, recallText string) []agentAPI.Message { + if recallText == "" { + return msgs + } + full := recallMsgMarker + "\n" + recallText + for i := len(msgs) - 1; i >= 0; i-- { + if msgs[i].Role == "system" && strings.HasPrefix(msgs[i].Content, recallMsgMarker) { + msgs[i].Content = full + return msgs + } + } + return append(msgs, agentAPI.Message{Role: "system", Content: full}) +} + // dropContinuationPlaceholders 移除此前由本机制插入的 user 占位。 // // 为什么必须移除而不仅仅是“不再追加”:`msgs` 在循环外创建、循环内只增不减, diff --git a/internal/agent/core/recallpolicy_test.go b/internal/agent/core/recallpolicy_test.go new file mode 100644 index 0000000..9f9cf27 --- /dev/null +++ b/internal/agent/core/recallpolicy_test.go @@ -0,0 +1,141 @@ +package core + +import ( + "path/filepath" + "strings" + "testing" + + agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" + agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory" + pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk" +) + +// 这一组测试锁死「默认召回、可显式关闭」这条语义。 +// +// 与 prune 刻意相反:裁剪是破坏性的、默认关;召回是只读增量、默认开。 +// 两者正交,一根 ContextPolicy 表达不了 2×2 的组合(只召回不裁剪 / 只裁不召回)。 +func TestRecallDeclared_DefaultsToRecall(t *testing.T) { + m := agentIO.NewIOManager() + a := &Agent{io: m} + + // 没有任何声明 → 默认召回(保持既有"每条输入都召回"的行为)。 + if !a.recallDeclared(&agentIO.InputEvent{Source: "unknown", Payload: map[string]interface{}{}}) { + t.Fatal("未声明的输入默认必须召回") + } + // 通道注册了但没设 RecallPolicy → 仍默认召回。 + m.RegisterInputChannel("plain", pubsdk.ChannelDef{}) + if !a.recallDeclared(&agentIO.InputEvent{Source: "plain", Payload: map[string]interface{}{}}) { + t.Fatal("ChannelDef 未设 RecallPolicy 应默认召回") + } + // nil 事件不能 panic,且按默认召回。 + if !a.recallDeclared(nil) { + t.Fatal("nil 事件应默认召回") + } +} + +func TestRecallDeclared_ChannelOptOut(t *testing.T) { + m := agentIO.NewIOManager() + m.RegisterInputChannel("meta", pubsdk.ChannelDef{RecallPolicy: pubsdk.RecallPolicyNone}) + m.RegisterInputChannel("talk", pubsdk.ChannelDef{RecallPolicy: pubsdk.RecallPolicyAuto}) + a := &Agent{io: m} + + if a.recallDeclared(&agentIO.InputEvent{Source: "meta", Payload: map[string]interface{}{}}) { + t.Fatal("通道声明 none 不应召回") + } + if !a.recallDeclared(&agentIO.InputEvent{Source: "talk", Payload: map[string]interface{}{}}) { + t.Fatal("通道声明 auto 应召回") + } +} + +// 注入点声明优先于通道定义:同一通道下的不同注入可以有不同意图。 +func TestRecallDeclared_InjectionOverridesChannel(t *testing.T) { + m := agentIO.NewIOManager() + a := &Agent{io: m} + m.RegisterInputChannel("qq", pubsdk.ChannelDef{RecallPolicy: pubsdk.RecallPolicyNone}) + + evt := &agentIO.InputEvent{Source: "qq", Payload: map[string]interface{}{ + "recall_policy": pubsdk.RecallPolicyAuto, + }} + if !a.recallDeclared(evt) { + t.Fatal("注入点声明 auto 应覆盖通道的 none") + } + + m.RegisterInputChannel("plain", pubsdk.ChannelDef{RecallPolicy: pubsdk.RecallPolicyAuto}) + evt = &agentIO.InputEvent{Source: "plain", Payload: map[string]interface{}{ + "recall_policy": pubsdk.RecallPolicyNone, + }} + if a.recallDeclared(evt) { + t.Fatal("注入点声明 none 应覆盖通道的 auto") + } +} + +// buildTaskMemoryContext 在声明 none 时必须返回空串(不注入记忆索引)。 +func TestBuildTaskMemoryContext_RespectsPolicy(t *testing.T) { + m := agentIO.NewIOManager() + m.RegisterInputChannel("meta", pubsdk.ChannelDef{RecallPolicy: pubsdk.RecallPolicyNone}) + a := &Agent{io: m, indexer: newTestIndexer(t, "咖啡", "张三")} + + f := &TaskFrame{Evt: &agentIO.InputEvent{Source: "meta", Payload: map[string]interface{}{}}} + if got := a.buildTaskMemoryContext(f, "咖啡", 0); got != "" { + t.Fatalf("声明 none 时不应注入记忆,实际 %q", got) + } + + f2 := &TaskFrame{Evt: &agentIO.InputEvent{Source: "plain", Payload: map[string]interface{}{}}} + if got := a.buildTaskMemoryContext(f2, "咖啡", 0); !strings.Contains(got, "【记忆索引】") { + t.Fatalf("默认应注入记忆索引,实际 %q", got) + } +} + +// 工具触发的召回:以(清洗后的)工具输出为 query,产出可注入的记忆文本。 +func TestRecallTextFor_UsesQuery(t *testing.T) { + a := &Agent{indexer: newTestIndexer(t, "咖啡", "张三")} + got := a.recallTextFor("咖啡", "tool:test", nil) + if !strings.Contains(got, "【记忆索引】") { + t.Fatalf("应产出记忆索引文本,实际 %q", got) + } + // 空 query 或无 indexer 时不产出、不 panic。 + if got := a.recallTextFor("", "tool:test", nil); got != "" { + t.Fatalf("空 query 应返回空串,实际 %q", got) + } + if got := (&Agent{}).recallTextFor("咖啡", "tool:test", nil); got != "" { + t.Fatalf("无 indexer 应返回空串,实际 %q", got) + } +} + +// 召回文本以 system 消息挂在末尾;同一任务内多次触发是**替换**而非累加。 +func TestAppendOrReplaceRecall(t *testing.T) { + msgs := []agentAPI.Message{{Role: "user", Content: "hi"}} + msgs = appendOrReplaceRecall(msgs, "第一段") + if len(msgs) != 2 || msgs[1].Role != "system" || !strings.Contains(msgs[1].Content, "第一段") { + t.Fatalf("首次应追加一条 system 召回消息,实际 %+v", msgs) + } + msgs = appendOrReplaceRecall(msgs, "第二段") + if len(msgs) != 2 { + t.Fatalf("再次触发应替换而非累加,实际 %d 条", len(msgs)) + } + if !strings.Contains(msgs[1].Content, "第二段") || strings.Contains(msgs[1].Content, "第一段") { + t.Fatalf("替换后应只含最新召回,实际 %q", msgs[1].Content) + } + if msgs = appendOrReplaceRecall(msgs, ""); len(msgs) != 2 { + t.Fatalf("空召回不应改变消息,实际 %d 条", len(msgs)) + } +} + +// newTestIndexer 造一个只含给定实体的图记忆 + 已同步的索引器。 +func newTestIndexer(t *testing.T, subject, object string) *memory.Indexer { + t.Helper() + db, err := memory.NewGraphDB(filepath.Join(t.TempDir(), "graph.db")) + if err != nil { + t.Fatalf("NewGraphDB: %v", err) + } + t.Cleanup(func() { db.Close() }) + if _, _, err := db.Commit([]memory.Triple{{Subject: subject, Relation: "喜欢", Object: object}}, "s", 0); err != nil { + t.Fatalf("Commit: %v", err) + } + idx := memory.NewIndexer(db) + if err := idx.Sync(); err != nil { + t.Fatalf("Sync: %v", err) + } + return idx +} diff --git a/internal/agent/core/scene_test.go b/internal/agent/core/scene_test.go new file mode 100644 index 0000000..8170037 --- /dev/null +++ b/internal/agent/core/scene_test.go @@ -0,0 +1,121 @@ +package core + +import ( + "strings" + "testing" + + agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory" +) + +// TestSceneKeysFor 钉住当前场景的推导优先级: +// 注入点显式声明 > 通道 > 工具;并列命中且去重。 +func TestSceneKeysFor(t *testing.T) { + // 通道 + 工具:两个都能独立成立的触发条件,都要带上 + got := sceneKeysFor(&agentIO.InputEvent{Source: "qq"}, "qq_get_message") + want := []string{"chan:qq", "tool:qq_get_message"} + if len(got) != len(want) { + t.Fatalf("sceneKeysFor = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("sceneKeysFor[%d] = %q, want %q", i, got[i], want[i]) + } + } + + // 注入点显式声明排最前;归一化生效;重复声明去重 + evt := &agentIO.InputEvent{ + Source: "QQ", + Payload: map[string]interface{}{"scene": " chan:qq/peer:group_1 "}, + } + got = sceneKeysFor(evt, "") + if len(got) != 2 || got[0] != "chan:qq/peer:group_1" || got[1] != "chan:qq" { + t.Errorf("显式声明应排最前且通道场景归一: %v", got) + } + + // 数组形式声明 + evt = &agentIO.InputEvent{ + Source: "webui", + Payload: map[string]interface{}{"scene": []interface{}{"chan:qq", "task:reminder"}}, + } + got = sceneKeysFor(evt, "") + if len(got) != 3 || got[0] != "chan:qq" || got[1] != "task:reminder" || got[2] != "chan:webui" { + t.Errorf("数组声明未生效: %v", got) + } + + // nil 事件不 panic + if got := sceneKeysFor(nil, ""); len(got) != 0 { + t.Errorf("nil 事件应无场景: %v", got) + } + // 未声明的 payload 键不影响 + evt = &agentIO.InputEvent{Source: "cli", Payload: map[string]interface{}{"recall_policy": "none"}} + if got := sceneKeysFor(evt, ""); len(got) != 1 || got[0] != "chan:cli" { + t.Errorf("无 scene 声明时应只有通道场景: %v", got) + } +} + +// TestSituationFeaturesFor 钉住指纹来源:全部是运行时可观察量, +// 不需要模型配合也不需要人工标注。 +func TestSituationFeaturesFor(t *testing.T) { + evt := &agentIO.InputEvent{ + Source: "QQ", + Payload: map[string]interface{}{"group_id": float64(1027993713)}, + } + feats := situationFeaturesFor(evt, "帮我看看排班表", "qq_get_message") + kinds := map[string]int{} + for _, f := range feats { + kinds[f.Kind]++ + } + if kinds["chan"] != 1 || kinds["peer_group"] != 1 || kinds["tool"] != 1 || kinds["part"] != 1 { + t.Fatalf("必备维度缺失: %+v", feats) + } + if kinds["topic"] == 0 { + t.Errorf("话题软特征缺失: %+v", feats) + } + if kinds["topic"] > 3 { + t.Errorf("话题最多 3 个,得到 %d", kinds["topic"]) + } + + sig := memory.NewSituation(feats...) + keys := sig.Keys() + // 归一化 + 数值 id 的转换 + found := false + for _, k := range keys { + if k == "chan:qq" { + found = true + } + } + if !found { + t.Errorf("通道特征未归一化: %v", keys) + } + for _, k := range keys { + if strings.Contains(k, "peer_group:1027993713") { + found = true + } + } + if !found { + t.Errorf("数值型 group_id 未转成特征: %v", keys) + } + + // 无事件时不 panic,且只有工具特征时也成立 + if feats := situationFeaturesFor(nil, "", "memory_recall"); len(feats) != 1 { + t.Errorf("仅工具场景应有 1 个特征: %+v", feats) + } +} + +// TestWritePathAttachesBothPaths 钉住写侧的「两条路都挂」: +// 显式声明优先;否则挂本轮声明的 + 涌现的场景集合。 +func TestTurnSceneKeysBothPaths(t *testing.T) { + // 声明的通道场景与工具场景都在,涌现键(若有)追加在后 + evt := &agentIO.InputEvent{Source: "qq", Payload: map[string]interface{}{"scene": "chan:qq/peer:group_1"}} + got := sceneKeysFor(evt, "qq_get_message") + want := []string{"chan:qq/peer:group_1", "chan:qq", "tool:qq_get_message"} + if len(got) != len(want) { + t.Fatalf("声明侧场景数不对: %v want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("声明侧[%d] = %q want %q", i, got[i], want[i]) + } + } +} diff --git a/internal/agent/core/task.go b/internal/agent/core/task.go index 0faad80..effeae8 100644 --- a/internal/agent/core/task.go +++ b/internal/agent/core/task.go @@ -30,6 +30,7 @@ import ( agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" "gitcode.com/JianFeeeee/HomeAgent/internal/events" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory" sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk" ) @@ -111,6 +112,13 @@ type TaskFrame struct { CurResult string Resp *agentAPI.CompletionResponse + // Scene 是本轮**涌现**出来的场景键(由场面指纹聚类得到,无人声明), + // sceneDone 标记是否已解析过——一轮只解析一次:多解析一次就多给场景 + // 加一次强度,「工具调得多」会被误当成「这个场面更常出现」。 + Scene string + turnScene memory.TurnScene + sceneDone bool + // 游标与终态 Step Step Response string @@ -275,7 +283,7 @@ func (a *Agent) rebaseFramePrefix(f *TaskFrame) { tail := append([]agentAPI.Message(nil), f.Msgs[f.PrefixLen:]...) budget := ComputeTokenBudget(a.provider, a.systemPrompt) - memContext := a.buildMemoryContext(f.Input, budget.MemoryTokens) + memContext := a.buildTaskMemoryContext(f, f.Input, budget.MemoryTokens) sysPrompt := a.buildSystemPrompt(memContext, f.Input) prefix := a.buildMessages(sysPrompt, f.Input, a.contextTokenBudget(budget)) @@ -395,10 +403,8 @@ func (a *Agent) prepareInputTask(evt *agentIO.InputEvent) (*TaskFrame, taskTermi } a.publishEvent(events.EventRawInput, rawPayload) - archived := a.pruneOnInput(evt, cleanInput) - if archived > 0 { - log.Printf("[agent] pruned %d low-relevance events to document memory", archived) - } + // 裁剪与审计统一在 memoryPass 内(日志已带 trigger)。 + a.pruneOnInput(evt, cleanInput) // 本轮 inputch(处理表按它记账)+ contextfull 检测(只有驻留子设了钩子)。 a.tableMu.Lock() @@ -497,7 +503,7 @@ func (a *Agent) step(f *TaskFrame) stepOutcome { func (a *Agent) stepPrepare(f *TaskFrame) stepOutcome { budget := ComputeTokenBudget(a.provider, a.systemPrompt) - memContext := a.buildMemoryContext(f.Input, budget.MemoryTokens) + memContext := a.buildTaskMemoryContext(f, f.Input, budget.MemoryTokens) sysPrompt := a.buildSystemPrompt(memContext, f.Input) f.Tools = a.buildToolDefs() @@ -718,7 +724,10 @@ func denialResultText(ctx *sdk.StageContext, toolName string) string { // stepToolExec 执行工具。**临界区**:见设计文档 §4.3。 func (a *Agent) stepToolExec(f *TaskFrame) stepOutcome { - result := a.executeToolCall(f.CurTool, f.OutputChannel) + // 执行工具前先解析本轮场景:写侧要用它给记忆自动挂场景(主动+被动两条路), + // 而工具步不一定走到下面的召回分支,所以不能等那里再解析。 + turn := a.resolveTurnScenes(f, f.CurTool.Name) + result := a.executeToolCall(f.CurTool, f.OutputChannel, turn.Keys...) f.CurResult = result f.ToolResults = append(f.ToolResults, ToolResultItem{Name: f.CurTool.Name, Output: result}) log.Printf("[agent] tool %s result: %s", f.CurTool.Name, truncateStr(result, 100)) @@ -743,16 +752,28 @@ func (a *Agent) stepToolAfter(f *TaskFrame) stepOutcome { result = r } } - // ContextPolicy: prune 工具调用后执行上下文裁剪(§13.8) - if def := a.stageHost.ToolDef(tc.Name); def != nil && def.ContextPolicy == "prune" { - if a.context != nil { - topK := a.maxContextSize - 1 - if topK < 1 { - topK = 1 + // 工具后处理:一次相关性过程,两个**正交**声明—— + // ContextPolicy=prune → 裁剪(踢出去,归档低相关 L0 事件) + // RecallPolicy=auto → 召回(取进来,注入 L2/L3 相关记忆) + // 两者共用同一份**清洗后**的 query,并统一走 memoryPass(同一入口、 + // 同一次预算与审计)。查询向量取清洗后的有效内容,否则噪声 + // (ANSI/base64/JSON 包装)会把相关性打分带偏,裁错事件、召回错记忆。 + var recallText string + if def := a.stageHost.ToolDef(tc.Name); def != nil { + needPrune := def.ContextPolicy == sdk.ContextPolicyPrune + needRecall := def.RecallPolicy == sdk.RecallPolicyAuto + if needPrune || needRecall { + query := a.toolOutputForQuery(tc.Name, result) + // 工具路召回的场景有两个来源:本轮输入的场面(如 chan:qq) + // 与这一步工具本身(如 tool:qq_get_message)。带上工具场景, + // 才能让「凡是要回 QQ 消息」这类规则在该步被取回。 + // 召回用两条路的并集:声明场景(注入点/通道/工具)+ 涌现场景 + scenes := sceneKeysFor(f.Evt, tc.Name) + turn := a.resolveTurnScenes(f, tc.Name) + for _, k := range turn.Keys { + scenes = append(scenes, k) } - // 查询向量取**清洗后**的有效内容,否则噪声(ANSI/base64/JSON 包装) - // 会把相关性打分带偏,裁掉本该保留的事件。 - a.context.Prune(a.toolOutputForQuery(tc.Name, result), topK, a.docStore) + recallText = a.memoryPass(query, "tool:"+tc.Name, needPrune, needRecall, scenes).RecallText } } @@ -822,6 +843,10 @@ func (a *Agent) stepToolAfter(f *TaskFrame) stepOutcome { // 必须紧跟在 toolMsg 之后:中间插入其他消息会让 tool_call_id 配对断开。 f.Msgs = append(f.Msgs, *mediaMsg) } + // 召回作为 system 消息挂在末尾(tool/assistant 配对已完成,插入此处不断链)。 + if recallText != "" { + f.Msgs = appendOrReplaceRecall(f.Msgs, recallText) + } a.publishEvent(events.EventToolCall, map[string]interface{}{ "tool": tc.Name, diff --git a/internal/agent/core/toolcall.go b/internal/agent/core/toolcall.go index 7bf4a1e..9e7e4c5 100644 --- a/internal/agent/core/toolcall.go +++ b/internal/agent/core/toolcall.go @@ -13,7 +13,7 @@ import ( "gitcode.com/JianFeeeee/HomeAgent/internal/memory/text" ) -func (a *Agent) executeToolCall(tc agentAPI.ToolCall, channel string) (ret string) { +func (a *Agent) executeToolCall(tc agentAPI.ToolCall, channel string, turnScenes ...string) (ret string) { defer func() { if r := recover(); r != nil { stack := debug.Stack() @@ -31,7 +31,7 @@ func (a *Agent) executeToolCall(tc agentAPI.ToolCall, channel string) (ret strin done := make(chan string, 1) go func() { - done <- a.executeToolCallInner(tc, channel) + done <- a.executeToolCallInner(tc, channel, turnScenes) }() select { @@ -43,12 +43,12 @@ func (a *Agent) executeToolCall(tc agentAPI.ToolCall, channel string) (ret strin } } -func (a *Agent) executeToolCallInner(tc agentAPI.ToolCall, channel string) string { +func (a *Agent) executeToolCallInner(tc agentAPI.ToolCall, channel string, turnScenes []string) string { switch { case tc.Name == "persona_set": return a.executePersonaTool(tc) case strings.HasPrefix(tc.Name, "memory_"): - return a.executeMemoryTool(tc) + return a.executeMemoryTool(tc, turnScenes) case strings.HasPrefix(tc.Name, "social_"): return a.executeSocialTool(tc) case strings.HasPrefix(tc.Name, "knowledge_"): @@ -123,7 +123,7 @@ func (a *Agent) executeToolCallInner(tc agentAPI.ToolCall, channel string) strin return fmt.Sprintf("%v", result) } -func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string { +func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) string { g := a.graphMem() if g == nil { if tc.Name == "memory_document_query" { @@ -174,13 +174,7 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string { parts = append(parts, fmt.Sprintf("- %s (提及%d次, 类型:%s)", e.Name, e.MentionCount, e.Type)) } parts = append(parts, fmt.Sprintf("找到 %d 条关系:", len(result.Relations))) - for i, r := range result.Relations { - if i >= 10 { - parts = append(parts, "...更多关系被截断") - break - } - parts = append(parts, fmt.Sprintf("- %s →(%s)→ %s", r.SourceName, r.RelationType, r.TargetName)) - } + parts = append(parts, formatRecallRelations(result.Relations, 10)...) // 命中的关系若挂着媒体块,把媒体说明附在结果末尾。 // // 关系行只有实体名和关系类型,看不出"这条记忆当时还带了一张图"。 @@ -215,6 +209,20 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string { if !ok { return "参数格式错误,需要 triples 数组" } + // 场景键:模型可以在三元组里逐条给(scene 字段),也可以在工具参数 + // 顶层给一次(scene 参数),后者作为本批次的默认场景。 + // 两条路都为空则这条记忆不参与场景召回——不做猜测:猜错的场景会把 + // 无关记忆钉死,之后每次进入该场面都会被注入,比漏标更难发现。 + batchScene := getString(tc.Arguments, "scene") + // 写侧的场景是**两条路都挂**: + // 显式声明(模型在参数里点名)优先; + // 否则挂本轮解析出的场景集合——主动声明的 + 被动涌现的。 + // 只挂一条会丢东西:只挂声明则细粒度唤起丢失,只挂涌现则首次交互 + // (场景还没长出来)没有兜底。 + var batchScenes []string + if batchScene == "" { + batchScenes = turnScenes + } var triples []memory.Triple for _, td := range triplesData { if m, ok := td.(map[string]interface{}); ok { @@ -223,6 +231,13 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string { Relation: getString(m, "relation"), Object: getString(m, "object"), SentenceText: getString(m, "sentence_text"), + Scene: getString(m, "scene"), + } + if t.Scene == "" { + t.Scene = batchScene + } + if len(t.Scenes) == 0 { + t.Scenes = batchScenes } // 模型显式关联的媒体:结构化字段随三元组一起提交, // 由 commitTriplesWithMedia 变成 L3 一等块并与句子建边—— @@ -357,6 +372,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, @@ -366,9 +395,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/agent/core/tooldefs.go b/internal/agent/core/tooldefs.go index cc7ab28..2cd1310 100644 --- a/internal/agent/core/tooldefs.go +++ b/internal/agent/core/tooldefs.go @@ -2,6 +2,7 @@ package core import ( "fmt" + "log" "strings" agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" @@ -9,11 +10,11 @@ import ( sdkmeta "gitcode.com/JianFeeeee/homeagent-sdk/meta" ) -func (a *Agent) buildMemoryContext(input string, maxTokens int) string { +func (a *Agent) buildMemoryContext(input string, maxTokens int, scenes []string) string { if a.indexer == nil { return "" } - injected := a.indexer.BuildContext(input) + injected := a.indexer.BuildContextInScene(input, scenes) s := a.indexer.FormatContext(injected) // 图库召回命中的实体若关联着带媒体的句子,把媒体说明一并注入。 @@ -39,6 +40,74 @@ func (a *Agent) buildMemoryContext(input string, maxTokens int) string { return s } +// buildTaskMemoryContext 按本任务声明的召回策略决定是否注入记忆索引。 +// +// 默认 auto(保持“每条输入都召回”的既有行为);输入/注入声明 +// recall_policy=none 时返回空串,从而不注入记忆。策略与裁剪(ContextPolicy)正交。 +// +// query 取**清洗后**的输入(通道 Cleaner 的输出),与裁剪侧同一套语义: +// 原始输入里的 ANSI/base64/JSON 包装会把相关性打分带偏。清洗为空时回退原文。 +func (a *Agent) buildTaskMemoryContext(f *TaskFrame, input string, maxTokens int) string { + scenes := sceneKeysFor(evtOf(f), "") + if f == nil { + return a.recallText(input, "input", maxTokens, scenes) + } + if !a.recallDeclared(f.Evt) { + return "" + } + query := strings.TrimSpace(f.CleanInput) + if query == "" { + query = input + } + trigger := "input" + if f.Evt != nil && f.Evt.Source != "" { + trigger = "input:" + f.Evt.Source + } + // 场景集合 = 声明(主动)+ 涌现(被动)两条路的并集。 + turn := a.resolveTurnScenes(f, "") + for _, k := range turn.Keys { + scenes = append(scenes, k) + } + return a.recallText(query, trigger, maxTokens, scenes) +} + +// evtOf 安全取出 TaskFrame 的事件(f 为 nil 时返回 nil)。 +func evtOf(f *TaskFrame) *agentIO.InputEvent { + if f == nil { + return nil + } + return f.Evt +} + +// recallTextFor 以 query 触发一次记忆召回,按当前预算截断,返回可注入的文本。 +// +// 这是“召回”侧的单一入口:与 Prune 共用同一份**清洗后**的 query, +// 使“取进来”(召回)与“踢出去”(裁剪)落在同一个相关性过程上。 +// trigger 仅用于日志溯源(如 "tool:qq_get_message")。 +func (a *Agent) recallTextFor(query, trigger string, scenes []string) string { + memTokens := 0 // 0 = 不截断 + if a != nil && a.provider != nil { + memTokens = ComputeTokenBudget(a.provider, a.systemPrompt).MemoryTokens + } + return a.recallText(query, trigger, memTokens, scenes) +} + +// recallText 是召回侧的共同实现:query → 记忆索引文本(空串表示无)。 +// +// 输入侧的 buildTaskMemoryContext 与工具侧的 recallTextFor 都收敛到这里, +// 使“同一份 query、同一次预算、同一条审计日志”只写一遍。 +func (a *Agent) recallText(query, trigger string, maxTokens int, scenes []string) string { + if a == nil || query == "" || a.indexer == nil { + return "" + } + text := a.buildMemoryContext(query, maxTokens, scenes) + if text == "" { + return "" + } + log.Printf("[agent] memory recall (%s): injected %d chars (scenes=%v)", trigger, len(text), scenes) + return text +} + // expandPromptVars 展开自定义提示词(人格卡)里的版本占位符。 // // 为什么需要:人格卡是**配置项**,一旦写死版本号就会随内核发版而说谎 —— diff --git a/internal/agent/io/channel.go b/internal/agent/io/channel.go index d48417b..e132f47 100644 --- a/internal/agent/io/channel.go +++ b/internal/agent/io/channel.go @@ -352,6 +352,9 @@ func applyInjectOpts(payload map[string]interface{}, opts InjectOptions) { if opts.ContextPolicy != "" { payload["context_policy"] = opts.ContextPolicy } + if opts.RecallPolicy != "" { + payload["recall_policy"] = opts.RecallPolicy + } if opts.CleanerName != "" { payload["cleaner_name"] = opts.CleanerName } diff --git a/internal/agent/io/injectopts_test.go b/internal/agent/io/injectopts_test.go index 3c234f8..25c1075 100644 --- a/internal/agent/io/injectopts_test.go +++ b/internal/agent/io/injectopts_test.go @@ -47,6 +47,7 @@ func TestInjectTextOpts_CarriesFlags(t *testing.T) { m.InjectTextOpts("src", "chan", "hello", InjectOptions{ NoMemory: true, ContextPolicy: "prune", + RecallPolicy: "none", CleanerName: "clean_me", }) evt := drainOne(t, m.InputChan()) @@ -57,6 +58,9 @@ func TestInjectTextOpts_CarriesFlags(t *testing.T) { if evt.Payload["context_policy"] != "prune" { t.Errorf("context_policy 未传递: %v", evt.Payload["context_policy"]) } + if evt.Payload["recall_policy"] != "none" { + t.Errorf("recall_policy 未传递: %v", evt.Payload["recall_policy"]) + } if evt.Payload["cleaner_name"] != "clean_me" { t.Errorf("cleaner_name 未传递: %v", evt.Payload["cleaner_name"]) } @@ -71,12 +75,15 @@ func TestInjectTextOpts_CarriesFlags(t *testing.T) { // 中断注入走另一条队列,标志位同样要带上(用户已确认中断允许声明 prune)。 func TestInjectInterruptTextOpts_CarriesFlags(t *testing.T) { m := NewIOManager() - m.InjectInterruptTextOpts("src", "chan", "alert", InjectOptions{ContextPolicy: "prune"}) + m.InjectInterruptTextOpts("src", "chan", "alert", InjectOptions{ContextPolicy: "prune", RecallPolicy: "none"}) evt := drainOne(t, m.InputInterruptChan()) if evt.Payload["context_policy"] != "prune" { t.Errorf("中断注入的 context_policy 未传递: %v", evt.Payload) } + if evt.Payload["recall_policy"] != "none" { + t.Errorf("中断注入的 recall_policy 未传递: %v", evt.Payload) + } if evt.Payload["type"] != "text" || evt.Payload["content"] != "alert" { t.Errorf("中断注入的基本字段不对: %v", evt.Payload) } 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/cut.go b/internal/memory/cut.go index 27f4815..a183f8b 100644 --- a/internal/memory/cut.go +++ b/internal/memory/cut.go @@ -191,6 +191,18 @@ var stopWords = map[string]bool{ "人": true, "人们": true, "东西": true, "事情": true, "问题": true, "情况": true, "时候": true, "地方": true, "方式": true, "方法": true, "原因": true, "结果": true, + // ── 人称代词补全(我们/你们/他们 早有,咱俩/咱们 漏了)── + "咱俩": true, "咱们": true, + // ── 限定/指代类:本身没有独立的指称对象 ── + "任何": true, "此": true, "本": true, "其中": true, "以及": true, + "那么": true, "这样": true, "那样": true, "一样": true, + "还有": true, "还要": true, "只是": true, "老是": true, + // ── 方位/整体类泛指 ── + "全部": true, "所有": true, "有些": true, "一些": true, + "别的": true, "其他": true, "其余": true, "各自": true, "本身": true, + "上面": true, "下面": true, "里面": true, "外面": true, + "前面": true, "后面": true, "左边": true, "右边": true, + "中间": true, "附近": true, "周围": true, "部分": true, "方面": true, // ── 高频语气组合 ── "好的": true, "好吧": true, "好": true, "好了": true, "对了": true, "行了": true, diff --git a/internal/memory/document/document.go b/internal/memory/document/document.go index ec0c6a4..b008b07 100644 --- a/internal/memory/document/document.go +++ b/internal/memory/document/document.go @@ -59,8 +59,6 @@ type Store struct { denseSpace vector.MultimodalEmbedder } -const maxSummaries = 10000 - // NewStore 创建文档存储。tokenizer 由外层注入(如 jieba),核心不直接依赖分词库。 func NewStore(dir string, tokenizer tfidf.Tokenizer) *Store { return &Store{ @@ -359,44 +357,63 @@ type DocHit struct { } func (s *Store) QueryScored(text string, topK int) []DocHit { - s.mu.RLock() - defer s.mu.RUnlock() if topK <= 0 { topK = 5 } - // 主路径 + var hits []DocHit + + // 主路径:稠密检索。只读,持读锁。 + s.mu.RLock() if s.denseSpace != nil && s.denseSpace.Loaded() { if qv, err := s.denseSpace.VectorizeDense(text); err == nil { - results := s.denseSearchScored(qv, topK) - for i := range results { - if d, ok := s.docs[results[i].Doc.ID]; ok { - d.AccessCount++ - d.LastAccess = time.Now() - results[i].Doc = d - } - } - return results + hits = s.denseSearchScored(qv, topK) } } + s.mu.RUnlock() // Fallback(需要写锁来 ensureTFIDF) - s.mu.RUnlock() - s.mu.Lock() - s.ensureTFIDF() - s.mu.Unlock() - s.mu.RLock() + if hits == nil { + s.mu.Lock() + s.ensureTFIDF() + results := s.tfidfIdx.Search(text, topK) + for _, r := range results { + if d, ok := s.docs[r.ID]; ok { + hits = append(hits, DocHit{Doc: d, Score: r.Score}) + } + } + s.mu.Unlock() + } - results := s.tfidfIdx.Search(text, topK) - var out []DocHit - for _, r := range results { - if d, ok := s.docs[r.ID]; ok { + // 访问计数是**写**:必须离开读锁后再取写锁更新。 + // + // 此前在 RLock 下直接 `d.AccessCount++` / `d.LastAccess = time.Now()`, + // 与 FindColdDocs / flush 的读并发构成数据竞争(-race 实测),且不置脏 —— + // 计数只在内存里涨,优雅关停也不落盘,FindColdDocs 的「≤2 次访问」 + // 冷度判据跨重启失真。 + s.markAccess(hits) + return hits +} + +// markAccess 记录一次检索命中:累加访问计数、刷新最后访问时间并置脏等待落盘。 +// 调用方不得持有 s.mu。 +func (s *Store) markAccess(hits []DocHit) { + if len(hits) == 0 { + return + } + s.mu.Lock() + defer s.mu.Unlock() + now := time.Now() + for _, h := range hits { + if h.Doc == nil { + continue + } + if d, ok := s.docs[h.Doc.ID]; ok { d.AccessCount++ - d.LastAccess = time.Now() - out = append(out, DocHit{Doc: d, Score: r.Score}) + d.LastAccess = now } } - return out + s.dirty = true } func (s *Store) denseSearchScored(queryVec []float64, topK int) []DocHit { diff --git a/internal/memory/document/document_test.go b/internal/memory/document/document_test.go index 399905e..347e6fa 100644 --- a/internal/memory/document/document_test.go +++ b/internal/memory/document/document_test.go @@ -1,7 +1,9 @@ package document import ( + "fmt" "os" + "sync" "testing" "time" @@ -652,3 +654,55 @@ func TestStopFlushesDirtyDocs(t *testing.T) { t.Fatalf("Stop 未落盘: %+v", got) } } + +// TestQueryScoredConcurrentAndDirty 锁死两点: +// 1. QueryScored 更新访问计数必须离开读锁(旧实现在 RLock 下写,-race 会报); +// 2. 更新后必须置脏,否则优雅关停也不落盘、FindColdDocs 的冷度判据跨重启失真。 +func TestQueryScoredConcurrentAndDirty(t *testing.T) { + dir := t.TempDir() + s := NewStore(dir, memory.TokenizeWords) + if err := s.Start(); err != nil { + t.Fatal(err) + } + defer s.Stop() + + for i := 0; i < 8; i++ { + // 文档必须互相区分:若 8 篇文本完全相同,查询词出现在每一篇里, + // IDF = log(N/df) = 0,词向量全零、检索恒为空,测试就成了假阴性。 + summary, content := "无关的天气与散步记录", "今天适合出门" + if i < 3 { + summary, content = "并发检索目标", "并发内容" + } + if err := s.Insert(&Doc{ID: fmt.Sprintf("d%d", i), Summary: summary, Content: content}); err != nil { + t.Fatal(err) + } + } + // 清掉 Insert 置的脏位,验证 QueryScored 自己会置脏。 + s.mu.Lock() + s.dirty = false + s.mu.Unlock() + + var wg sync.WaitGroup + for g := 0; g < 8; g++ { + wg.Add(1) + go func() { + defer wg.Done() + for k := 0; k < 30; k++ { + s.QueryScored("并发检索目标", 5) + s.FindColdDocs(72*time.Hour, 2) + } + }() + } + wg.Wait() + + s.mu.RLock() + dirty := s.dirty + access := s.docs["d0"].AccessCount + s.mu.RUnlock() + if access <= 1 { + t.Errorf("AccessCount 未随检索累加: %d", access) + } + if !dirty { + t.Error("QueryScored 更新访问计数后未置脏") + } +} diff --git a/internal/memory/graph.go b/internal/memory/graph.go index 0c6a984..3470483 100644 --- a/internal/memory/graph.go +++ b/internal/memory/graph.go @@ -4,6 +4,7 @@ import ( "database/sql" "encoding/json" "fmt" + "log" "strings" "sync" "time" @@ -11,6 +12,20 @@ import ( _ "github.com/mattn/go-sqlite3" ) +// maxKeywordEntities 是单个关键词能取回的实体上限。 +// +// 无上限时,一个宽关键词("QQ")会命中上百个实体并逐个参与深度扩展, +// 把一次召回变成一次全表扫描。 +const maxKeywordEntities = 50 + +// maxAdjacentRelations 是深度扩展里**每层**读取的关系上限。 +const maxAdjacentRelations = 200 + +// maxFullRecallEntities 是「无关键词全量读取」路径的实体上限。 +// 该路径只服务于内部整备(Indexer.Sync / 实体合并检测),并非用户检索; +// 无上限时一张大图会被整表 read 进内存。超限时 GraphDB.Recall 会记日志。 +const maxFullRecallEntities = 10000 + type Entity struct { ID int64 `json:"id"` Name string `json:"name"` @@ -49,6 +64,18 @@ type Triple struct { // 媒体不再靠正文 marker 反解:结构化字段直接给出归属, // 由调用方(core)把它变成 L3 一等块并与句子建立结构边。 MediaDigests []string `json:"media_digests,omitempty"` + // Scene 是这条记忆所属的**场景键**(可空)。写完后该三元组的两个实体 + // 与这条关系都会被挂到这个场景上,场景重现时按场景召回。 + // 约定见 memory.NormalizeSceneKey:`chan:qq`、`chan:qq/peer:group_123`。 + Scene string `json:"scene,omitempty"` + // Scenes 是同一批多场景挂载(可空):主动**声明**的场景(如 chan:qq) + // 与被动**涌现**出来的场景(如 auto:chan:qq+peer_group:1027)可以同时挂。 + // + // 为什么要两条都挂:声明路是"我知道这是哪个场面",稳定、可读、可兜底; + // 涌现路是"这轮看起来像哪个场面",细粒度、不需要任何人声明。 + // 只挂一条的代价:只挂声明则细粒度唤起丢失,只挂涌现则首次交互(场景 + // 还没长出来)没有兜底。 + Scenes []string `json:"scenes,omitempty"` } type GraphDB struct { @@ -104,6 +131,10 @@ func (g *GraphDB) initSchema() error { } defer tx.Rollback() + // scene_features 是场面指纹的特征集合:场景 = 一组反复共现的可观察特征, + // 相似度按加权 Jaccard 算(权重由特征种类决定,chan/peer 最强)。 + // situation_evidence 记录一次性指纹的足迹:同类指纹重复出现到 + // minSceneEvidence 次才长出场景。 schemas := []string{ `CREATE TABLE IF NOT EXISTS entities ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -148,6 +179,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 )`, @@ -166,18 +198,71 @@ func (g *GraphDB) initSchema() error { summary TEXT DEFAULT '', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP )`, + // 场景引用:给「记忆节点」再赋一层**触发条件**。 + // + // 为什么需要它:词法/向量召回都靠「字面或语义相似」,而带条件的规则 + // (「回 QQ 消息不要用 Markdown」「老大消息优先」)在措辞不重合时根本 + // 召不回来。场景是这类记忆的**索引键**:节点记住自己「属于哪个场面」, + // 场面重现(又来一条 QQ 消息)时直接按场景取回,不靠字面命中。 + `CREATE TABLE IF NOT EXISTS scenes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT UNIQUE NOT NULL, + -- strength 是场景被重现的次数:场景不是被声明出来的,是被反复遇到 + -- 长出来的(见 EnterScene / minSceneEvidence)。 + strength INTEGER DEFAULT 1, + -- origin 区分两条路:'declared' 是主动声明出来的(键由人/插件给, + -- 召走精确+前缀匹配),'emergent' 是被动涌现的(召走相似度)。 + -- 两者刻意**分开**参与匹配:若让声明场景也吸收整轮指纹, + -- 它会在相似度上压过一切,被动路就再也长不出更细的场面了。 + origin TEXT NOT NULL DEFAULT 'emergent', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE TABLE IF NOT EXISTS scene_features ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + scene_id INTEGER NOT NULL, + feature TEXT NOT NULL, + weight REAL DEFAULT 1.0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(scene_id, feature) + )`, + `CREATE TABLE IF NOT EXISTS situation_evidence ( + label TEXT PRIMARY KEY, + count INTEGER DEFAULT 1, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE INDEX IF NOT EXISTS idx_scene_features_scene ON scene_features(scene_id)`, + `CREATE INDEX IF NOT EXISTS idx_scene_features_feature ON scene_features(feature)`, + // ref_id 的解释由 kind 决定(relation / entity)。这里不用外键: + // 节点可能先于引用被清理(PurgeNoise/PurgeOrphans),悬空引用由 + // 读取侧的 JOIN 自然过滤掉,而级联删除会把清理变成一个跨表事务。 + `CREATE TABLE IF NOT EXISTS scene_refs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + 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, + -- decayed_at 是半衰期衰减的计时起点:每个引用至多每 halfLife + -- 衰减一次(见 DecaySceneRefs)。重复写入/强化会把它刷成当前时刻。 + decayed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + 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)`, `CREATE INDEX IF NOT EXISTS idx_memory_blocks_modality ON memory_blocks(modality)`, `CREATE INDEX IF NOT EXISTS idx_memory_blocks_digest ON memory_blocks(payload_digest)`, `CREATE INDEX IF NOT EXISTS idx_memory_block_edges_source ON memory_block_edges(source_kind, source_id)`, `CREATE INDEX IF NOT EXISTS idx_memory_block_edges_target ON memory_block_edges(target_kind, target_id)`, - `CREATE INDEX IF NOT EXISTS idx_entity_name ON entities(name)`, `CREATE INDEX IF NOT EXISTS idx_entity_type ON entities(type)`, `CREATE INDEX IF NOT EXISTS idx_relation_source ON relations(source_id)`, `CREATE INDEX IF NOT EXISTS idx_relation_target ON relations(target_id)`, `CREATE INDEX IF NOT EXISTS idx_relation_type ON relations(relation_type)`, `CREATE INDEX IF NOT EXISTS idx_relation_status ON relations(status)`, `CREATE INDEX IF NOT EXISTS idx_relation_session ON relations(session_id)`, - `CREATE INDEX IF NOT EXISTS idx_sentences_text ON sentences(text)`, } for _, s := range schemas { @@ -190,6 +275,59 @@ 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 ''`) + // 迁移6:旧 scenes 表加 strength 列(涌现侧的强度计数) + tx.Exec(`ALTER TABLE scenes ADD COLUMN strength INTEGER DEFAULT 1`) + // 迁移7:旧 scenes 表加 origin。既有行都是声明/存量引导来的(建表时还没有 + // 涌现机制),标成 declared;新建的涌现场景在 createSceneLocked 里写 emergent。 + if !columnExists(tx, "scenes", "origin") { + tx.Exec(`ALTER TABLE scenes ADD COLUMN origin TEXT NOT NULL DEFAULT 'emergent'`) + tx.Exec(`UPDATE scenes SET origin = 'declared'`) + } + // 迁移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) + } + } + } + // 迁移8:scene_refs 加 decayed_at(半衰期衰减的计时起点)。 + // ALTER 不接受非常量默认值,先加可空列再用 created_at 回填, + // 于是既有引用的「上一次衰减」就定在它被写入的时刻,不会被立即清掉。 + if !columnExists(tx, "scene_refs", "decayed_at") { + tx.Exec(`ALTER TABLE scene_refs ADD COLUMN decayed_at TIMESTAMP`) + tx.Exec(`UPDATE scene_refs SET decayed_at = created_at WHERE decayed_at IS NULL`) + } + // 冗余索引清理:entities.name 与 sentences.text 上的 UNIQUE 已隐含等价索引 + // (sqlite_autoindex_*),再建一个同列索引只增加写放大,查询不会用到。 + tx.Exec(`DROP INDEX IF EXISTS idx_entity_name`) + tx.Exec(`DROP INDEX IF EXISTS idx_sentences_text`) // 迁移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 != ''`) @@ -255,10 +393,14 @@ func (g *GraphDB) migrateRelationUnique(tx *sql.Tx) error { return nil } -// Commit 把三元组写入图库,返回新建的实体数与关系数。 // Path 返回本库的存储路径(父 agent 用它为驻留子打开**受限句柄**)。 func (g *GraphDB) Path() string { return g.dbPath } +// Commit 把三元组写入图库,返回通过实体名校验并写入/刷新的实体数与**新建**的关系数。 +// +// 两个计数的语义刻意不同,因为上游只用它们判断「有没有东西写进去」: +// 实体计数含已存在实体的 mention_count 刷新(见 upsertEntity), +// 关系计数只统计真正新建的关系(已存在则仅刷新 confidence)。 func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, int, error) { _, ec, rc, err := g.commit(triples, sessionID, turnID, false) return ec, rc, err @@ -355,24 +497,27 @@ func (g *GraphDB) commit(triples []Triple, sessionID string, turnID int, trackSe } } - var existing int + var existing int64 err = tx.QueryRow( - `SELECT 1 FROM relations WHERE source_id = ? AND target_id = ? AND relation_type = ? AND session_id = ?`, + `SELECT id FROM relations WHERE source_id = ? AND target_id = ? AND relation_type = ? AND session_id = ?`, sourceID, targetID, t.Relation, sessionID, ).Scan(&existing) + var relID int64 if err == sql.ErrNoRows { - _, err = tx.Exec( + res, ierr := tx.Exec( `INSERT INTO relations (source_id, target_id, relation_type, confidence, session_id, turn_id, date_bucket, sentence_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, sourceID, targetID, t.Relation, confidence, sessionID, turnID, dateBucket, sentenceID, ) - if err != nil { - return nil, 0, 0, err + if ierr != nil { + return nil, 0, 0, ierr } + relID, _ = res.LastInsertId() relationsCreated++ } else if err != nil { return nil, 0, 0, err } else { + relID = existing // 同一(会话内)三元组已存在:仅刷新置信度与时间戳,不重复计数 _, err = tx.Exec( `UPDATE relations SET confidence = ?, updated_at = CURRENT_TIMESTAMP @@ -383,6 +528,17 @@ func (g *GraphDB) commit(triples []Triple, sessionID string, turnID int, trackSe return nil, 0, 0, err } } + + // 场景引用:写完关系立即把「关系 + 两端实体」挂到**每个**场景上 + // (主动声明的 + 被动涌现的)。同一事务内完成,避免出现「关系写进去了 + // 但场景引用丢了」——那会让这条记忆在后来的场景里永远召不回来,且无声无息。 + if relID != 0 { + for _, sc := range effectiveScenes(t) { + if err := tagSceneTx(tx, sc, relID, []int64{sourceID, targetID}, confidence); err != nil { + return nil, 0, 0, err + } + } + } } if err := tx.Commit(); err != nil { @@ -409,6 +565,12 @@ func validEntityName(name string) bool { return hasLetter } +// upsertEntity 写入/刷新一个实体,返回 1 表示该实体**通过名校验并被写入或刷新**, +// 0 表示名校验未通过。 +// +// 注意返回值语义不是「新建数」:`ON CONFLICT DO UPDATE` 在更新时 +// RowsAffected 同样为 1,所以返回值等于「通过校验的 upsert 次数」。 +// 调用方(Commit)把它当「写入了几个实体」用,不是「新建了几个」。 func (g *GraphDB) upsertEntity(tx *sql.Tx, name string, entityType string) (int, error) { if !validEntityName(name) { return 0, nil @@ -442,9 +604,12 @@ func (g *GraphDB) Recall(keywords []string, seedEntities []string, depth int, se result := &RecallResult{} if len(keywords) == 0 && len(seedEntities) == 0 { + // 全量读取仅用于内部整备(Indexer.Sync / 实体合并检测), + // 必须加限额:无 LIMIT 时大图会被整表读进内存。 rows, err := g.db.Query( `SELECT id, name, type, mention_count, created_at, updated_at - FROM entities ORDER BY mention_count DESC`, + FROM entities ORDER BY mention_count DESC LIMIT ?`, + maxFullRecallEntities, ) if err != nil { return nil, err @@ -458,6 +623,9 @@ func (g *GraphDB) Recall(keywords []string, seedEntities []string, depth int, se } result.Entities = append(result.Entities, e) } + if len(result.Entities) >= maxFullRecallEntities { + log.Printf("[graph] full recall 命中实体上限 %d,可能有实体未纳入", maxFullRecallEntities) + } relRows, err := g.db.Query( `SELECT r.id, r.source_id, r.target_id, e1.name, e2.name, @@ -492,10 +660,25 @@ func (g *GraphDB) Recall(keywords []string, seedEntities []string, depth int, se entityIDs := make(map[int64]bool) for _, kw := range keywords { + // 相关度排序 + 限额。 + // + // 此前这里既没有 ORDER BY 也没有 LIMIT:拿回来的顺序就是建表顺序 + // (rowid 升序),于是注入进 prompt 的"前 5 个实体"是**最早创建的**, + // 越新越准的记忆越排后面被截掉(实测:输入「QQ回复格式」命中 148 个, + // 规则实体排第 32,前 5 里根本没有它)。 + // + // 相关度分三层:完全相等 > 前缀命中 > 包含命中;同层按提及次数、 + // 再按名字长度(短名更可能是实体本身而不是长描述)。 rows, err := g.db.Query( `SELECT id, name, type, mention_count, created_at, updated_at - FROM entities WHERE LOWER(name) LIKE ?`, - "%"+kw+"%", + FROM entities WHERE LOWER(name) LIKE ? + ORDER BY CASE + WHEN LOWER(name) = LOWER(?) THEN 0 + WHEN LOWER(name) LIKE LOWER(?) || '%' THEN 1 + ELSE 2 END, + mention_count DESC, LENGTH(name) ASC + LIMIT ?`, + "%"+kw+"%", kw, kw, maxKeywordEntities, ) if err != nil { return nil, err @@ -532,6 +715,14 @@ func (g *GraphDB) Recall(keywords []string, seedEntities []string, depth int, se return result, nil } + // seenRel 跨层去重。 + // + // 每层都用**已累积的** entityIDs 查邻接关系,因此上一层刚产出、以及 + // 两个已访问实体之间的关系会在下一层被重复查回并再次 append。 + // 深度 2、稠密图上重复会淹没 memory_recall 的 10 条关系预算—— + // 模型看到的是同一句话刷屏,真正的新关系被截断。 + seenRel := make(map[int64]bool) + for depthLevel := 0; depthLevel < depth; depthLevel++ { ids := make([]interface{}, 0, len(entityIDs)) for id := range entityIDs { @@ -562,6 +753,11 @@ func (g *GraphDB) Recall(keywords []string, seedEntities []string, depth int, se query += " AND r.session_id = ?" allIDs = append(allIDs, sessionFilter) } + // 每层限额:热实体("文档"这类)的邻接可能是上千条,无上限时每层都 + // 整片读进内存,而调用方(memory_recall 注入 10 条、自动注入只要实体名) + // 根本用不到。按置信度取最相关的一批。 + query += " ORDER BY r.confidence DESC, r.updated_at DESC LIMIT ?" + allIDs = append(allIDs, maxAdjacentRelations) relRows, err := g.db.Query(query, allIDs...) if err != nil { @@ -578,7 +774,10 @@ func (g *GraphDB) Recall(keywords []string, seedEntities []string, depth int, se relRows.Close() return nil, err } - result.Relations = append(result.Relations, rel) + if !seenRel[rel.ID] { + seenRel[rel.ID] = true + result.Relations = append(result.Relations, rel) + } if !entityIDs[rel.SourceID] { newIDs[rel.SourceID] = true @@ -743,9 +942,16 @@ func (g *GraphDB) Purge(criteria map[string]string, mode string) (int, error) { } n, _ := result.RowsAffected() - g.db.Exec(`DELETE FROM entities WHERE id NOT IN ( - SELECT DISTINCT source_id FROM relations - UNION SELECT DISTINCT target_id FROM relations)`) + // 这里**不再**顺手全局删孤儿实体。 + // + // 原来那句 `DELETE FROM entities WHERE id NOT IN (relations 两端)` 是与 + // 调用方意图无关的全局副作用:memory_edit 只想去掉一条关系,却可能把 + // 图里其它孤零零的实体一并清掉。孤儿清理交给 PurgeOrphans + // (显式、可 dry-run、有计数与审计),一次改动只做一件事。 + // + // 关系没了,它的场景引用必须跟着对齐:残留引用会让场景看着很大、 + // 召回却是空的(SceneStats 也跟着说谎)。 + g.purgeStaleSceneRefsLocked() return int(n), nil } @@ -758,6 +964,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 } @@ -1047,6 +1256,8 @@ func (g *GraphDB) Archive(days int) (int, error) { // ClearSentenceID 清除指定关系的 sentence_id(LLM复审后解除句子引用) func (g *GraphDB) ClearSentenceID(relationID int64) error { + g.mu.Lock() + defer g.mu.Unlock() _, err := g.db.Exec( `UPDATE relations SET sentence_id = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, relationID, @@ -1062,6 +1273,12 @@ func (g *GraphDB) ClearSentenceID(relationID int64) error { func (g *GraphDB) CleanupOrphanedSentences() (int, error) { g.mu.Lock() defer g.mu.Unlock() + return g.cleanupOrphanedSentencesLocked() +} + +// cleanupOrphanedSentencesLocked 是 CleanupOrphanedSentences 的加锁内联版, +// 供已在写锁内的调用方(PurgeNoise)复用,避免自锁死。 +func (g *GraphDB) cleanupOrphanedSentencesLocked() (int, error) { tx, err := g.db.Begin() if err != nil { return 0, err @@ -1110,3 +1327,83 @@ 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 +} + +// effectiveScenes 合并一条三元组的场景键(Scenes 多值 + Scene 单值),去重且保序。 +// +// 单值 Scene 保留是为了兼容既有调用方与 memory_commit 的模型参数; +// 多值 Scenes 是"声明 + 涌现"两条路同时挂载的载体。 +func effectiveScenes(t Triple) []string { + if len(t.Scenes) == 0 && t.Scene == "" { + return nil + } + seen := make(map[string]bool, len(t.Scenes)+1) + out := make([]string, 0, len(t.Scenes)+1) + add := func(k string) { + k = NormalizeSceneKey(k) + if k == "" || seen[k] { + return + } + seen[k] = true + out = append(out, k) + } + for _, k := range t.Scenes { + add(k) + } + add(t.Scene) + return out +} diff --git a/internal/memory/graph_test.go b/internal/memory/graph_test.go index ee32ed5..117843e 100644 --- a/internal/memory/graph_test.go +++ b/internal/memory/graph_test.go @@ -260,6 +260,22 @@ func TestRecallWithDepth(t *testing.T) { if len(result.Relations) == 0 { t.Error("expected relations with depth search") } + + // 逐层查邻接会把已访问实体之间的关系反复查回。不跨层去重时, + // 小明→小红 会在 depth=2 出现两次,memory_recall 的 10 条关系预算 + // 被同一句话刷屏、真正的新关系(小红→小刚)被截断。 + seen := map[int64]int{} + for _, r := range result.Relations { + seen[r.ID]++ + } + for id, n := range seen { + if n > 1 { + t.Errorf("关系 id=%d 在深度遍历中重复 %d 次", id, n) + } + } + if len(result.Relations) != 2 { + t.Errorf("depth=2 应得 2 条关系(小明→小红、小红→小刚),实际 %d", len(result.Relations)) + } } func TestPurgeHard(t *testing.T) { diff --git a/internal/memory/indexer.go b/internal/memory/indexer.go index 732d79f..f9283e9 100644 --- a/internal/memory/indexer.go +++ b/internal/memory/indexer.go @@ -3,8 +3,10 @@ package memory import ( "fmt" "log" + "sort" "strings" "sync" + "time" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector" ) @@ -16,8 +18,31 @@ type Indexer struct { mu sync.RWMutex trained bool recalled map[string]bool // 已通过工具调用显式召回的实体名,自动注入时跳过 + + // recalledOrder 记录 recalled 的插入顺序,用于超限时按 FIFO 淘汰。 + recalledOrder []string + + // 增量同步状态:运行中新增的实体此前要等下一个归档心跳(Indexer.Sync) + // 才进向量索引,在那之前只能靠关键词路命中——"刚记住的东西过一会儿才想得 + // 起来"就是这么来的。这里记下上次同步的实体数,召回前发现数量变了就补一次。 + lastSyncCount int + lastSyncAt time.Time } +// retrainInterval 是增量重训的最小间隔。 +// +// 为什么不每次都重训:TF-IDF 向量器是**全局**重训(词表与 idf 都变), +// 一次是 O(实体数)。没有下限时,密集写入的场景下每轮召回都触发一次全量重训, +// 把一个读操作变成写放大的热点。心跳(30min)+ 这个下限,够快也够稳。 +const retrainInterval = 30 * time.Second + +// maxRecalledEntities 是「已召回实体」去重集的上限。 +// +// 无上限时它只增不减:进程活得越久,被永久跳过的实体越多,自动注入 +// 越来越「沉默」——一个只在长跑进程里才暴露的隐蔽退化。超限后淘汰最旧的 +// 名字(允许重新注入),而不是丢弃整个集合。 +const maxRecalledEntities = 1024 + func NewIndexer(db *GraphDB) *Indexer { return &Indexer{ db: db, @@ -32,7 +57,16 @@ func (idx *Indexer) MarkRecalled(names ...string) { idx.mu.Lock() defer idx.mu.Unlock() for _, name := range names { + if idx.recalled[name] { + continue + } idx.recalled[name] = true + idx.recalledOrder = append(idx.recalledOrder, name) + } + for len(idx.recalledOrder) > maxRecalledEntities { + oldest := idx.recalledOrder[0] + idx.recalledOrder = idx.recalledOrder[1:] + delete(idx.recalled, oldest) } } @@ -57,6 +91,10 @@ func (idx *Indexer) Sync() error { } if len(names) == 0 { + // 没有实体也是一次成功的同步:记下基线,否则 syncIfStale 会在 + // 「基线还停在旧值」与「本轮无实体」之间反复误判、每 30s 重跑一次。 + idx.lastSyncCount = 0 + idx.lastSyncAt = time.Now() return nil } @@ -74,24 +112,85 @@ func (idx *Indexer) Sync() error { } idx.trained = true + idx.lastSyncCount = len(names) + idx.lastSyncAt = time.Now() log.Printf("[indexer] synced %d entities to vector index", len(names)) return nil } +// syncIfStale 在实体数发生变化时补一次重建(调用方必须**不持锁**)。 +// +// 返回是否发生了重建。计数查询走 idx_count 索引,成本可忽略; +// 只有真的变了、且离上次同步超过 retrainInterval 才重训。 +func (idx *Indexer) syncIfStale() bool { + if idx.db == nil { + return false + } + var count int + if err := idx.db.db.QueryRow(`SELECT COUNT(*) FROM entities`).Scan(&count); err != nil { + return false + } + // 基线口径必须与 Sync 一致:Sync 走的是「无关键词全量召回」,实体数被 + // maxFullRecallEntities 封顶。直接拿 COUNT(*) 比会在实体数超过上限的大图上 + // 永远不相等——每 30s 全量重训一次,把一条读路径变成写放大热点。 + expected := count + if expected > maxFullRecallEntities { + expected = maxFullRecallEntities + } + + idx.mu.RLock() + known, last := idx.lastSyncCount, idx.lastSyncAt + idx.mu.RUnlock() + if expected == known { + return false + } + if !last.IsZero() && time.Since(last) < retrainInterval { + return false + } + if err := idx.Sync(); err != nil { + log.Printf("[indexer] 增量同步失败(沿用旧索引): %v", err) + return false + } + log.Printf("[indexer] 实体数 %d→%d,已增量重建实体名向量索引", known, count) + return true +} + type InjectedContext struct { Entities []Entity `json:"entities"` Relations []Relation `json:"relations"` Summary string `json:"summary"` TokenEstimate int `json:"token_estimate"` + + // Scenes 是本轮识别出的当前场景;SceneRelations 是被钉在这些场景上的 + // 记忆(带 relation_type 与原句)。两者都进注入文本——场景记忆是 + // **带条件的规则**,只给实体名等于没召回。 + Scenes []string `json:"scenes,omitempty"` + SceneRelations []Relation `json:"scene_relations,omitempty"` + SceneBlocks []MemoryBlock `json:"scene_blocks,omitempty"` } +// BuildContext 不带场景的召回(保持既有行为:词法 + 实体名向量)。 func (idx *Indexer) BuildContext(userInput string) *InjectedContext { + return idx.BuildContextInScene(userInput, nil) +} + +// BuildContextInScene 在词法/向量召回之上叠加**场景召回**。 +// +// 两条路正交且都要保留: +// - 词法/向量:话题相关(「上次那个 bug 怎么修的」) +// - 场景:条件相关(「在 QQ 上回消息」→ 不要 Markdown) +// +// 场景路不参与相似度打分、也不受关键词为空的影响:只要场面重现就该取回。 +func (idx *Indexer) BuildContextInScene(userInput string, scenes []string) *InjectedContext { if idx.db == nil { return &InjectedContext{Summary: ""} } input := CleanText(userInput) + // 0. 索引保鲜:运行中新增的实体不该等到下一个心跳才可被召回 + idx.syncIfStale() + // 1. 向量搜索:从实体名向量索引中找到相关实体 vectorEntities := idx.vectorSearchEntities(input) @@ -110,23 +209,42 @@ func (idx *Indexer) BuildContext(userInput string) *InjectedContext { result, err := idx.db.Recall(allKeywords, nil, 2, "") if err != nil || result == nil { - return &InjectedContext{Summary: ""} + result = &RecallResult{} } - // 过滤已被工具调用显式召回的实体,避免重复注入 - idx.mu.RLock() - filtered := result.Entities[:0] - for _, e := range result.Entities { - if !idx.recalled[e.Name] { - filtered = append(filtered, e) + // 3. 场景召回:当前场面钉住的记忆 + sceneRecall, err := idx.db.RecallByScene(scenes, maxSceneRecallRelations) + if err != nil { + sceneRecall = nil + } + sceneEntityNames := make(map[string]bool) + if sceneRecall != nil { + for _, e := range sceneRecall.Entities { + sceneEntityNames[e.Name] = true } } + + // 过滤已被工具调用显式召回的实体,避免重复注入;场景实体已在场景块 + // 里给过,也不在索引里再占位。 + idx.mu.RLock() + filtered := make([]Entity, 0, len(result.Entities)) + for _, e := range result.Entities { + if idx.recalled[e.Name] || sceneEntityNames[e.Name] { + continue + } + filtered = append(filtered, e) + } idx.mu.RUnlock() ctx := &InjectedContext{ Entities: filtered, Relations: nil, } + if sceneRecall != nil { + ctx.Scenes = sceneRecall.Scenes + ctx.SceneRelations = sceneRecall.Relations + ctx.SceneBlocks = sceneRecall.Blocks + } if len(filtered) > 0 { summary := buildIndexSummary(filtered) @@ -175,8 +293,13 @@ func (idx *Indexer) BuildToolPrompt() string { 将三元组写入图记忆。 参数: - triples: [{"subject": "实体名", "relation": "关系类型", "object": "目标实体", - "sentence_text": "原始句子(可选)", "media_digests": ["图片digest(可选)"]}] + "sentence_text": "原始句子(可选)", "media_digests": ["图片digest(可选)"], + "scene": "场景键(可选)"}] 填了 media_digests,日后从这条记忆就能取回当时那张图/那段音频。 + 填了 scene,这条记忆就挂在那个**场面**上:场面重现时(如又来一条 QQ 消息) + 不靠字面命中也会被召回。「在什么场合该怎么做」这类约定/规则都该填, + 例如回 QQ 消息的格式约定 → scene="chan:qq"。 +- scene: 本批次默认场景键(可选,逐条 triples 里的 scene 优先)。 ### memory_introspect 查看记忆统计信息。 @@ -191,11 +314,58 @@ func (idx *Indexer) BuildToolPrompt() string { } func (idx *Indexer) FormatContext(ctx *InjectedContext) string { - if ctx == nil || len(ctx.Entities) == 0 { + if ctx == nil || (len(ctx.Entities) == 0 && len(ctx.SceneRelations) == 0 && len(ctx.SceneBlocks) == 0) { return "" } var b strings.Builder + + // 场景记忆排在前面:它们是**带条件的规则**(在什么场面下该怎么做), + // 对行为的约束强于“话题相关的实体名”。也正因为带有触发条件, + // 它们的正确性不依赖本轮措辞是否命中了字面。 + if len(ctx.SceneRelations) > 0 { + b.WriteString(fmt.Sprintf("【场景记忆 %s】\n", strings.Join(ctx.Scenes, ", "))) + for i, rel := range ctx.SceneRelations { + if i >= maxSceneRecallRelations { + break + } + b.WriteString(fmt.Sprintf("- %s --%s--> %s", rel.SourceName, rel.RelationType, rel.TargetName)) + if rel.SentenceText != "" { + b.WriteString("(") + b.WriteString(truncateRunes(rel.SentenceText, sceneSentenceMaxRunes)) + b.WriteString(")") + } + 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") + } + + if len(ctx.Entities) == 0 { + return strings.TrimRight(b.String(), "\n") + } + b.WriteString("【记忆索引】") if ctx.Summary != "" { @@ -222,6 +392,34 @@ func (idx *Indexer) FormatContext(ctx *InjectedContext) string { return b.String() } +// maxSceneRecallRelations 是单次场景召回的关系上限。 +// +// 场景是**每轮都要注入**的常驻内容:不封顶时一个宽场景(如 chan:qq) +// 会把它下面所有关系都推进 prompt,把 token 预算吃光。 +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 { + return s + } + return string(r[:max]) + "…" +} + func (idx *Indexer) GetToolDefinitions() []map[string]interface{} { return []map[string]interface{}{ { @@ -272,6 +470,10 @@ func (idx *Indexer) GetToolDefinitions() []map[string]interface{} { "description": "可选:这条记忆关联的媒体 digest(对话或 memory_recall 的「关联媒体」里显示的十六进制串,短的即可)。填了以后从这条记忆能取回原图/音频。", "items": map[string]interface{}{"type": "string"}, }, + "scene": map[string]interface{}{ + "type": "string", + "description": "可选:这条记忆所属的场景键(如 chan:qq、chan:qq/peer:group_123、tool:qq_get_message)。「在什么场合该怎么做」这类条件性约定/规则必须填:场面重现时它会被直接召回,与用户措辞无关。", + }, }, "required": []string{"subject", "relation", "object"}, }, @@ -303,16 +505,30 @@ func buildIndexSummary(entities []Entity) string { var b strings.Builder b.WriteString(fmt.Sprintf("关联 %d 个记忆实体", len(entities))) + // 「高频」必须真的按提及次数排。 + // + // 此前取的是 entities[0..2],也就是 Recall 的返回顺序(旧实现下等于建表 + // 顺序),却写着"高频"两个字:给模型的暗示是"这几条最重要",实际是 + // "这几条建得最早"。名不副实的标签比没有标签更坏——它会稳定地误导。 + byMentions := make([]Entity, len(entities)) + copy(byMentions, entities) + sort.SliceStable(byMentions, func(i, j int) bool { + if byMentions[i].MentionCount != byMentions[j].MentionCount { + return byMentions[i].MentionCount > byMentions[j].MentionCount + } + return byMentions[i].Name < byMentions[j].Name + }) + topN := 3 - if len(entities) < topN { - topN = len(entities) + if len(byMentions) < topN { + topN = len(byMentions) } b.WriteString(",高频:") for i := 0; i < topN; i++ { if i > 0 { b.WriteString("、") } - b.WriteString(entities[i].Name) + b.WriteString(byMentions[i].Name) } return b.String() diff --git a/internal/memory/indexer_test.go b/internal/memory/indexer_test.go index 5b5b937..564a6f4 100644 --- a/internal/memory/indexer_test.go +++ b/internal/memory/indexer_test.go @@ -3,6 +3,7 @@ package memory import ( "path/filepath" "testing" + "time" ) func TestNewIndexer(t *testing.T) { @@ -48,6 +49,52 @@ func TestIndexerSync(t *testing.T) { } } +// TestSyncIfStaleBaseline 钉住增量同步的基线口径: +// - 实体数未变 → 不重训 +// - 实体数变了但不足 retrainInterval → 先不重训(避免密集写入时写放大) +// - 实体数变了且间隔已过 → 重训,并把基线追到新值 +func TestSyncIfStaleBaseline(t *testing.T) { + db, err := NewGraphDB(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + if _, _, err := db.Commit([]Triple{ + {Subject: "张三", Relation: "喜欢", Object: "篮球"}, + }, "test", 0); err != nil { + t.Fatal(err) + } + + idx := NewIndexer(db) + if !idx.syncIfStale() { + t.Fatal("首次应建立索引") + } + if idx.syncIfStale() { + t.Error("实体数未变不该重训") + } + + if _, _, err := db.Commit([]Triple{ + {Subject: "李四", Relation: "喜欢", Object: "足球"}, + }, "test", 0); err != nil { + t.Fatal(err) + } + if idx.syncIfStale() { + t.Error("retrainInterval 内不该重训(避免写放大)") + } + + // 把上次同步时刻推老,计数变化才该触发重训 + idx.mu.Lock() + idx.lastSyncAt = time.Now().Add(-2 * retrainInterval) + idx.mu.Unlock() + if !idx.syncIfStale() { + t.Error("计数变化且间隔已过应重训") + } + if idx.syncIfStale() { + t.Error("重训后基线应追上,不该再重训") + } +} + func TestIndexerBuildContext(t *testing.T) { db, err := NewGraphDB(filepath.Join(t.TempDir(), "test.db")) if err != nil { diff --git a/internal/memory/media/media.go b/internal/memory/media/media.go index de6c5af..e5a5f3e 100644 --- a/internal/memory/media/media.go +++ b/internal/memory/media/media.go @@ -235,7 +235,7 @@ func (s *Store) Stat(digest string) (*Item, error) { FROM media WHERE digest = ?`, digest)) } -// Stat 返回元数据,不读内容。 +// Delete 删除媒体内容与元数据。 // // 这不是 GC,也不看引用计数:调用方是记忆系统本身——当它把一个记忆块 // 永久地从三层记忆中删掉(而非在层间迁移)时,媒体作为块的内容一并删除。 diff --git a/internal/memory/noise.go b/internal/memory/noise.go new file mode 100644 index 0000000..da3eda2 --- /dev/null +++ b/internal/memory/noise.go @@ -0,0 +1,263 @@ +package memory + +import ( + "sort" + "strings" +) + +// ────────────────────────────────────────────── +// 图记忆「噪音实体」判定与清理 +// +// 为什么需要这一层:doc→graph 蒸馏在 1cb3e87 从「CutExact 滑窗词链」换成 +// NLP 依存提取器后,落库闸门只剩 validEntityName(长度 2–50、含字母/汉字)。 +// validEntityName 挡的是「不像名字的字符串」,不挡「像名字的常用词」—— +// 于是「结果 / 什么 / 哪个 / 待命 / 报告」这类词被反复写成实体, +// mention_count 冲到几百,度数却只有 1~2:它们占着热实体位、挤满召回预算, +// 却不带任何结构。CutExact(去停用词 + validEntityName + 去重)本可以做这层 +// 过滤,但它当年只挂在 doc→graph 上,换提取器时被整条摘掉,如今只剩测试调用。 +// +// 这里把判定收敛到一处,供三个地方共用: +// - 自动填充路:docToTriples(冷文档归档)、extractKeyTriples(对话蒸馏) +// - 历史数据清理:GraphDB.PurgeNoise(老库里的存量垃圾) +// ────────────────────────────────────────────── + +// 模板摘要的判定片段:summarizeEntries 产出形如 +// 「来自 N 个来源的 M 条对话 (src…) 涉及: kw…」。这类串只是「哪些词出现过」 +// 的回声,没有独立信息量,写进图库会把「文档 --主题--> …」变成同构垃圾边。 +// 判定与 agent/core.isTemplateSummary 保持一致。 +const ( + templateSummaryPrefix = "来自 " + templateSummaryMarker = "条对话" +) + +// archivedContextSource 是归档上下文文档写死的 source 标记。 +// 它是内部状态而非概念,不应以「来源」实体形式存在于图库中。 +const archivedContextSource = "context_archived" + +// IsNoiseEntity 判定一个实体名是否属于「不该进图记忆的噪音」。 +// +// 判定刻意保守,只覆盖三类**客观**噪音,不做「这个词有没有信息量」的价值判断: +// 1. 停用词表命中的常用词(代词/助词/连词/介词/泛化名词/英文虚词) +// 2. 归档上下文内部标记(context_archived) +// 3. summarizeEntries 的模板摘要串 +// +// 开放类词(「报告 / 对话 / 处理」这类真名词真动词)不在此列:它们在别的 +// 语境下可能是有意义的实体,挡不挡属于领域决策,不该由这个函数替使用者拍板。 +func IsNoiseEntity(name string) bool { + n := strings.TrimSpace(name) + if n == "" { + return true + } + if stopWords[n] { + return true + } + if n == archivedContextSource { + return true + } + return IsTemplateSummaryName(n) +} + +// IsTemplateSummaryName 判断实体名是否为模板摘要串(供调用方单独使用)。 +func IsTemplateSummaryName(name string) bool { + n := strings.TrimSpace(name) + return strings.HasPrefix(n, templateSummaryPrefix) && strings.Contains(n, templateSummaryMarker) +} + +// FilterNoiseTriples 丢弃任一端为噪音实体的三元组,保持原顺序。 +// +// 为什么在自动填充入口过滤、而不是在 Commit 里过滤:Commit 同时是 +// memory_commit 工具(模型显式写入)的落库口。模型主动写「结果 --是--> X」 +// 是它的自由(也可能它当时真的想记),而自动填充是无人在环的批量产出, +// 必须自己保证质量——闸门开在产生噪声的那一端。 +func FilterNoiseTriples(triples []Triple) []Triple { + if len(triples) == 0 { + return triples + } + out := make([]Triple, 0, len(triples)) + for _, t := range triples { + if IsNoiseEntity(t.Subject) || IsNoiseEntity(t.Object) { + continue + } + out = append(out, t) + } + return out +} + +// NoiseEntities 列出库中现存的噪音实体,按 mention_count 降序。 +func (g *GraphDB) NoiseEntities() ([]Entity, error) { + g.mu.RLock() + defer g.mu.RUnlock() + return g.noiseEntitiesLocked() +} + +func (g *GraphDB) noiseEntitiesLocked() ([]Entity, error) { + rows, err := g.db.Query( + `SELECT id, name, type, mention_count, created_at, updated_at FROM entities`) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []Entity + for rows.Next() { + var e Entity + if err := rows.Scan(&e.ID, &e.Name, &e.Type, &e.MentionCount, &e.CreatedAt, &e.UpdatedAt); err != nil { + return nil, err + } + if IsNoiseEntity(e.Name) { + out = append(out, e) + } + } + if err := rows.Err(); err != nil { + return nil, err + } + sort.Slice(out, func(i, j int) bool { + if out[i].MentionCount != out[j].MentionCount { + return out[i].MentionCount > out[j].MentionCount + } + return out[i].Name < out[j].Name + }) + return out, nil +} + +// PurgeNoise 清理库中已存在的噪音实体及其关系,返回删除的实体数与关系数。 +// +// dryRun 为 true 时只统计、不写库——清理生产库前先看清楚要动什么。 +// 关系按「任一端是噪音实体」删除;删完实体后顺带清理失去引用的孤儿句子 +// (复用 cleanupOrphanedSentencesLocked,不在这里重写一遍判定)。 +func (g *GraphDB) PurgeNoise(dryRun bool) (int, int, error) { + g.mu.Lock() + defer g.mu.Unlock() + + junk, err := g.noiseEntitiesLocked() + if err != nil { + return 0, 0, err + } + if len(junk) == 0 { + return 0, 0, nil + } + + ids := make([]interface{}, 0, len(junk)) + for _, e := range junk { + ids = append(ids, e.ID) + } + ph := placeholders(len(junk)) + args := append(append([]interface{}{}, ids...), ids...) + + // 关系数按 DISTINCT id 统计:两端都是噪音的关系不能被算两次。 + var relCount int + if err := g.db.QueryRow( + `SELECT COUNT(DISTINCT id) FROM relations + WHERE source_id IN (`+ph+`) OR target_id IN (`+ph+`)`, + args..., + ).Scan(&relCount); err != nil { + return 0, 0, err + } + + if dryRun { + return len(junk), relCount, nil + } + + tx, err := g.db.Begin() + if err != nil { + return 0, 0, err + } + defer tx.Rollback() + + if _, err := tx.Exec( + `DELETE FROM relations WHERE source_id IN (`+ph+`) OR target_id IN (`+ph+`)`, + args...); err != nil { + return 0, 0, err + } + if _, err := tx.Exec( + `DELETE FROM entities WHERE id IN (`+ph+`)`, ids...); err != nil { + return 0, 0, err + } + if err := tx.Commit(); err != nil { + return 0, 0, err + } + + if _, err := g.cleanupOrphanedSentencesLocked(); err != nil { + return len(junk), relCount, err + } + // 节点没了,场景引用必须跟着对齐:残留引用会让场景看着大、召回却是空的。 + if _, err := g.purgeStaleSceneRefsLocked(); err != nil { + return len(junk), relCount, err + } + return len(junk), relCount, nil +} + +// OrphanEntities 列出「没有任何关系的孤立实体」,按 mention_count 降序。 +// +// 孤立实体在图里只剩一个名字:关系召回永远够不到它,唯一的副作用是 +// 混进实体名向量索引、被自动注入当成相关实体。它们出现的典型路径是 +// 噪音实体被清理后留下的另一端(边没了,节点还在),或提取器把关系写重了。 +func (g *GraphDB) OrphanEntities() ([]Entity, error) { + g.mu.RLock() + defer g.mu.RUnlock() + return g.orphanEntitiesLocked() +} + +func (g *GraphDB) orphanEntitiesLocked() ([]Entity, error) { + rows, err := g.db.Query( + `SELECT id, name, type, mention_count, created_at, updated_at FROM entities e + WHERE NOT EXISTS (SELECT 1 FROM relations r WHERE r.source_id = e.id OR r.target_id = e.id) + -- 与媒体块有边的实体不算孤立:那是 sentence/document --contains--> block + -- 体系的一部分,删了会让块边悬空。 + AND NOT EXISTS (SELECT 1 FROM memory_block_edges b + WHERE (b.source_kind = 'entity' AND b.source_id = CAST(e.id AS TEXT)) + OR (b.target_kind = 'entity' AND b.target_id = CAST(e.id AS TEXT)))`) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []Entity + for rows.Next() { + var e Entity + if err := rows.Scan(&e.ID, &e.Name, &e.Type, &e.MentionCount, &e.CreatedAt, &e.UpdatedAt); err != nil { + return nil, err + } + out = append(out, e) + } + if err := rows.Err(); err != nil { + return nil, err + } + sort.Slice(out, func(i, j int) bool { + if out[i].MentionCount != out[j].MentionCount { + return out[i].MentionCount > out[j].MentionCount + } + return out[i].Name < out[j].Name + }) + return out, nil +} + +// PurgeOrphans 删除没有任何关系的孤立实体,返回删除数。 +// +// dryRun 为 true 时只统计。与 PurgeNoise 分开:噪音是「这个名字本身不该在」, +// 孤立是「这个名字虽然可能合理,但它已经不在图里了」——两件事,别混在一个开关里。 +func (g *GraphDB) PurgeOrphans(dryRun bool) (int, error) { + g.mu.Lock() + defer g.mu.Unlock() + + orphans, err := g.orphanEntitiesLocked() + if err != nil { + return 0, err + } + if dryRun || len(orphans) == 0 { + return len(orphans), nil + } + + ids := make([]interface{}, 0, len(orphans)) + for _, e := range orphans { + ids = append(ids, e.ID) + } + if _, err := g.db.Exec( + `DELETE FROM entities WHERE id IN (`+placeholders(len(ids))+`)`, ids...); err != nil { + return 0, err + } + if _, err := g.purgeStaleSceneRefsLocked(); err != nil { + return 0, err + } + return len(orphans), nil +} diff --git a/internal/memory/noise_test.go b/internal/memory/noise_test.go new file mode 100644 index 0000000..dd46205 --- /dev/null +++ b/internal/memory/noise_test.go @@ -0,0 +1,272 @@ +package memory + +import ( + "fmt" + "os" + "testing" +) + +func TestIsNoiseEntity(t *testing.T) { + cases := []struct { + name string + want bool + why string + }{ + {"结果", true, "停用词表内的泛化名词"}, + {"什么", true, "疑问代词"}, + {"哪个", true, "疑问代词"}, + {"咱俩", true, "人称代词(2026-09 补全)"}, + {"任何", true, "限定词"}, + {"部分", true, "泛指名词"}, + {"context_archived", true, "归档上下文内部标记"}, + {"来自 1 个来源的 2 条对话 (agent) 涉及: qq, 通道", true, "模板摘要回声"}, + {"来自 2 个来源的 2 条对话 (cli, agent)", true, "模板摘要回声(无涉及段)"}, + {"", true, "空名"}, + {" ", true, "纯空白"}, + {"文档", false, "doc→graph 的模板主语,保留"}, + {"小宅", false, "人名"}, + {"CodeGraph", false, "专名"}, + {"报告", false, "开放类词:可能是有意义的实体,不由本函数拦截"}, + {"对话", false, "开放类词"}, + } + for _, c := range cases { + if got := IsNoiseEntity(c.name); got != c.want { + t.Errorf("IsNoiseEntity(%q) = %v, want %v (%s)", c.name, got, c.want, c.why) + } + } +} + +func TestFilterNoiseTriples(t *testing.T) { + in := []Triple{ + {Subject: "结果", Relation: "是", Object: "问题"}, // 两端噪音 + {Subject: "小宅", Relation: "需要", Object: "什么"}, // 一端噪音 + {Subject: "文档", Relation: "主题", Object: "来自 1 个来源的 2 条对话 (agent)"}, // 一端噪音(模板摘要) + {Subject: "小宅", Relation: "使用", Object: "CodeGraph"}, // 干净 + {Subject: "小宅", Relation: "继续", Object: "待命"}, // 开放类词:本层不拦 + } + out := FilterNoiseTriples(in) + if len(out) != 2 { + t.Fatalf("FilterNoiseTriples 保留 %d 条,want 2: %+v", len(out), out) + } + if out[0].Object != "CodeGraph" || out[1].Object != "待命" { + t.Errorf("留下的不是那两条干净三元组: %+v", out) + } + + // 空输入不应被改写成非 nil(调用方按 len 判断,别制造意外) + if FilterNoiseTriples(nil) != nil { + t.Error("nil 输入应原样返回 nil") + } +} + +func TestPurgeNoise(t *testing.T) { + g := newTestGraph(t) + defer os.Remove(g.dbPath) + defer g.Close() + + triples := []Triple{ + {Subject: "结果", Relation: "是", Object: "问题", Confidence: 1.0}, + {Subject: "小宅", Relation: "继续", Object: "待命", Confidence: 1.0}, + {Subject: "文档", Relation: "来源", Object: "context_archived", Confidence: 1.0}, + {Subject: "文档", Relation: "主题", Object: "来自 1 个来源的 2 条对话 (agent)", Confidence: 1.0}, + {Subject: "小宅", Relation: "使用", Object: "CodeGraph", Confidence: 1.0}, + } + if _, _, err := g.Commit(triples, "test", 0); err != nil { + t.Fatalf("commit: %v", err) + } + + before, err := g.NoiseEntities() + if err != nil { + t.Fatalf("NoiseEntities: %v", err) + } + if len(before) != 4 { + // 4 个噪音:结果 / 问题 / context_archived / 模板摘要串 + // (「待命」是开放类词,不在停用词表内,故意不算噪音) + t.Fatalf("噪音实体 %d 个,want 4: %+v", len(before), before) + } + // 按 mention_count 降序;都为 1 时按名字升序,只验证顺序单调。 + for i := 1; i < len(before); i++ { + if before[i-1].MentionCount < before[i].MentionCount { + t.Errorf("NoiseEntities 未按 mention_count 降序: %+v", before) + break + } + } + + // dry-run 不得写库 + de, dr, err := g.PurgeNoise(true) + if err != nil { + t.Fatalf("PurgeNoise(dryRun): %v", err) + } + // 3 条关系:结果→问题(两端噪音算一次)、文档→context_archived、文档→模板摘要 + if de != 4 || dr != 3 { + t.Errorf("dry-run 统计 entities=%d relations=%d, want 4/3", de, dr) + } + if again, _ := g.NoiseEntities(); len(again) != 4 { + t.Fatalf("dry-run 改了库:噪音实体剩 %d 个", len(again)) + } + + // 真清理 + de, dr, err = g.PurgeNoise(false) + if err != nil { + t.Fatalf("PurgeNoise: %v", err) + } + if de != 4 || dr != 3 { + t.Errorf("清理统计 entities=%d relations=%d, want 4/3", de, dr) + } + + left, err := g.NoiseEntities() + if err != nil { + t.Fatalf("NoiseEntities: %v", err) + } + if len(left) != 0 { + t.Errorf("清理后仍有噪音实体: %+v", left) + } + + // 干净的那条必须活着 + res, err := g.Recall(nil, nil, 1, "") + if err != nil { + t.Fatalf("Recall: %v", err) + } + names := make(map[string]bool) + for _, e := range res.Entities { + names[e.Name] = true + } + if !names["小宅"] || !names["CodeGraph"] { + t.Errorf("清理误伤干净实体,现存: %v", names) + } + for _, n := range []string{"结果", "问题", "context_archived"} { + if names[n] { + t.Errorf("噪音实体 %q 仍在库中", n) + } + } + // 「文档」是 doc→graph 的模板主语,有意保留(清理只摘它的噪音边) + if !names["文档"] { + t.Error("模板主语「文档」不应被清理") + } + if !names["待命"] { + t.Error("开放类词「待命」不应被清理(它不在停用词表内)") + } + + // 幂等:再清一次应为 0/0 + de, dr, err = g.PurgeNoise(false) + if err != nil { + t.Fatalf("PurgeNoise 二次: %v", err) + } + if de != 0 || dr != 0 { + t.Errorf("二次清理 entities=%d relations=%d, want 0/0", de, dr) + } +} + +// TestPurgeNoiseKeepsBlockBackedSentences 钉住 PurgeNoise 不能误删仍被媒体块 +// 引用的句子——它复用 CleanupOrphanedSentences,那个判定必须照旧生效。 +func TestPurgeNoiseKeepsBlockBackedSentences(t *testing.T) { + g := newTestGraph(t) + defer os.Remove(g.dbPath) + defer g.Close() + + sentence := "小宅 说 结果 很好" + if _, _, err := g.Commit([]Triple{ + {Subject: "结果", Relation: "是", Object: "问题", SentenceText: sentence, Confidence: 1.0}, + }, "test", 0); err != nil { + t.Fatalf("commit: %v", err) + } + + var sid int64 + if err := g.db.QueryRow(`SELECT id FROM sentences WHERE text = ?`, sentence).Scan(&sid); err != nil { + t.Fatalf("sentence 未写入: %v", err) + } + if _, err := g.db.Exec( + `INSERT INTO memory_blocks (id, modality, payload_digest, mime) VALUES ('blk1', 'image', 'digest1', 'image/png')`); err != nil { + t.Fatalf("insert block: %v", err) + } + if _, err := g.db.Exec( + `INSERT INTO memory_block_edges (source_kind, source_id, target_kind, target_id, edge_type) + VALUES ('sentence', ?, 'block', 'blk1', 'contains')`, + fmt.Sprintf("%d", sid)); err != nil { + t.Fatalf("insert edge: %v", err) + } + + if _, _, err := g.PurgeNoise(false); err != nil { + t.Fatalf("PurgeNoise: %v", err) + } + + var n int + if err := g.db.QueryRow(`SELECT COUNT(*) FROM sentences WHERE id = ?`, sid).Scan(&n); err != nil { + t.Fatalf("count sentence: %v", err) + } + if n != 1 { + t.Error("PurgeNoise 删掉了仍被媒体块边引用的句子") + } +} + +func TestPurgeOrphans(t *testing.T) { + g := newTestGraph(t) + defer os.Remove(g.dbPath) + defer g.Close() + + if _, _, err := g.Commit([]Triple{ + {Subject: "小宅", Relation: "使用", Object: "CodeGraph", Confidence: 1.0}, + {Subject: "结果", Relation: "是", Object: "问题", Confidence: 1.0}, + }, "test", 0); err != nil { + t.Fatalf("commit: %v", err) + } + + // 清掉噪音后,「结果」「问题」两个节点被删、边也没了; + // 但先看清理前的孤立集合:应为 0(都有边)。 + if list, err := g.OrphanEntities(); err != nil { + t.Fatalf("OrphanEntities: %v", err) + } else if len(list) != 0 { + t.Fatalf("清理前不应有孤立实体: %+v", list) + } + + // 造一个「边被清掉、节点还在」的壳:直接删边 + if _, err := g.db.Exec(`DELETE FROM relations WHERE relation_type = '是'`); err != nil { + t.Fatalf("delete relation: %v", err) + } + // 再补一个从未有过边的孤立实体 + if _, _, err := g.Commit([]Triple{{Subject: "孤零零", Relation: "是", Object: "小宅", Confidence: 1.0}}, "test", 0); err != nil { + t.Fatalf("commit: %v", err) + } + if _, err := g.db.Exec(`DELETE FROM relations WHERE source_id = (SELECT id FROM entities WHERE name = '孤零零')`); err != nil { + t.Fatalf("delete relation 2: %v", err) + } + + list, err := g.OrphanEntities() + if err != nil { + t.Fatalf("OrphanEntities: %v", err) + } + names := make(map[string]bool) + for _, e := range list { + names[e.Name] = true + } + for _, want := range []string{"结果", "问题", "孤零零"} { + if !names[want] { + t.Errorf("%q 应被识别为孤立实体,实际: %+v", want, list) + } + } + if names["小宅"] || names["CodeGraph"] { + t.Errorf("有边的实体被误判为孤立: %+v", list) + } + + // dry-run 不写库 + if n, err := g.PurgeOrphans(true); err != nil || n != len(list) { + t.Fatalf("PurgeOrphans(dryRun) = %d, %v; want %d", n, err, len(list)) + } + if again, _ := g.OrphanEntities(); len(again) != len(list) { + t.Fatal("dry-run 改了库") + } + + n, err := g.PurgeOrphans(false) + if err != nil { + t.Fatalf("PurgeOrphans: %v", err) + } + if n != len(list) { + t.Errorf("删除 %d 个,want %d", n, len(list)) + } + if left, _ := g.OrphanEntities(); len(left) != 0 { + t.Errorf("清理后仍有孤立实体: %+v", left) + } + // 幂等 + if n, err := g.PurgeOrphans(false); err != nil || n != 0 { + t.Errorf("二次清理 = %d, %v; want 0", n, err) + } +} diff --git a/internal/memory/pipeline/pipeline.go b/internal/memory/pipeline/pipeline.go index ac6a8f6..20a7ed9 100644 --- a/internal/memory/pipeline/pipeline.go +++ b/internal/memory/pipeline/pipeline.go @@ -24,6 +24,12 @@ type RawRecord struct { Content string `json:"content"` CreatedAt time.Time `json:"created_at"` Distilled bool `json:"distilled"` + + // persisted 表示该记录已经写在磁盘 raw 文件里。 + // 未导出:只影响本进程的落盘行为,不进 JSON。 + // 作用:flush 只写**尚未落盘**的记录,否则 loadExisting 载入的记录 + // 会被再写一份,重启后同一批记录从新旧两个文件各读回一次。 + persisted bool } type DistillerConfig struct { @@ -33,16 +39,16 @@ type DistillerConfig struct { } type Distiller struct { - mu sync.Mutex - db *memory.GraphDB - rawPath string - records []RawRecord - nextID int64 - cfg DistillerConfig - ctx context.Context - cancel context.CancelFunc - onMemory func(input, response string) - embedder nlp.Vectorizer + mu sync.Mutex + db *memory.GraphDB + rawPath string + records []RawRecord + nextID int64 + cfg DistillerConfig + ctx context.Context + cancel context.CancelFunc + onMemory func(input, response string) + embedder nlp.Vectorizer } func (d *Distiller) SetEmbedder(ev nlp.Vectorizer) { d.embedder = ev } @@ -76,6 +82,22 @@ func (d *Distiller) Stop() { d.flush() } +// Stopped 报告蒸馏循环是否已被 Stop() 取消。 +// +// Stop() 里的 cancel() 是同步生效的,所以本方法在 Stop() 返回后立即为 true, +// 不受循环 goroutine 何时退出的影响。启动自检、健康检查用它确认 +// 「Start 之后没有被立即 Stop 掉」——历史回归:main() 拆分时 +// initMemoryStack 里残留一句 defer distiller.Stop(),函数一返回就把刚起的 +// 循环杀了,10min 心跳从不运行。Start() 之前返回 false(尚未被停)。 +func (d *Distiller) Stopped() bool { + select { + case <-d.ctx.Done(): + return true + default: + return false + } +} + func (d *Distiller) Append(sessionID string, role string, content string) { d.mu.Lock() defer d.mu.Unlock() @@ -89,19 +111,35 @@ func (d *Distiller) Append(sessionID string, role string, content string) { func (d *Distiller) flush() { d.mu.Lock() defer d.mu.Unlock() - if len(d.records) == 0 { + d.flushLocked() +} + +// flushLocked 把**尚未落盘**的记录追加写入一个新的 raw 文件(原子写)。 +// +// 只写 !persisted 的记录:loadExisting 载入的记录已经在磁盘上,若 flush 再把 +// 它们整体重写一份,重启后同一批记录会同时从旧文件与新文件被读回, +// 实体 mention_count 与关系被重复蒸馏。 +func (d *Distiller) flushLocked() { + var pending []RawRecord + for _, r := range d.records { + if !r.persisted { + pending = append(pending, r) + } + } + if len(pending) == 0 { return } path := filepath.Join(d.rawPath, fmt.Sprintf("raw_%d.tsv", time.Now().UnixNano())) - f, err := os.Create(path) - if err != nil { + var sb strings.Builder + for _, r := range pending { + fmt.Fprintf(&sb, "%d\t%s\t%s\t%s\t%d\n", r.ID, r.SessionID, r.Role, r.Content, r.CreatedAt.Unix()) + } + if err := writeFileAtomic(path, []byte(sb.String())); err != nil { log.Printf("[memory] flush error: %v", err) return } - defer f.Close() - for _, r := range d.records { - line := fmt.Sprintf("%d\t%s\t%s\t%s\t%d\n", r.ID, r.SessionID, r.Role, r.Content, r.CreatedAt.Unix()) - f.WriteString(line) + for i := range d.records { + d.records[i].persisted = true } } @@ -167,6 +205,7 @@ func (d *Distiller) loadExisting() { } d.records = append(d.records, RawRecord{ ID: d.nextID, SessionID: parts[1], Role: parts[2], Content: parts[3], CreatedAt: createdAt, + persisted: true, }) d.nextID++ loaded++ @@ -222,6 +261,9 @@ func (d *Distiller) distillOnce() { } if d.distillBatch(toDistill[i:end]) { distilled += end - i + // 成功即从磁盘 raw 文件里删除这些行,否则重启后 loadExisting + // 会把它们当未蒸馏记录重新读回,每次启动重蒸同一批历史。 + d.removeRawRecords(toDistill[i:end]) } else { // 蒸馏失败:记录写回待处理队列,下次 tick 重试 d.mu.Lock() @@ -279,6 +321,80 @@ func (d *Distiller) cleanupRawFiles() { } } +// writeFileAtomic 写临时文件再 rename,避免进程在写一半时崩溃留下半个文件。 +func writeFileAtomic(path string, data []byte) error { + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0644); err != nil { + return err + } + return os.Rename(tmp, path) +} + +// rawKey 唯一标识一条原始记录,用于在 raw 文件里按内容定位并删除。 +// +// 为什么按内容而不是 ID:loadExisting 读回记录时会重新分配连续 ID, +// 文件里的 ID 与内存 ID 并不一一对应。 +func rawKey(session, role, content string, ts int64) string { + return session + "\x00" + role + "\x00" + content + "\x00" + strconv.FormatInt(ts, 10) +} + +// removeRawRecords 从磁盘 raw 文件中删除已成功蒸馏的记录。 +// +// 蒸馏成功后记录若只从内存移除、磁盘文件不动,下次启动 loadExisting 会把 +// 它们当未蒸馏记录重新读回,导致每次重启都重蒸同一批历史(实体 +// mention_count 膨胀,且 distillBatch 的 sessionID 取自 map 首个键, +// 不确定性会放大重复)。 +func (d *Distiller) removeRawRecords(batch []RawRecord) { + if len(batch) == 0 { + return + } + drop := make(map[string]bool, len(batch)) + for _, r := range batch { + drop[rawKey(r.SessionID, r.Role, r.Content, r.CreatedAt.Unix())] = true + } + entries, err := os.ReadDir(d.rawPath) + if err != nil { + return + } + for _, entry := range entries { + ext := filepath.Ext(entry.Name()) + if ext != ".tsv" && ext != ".jsonl" { + continue + } + path := filepath.Join(d.rawPath, entry.Name()) + data, err := os.ReadFile(path) + if err != nil { + continue + } + var kept []string + removed := false + for _, line := range strings.Split(strings.TrimRight(string(data), "\n"), "\n") { + if line == "" { + continue + } + parts := splitLine(line) + if len(parts) >= 5 { + if ts, err := strconv.ParseInt(parts[4], 10, 64); err == nil && + drop[rawKey(parts[1], parts[2], parts[3], ts)] { + removed = true + continue + } + } + kept = append(kept, line) + } + if !removed { + continue + } + if len(kept) == 0 { + os.Remove(path) + continue + } + if err := writeFileAtomic(path, []byte(strings.Join(kept, "\n")+"\n")); err != nil { + log.Printf("[memory] rewrite raw %s: %v", entry.Name(), err) + } + } +} + func extractKeyTriples(userContent, assistantContent string, embedder nlp.Vectorizer) []memory.Triple { var triples []memory.Triple @@ -300,6 +416,11 @@ func extractKeyTriples(userContent, assistantContent string, embedder nlp.Vector } } + // 注意:这里**不**做噪音过滤。对话蒸馏的抽取器(与 doc→graph 共用一个 + // NLP 提取器)历史上就没有常用词闸门:CutExact 那层当年只挂在 doc→graph 上。 + // 而且 pipeline_test 明确断言「我 --读书--> 杭州」必须被抽出(代词作主语是 + // 该路的既定行为)。要不要在对话路也拦常用词是行为决策,不在此处擅改, + // 参见 memory.IsNoiseEntity 的说明。 return triples } @@ -310,13 +431,6 @@ func truncate(s string, max int) string { return s } -func parseLines(data string) []string { - if data == "" { - return nil - } - return strings.Split(strings.TrimRight(data, "\n"), "\n") -} - func splitLine(line string) []string { if line == "" { return nil diff --git a/internal/memory/pipeline/pipeline_test.go b/internal/memory/pipeline/pipeline_test.go index 62f68de..63d6b85 100644 --- a/internal/memory/pipeline/pipeline_test.go +++ b/internal/memory/pipeline/pipeline_test.go @@ -185,9 +185,9 @@ func TestDistillOnceBatchLimit(t *testing.T) { func TestExtractKeyTriples(t *testing.T) { tests := []struct { - user string + user string assistant string - check func([]memory.Triple) bool + check func([]memory.Triple) bool }{ { user: "我住在北京", @@ -201,7 +201,7 @@ func TestExtractKeyTriples(t *testing.T) { }, }, { - user: "我在杭州读书", + user: "我在杭州读书", assistant: "好的", check: func(triples []memory.Triple) bool { for _, tr := range triples { @@ -264,3 +264,25 @@ func TestTruncate(t *testing.T) { t.Errorf("expected 'hi', got %q", truncate("hi", 10)) } } + +// TestStartKeepsLoopRunningUntilStop 锁死 Start/Stop 的接线契约: +// Start 之后蒸馏循环必须处于运行态,只有显式 Stop 才退出。 +// +// 这条回归的直接来源:main() 拆分时 initMemoryStack 里残留一句 +// defer distiller.Stop(),函数返回即 cancel,循环启动即死——而 +// TestDistillOnce* 直接调 distillOnce,绕过了 Start/Stop,照不出这个洞。 +func TestStartKeepsLoopRunningUntilStop(t *testing.T) { + d := NewDistiller(nil, t.TempDir(), DistillerConfig{ + Interval: time.Hour, // 不依赖 tick,只验证循环存活 + RetentionDays: 7, + BatchSize: 50, + }) + d.Start() + if d.Stopped() { + t.Fatal("Start 之后蒸馏循环必须处于运行态(不可被 defer Stop 杀掉)") + } + d.Stop() + if !d.Stopped() { + t.Fatal("Stop 之后应处于已停止态") + } +} diff --git a/internal/memory/scene.go b/internal/memory/scene.go new file mode 100644 index 0000000..2ab7e6a --- /dev/null +++ b/internal/memory/scene.go @@ -0,0 +1,582 @@ +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:)——存一份 +// 冗余的 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() +} diff --git a/internal/memory/scene_emerge.go b/internal/memory/scene_emerge.go new file mode 100644 index 0000000..ce14ea3 --- /dev/null +++ b/internal/memory/scene_emerge.go @@ -0,0 +1,628 @@ +package memory + +import ( + "database/sql" + "fmt" + "sort" + "strings" + "time" +) + +// ────────────────────────────────────────────── +// 场景的**涌现**:从交互流自己长出场面来 +// +// 上一版场景是「声明/派生」的:要么调用方写 `scene="chan:qq"`,要么由通道机械 +// 派生。那不是涌现,那是给记忆贴标签,标签谁定、怎么定全靠人。 +// +// 这里换成人的记忆那种机制: +// +// 每轮交互都有一个可观察的**场面指纹**(在哪个通道、跟谁、在干什么、聊什么); +// 指纹反复重合的交互,会自己聚成一个场景(没人声明过它); +// 场景里写下的记忆自动挂上去; +// 下次指纹再次重合,挂在这个场景上的记忆**自动被唤起**,与措辞无关。 +// +// 三条与生俱来的性质: +// - 自动:指纹全部来自运行时可观察量,无需模型配合、无需人工标注; +// - 涌现:场景在重复中长出来(首次不建场景,见 minSceneEvidence); +// - 强化与遗忘:场景每次重现强度 +1,记忆的挂载权重按重现次数与置信度累积, +// 长期不用的按半衰期衰减——与人的记忆一样,不用就淡。 +// ────────────────────────────────────────────── + +// 特征权重。决定「哪些特征算同一个场面」:通道与对象是最强的同一性信号 +// (在 QQ 上、对老大),工具是行为信号,话题是软信号(同一场面的不同话题 +// 不该被拆开,所以权重低),时段最弱。 +const ( + wFeatChan = 1.0 + wFeatPeer = 1.0 + wFeatTool = 0.8 + wFeatTopic = 0.4 + wFeatPart = 0.2 +) + +// 聚类阈值。 +// +// joinSceneThreshold 是「这轮属于既有场景」的下限——定在 0.5 意味着 +// 「要么主体特征重合,要么好几条信号一起重合」才算同一个场面。 +// recallSceneThreshold 比它低:**唤起**比**归属**宽松,想不起来是损失, +// 多想起一条只是多几行上下文(与人的联想一致)。 +const ( + joinSceneThreshold = 0.5 + recallSceneThreshold = 0.35 + // minSceneEvidence 是长成场景所需的最少重现次数。 + // + // 为什么首次不建场景:一次性的交互不是「场面」,给它建场景会让图库被 + // 一次性事件撑满,之后每次路过都要召回一堆只发生过一次的事。 + // 第 2 次出现同类指纹时才认定「这事会重复」。 + minSceneEvidence = 2 + // maxSituationFeatures 是单轮指纹的特征上限(防长输入把相似度算糊涂)。 + maxSituationFeatures = 24 +) + +// SituationFeature 是一轮交互里的一个可观察信号,形如 `chan:qq`、`peer:group_1027`。 +type SituationFeature struct { + Kind string + Value string +} + +// Key 返回规范化的特征串。Kind 与 Value 都过场景键归一化, +// 保证 `chan:QQ` 与 `chan:qq` 是同一个特征。 +func (f SituationFeature) Key() string { + kind := NormalizeSceneKey(f.Kind) + val := NormalizeSceneKey(f.Value) + if kind == "" || val == "" { + return "" + } + return kind + ":" + val +} + +// Weight 返回该特征的权重(按 Kind)。 +func (f SituationFeature) Weight() float64 { + switch NormalizeSceneKey(f.Kind) { + case "chan": + return wFeatChan + case "peer", "peer_group": + // 群与私聊都是「对话对象」这一维:都是最强的同一性信号。 + // 分开 kind 是为了让 `peer:group_1` 与 `peer:user_1` 不互相命中, + // 不是让群身份降级成软信号(漏掉这里它就只剩 topic 权重 0.4)。 + return wFeatPeer + case "tool": + return wFeatTool + case "topic": + return wFeatTopic + case "part": + return wFeatPart + default: + return wFeatTopic + } +} + +// Situation 是一轮交互的场面指纹(去重、上限裁剪后的特征集合)。 +type Situation struct { + Features []SituationFeature +} + +// NewSituation 由若干特征构造指纹:归一化、去重、按权重降序裁剪到上限。 +func NewSituation(features ...SituationFeature) Situation { + seen := make(map[string]bool, len(features)) + out := make([]SituationFeature, 0, len(features)) + for _, f := range features { + k := f.Key() + if k == "" || seen[k] { + continue + } + seen[k] = true + out = append(out, f) + } + sort.SliceStable(out, func(i, j int) bool { return out[i].Weight() > out[j].Weight() }) + if len(out) > maxSituationFeatures { + out = out[:maxSituationFeatures] + } + return Situation{Features: out} +} + +// Keys 返回指纹的特征串列表。 +func (s Situation) Keys() []string { + out := make([]string, 0, len(s.Features)) + for _, f := range s.Features { + out = append(out, f.Key()) + } + return out +} + +// Empty 表示指纹里没有任何可判定的信号。 +func (s Situation) Empty() bool { return len(s.Features) == 0 } + +// Label 用权重最高的少数特征给场景起个可读名字(`chan:qq+tool:qq_get_message`)。 +// 只用于人看,不参与匹配——匹配永远走特征集合。 +func (s Situation) Label(max int) string { + if max <= 0 { + max = 2 + } + keys := s.Keys() + if len(keys) > max { + keys = keys[:max] + } + return strings.Join(keys, "+") +} + +// emergentScene 是一次聚类计算中的场景视图。 +type emergentScene struct { + ID int64 + Key string + Strength int + Weights map[string]float64 +} + +// similarity 是加权 Jaccard:共享特征的权重和 / 并集特征的权重和。 +// +// 为什么加权:`chan:qq` 与 `topic:排班` 对「是不是同一个场面」的证据力差 2.5 倍, +// 不加权会让一次偶然的话题重合把两个不同场面并成一个。 +func (a emergentScene) similarity(b emergentScene) float64 { + if len(a.Weights) == 0 || len(b.Weights) == 0 { + return 0 + } + shared, union := 0.0, 0.0 + for k, w := range a.Weights { + if w2, ok := b.Weights[k]; ok { + shared += min(w, w2) + union += max(w, w2) + } else { + union += w + } + } + for k, w := range b.Weights { + if _, ok := a.Weights[k]; !ok { + union += w + } + } + if union == 0 { + return 0 + } + return shared / union +} + +// situationSimilarity 计算指纹与既有场景的相似度。 +func situationSimilarity(sig Situation, sc emergentScene) float64 { + cur := make(map[string]float64, len(sig.Features)) + for _, f := range sig.Features { + cur[f.Key()] = f.Weight() + } + return emergentScene{Weights: cur}.similarity(sc) +} + +// EnterScene 是本机制的主入口:给一轮交互的指纹找到(或长出)它的场景。 +// +// 返回解析出的场景键与是否新建。调用方拿这个键去做两件事: +// 1. 本轮写下的记忆自动挂到它上面(Triple.Scene) +// 2. 本轮召回按它(以及相似场景)取回记忆 +// +// 「首次不建场景」的例外:指纹只出现一次时返回空键——一次性交互不该有场面, +// 见 minSceneEvidence 的说明。 +func (g *GraphDB) EnterScene(sig Situation) (string, bool, error) { + if sig.Empty() { + return "", false, nil + } + g.mu.Lock() + defer g.mu.Unlock() + + scenes, err := g.loadEmergentScenesLocked() + if err != nil { + return "", false, err + } + + bestIdx, bestSim := -1, 0.0 + for i, sc := range scenes { + if sim := situationSimilarity(sig, sc); sim > bestSim { + bestIdx, bestSim = i, sim + } + } + + // 命中既有场景:强化(并入新特征、强度 +1、时间刷新) + if bestIdx >= 0 && bestSim >= joinSceneThreshold { + sc := scenes[bestIdx] + if err := g.reinforceSceneLocked(sc.ID, sig); err != nil { + return "", false, err + } + return sc.Key, false, nil + } + + // 未命中:看有没有「同类指纹的足迹」——首次出现只登记线索,不建场景 + evidence, err := g.recordSituationEvidenceLocked(sig) + if err != nil { + return "", false, err + } + if evidence < minSceneEvidence { + return "", false, nil + } + + key, err := g.createSceneLocked(sig) + if err != nil { + return "", false, err + } + return key, true, nil +} + +// loadEmergentScenesLocked 读入参与**被动聚类**的场景(origin='emergent')及其特征权重。 +// +// 为什么不带上声明场景:声明场景若也进相似度空间,它一旦吸收了整轮指纹就会 +// 以接近 1.0 的相似度吃掉后续所有同类轮次,被动路再也长不出更细的场面。 +// 声明路的泛化靠**层级键前缀**(chan:qq 覆盖 chan:qq/peer:x),各有各的机制。 +func (g *GraphDB) loadEmergentScenesLocked() ([]emergentScene, error) { + rows, err := g.db.Query(`SELECT id, key, COALESCE(strength, 1) FROM scenes + WHERE COALESCE(origin, 'emergent') = 'emergent'`) + if err != nil { + return nil, err + } + byID := make(map[int64]*emergentScene) + var out []emergentScene + for rows.Next() { + var sc emergentScene + if err := rows.Scan(&sc.ID, &sc.Key, &sc.Strength); err != nil { + rows.Close() + return nil, err + } + sc.Weights = make(map[string]float64) + byID[sc.ID] = &sc + out = append(out, sc) + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, err + } + if len(out) == 0 { + return nil, nil + } + + frows, err := g.db.Query(`SELECT scene_id, feature, weight FROM scene_features`) + if err != nil { + return nil, err + } + defer frows.Close() + for frows.Next() { + var sid int64 + var feat string + var w float64 + if err := frows.Scan(&sid, &feat, &w); err != nil { + return nil, err + } + if sc, ok := byID[sid]; ok { + sc.Weights[feat] = w + } + } + if err := frows.Err(); err != nil { + return nil, err + } + // 回填(out 里的元素是值拷贝,Weights 是同一 map,指针内容已更新) + for i := range out { + if sc, ok := byID[out[i].ID]; ok { + out[i].Weights = sc.Weights + } + } + return out, nil +} + +// reinforceSceneLocked 把一轮指纹并入既有场景并强化它。 +func (g *GraphDB) reinforceSceneLocked(sceneID int64, sig Situation) error { + tx, err := g.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + for _, f := range sig.Features { + if _, err := tx.Exec( + `INSERT INTO scene_features (scene_id, feature, weight) VALUES (?, ?, ?) + ON CONFLICT(scene_id, feature) DO UPDATE SET weight = MAX(weight, excluded.weight)`, + sceneID, f.Key(), f.Weight()); err != nil { + return err + } + } + if _, err := tx.Exec( + `UPDATE scenes SET strength = COALESCE(strength, 1) + 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, + sceneID); err != nil { + return err + } + return tx.Commit() +} + +// createSceneLocked 用指纹长出一个新场景(键由主导特征派生,仅作可读名)。 +func (g *GraphDB) createSceneLocked(sig Situation) (string, error) { + base := "auto:" + sig.Label(2) + key := base + + tx, err := g.db.Begin() + if err != nil { + return "", err + } + defer tx.Rollback() + + // 键冲突(同一可读名已被占)时加后缀,不合并——真正的合并交给相似度判定。 + for i := 2; ; i++ { + var exists int + if err := tx.QueryRow(`SELECT COUNT(*) FROM scenes WHERE key = ?`, key).Scan(&exists); err != nil { + return "", err + } + if exists == 0 { + break + } + key = fmt.Sprintf("%s#%d", base, i) + } + + res, err := tx.Exec(`INSERT INTO scenes (key, strength, origin) VALUES (?, 1, 'emergent')`, key) + if err != nil { + return "", err + } + sceneID, _ := res.LastInsertId() + for _, f := range sig.Features { + if _, err := tx.Exec( + `INSERT INTO scene_features (scene_id, feature, weight) VALUES (?, ?, ?) + ON CONFLICT(scene_id, feature) DO UPDATE SET weight = MAX(weight, excluded.weight)`, + sceneID, f.Key(), f.Weight()); err != nil { + return "", err + } + } + // 场景成立后,把此前登记的同类线索清掉(它们已被这次长出吸收) + if _, err := tx.Exec(`DELETE FROM situation_evidence`); err != nil { + return "", err + } + if err := tx.Commit(); err != nil { + return "", err + } + return key, nil +} + +// recordSituationEvidenceLocked 登记一次「同类指纹出现过」,返回累计次数。 +// +// 用指纹标签(主导特征)做粗聚类桶,只服务于「首次不建场景」的门槛判定, +// 不参与后续匹配——匹配永远走 EnterScene 的相似度。 +func (g *GraphDB) recordSituationEvidenceLocked(sig Situation) (int, error) { + label := sig.Label(2) + if _, err := g.db.Exec( + `INSERT INTO situation_evidence (label, count, updated_at) VALUES (?, 1, CURRENT_TIMESTAMP) + ON CONFLICT(label) DO UPDATE SET count = count + 1, updated_at = CURRENT_TIMESTAMP`, label); err != nil { + return 0, err + } + var n int + if err := g.db.QueryRow(`SELECT count FROM situation_evidence WHERE label = ?`, label).Scan(&n); err != nil { + return 0, err + } + return n, nil +} + +// RecallBySituation 按**场面相似**取回记忆:不是键相等,而是「像不像同一个场面」。 +// +// 命中多个场景时取并集,跨场景去重(同一关系只出现一次);命中的场景先按 +// 相似度排序再交给 RecallByScene。最终顺序以**场景内引用权重**(写入时的 +// 置信度)为准,相似度只决定哪些场景参与、不参与每条关系的排序。 +// 这正是「类似的场景自动唤起对应的记忆」那一下。 +func (g *GraphDB) RecallBySituation(sig Situation, limit int) (*SceneRecall, error) { + if sig.Empty() { + return &SceneRecall{}, nil + } + scenes, err := g.loadEmergentScenesLocked() + if err != nil { + return nil, err + } + if len(scenes) == 0 { + return &SceneRecall{}, nil + } + + type hit struct { + key string + sim float64 + } + var hits []hit + for _, sc := range scenes { + sim := situationSimilarity(sig, sc) + if sim >= recallSceneThreshold { + hits = append(hits, hit{key: sc.Key, sim: sim}) + } + } + if len(hits) == 0 { + return &SceneRecall{}, nil + } + // 相似度高的场景排前面;同相似度时强度高的优先(更常重现的场面更可信) + sort.SliceStable(hits, func(i, j int) bool { + if hits[i].sim != hits[j].sim { + return hits[i].sim > hits[j].sim + } + return hits[i].key < hits[j].key + }) + + keys := make([]string, 0, len(hits)) + for _, h := range hits { + keys = append(keys, h.key) + } + // 复用按场景键的取回逻辑(前缀语义 + weight 排序) + out, err := g.RecallByScene(keys, limit) + if err != nil { + return nil, err + } + return out, nil +} + +// DecaySceneRefs 让久未重现的场景记忆按半衰期淡出,返回删除的引用数。 +// +// 人的记忆是靠「用进废退」维持秩序的:不做衰减,一次性的巧合关联会 +// 永远留在场景里,每次路过都被注入,越攒越多直到注入预算被吃光。 +// 权重按半衰期折半;低于 floor 的引用直接删除(关联已无信息量)。 +// +// 关键在「按半衰期」:每个引用**至多每 halfLife 衰减一次**,计时起点记在 +// scene_refs.decayed_at 上。只按 created_at 判龄会在每次心跳都把老引用对半 +// 砍——archive 心跳默认 60 分钟、halfLife 传 30 天,于是 30 天前的关联会在 +// 几小时内被砍到 floor 以下清空。那不是半衰期,是骤死。 +func (g *GraphDB) DecaySceneRefs(halfLife time.Duration, floor float64) (int, error) { + if halfLife <= 0 { + return 0, nil + } + if floor <= 0 { + floor = 0.05 + } + g.mu.Lock() + defer g.mu.Unlock() + // 时间基准必须与 CURRENT_TIMESTAMP 一致(SQLite 用 UTC):如果在 Go 侧用 + // 本地时间拼字符串比较,东八区会凭空多出 8 小时的“年龄”,刚刷新的 + // decayed_at 会被判定为还没到点。这里交给 SQLite 的 datetime('now', …)。 + mod := fmt.Sprintf("-%d seconds", int(halfLife.Seconds())) + if _, err := g.db.Exec( + `UPDATE scene_refs SET weight = weight * 0.5, decayed_at = CURRENT_TIMESTAMP + WHERE decayed_at < datetime('now', ?) AND id NOT IN ( + SELECT sr.id FROM scene_refs sr JOIN scenes s ON sr.scene_id = s.id + WHERE s.updated_at >= datetime('now', ?))`, mod, mod); err != nil { + return 0, err + } + res, err := g.db.Exec(`DELETE FROM scene_refs WHERE weight < ?`, floor) + if err != nil { + return 0, err + } + n, _ := res.RowsAffected() + return int(n), nil +} + +// TurnScene 是一轮交互解析出来的场景集合。 +type TurnScene struct { + // Primary 是本轮写记忆时的**首选**场景:优先用涌现出来的(细粒度、 + // 与措辞无关),没有(首次出现、场景还没长出来)时退到第一个声明场景。 + Primary string + // Keys 是声明场景 + 涌现场景的全集(去重保序),供召回并集使用。 + Keys []string + // Emergent 标记 Primary 是否来自涌现。 + Emergent bool + // DeclaredCreated 是本次**新建**的声明场景(此前不存在,因声明而成立)。 + DeclaredCreated []string +} + +// EnterSceneWithHint 同时走**主动声明**与**被动涌现**两条路。 +// +// 主动路(declaredKeys 非空):确保这些场景存在(不存在即建,不等第二次涌现 +// ——人明确说了"这是哪个场面",就不该再等它自己涌现),强度 +1,并给它记下 +// 从**键自身**解析出的特征。声明路刻意**不吸收本轮整场指纹**:一旦吸收,它 +// 会在相似度上压过一切,被动聚类再也长不出更细的场面(见 EnsureScene)。 +// +// 被动路(始终执行):EnterScene 的聚类,指纹重复到 minSceneEvidence 次时 +// 自己长出场景。首次交互这里返回空,此时 Primary 落到声明场景兜底—— +// 这正是"只挂一条会丢东西"的那一半。 +func (g *GraphDB) EnterSceneWithHint(sig Situation, declaredKeys []string) (TurnScene, error) { + out := TurnScene{} + + // 主动路:声明场景存在化 + 学特征 + 强化 + seen := make(map[string]bool) + for _, raw := range declaredKeys { + key := NormalizeSceneKey(raw) + if key == "" || seen[key] { + continue + } + seen[key] = true + learned, err := g.EnsureScene(key) + if err != nil { + return out, err + } + if learned { + out.DeclaredCreated = append(out.DeclaredCreated, key) + } + out.Keys = append(out.Keys, key) + } + if len(out.Keys) > 0 { + out.Primary = out.Keys[0] + } + + // 被动路:指纹聚类(可能返回已聚合的场景、也可能首次为空) + // + // EnterScene 只在真的命中/新建时返回非空键;返回空键时 created 必为 + // false(首次只登记足迹),所以这里无需再判 created。 + if !sig.Empty() { + key, _, err := g.EnterScene(sig) + if err != nil { + return out, err + } + if key != "" { + if !seen[key] { + out.Keys = append(out.Keys, key) + } + out.Primary = key + out.Emergent = true + } + } + return out, nil +} + +// EnsureScene 让一个**声明出来的**场景存在(不存在则建),并给它记一次强度。 +// +// 特征只从**键自身**解析(`chan:qq/peer:group_1` → {chan:qq, peer:group_1}), +// 不吸收本轮的整轮指纹。这条边界很关键:声明场景若吸收整轮指纹,它会在相似度 +// 上压过一切,被动聚类再也长不出更细的场面(实测过,见 loadEmergentScenesLocked)。 +// 声明路的泛化靠层级键前缀,不需要靠学指纹。 +func (g *GraphDB) EnsureScene(key string) (bool, error) { + key = NormalizeSceneKey(key) + if key == "" { + return false, nil + } + g.mu.Lock() + defer g.mu.Unlock() + + var sceneID int64 + err := g.db.QueryRow(`SELECT id FROM scenes WHERE key = ?`, key).Scan(&sceneID) + created := false + if err == sql.ErrNoRows { + res, ierr := g.db.Exec(`INSERT INTO scenes (key, strength, origin) VALUES (?, 1, 'declared')`, key) + if ierr != nil { + return false, ierr + } + sceneID, _ = res.LastInsertId() + created = true + } else if err != nil { + return false, err + } + + if _, err := g.db.Exec( + `UPDATE scenes SET strength = COALESCE(strength,1) + 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, + sceneID); err != nil { + return created, err + } + for _, f := range FeaturesFromSceneKey(key) { + if _, err := g.db.Exec( + `INSERT INTO scene_features (scene_id, feature, weight) VALUES (?, ?, ?) + ON CONFLICT(scene_id, feature) DO UPDATE SET weight = MAX(weight, excluded.weight)`, + sceneID, f.Key(), f.Weight()); err != nil { + return created, err + } + } + return created, nil +} + +// FeaturesFromSceneKey 从场景键解析它自身蕴含的场面特征。 +// +// chan:qq → {chan:qq} +// chan:qq/peer:group_1027 → {chan:qq, peer:group_1027} +// 老大2026-09-04_12:27_qq私聊图片 → {}(无 kind:value 结构,不猜) +// +// 只有 `kind:value` 形态的层才算特征——猜不出结构的键宁可留空, +// 也不要往特征空间里灌进会污染相似度的东西。 +func FeaturesFromSceneKey(key string) []SituationFeature { + parts := strings.Split(NormalizeSceneKey(key), "/") + var out []SituationFeature + for _, p := range parts { + i := strings.Index(p, ":") + if i <= 0 || i == len(p)-1 { + continue + } + kind, val := p[:i], p[i+1:] + // 白名单,不猜:`老大2026-09-04_12:27_qq私聊图片` 里的 `12:27` 也是 + // `kind:value` 形态,放进特征空间就是往相似度里灌垃圾。 + if !knownFeatureKinds[kind] { + continue + } + feat := SituationFeature{Kind: kind, Value: val} + if feat.Key() == "" { + continue + } + out = append(out, feat) + } + return out +} + +// knownFeatureKinds 是允许进入场面指纹的特征种类(新的种类在这里登记, +// 并在 SituationFeature.Weight 里给权重)。 +var knownFeatureKinds = map[string]bool{ + "chan": true, "peer": true, "peer_group": true, + "tool": true, "topic": true, "part": true, +} diff --git a/internal/memory/scene_emerge_test.go b/internal/memory/scene_emerge_test.go new file mode 100644 index 0000000..2a7888f --- /dev/null +++ b/internal/memory/scene_emerge_test.go @@ -0,0 +1,348 @@ +package memory + +import ( + "os" + "strings" + "testing" + "time" +) + +func mkSig(chanName, peer, tool string, topics ...string) Situation { + feats := []SituationFeature{{Kind: "chan", Value: chanName}} + if peer != "" { + feats = append(feats, SituationFeature{Kind: "peer", Value: peer}) + } + if tool != "" { + feats = append(feats, SituationFeature{Kind: "tool", Value: tool}) + } + feats = append(feats, SituationFeature{Kind: "part", Value: "morning"}) + for _, t := range topics { + feats = append(feats, SituationFeature{Kind: "topic", Value: t}) + } + return NewSituation(feats...) +} + +// TestSceneEmergesFromRepetition 是这套机制的核心证据: +// 场景**没有人声明过**——同类场面重复出现时自己长出来。 +func TestSceneEmergesFromRepetition(t *testing.T) { + g := newTestGraph(t) + defer os.Remove(g.dbPath) + defer g.Close() + + // 第 1 次:只登记足迹,不建场景(一次性的交互不是「场面」) + key, created, err := g.EnterScene(mkSig("qq", "group_1027", "qq_get_message", "排班")) + if err != nil { + t.Fatalf("EnterScene: %v", err) + } + if key != "" || created { + t.Fatalf("首次出现不该长出场景,得到 key=%q created=%v", key, created) + } + + // 第 2 次同类场面:场景长出来了 + key, created, err = g.EnterScene(mkSig("qq", "group_1027", "qq_get_message", "排班表")) + if err != nil { + t.Fatalf("EnterScene: %v", err) + } + if key == "" || !created { + t.Fatalf("第 2 次同类场面应长出场景,得到 key=%q created=%v", key, created) + } + emergentKey := key + + // 第 3 次:同样的场面、**不同的话题**——仍属于同一个场景(是场景,不是每轮一个键) + key3, created3, err := g.EnterScene(mkSig("qq", "group_1027", "qq_get_message", "发版")) + if err != nil { + t.Fatalf("EnterScene: %v", err) + } + if created3 || key3 != emergentKey { + t.Fatalf("同场面不同话题应并入既有场景: key=%q created=%v want=%q", key3, created3, emergentKey) + } + + // 另一个场面(换通道)不会被并进去,重复两次后自己长出一个 + if k, c, _ := g.EnterScene(mkSig("webui", "", "", "排班")); k != "" && !c { + t.Fatalf("不同通道不应并进 QQ 场景: %q", k) + } + if k2, c2, _ := g.EnterScene(mkSig("webui", "", "", "排班")); k2 == "" || !c2 || k2 == emergentKey { + t.Fatalf("webui 场面应自己长出独立场景: key=%q created=%v", k2, c2) + } + + scenes, err := g.SceneStats() + if err != nil { + t.Fatalf("SceneStats: %v", err) + } + if len(scenes) != 2 { + t.Fatalf("应长出 2 个场景,得到 %d: %+v", len(scenes), scenes) + } + // 强化:QQ 场景被遇到 3 次(2 次缔造 + 1 次并入)→ strength > 1 + var qq SceneStat + for _, sc := range scenes { + if sc.Key == emergentKey { + qq = sc + } + } + if qq.Strength < 2 { + t.Errorf("场景强度应随重现增加,得到 %d", qq.Strength) + } + if qq.Features < 3 { + t.Errorf("场景应记住多个特征,得到 %d", qq.Features) + } +} + +// TestSceneRecallsBySituationNotWording 钉住「类似场面自动唤起记忆」: +// 唤起靠场面相似,而不是措辞命中。 +func TestSceneRecallsBySituationNotWording(t *testing.T) { + g := newTestGraph(t) + defer os.Remove(g.dbPath) + defer g.Close() + + // 让 QQ 场面长出场景 + g.EnterScene(mkSig("qq", "group_1027", "qq_get_message", "排班")) + sceneKey, _, err := g.EnterScene(mkSig("qq", "group_1027", "qq_get_message", "排班表")) + if err != nil || sceneKey == "" { + t.Fatalf("场景未长出: %q %v", sceneKey, err) + } + + // 在这个场面里写下的规则(与后面提问的措辞零重合) + if _, _, err := g.Commit([]Triple{ + {Subject: "群规", Relation: "禁止", Object: "Markdown排版", Confidence: 1.0, + Scene: sceneKey, SentenceText: "本群只发纯文本"}, + }, "main", 0); err != nil { + t.Fatalf("commit: %v", err) + } + + // 换一种措辞、但同一个场面:应当自动唤起上面那条 + r, err := g.RecallBySituation(mkSig("qq", "group_1027", "qq_get_message", "统计"), 8) + if err != nil { + t.Fatalf("RecallBySituation: %v", err) + } + if len(r.Relations) != 1 || r.Relations[0].TargetName != "Markdown排版" { + t.Fatalf("同场面应唤起记忆: %+v", r.Relations) + } + if r.Relations[0].SentenceText != "本群只发纯文本" { + t.Errorf("唤起时要带原句: %+v", r.Relations[0]) + } + + // 别的场面不该被唤起 + other, err := g.RecallBySituation(mkSig("webui", "", "", "统计"), 8) + if err != nil { + t.Fatalf("RecallBySituation: %v", err) + } + if len(other.Relations) != 0 { + t.Errorf("无关场面不该唤起: %+v", other.Relations) + } +} + +// TestSceneRefDecay 钉住「用进废退」:久未重现的关联会淡出并被清掉。 +// +// 半衰期的语义是「每个引用至多每 halfLife 衰减一次」:衰减计时起点在 +// scene_refs.decayed_at 上,同一半衰期内重复跑心跳不会再砍。 +func TestSceneRefDecay(t *testing.T) { + g := newTestGraph(t) + defer os.Remove(g.dbPath) + defer g.Close() + + if _, _, err := g.Commit([]Triple{ + {Subject: "甲组", Relation: "是", Object: "乙组", Confidence: 1.0, Scene: "chan:qq"}, + }, "main", 0); err != nil { + t.Fatalf("commit: %v", err) + } + var before float64 + if err := g.db.QueryRow(`SELECT weight FROM scene_refs LIMIT 1`).Scan(&before); err != nil { + t.Fatal(err) + } + + // 把时间推旧:模拟长期未重现(上次衰减也在同一时刻)。用 SQLite 的 + // datetime('now') 与 CURRENT_TIMESTAMP 同一时间基准(UTC)。 + if _, err := g.db.Exec(`UPDATE scene_refs + SET created_at = datetime('now','-48 hours'), decayed_at = datetime('now','-48 hours')`); err != nil { + t.Fatal(err) + } + if _, err := g.db.Exec(`UPDATE scenes SET updated_at = datetime('now','-48 hours')`); err != nil { + t.Fatal(err) + } + + // 一个半衰期:权重减半(1.0 → 0.5),仍高于 floor,保留 + if _, err := g.DecaySceneRefs(time.Hour, 0.4); err != nil { + t.Fatalf("DecaySceneRefs: %v", err) + } + var after float64 + if err := g.db.QueryRow(`SELECT weight FROM scene_refs LIMIT 1`).Scan(&after); err != nil { + t.Fatal(err) + } + if after >= before { + t.Errorf("一个半衰期后权重应减半: %v → %v", before, after) + } + + // 同一半衰期内再跑:decayed_at 已刷新,不该再砍一次 + // (否则心跳频率就成了实际半衰期,30 天的关联几小时就被清空) + if _, err := g.DecaySceneRefs(time.Hour, 0.4); err != nil { + t.Fatalf("DecaySceneRefs: %v", err) + } + var same float64 + if err := g.db.QueryRow(`SELECT weight FROM scene_refs LIMIT 1`).Scan(&same); err != nil { + t.Fatal(err) + } + if same != after { + t.Errorf("同一半衰期内不该重复衰减: %v → %v", after, same) + } + + // 进入第二个半衰期:再推旧 decayed_at,权重低于 floor,清掉 + if _, err := g.db.Exec(`UPDATE scene_refs SET decayed_at = datetime('now','-48 hours')`); err != nil { + t.Fatal(err) + } + n, err := g.DecaySceneRefs(time.Hour, 0.4) + if err != nil { + t.Fatalf("DecaySceneRefs: %v", err) + } + if n != 3 { + t.Errorf("三个低权重引用应被清掉,得到 %d", n) + } + var left int + if err := g.db.QueryRow(`SELECT COUNT(*) FROM scene_refs`).Scan(&left); err != nil { + t.Fatal(err) + } + if left != 0 { + t.Errorf("衰减后不该剩下引用,得到 %d", left) + } + + // 仍在重现的场景不受影响(updated_at 新) + if _, _, err := g.Commit([]Triple{ + {Subject: "丙组", Relation: "是", Object: "丁组", Confidence: 1.0, Scene: "chan:webui"}, + }, "main", 0); err != nil { + t.Fatal(err) + } + if _, err := g.DecaySceneRefs(time.Hour, 0.4); err != nil { + t.Fatal(err) + } + if left, _ := g.RecallByScene([]string{"chan:webui"}, 8); len(left.Relations) != 1 { + t.Errorf("刚用过的场景不该被衰减掉: %+v", left.Relations) + } +} + +// TestDeclaredAndEmergentBothLearn 钉住「主动 + 被动两条路」的相互长进: +// - 主动:声明即建场景(不等第二次涌现),特征只从键自身解析 +// - 被动:指纹聚类自己长出场景;声明场景**不**进相似度空间,靠声明/前缀键取回 +// - 第一次交互(涌现场景还没长出来)由声明场景兜底 +func TestDeclaredAndEmergentBothLearn(t *testing.T) { + g := newTestGraph(t) + defer os.Remove(g.dbPath) + defer g.Close() + + // 第 1 轮:声明了 chan:qq。此刻还没有涌现场景 → Primary 必须兜底到声明场景 + turn, err := g.EnterSceneWithHint(mkSig("qq", "group_1027", "qq_get_message", "排班"), []string{"chan:qq"}) + if err != nil { + t.Fatalf("EnterSceneWithHint: %v", err) + } + if turn.Primary != "chan:qq" { + t.Fatalf("首次交互应兜底到声明场景,得到 %q", turn.Primary) + } + if turn.Emergent { + t.Error("首次交互不该有涌现场景") + } + if len(turn.DeclaredCreated) != 1 || turn.DeclaredCreated[0] != "chan:qq" { + t.Errorf("声明即建场景(不等第二次涌现): %+v", turn.DeclaredCreated) + } + + // 第 2 轮同类场面:涌现场景长出来,且它比声明场景**更优先**用于写入 + turn2, err := g.EnterSceneWithHint(mkSig("qq", "group_1027", "qq_get_message", "排班表"), []string{"chan:qq"}) + if err != nil { + t.Fatalf("EnterSceneWithHint: %v", err) + } + if !turn2.Emergent || !strings.HasPrefix(turn2.Primary, "auto:") { + t.Fatalf("第 2 轮应涌现出细粒度场景并优先: %+v", turn2) + } + if len(turn2.Keys) < 2 { + t.Fatalf("两条路都要进召回集合: %+v", turn2.Keys) + } + + // 在声明场景里写上一条(模拟首次交互时写的记忆) + if _, _, err := g.Commit([]Triple{{ + Subject: "群规", Relation: "禁止", Object: "Markdown排版", Confidence: 1.0, + Scenes: []string{"chan:qq"}, + }}, "main", 0); err != nil { + t.Fatalf("commit: %v", err) + } + // 在涌现场景里写上一条 + if _, _, err := g.Commit([]Triple{{ + Subject: "排班表", Relation: "格式", Object: "纯文本", Confidence: 1.0, + Scenes: []string{turn2.Primary}, + }}, "main", 0); err != nil { + t.Fatalf("commit: %v", err) + } + + // **没人声明**的同场面提问:被动路按相似度命中涌现场景; + // 声明场景不在相似度空间里(否则它会吃掉被动路),靠声明/前缀路命中。 + sig := mkSig("qq", "group_1027", "qq_get_message", "统计") + r, err := g.RecallBySituation(sig, 8) + if err != nil { + t.Fatalf("RecallBySituation: %v", err) + } + got := map[string]bool{} + for _, rel := range r.Relations { + got[rel.TargetName] = true + } + if !got["纯文本"] { + t.Errorf("涌现场景应被被动命中: %+v", r.Relations) + } + if got["Markdown排版"] { + t.Errorf("声明场景不该进相似度空间(会压死被动路): %+v", r.Relations) + } + // 声明场景走声明键,照样取回 + byKey, err := g.RecallByScene([]string{"chan:qq"}, 8) + if err != nil { + t.Fatalf("RecallByScene: %v", err) + } + if len(byKey.Relations) != 1 || byKey.Relations[0].TargetName != "Markdown排版" { + t.Errorf("声明路应取回声明场景的记忆: %+v", byKey.Relations) + } + // 而完整的一轮(声明+涌现)两条路都进召回集合 + full, err := g.EnterSceneWithHint(mkSig("qq", "group_1027", "qq_get_message", "统计"), []string{"chan:qq"}) + if err != nil { + t.Fatalf("EnterSceneWithHint: %v", err) + } + if len(full.Keys) < 2 { + t.Errorf("两条路都应进召回集合: %+v", full.Keys) + } + // 声明键从自身解析特征(不含整轮指纹) + feats := FeaturesFromSceneKey("chan:qq/peer:group_1027") + if len(feats) != 2 || feats[0].Key() != "chan:qq" || feats[1].Key() != "peer:group_1027" { + t.Errorf("声明键特征解析失败: %+v", feats) + } + if len(FeaturesFromSceneKey("老大2026-09-04_12:27_qq私聊图片")) != 0 { + t.Error("无 kind:value 结构的键不该硬猜特征") + } + + // 声明场景不该被覆盖成涌现键:两者的身份各自保留 + if _, err := g.EnsureScene("chan:qq"); err != nil { + t.Fatalf("EnsureScene: %v", err) + } + var n int + if err := g.db.QueryRow(`SELECT COUNT(*) FROM scenes WHERE key = 'chan:qq'`).Scan(&n); err != nil { + t.Fatal(err) + } + if n != 1 { + t.Errorf("声明场景应保持独立存在,得到 %d", n) + } +} + +// TestPeerGroupWeight 钉住群身份是**强**同一性信号:peer_group 与 peer 同为 +// wFeatPeer。漏掉 peer_group 会让它在 Weight 里落到 default(话题级 0.4), +// 群聊场面被降级成软信号。 +func TestPeerGroupWeight(t *testing.T) { + if got := (SituationFeature{Kind: "peer_group", Value: "group_1"}).Weight(); got != wFeatPeer { + t.Errorf("peer_group 权重应为 %v,得到 %v", wFeatPeer, got) + } + if got := (SituationFeature{Kind: "peer", Value: "user_1"}).Weight(); got != wFeatPeer { + t.Errorf("peer 权重应为 %v,得到 %v", wFeatPeer, got) + } +} + +// TestEffectiveScenes 覆盖「单值声明 + 多值」合并去重。 +func TestEffectiveScenes(t *testing.T) { + got := effectiveScenes(Triple{Scene: "chan:qq", Scenes: []string{"auto:a", "chan:qq", ""}}) + if len(got) != 2 || got[0] != "auto:a" || got[1] != "chan:qq" { + t.Errorf("合并去重保序失败: %v", got) + } + if effectiveScenes(Triple{}) != nil { + t.Error("无场景应返回 nil") + } +} diff --git a/internal/memory/scene_test.go b/internal/memory/scene_test.go new file mode 100644 index 0000000..45474ff --- /dev/null +++ b/internal/memory/scene_test.go @@ -0,0 +1,487 @@ +package memory + +import ( + "os" + "strings" + "testing" +) + +func TestNormalizeSceneKey(t *testing.T) { + cases := []struct{ in, want string }{ + {"chan:qq", "chan:qq"}, + {"chan:QQ", "chan:qq"}, + {" chan:qq ", "chan:qq"}, + {"chan:qq/peer:group_1027993713", "chan:qq/peer:group_1027993713"}, + {"chan:qq / peer:1", "chan:qq/peer:1"}, + {"chan:qq/", "chan:qq"}, + {"chan:qq///peer:1", "chan:qq/peer:1"}, + {"老大2026-09-04 12:27 QQ私聊图片", "老大2026-09-04_12:27_qq私聊图片"}, + {"", ""}, + {" ", ""}, + {"///", ""}, + } + for _, c := range cases { + if got := NormalizeSceneKey(c.in); got != c.want { + t.Errorf("NormalizeSceneKey(%q) = %q, want %q", c.in, got, c.want) + } + } + // 超长键要截到上限且不留尾部分隔符 + long := strings.Repeat("a", maxSceneKeyLen+40) + if got := NormalizeSceneKey(long); len(got) > maxSceneKeyLen { + t.Errorf("超长键未截断: %d", len(got)) + } + if strings.HasSuffix(NormalizeSceneKey(long+"/x"), "/") { + t.Error("截断后不应留尾部分隔符") + } +} + +func TestChannelAndToolScene(t *testing.T) { + if got := ChannelScene("qq"); got != "chan:qq" { + t.Errorf("ChannelScene(qq) = %q", got) + } + if got := ChannelScene(""); got != "" { + t.Errorf("空 source 应为空场景,得到 %q", got) + } + if got := ToolScene("qq_get_message"); got != "tool:qq_get_message" { + t.Errorf("ToolScene = %q", got) + } +} + +// TestSceneRefsFromCommit 钉住「写入即挂场景」:三元组带 Scene 时, +// 关系与两端实体都进场景,且同一场景的其它记忆不受影响。 +func TestSceneRefsFromCommit(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", SentenceText: "回QQ消息别用markdown"}, + {Subject: "小宅", Relation: "使用", Object: "CodeGraph", Confidence: 1.0}, + }, "main", 0); err != nil { + t.Fatalf("commit: %v", err) + } + + r, err := g.RecallByScene([]string{"chan:qq"}, 8) + if err != nil { + t.Fatalf("RecallByScene: %v", err) + } + if len(r.Relations) != 1 || r.Relations[0].RelationType != "偏好" { + t.Fatalf("场景关系不对: %+v", r.Relations) + } + if r.Relations[0].SentenceText != "回QQ消息别用markdown" { + t.Errorf("场景召回必须带原句,得到 %q", r.Relations[0].SentenceText) + } + names := map[string]bool{} + for _, e := range r.Entities { + names[e.Name] = true + } + if !names["老大"] || !names["QQ回复禁用Markdown格式"] { + t.Errorf("两端实体都应进场景: %v", names) + } + if names["CodeGraph"] { + t.Error("未标场景的三元组实体不该被卷进场景") + } + + // 无关场景不命中 + if r2, _ := g.RecallByScene([]string{"chan:webui"}, 8); len(r2.Relations) != 0 { + t.Errorf("chan:webui 不该命中: %+v", r2.Relations) + } +} + +// TestRecallByScenePrefix 钉住前缀语义:宽场景召回包含更窄的场景, +// 但不会把同前缀不同层的场景(chan:qq2)吞进来。 +func TestRecallByScenePrefix(t *testing.T) { + g := newTestGraph(t) + defer os.Remove(g.dbPath) + defer g.Close() + + if _, _, err := g.Commit([]Triple{ + {Subject: "群规", Relation: "禁止", Object: "刷屏", Confidence: 1.0, Scene: "chan:qq/peer:group_1027993713"}, + {Subject: "别的", Relation: "是", Object: "无关", Confidence: 1.0, Scene: "chan:qq2"}, + }, "main", 0); err != nil { + t.Fatalf("commit: %v", err) + } + + r, err := g.RecallByScene([]string{"chan:qq"}, 8) + if err != nil { + t.Fatalf("RecallByScene: %v", err) + } + if len(r.Relations) != 1 || r.Relations[0].SourceName != "群规" { + t.Fatalf("宽场景应取回窄场景的记忆: %+v", r.Relations) + } +} + +// TestRecallBySceneOrderAndLimit 钉住排序(weight 降序)与上限。 +func TestRecallBySceneOrderAndLimit(t *testing.T) { + g := newTestGraph(t) + defer os.Remove(g.dbPath) + defer g.Close() + + if _, _, err := g.Commit([]Triple{ + {Subject: "低值", Relation: "置信", Object: "甲组", Confidence: 0.2, Scene: "chan:qq"}, + {Subject: "高值", Relation: "置信", Object: "乙组", Confidence: 0.9, Scene: "chan:qq"}, + {Subject: "中值", Relation: "置信", Object: "丙组", Confidence: 0.5, Scene: "chan:qq"}, + }, "main", 0); err != nil { + t.Fatalf("commit: %v", err) + } + r, err := g.RecallByScene([]string{"chan:qq"}, 2) + if err != nil { + t.Fatalf("RecallByScene: %v", err) + } + if len(r.Relations) != 2 { + t.Fatalf("limit 未生效: %d", len(r.Relations)) + } + if r.Relations[0].SourceName != "高值" || r.Relations[1].SourceName != "中值" { + t.Errorf("应按 weight 降序: %s, %s", r.Relations[0].SourceName, r.Relations[1].SourceName) + } +} + +// TestTagSceneByEntityGlob 覆盖存量引导(dry-run 不写库、apply 后才建引用)。 +func TestTagSceneByEntityGlob(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}, + {Subject: "小宅", Relation: "使用", Object: "CodeGraph", Confidence: 1.0}, + }, "main", 0); err != nil { + t.Fatalf("commit: %v", err) + } + + n, err := g.TagSceneByEntityGlob("chan:qq", "*QQ*", true) + if err != nil { + t.Fatalf("dry-run: %v", err) + } + if n != 1 { + t.Errorf("dry-run 命中 %d,want 1", n) + } + if r, _ := g.RecallByScene([]string{"chan:qq"}, 8); len(r.Relations) != 0 { + t.Error("dry-run 不应写库") + } + + n, err = g.TagSceneByEntityGlob("chan:qq", "*QQ*", false) + if err != nil { + t.Fatalf("apply: %v", err) + } + if n != 1 { + t.Errorf("apply 标注 %d,want 1", n) + } + r, _ := g.RecallByScene([]string{"chan:qq"}, 8) + if len(r.Relations) != 1 || r.Relations[0].TargetName != "QQ回复禁用Markdown格式" { + t.Errorf("标注后应能按场景取回: %+v", r.Relations) + } +} + +// TestPurgeNoiseClearsSceneRefs 钉住清理后不留悬空场景引用。 +func TestPurgeNoiseClearsSceneRefs(t *testing.T) { + g := newTestGraph(t) + defer os.Remove(g.dbPath) + defer g.Close() + + if _, _, err := g.Commit([]Triple{ + {Subject: "结果", Relation: "是", Object: "问题", Confidence: 1.0, Scene: "chan:qq"}, + {Subject: "小宅", Relation: "使用", Object: "CodeGraph", Confidence: 1.0, Scene: "chan:qq"}, + }, "main", 0); err != nil { + t.Fatalf("commit: %v", err) + } + if _, _, err := g.PurgeNoise(false); err != nil { + t.Fatalf("purge: %v", err) + } + + var refs int + if err := g.db.QueryRow(`SELECT COUNT(*) FROM scene_refs sr + WHERE (sr.kind='relation' AND sr.ref_id NOT IN (SELECT id FROM relations)) + OR (sr.kind='entity' AND sr.ref_id NOT IN (SELECT id FROM entities))`).Scan(&refs); err != nil { + t.Fatalf("count: %v", err) + } + if refs != 0 { + t.Errorf("清理后仍有 %d 条悬空场景引用", refs) + } + // 干净的那条仍在场景里 + r, _ := g.RecallByScene([]string{"chan:qq"}, 8) + if len(r.Relations) != 1 || r.Relations[0].TargetName != "CodeGraph" { + t.Errorf("清理误伤场景记忆: %+v", r.Relations) + } +} + +func TestSceneStats(t *testing.T) { + g := newTestGraph(t) + defer os.Remove(g.dbPath) + defer g.Close() + + if _, _, err := g.Commit([]Triple{ + {Subject: "甲组", Relation: "是", Object: "乙组", Confidence: 1.0, Scene: "chan:qq"}, + {Subject: "丙组", Relation: "是", Object: "丁组", Confidence: 1.0, Scene: "chan:webui"}, + }, "main", 0); err != nil { + t.Fatalf("commit: %v", err) + } + stats, err := g.SceneStats() + if err != nil { + t.Fatalf("SceneStats: %v", err) + } + if len(stats) != 2 { + t.Fatalf("场景数 %d,want 2: %+v", len(stats), stats) + } + for _, st := range stats { + // 1 条关系 + 2 个实体 + if st.Refs != 3 || st.Relations != 1 || st.Entities != 2 { + t.Errorf("场景 %s 统计不对: %+v", st.Key, st) + } + } +} + +// TestBuildContextInScene 覆盖注入面:场景块要给到关系全文与原句, +// 且场景实体不在【记忆索引】里重复占位。 +func TestBuildContextInScene(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", SentenceText: "以后回QQ消息不要用markdown"}, + }, "main", 0); err != nil { + t.Fatalf("commit: %v", err) + } + idx := NewIndexer(g) + if err := idx.Sync(); err != nil { + t.Fatalf("sync: %v", err) + } + + // 措辞与记忆零字面重合,词法/向量路召不回;场景路必须兜住。 + ctx := idx.BuildContextInScene("在吗", []string{"chan:qq"}) + text := idx.FormatContext(ctx) + if !strings.Contains(text, "【场景记忆 chan:qq】") { + t.Fatalf("没有场景块: %q", text) + } + if !strings.Contains(text, "老大 --偏好--> QQ回复禁用Markdown格式") { + t.Errorf("场景块里没有关系全文: %q", text) + } + if !strings.Contains(text, "以后回QQ消息不要用markdown") { + t.Errorf("场景块里没有原句: %q", text) + } + if strings.Contains(text, "【记忆索引】") && strings.Contains(text, "索引: 老大") { + t.Errorf("场景实体不该在索引里重复占位: %q", text) + } + + // 无场景时行为与原来一致:不出现场景块 + plain := idx.FormatContext(idx.BuildContextInScene("在吗", nil)) + if strings.Contains(plain, "【场景记忆") { + 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) + } +} diff --git a/internal/plugin/lua_plugin.go b/internal/plugin/lua_plugin.go index 7a2100d..ed43708 100644 --- a/internal/plugin/lua_plugin.go +++ b/internal/plugin/lua_plugin.go @@ -1058,6 +1058,7 @@ func parseToolDef(L *lua.LState, defTbl *lua.LTable, plg *luaPlugin, name string goDef.NoMemory = lua.LVAsBool(v) } goDef.ContextPolicy = defTbl.RawGetString("context_policy").String() + goDef.RecallPolicy = defTbl.RawGetString("recall_policy").String() if v := defTbl.RawGetString("cleaner"); v != nil && v.Type() == lua.LTFunction { goDef.Cleaner = makeLuaCleaner(plg, v.(*lua.LFunction)) } @@ -1078,6 +1079,7 @@ func parseChannelDef(L *lua.LState, defTbl *lua.LTable, plg *luaPlugin) sdk.Chan chDef.NoMemory = lua.LVAsBool(v) } chDef.ContextPolicy = defTbl.RawGetString("context_policy").String() + chDef.RecallPolicy = defTbl.RawGetString("recall_policy").String() if v := defTbl.RawGetString("cleaner"); v != nil && v.Type() == lua.LTFunction { chDef.Cleaner = makeLuaCleaner(plg, v.(*lua.LFunction)) } @@ -1085,8 +1087,9 @@ func parseChannelDef(L *lua.LState, defTbl *lua.LTable, plg *luaPlugin) sdk.Chan } // parseInjectOptions 解析 Lua 侧 options table 为 SDK InjectOptions。 -// 支持的键:no_memory(bool)、context_policy(string)、cleaner_name(string)、priority(string)。 -// 缺省/非表等价于零值(记入记忆 + 不裁剪),与旧的三参数注入完全等价。 +// 支持的键:no_memory(bool)、context_policy(string)、recall_policy(string)、 +// cleaner_name(string)、priority(string)。 +// 缺省/非表等价于零值(记入记忆 + 不裁剪 + 召回),与旧的三参数注入完全等价。 func parseInjectOptions(L *lua.LState, idx int) sdk.InjectOptions { opts := sdk.InjectOptions{} tbl, ok := L.Get(idx).(*lua.LTable) @@ -1097,6 +1100,7 @@ func parseInjectOptions(L *lua.LState, idx int) sdk.InjectOptions { opts.NoMemory = lua.LVAsBool(v) } opts.ContextPolicy = tbl.RawGetString("context_policy").String() + opts.RecallPolicy = tbl.RawGetString("recall_policy").String() opts.CleanerName = tbl.RawGetString("cleaner_name").String() opts.Priority = tbl.RawGetString("priority").String() return opts diff --git a/internal/plugin/proc/corehandler.go b/internal/plugin/proc/corehandler.go index 4fb3fd6..53fe433 100644 --- a/internal/plugin/proc/corehandler.go +++ b/internal/plugin/proc/corehandler.go @@ -215,6 +215,7 @@ type injectParams struct { TextRef SharedRef `json:"text_ref,omitempty"` NoMemory bool `json:"no_memory,omitempty"` ContextPolicy string `json:"context_policy,omitempty"` + RecallPolicy string `json:"recall_policy,omitempty"` CleanerName string `json:"cleaner_name,omitempty"` // Priority 声明中断注入的优先级(L1..L3);L4 内核独占,见 InjectOptions。 Priority string `json:"priority,omitempty"` @@ -237,6 +238,7 @@ type injectMediaParams struct { BlocksRef SharedRef `json:"blocks_ref,omitempty"` NoMemory bool `json:"no_memory,omitempty"` ContextPolicy string `json:"context_policy,omitempty"` + RecallPolicy string `json:"recall_policy,omitempty"` CleanerName string `json:"cleaner_name,omitempty"` Priority string `json:"priority,omitempty"` } @@ -245,10 +247,11 @@ type injectMediaParams struct { // // 单独提一个转换函数是为了让「默认值」只有一个出处:零值即记入记忆 + 不裁剪, // 与旧三参数注入等价。 -func pubSdkInjectOpts(noMemory bool, policy, cleanerName, priority string) pubsdk.InjectOptions { +func pubSdkInjectOpts(noMemory bool, policy, recallPolicy, cleanerName, priority string) pubsdk.InjectOptions { return pubsdk.InjectOptions{ - NoMemory: noMemory, ContextPolicy: policy, CleanerName: cleanerName, - Priority: clampExternalPriority(priority), + NoMemory: noMemory, ContextPolicy: policy, RecallPolicy: recallPolicy, + CleanerName: cleanerName, + Priority: clampExternalPriority(priority), } } @@ -280,6 +283,14 @@ func validateContextPolicy(where, policy string) error { return nil } +// validateRecallPolicy 校验召回策略取值,与 context_policy 同一套规则。 +func validateRecallPolicy(where, policy string) error { + if !pubsdk.ValidRecallPolicy(policy) { + return fmt.Errorf("%s: recall_policy 只允许 none/auto,实际 %q", where, policy) + } + return nil +} + // resolveJSONRef 若 ref 非零则从共享内存读取并 JSON 反序列化到 out; // ref 为零时不动 out(调用方已填的内联值生效)。 // @@ -424,6 +435,9 @@ func (h *coreHandler) toolRegister(params json.RawMessage) (interface{}, error) if err := validateContextPolicy("tool.register", p.Def.ContextPolicy); err != nil { return nil, err } + if err := validateRecallPolicy("tool.register", p.Def.RecallPolicy); err != nil { + return nil, err + } p.Def.Plugin = h.name // 函数本身不进 JSON;has_cleaner 只声明其存在,实际执行回到插件进程。 cleaner, err := h.cleanerProxy(CleanerScopeTool, p.Name, p.HasCleaner) diff --git a/internal/plugin/proc/corehandler_inject.go b/internal/plugin/proc/corehandler_inject.go index 8440bc6..95d8f53 100644 --- a/internal/plugin/proc/corehandler_inject.go +++ b/internal/plugin/proc/corehandler_inject.go @@ -24,7 +24,10 @@ func (h *coreHandler) handleInject(method string, params json.RawMessage) (inter if err := validateContextPolicy("io.injectText", p.ContextPolicy); err != nil { return nil, err } - h.sdk.InjectTextOpts(p.Source, p.Channel, h.resolveText(p), pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName, p.Priority)) + if err := validateRecallPolicy("io.injectText", p.RecallPolicy); err != nil { + return nil, err + } + h.sdk.InjectTextOpts(p.Source, p.Channel, h.resolveText(p), pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.RecallPolicy, p.CleanerName, p.Priority)) return nil, nil case MethodIOInjectInterrupt: var p injectParams @@ -34,7 +37,10 @@ func (h *coreHandler) handleInject(method string, params json.RawMessage) (inter if err := validateContextPolicy("io.injectInterrupt", p.ContextPolicy); err != nil { return nil, err } - h.sdk.InjectInterruptTextOpts(p.Source, p.Channel, h.resolveText(p), pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName, p.Priority)) + if err := validateRecallPolicy("io.injectInterrupt", p.RecallPolicy); err != nil { + return nil, err + } + h.sdk.InjectInterruptTextOpts(p.Source, p.Channel, h.resolveText(p), pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.RecallPolicy, p.CleanerName, p.Priority)) return nil, nil case MethodIOInjectTextNoMem: var p injectParams @@ -44,8 +50,11 @@ func (h *coreHandler) handleInject(method string, params json.RawMessage) (inter if err := validateContextPolicy("io.injectTextNoMem", p.ContextPolicy); err != nil { return nil, err } + if err := validateRecallPolicy("io.injectTextNoMem", p.RecallPolicy); err != nil { + return nil, err + } // 旧 RPC 语义就是「不进记忆」,显式标志位只可能再叠上 context_policy。 - h.sdk.InjectTextOpts(p.Source, p.Channel, h.resolveText(p), pubSdkInjectOpts(true, p.ContextPolicy, p.CleanerName, p.Priority)) + h.sdk.InjectTextOpts(p.Source, p.Channel, h.resolveText(p), pubSdkInjectOpts(true, p.ContextPolicy, p.RecallPolicy, p.CleanerName, p.Priority)) return nil, nil case MethodIOInjectSync: var p injectParams @@ -55,7 +64,10 @@ func (h *coreHandler) handleInject(method string, params json.RawMessage) (inter if err := validateContextPolicy("io.injectInputSync", p.ContextPolicy); err != nil { return nil, err } - reply := h.sdk.InjectInputSyncOpts(p.Source, p.Channel, h.resolveText(p), pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName, p.Priority)) + if err := validateRecallPolicy("io.injectInputSync", p.RecallPolicy); err != nil { + return nil, err + } + reply := h.sdk.InjectInputSyncOpts(p.Source, p.Channel, h.resolveText(p), pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.RecallPolicy, p.CleanerName, p.Priority)) return map[string]interface{}{"reply": reply}, nil case MethodIOInjectMedia: @@ -66,11 +78,14 @@ func (h *coreHandler) handleInject(method string, params json.RawMessage) (inter if err := validateContextPolicy("io.injectMedia", p.ContextPolicy); err != nil { return nil, err } + if err := validateRecallPolicy("io.injectMedia", p.RecallPolicy); err != nil { + return nil, err + } blocks, err := h.resolveBlocks(p) if err != nil { return nil, err } - h.sdk.InjectInputMediaOpts(p.Source, p.Channel, p.Text, blocks, pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName, p.Priority)) + h.sdk.InjectInputMediaOpts(p.Source, p.Channel, p.Text, blocks, pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.RecallPolicy, p.CleanerName, p.Priority)) return nil, nil case MethodIOInjectMediaSync: @@ -81,11 +96,14 @@ func (h *coreHandler) handleInject(method string, params json.RawMessage) (inter if err := validateContextPolicy("io.injectMediaSync", p.ContextPolicy); err != nil { return nil, err } + if err := validateRecallPolicy("io.injectMediaSync", p.RecallPolicy); err != nil { + return nil, err + } blocks, err := h.resolveBlocks(p) if err != nil { return nil, err } - reply := h.sdk.InjectInputMediaSyncOpts(p.Source, p.Channel, p.Text, blocks, pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName, p.Priority)) + reply := h.sdk.InjectInputMediaSyncOpts(p.Source, p.Channel, p.Text, blocks, pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.RecallPolicy, p.CleanerName, p.Priority)) return map[string]interface{}{"reply": reply}, nil case MethodIOInjectInterruptMedia: @@ -96,11 +114,14 @@ func (h *coreHandler) handleInject(method string, params json.RawMessage) (inter if err := validateContextPolicy("io.injectInterruptMedia", p.ContextPolicy); err != nil { return nil, err } + if err := validateRecallPolicy("io.injectInterruptMedia", p.RecallPolicy); err != nil { + return nil, err + } blocks, err := h.resolveBlocks(p) if err != nil { return nil, err } - h.sdk.InjectInterruptMediaOpts(p.Source, p.Channel, p.Text, blocks, pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName, p.Priority)) + h.sdk.InjectInterruptMediaOpts(p.Source, p.Channel, p.Text, blocks, pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.RecallPolicy, p.CleanerName, p.Priority)) return nil, nil // ---- 多模态注入 ---- diff --git a/internal/plugin/proc/corehandler_register.go b/internal/plugin/proc/corehandler_register.go index 9121a29..45641c7 100644 --- a/internal/plugin/proc/corehandler_register.go +++ b/internal/plugin/proc/corehandler_register.go @@ -47,6 +47,9 @@ func (h *coreHandler) handleRegister(method string, params json.RawMessage) (int if err := validateContextPolicy("input.register", p.Def.ContextPolicy); err != nil { return nil, err } + if err := validateRecallPolicy("input.register", p.Def.RecallPolicy); err != nil { + return nil, err + } // 整体传 p.Def(只是把函数型的 Cleaner 换成代理),不要手写字段白名单: // 白名单会让新增字段静默丢失。 def := p.Def diff --git a/internal/plugin/proc/priority_clamp_test.go b/internal/plugin/proc/priority_clamp_test.go index fa49b1b..1197692 100644 --- a/internal/plugin/proc/priority_clamp_test.go +++ b/internal/plugin/proc/priority_clamp_test.go @@ -35,14 +35,14 @@ func TestClampExternalPriority_RejectsL4(t *testing.T) { // 贯穿 pubSdkInjectOpts:RPC 报文里的 priority 必须经过夹取才落到 InjectOptions。 func TestPubSdkInjectOpts_ClampsPriority(t *testing.T) { - got := pubSdkInjectOpts(true, "prune", "cleaner", "L4") + got := pubSdkInjectOpts(true, "prune", "none", "cleaner", "L4") if got.Priority != pubsdk.PriorityL3 { t.Fatalf("经桥后的优先级=%q,期望 L3", got.Priority) } - if !got.NoMemory || got.ContextPolicy != "prune" || got.CleanerName != "cleaner" { + if !got.NoMemory || got.ContextPolicy != "prune" || got.RecallPolicy != "none" || got.CleanerName != "cleaner" { t.Fatalf("其它字段被改动:%+v", got) } - if l2 := pubSdkInjectOpts(false, "", "", "L2"); l2.Priority != pubsdk.PriorityL2 { + if l2 := pubSdkInjectOpts(false, "", "", "", "L2"); l2.Priority != pubsdk.PriorityL2 { t.Fatalf("L2 应原样通过,实际 %q", l2.Priority) } } diff --git a/internal/plugin/proc/recallpolicy_test.go b/internal/plugin/proc/recallpolicy_test.go new file mode 100644 index 0000000..5fe00c1 --- /dev/null +++ b/internal/plugin/proc/recallpolicy_test.go @@ -0,0 +1,31 @@ +package proc + +import ( + "strings" + "testing" +) + +// 非法 recall_policy 必须报错,而不是静默当成默认(auto)。 +// +// 与 context_policy 同理:静默降级会让调用方以为自己声明的“不召回”在生效, +// 而 meta 文本(如中断通知)仍在照常召回,且没有任何报错可循。 +func TestValidateRecallPolicy(t *testing.T) { + ok := []string{"", "none", "auto"} + for _, policy := range ok { + if err := validateRecallPolicy("tool.register", policy); err != nil { + t.Errorf("合法取值 %q 被拒绝: %v", policy, err) + } + } + + bad := []string{"auto ", "AUTO", "None", "true", "always", "召回"} + for _, policy := range bad { + err := validateRecallPolicy("io.injectText", policy) + if err == nil { + t.Errorf("非法取值 %q 应被拒绝", policy) + continue + } + if !strings.Contains(err.Error(), "io.injectText") || !strings.Contains(err.Error(), policy) { + t.Errorf("错误信息应包含位置与实际值,实际: %v", err) + } + } +} diff --git a/internal/sdk/plugin.go b/internal/sdk/plugin.go index 468b3d4..4cb5b3a 100644 --- a/internal/sdk/plugin.go +++ b/internal/sdk/plugin.go @@ -72,6 +72,12 @@ const ( ContextPolicyPrune = pubsdk.ContextPolicyPrune ) +// 召回策略取值:与 ContextPolicy 正交(裁剪 vs 召回)。 +const ( + RecallPolicyNone = pubsdk.RecallPolicyNone + RecallPolicyAuto = pubsdk.RecallPolicyAuto +) + type DisabledPluginInfo struct { Name string `json:"name"` DisabledAt string `json:"disabled_at"` diff --git a/third_party/homeagent-sdk/example/qq/plugin.go b/third_party/homeagent-sdk/example/qq/plugin.go index 9b375c9..d295527 100644 --- a/third_party/homeagent-sdk/example/qq/plugin.go +++ b/third_party/homeagent-sdk/example/qq/plugin.go @@ -439,7 +439,9 @@ type 枚举: text(文字)/ voice(语音转文字后发送)/ image(图 } return cleaned } - s.RegisterInputChannel("qq", sdk.ChannelDef{NoMemory: true, Cleaner: inputCleaner}) + // qq 通道到达的是**中断通知(meta)**,不是用户正文,不据它召回; + // 真实正文由 qq_get_message 取回后由该工具声明 RecallPolicy=auto 触发召回。 + s.RegisterInputChannel("qq", sdk.ChannelDef{NoMemory: true, Cleaner: inputCleaner, RecallPolicy: sdk.RecallPolicyNone}) // 查询类工具输出清洗器:提取 JSON 中的 content/文本字段参与向量化 cleaner := func(output string) string { @@ -459,6 +461,9 @@ type 枚举: text(文字)/ voice(语音转文字后发送)/ image(图 // 不裁的后果是每条 QQ 消息的完整正文都留在 L0 上下文里, // 长会话下持续挤占 token 预算(§13.8)。 ContextPolicy: "prune", + // 正文才是真实内容:取回后用**正文**触发一次召回, + // 而不是用中断通知的 meta 文本去召回(那是无关词)。 + RecallPolicy: "auto", Parameters: map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "message_id": map[string]interface{}{"type": "integer", "description": "NapCat消息ID(从中断消息的 message_id=N 或 reply_to.message_id 获取)"}, @@ -484,6 +489,12 @@ type 枚举: text(文字)/ voice(语音转文字后发送)/ image(图 Name: tp + "get_history", Description: "获取QQ群聊/私聊最近历史消息。当收到引用回复消息或需要了解对话上下文时应优先调用此工具查看前后文。返回值每条格式为 [时间] 发送者: 消息内容。如果消息包含文件,会额外返回 files 字段(含 file_id 和 name),可用 qq_download_file 工具下载。", NoMemory: false, Cleaner: cleaner, + // 与 get_message 同理:返回的是**真实聊天正文**,不只当轮需要, + // 还可能牵出与这些正文相关的长期记忆。故取回后既裁剪(用完不长期占 + // L0)又据正文召回(取进来)。不声明 recall 的话就是「记忆里有、但 + // 拉回历史消息时不注入」的盲区。 + ContextPolicy: "prune", + RecallPolicy: "auto", Parameters: map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "group_id": map[string]interface{}{"type": "integer", "description": "群号(与user_id二选一)"}, @@ -1482,6 +1493,8 @@ func (p *Plugin) injectInterrupt(text, level string) { p.sdk.InjectInterruptTextOpts(p.name, p.name, text, sdk.InjectOptions{ NoMemory: true, Priority: level, + // 中断文本是路由/取正文的指令,不是对话内容,不据它召回。 + RecallPolicy: sdk.RecallPolicyNone, }) } @@ -2802,7 +2815,7 @@ func (p *Plugin) handleDownloadFile(args map[string]interface{}) (interface{}, e // Priority:同上,QQ 侧一律低级别中断(L1)。 p.sdk.InjectInterruptTextOpts(p.name, p.name, fmt.Sprintf("文件下载完成: %s,保存在 %s", filepath.Base(savePath), savePath), - sdk.InjectOptions{NoMemory: true, Priority: sdk.PriorityL1}) + sdk.InjectOptions{NoMemory: true, Priority: sdk.PriorityL1, RecallPolicy: sdk.RecallPolicyNone}) } } else { errMsg = "下载失败,文件可能已过期" diff --git a/third_party/homeagent-sdk/sdk/plugin.go b/third_party/homeagent-sdk/sdk/plugin.go index 72451a7..a1fab03 100644 --- a/third_party/homeagent-sdk/sdk/plugin.go +++ b/third_party/homeagent-sdk/sdk/plugin.go @@ -54,6 +54,26 @@ func ValidContextPolicy(policy string) bool { return false } +// 召回策略:决定一次工具调用/输入/注入是否据其内容**召回**(注入)相关记忆。 +// +// 与 ContextPolicy **正交**:ContextPolicy 管「裁剪」(把低相关 L0 事件归档), +// RecallPolicy 管「召回」(把 L2/L3 的相关记忆注入本轮)。两者默认值刻意相反—— +// 裁剪是破坏性的,默认关(必须显式声明);召回是只读增量、日常对话本就需要, +// 默认 auto(输入/注入),仅**工具**默认 none(工具输出多为噪声,按需声明)。 +const ( + RecallPolicyNone = "none" + RecallPolicyAuto = "auto" +) + +// ValidRecallPolicy 校验召回策略取值;空串按调用面取默认值。 +func ValidRecallPolicy(policy string) bool { + switch policy { + case "", RecallPolicyNone, RecallPolicyAuto: + return true + } + return false +} + // InjectOptions 声明一次注入行为在记忆层与上下文层的表现。 // // 零值 = 记入记忆 + 不裁剪上下文,与历史行为(三参数注入方法)完全一致, @@ -65,6 +85,7 @@ func ValidContextPolicy(policy string) bool { // // NoMemory: 此次注入不参与记忆计算(向量化/关键词提取/蒸馏),原文仍留在上下文 // ContextPolicy: 此次注入后是否依据(清洗后的)内容裁剪上下文;默认不裁剪。 +// RecallPolicy: 此次注入是否依据(清洗后的)内容召回相关记忆;默认 auto(召回)。 // // 中断注入也允许声明 prune——它同样会携带内容进入上下文。 // @@ -77,7 +98,11 @@ func ValidContextPolicy(policy string) bool { type InjectOptions struct { NoMemory bool ContextPolicy string - CleanerName string + // RecallPolicy 声明此次注入是否据其内容召回相关记忆。 + // 空串 = 默认(输入/注入 auto,即保持既有「每条输入都召回」的行为); + // RecallPolicyNone 显式关闭(如中断通知的 meta 文本不该据它召回)。 + RecallPolicy string + CleanerName string // Priority 声明**中断注入**的优先级(仅 InjectInterrupt* 有意义)。 // @@ -106,6 +131,7 @@ const ( // NoMemory: 此通道输入/输出不参与记忆计算(向量化/关键词提取/蒸馏),但原文保留在上下文中 // Cleaner: 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏/存档提取关键词时调用 // ContextPolicy: 此通道的输入到达后是否据此裁剪上下文,默认 none(不裁剪) +// RecallPolicy: 此通道的输入到达后是否据此召回相关记忆,默认 auto(召回) // // JSON tag 是必需的:通道定义要跨进程传给内核,而 Cleaner 是函数(必须忽略)。 // 没有 tag 时既无法整体 marshal(func 不支持),又会诱使调用方手写字段白名单—— @@ -114,6 +140,8 @@ type ChannelDef struct { NoMemory bool `json:"no_memory,omitempty"` Cleaner func(string) string `json:"-"` ContextPolicy string `json:"context_policy,omitempty"` + // RecallPolicy 见 InjectOptions.RecallPolicy;空串等价 auto(保持既有行为)。 + RecallPolicy string `json:"recall_policy,omitempty"` } // StageContext provides context for stage handlers. @@ -180,6 +208,10 @@ type ToolDef struct { NoMemory bool `json:"no_memory,omitempty"` // 此工具输出不参与记忆计算,但原文保留 Cleaner func(string) string `json:"-"` // 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏时调用 ContextPolicy string `json:"context_policy,omitempty"` // 上下文策略:""(默认,不裁剪) / ContextPolicyNone / ContextPolicyPrune + // RecallPolicy 声明此工具输出是否触发一次记忆召回(注入)。 + // ""(默认 none) / RecallPolicyNone / RecallPolicyAuto。 + // 默认 none:多数工具输出是噪声;需要「取回真实内容后据它召回」的工具(如 qq_get_message)应显式声明 auto。 + RecallPolicy string `json:"recall_policy,omitempty"` } // IOInjector provides methods for injecting input and interrupts into the agent pipeline.