merge: 回流场景式关联召回 + 记忆整备 + 客户端修复(feature/recall-policy)

- 场景式关联召回:声明/涌现双通道、场面指纹聚类、场景前缀/相似度召回
- 记忆整备:doc→graph 闸门、噪音/孤立清理、关系去重、原句回显、memoryPass 收敛
- 本轮修复:衰减真半衰期、索引同步基线、Ensence 死参/死分支、冗余索引
- 客户端:鸿蒙未连接连接入口 + camerasue;waiter 斜杠命令本地语义修复
- 版本:GUI/鸿蒙/waiter 与内核统一 1.4.0(make check-client-versions)
- 客户端版本同步文档 §四
This commit is contained in:
JianFeeeee
2026-09-15 11:13:04 +08:00
68 changed files with 5344 additions and 196 deletions

View File

@ -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

View File

@ -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

View File

@ -1,6 +1,6 @@
{
"name": "homeagent-gui",
"version": "1.0.0",
"version": "1.4.0",
"author": "JianFeeeee <jianfeeeee@homeagent.local>",
"homepage": "https://gitcode.com/JianFeeeee/HomeAgent",
"description": "HomeAgent Desktop GUI - Multi-connection management dashboard",

View File

@ -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},

View File

@ -0,0 +1,42 @@
package main
import (
"os"
"path/filepath"
"testing"
)
// TestInitMemoryStackKeepsDistillerRunning 锁死启动接线回归:
// initMemoryStack 必须返回一个**仍在运行**的蒸馏器。
//
// 历史 bugmain() 拆分时函数体内残留一句 `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 之后蒸馏器应已停止")
}
}

123
cmd/memgc/main.go Normal file
View File

@ -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-<ts>'"
// 不要用 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重建实体名向量索引无需重启。")
}
}

View File

@ -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",

View File

@ -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 '';
}
}

View File

@ -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<CapResult> {
}
}
// ===== camerasue系统相机抓拍 =====
//
// 与桌面/CLI 端的实现路径不同:鸿蒙三方应用不能无界面地直接驱动摄像头
// CameraKit 需要预览 surface + CAMERA 权限,且后台采集受限),能拿到
// “用户正在拍的这一张”的合规路径是系统相机选择器 cameraPicker —— 由系统
// 相机完成采集,本应用只取回结果文件。语义与桌面端一致:现在给 agent 拍一张。
//
// 结果落在应用沙箱saveUri 指向 filesDir不写系统媒体库也就不需要
// READ_IMAGEVIDEO 这类受限权限。
const CAMERASUE_MAX_B64: number = 950000;
/**
* camerasue 实现。
*
* 参数语义与 homeagent-cmdrun 的说明一致:无参数 = 抓拍单张;
* `<N秒>` = 录 N 秒视频。录像回传暂未接二进制分块DeviceBridge 已具备
* sendDataChunked接线待做——这里明确拒掉而不是回一个超长 base64
* 把模型上下文和 bridge 都撑爆。
*/
export async function capCamerasue(context: common.UIAbilityContext,
rawArgs: string): Promise<CapResult> {
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<Permissions> = ['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,

View File

@ -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 = {

View File

@ -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<CapResult
}
return errRes('展示界面尚未就绪,请保持应用在前台后重试');
}
if (name === 'camerasue') {
if (appContext === null) {
return errRes('相机能力尚未就绪,请保持应用在前台后重试');
}
return capCamerasue(appContext, args);
}
if (name === 'clipboardsee') {
if (hasArgs(args)) {
return errRes('clipboardsee 不接受额外参数');

View File

@ -17,6 +17,18 @@ export const DEFAULT_WS_PORT: number = 9890;
/** 聊天历史首屏条数:只拉最新 N 条,向上滚动触顶再加载更早的 */
export const CHAT_PAGE_SIZE: number = 40;
// ===== AppStorage 跨页面信号键 =====
//
// 未连接后端时的“入口可达性”靠这三个键串起来:聊天空态按钮 → 切主 Tab →
// 设置页打开连接二级页。不用组件回调是因为按钮与目标分属不同的 Swiper 子页,
// 中间还隔着 Index逐层传回调会把两个无关页面耦在一起。
/** 当前是否已配置并激活后端连接(空态/入口的响应式判断) */
export const K_HAS_CONN: string = 'hasConn';
/** 外部请求切换主 Tab-1 = 无请求),由 Index 监听 */
export const K_REQUESTED_TAB: string = 'requestedTab';
/** 请求设置页打开某个二级页(空串 = 无请求),由 SettingsPage 监听 */
export const K_SETTINGS_SUB: string = 'settingsSubRequest';
// ===== sakura / frost palette (style.css :root) =====
export const COLOR_SAKURA_100: string = 'rgba(10, 89, 247, 0.1)';
export const COLOR_SAKURA_200: string = 'rgba(10, 89, 247, 0.16)';

View File

@ -8,7 +8,8 @@
*/
import { ChatMessage } from '../model/Model';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, ANIM_FAST } from '../common/Constants';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, ANIM_FAST, K_HAS_CONN, K_REQUESTED_TAB, K_SETTINGS_SUB } from '../common/Constants';
import { SUB_CONNECTIONS } from '../common/SettingsModel';
import { navBar } from '../common/NavBarController';
import { ChatAttachment } from '../model/Model';
import { chatStore, K_CHAT_REV, K_CHAT_SCROLL_REV, K_CHAT_LOADING, K_CHAT_STAGE } from '../common/ChatStore';
@ -20,6 +21,8 @@ import { PageTopBar } from './PageTopBar';
@Component
export struct ChatStream {
@StorageProp('themeIsDark') private isDark: boolean = true;
/** 是否已配置后端连接(决定空态是引导连接还是引导开聊) */
@StorageProp(K_HAS_CONN) private hasConn: boolean = false;
@StorageProp(K_CHAT_LOADING) private loading: boolean = false;
@StorageProp(K_CHAT_STAGE) private stage: string = '';
/** 数组快照的订阅信号 */
@ -186,6 +189,50 @@ export struct ChatStream {
.width('100%')
.height('100%')
// 层1.05:空态 —— 未连接后端时给出明确的“去设置连接”入口。
//
// 为什么必须有:全新安装时聊天页只有一条空列表 + 输入框,用户看不到
// 任何连后端的入口(入口在设置页的二级页里,很容易找不到)。
if (this.messages.length === 0 && !this.loading) {
Column({ space: 10 }) {
Image($r('app.media.ic_link'))
.width(34)
.height(34)
.fillColor(this.palette().textMuted)
.draggable(false)
Text(this.hasConn ? '开始新的对话' : '尚未连接后端服务')
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor(this.palette().textPrimary)
Text(this.hasConn
? '在下方输入框发送第一条消息'
: '请先在“后端连接”里填写服务地址与 API Key')
.fontSize(12)
.fontColor(this.palette().textMuted)
.textAlign(TextAlign.Center)
if (!this.hasConn) {
Button('去设置连接')
.height(34)
.fontSize(13)
.backgroundColor(this.palette().accent)
.fontColor(Color.White)
.margin({ top: 4 })
.onClick(() => {
// 跨页信号:切到设置 Tab并让设置页直接打开连接二级页
AppStorage.setOrCreate<string>(K_SETTINGS_SUB, SUB_CONNECTIONS);
AppStorage.setOrCreate<number>(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: '聊天' })

View File

@ -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<boolean>(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);
});

View File

@ -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<boolean>(K_HAS_CONN, apiClient.hasConnection());
AppStorage.setOrCreate<number>(K_REQUESTED_TAB, -1);
AppStorage.setOrCreate<string>(K_SETTINGS_SUB, '');
// 种子化自定义背景图状态到 AppStorageGradientBackground 响应读取
const st = connStore.getSettings();
AppStorage.setOrCreate<string>('bgImage', st.bgImage ?? '');
@ -191,6 +199,23 @@ struct Index {
AppStorage.set<number>('currentTab', this.currentTab);
}
/**
* 响应外部切 Tab 请求(聊天空态的“去设置连接”)。
*
* 为什么不能直接改 AppStorage 的 currentTabIndex 的 currentTab 是
* @StateSwiper.index() 只认它;外部写 AppStorage 不会驱动 Swiper。
* 所以用独立请求键 + @Watch 把请求转成自己的状态变更。
*/
private onRequestedTab(): void {
const t: number = this.requestedTab;
AppStorage.set<number>(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';

View File

@ -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<boolean>(K_HAS_CONN, apiClient.hasConnection());
// 可能从聊天空态带着“打开连接页”的请求进来(本页尚未挂载时请求已写入)
const pending: string = AppStorage.get<string>(K_SETTINGS_SUB) ?? '';
if (pending.length > 0) {
AppStorage.set<string>(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<string>(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);
}
}
})
}

View File

@ -24,7 +24,7 @@ func handleBuiltin(cmd string, cfg *Config, state *State, reconnect func(), out
/conn use <name> switch to saved connection
/conn del <name> 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 <url> install plugin
/plugin remove <name> remove plugin
/plugin disable <name> disable plugin
/plugin enable <name> enable plugin
/plugin info <name> plugin details
/memory query <text> 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 <name>")
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)")

View File

@ -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/metadeviceinfo 回显的软件版本
// 因此与 homed 一致,不再是一个空缺字段。
"version": meta.Version,
}
// 确保 gateway URL 格式正确

View File

@ -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.")

View File

@ -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

View File

@ -261,6 +261,11 @@ git switch main && git cherry-pick <sha> # 遵守 §三:只 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` 读,
不再硬编码。
---

View File

@ -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)
}
}

View File

@ -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) {

View File

@ -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

View File

@ -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 生成的模板化摘要

View File

@ -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 解析这条输入在计算层应当使用的清洗文本。
//
// 优先级:注入点声明的 cleanerpayload.cleaner_name引用某个已注册的通道

View File

@ -47,7 +47,7 @@ func (a *Agent) migrateLegacyGraphMedia() {
// attachBlocksToSentence 把一组 digest 变成 L3 一等块并挂到句子上。
// seed 允许复用已持有块的 IDL2→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

View File

@ -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])
}
}

View File

@ -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 ""
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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("图库召回为空(本测试不再依赖文本描述,仅记录现状)")

View File

@ -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. 同一 queryprune 与 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
}

View File

@ -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
}

View File

@ -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` 在循环外创建、循环内只增不减,

View File

@ -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
}

View File

@ -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])
}
}
}

View File

@ -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,

View File

@ -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 {

View File

@ -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 展开自定义提示词(人格卡)里的版本占位符。
//
// 为什么需要:人格卡是**配置项**,一旦写死版本号就会随内核发版而说谎 ——

View File

@ -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
}

View File

@ -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)
}

View File

@ -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()
}

View File

@ -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,

View File

@ -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 {

View File

@ -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 更新访问计数后未置脏")
}
}

View File

@ -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)
}
}
}
// 迁移8scene_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_idLLM复审后解除句子引用
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
}

View File

@ -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) {

View File

@ -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()

View File

@ -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 {

View File

@ -235,7 +235,7 @@ func (s *Store) Stat(digest string) (*Item, error) {
FROM media WHERE digest = ?`, digest))
}
// Stat 返回元数据,不读内容
// Delete 删除媒体内容与元数据
//
// 这不是 GC也不看引用计数调用方是记忆系统本身——当它把一个记忆块
// 永久地从三层记忆中删掉(而非在层间迁移)时,媒体作为块的内容一并删除。

263
internal/memory/noise.go Normal file
View File

@ -0,0 +1,263 @@
package memory
import (
"sort"
"strings"
)
// ──────────────────────────────────────────────
// 图记忆「噪音实体」判定与清理
//
// 为什么需要这一层doc→graph 蒸馏在 1cb3e87 从「CutExact 滑窗词链」换成
// NLP 依存提取器后,落库闸门只剩 validEntityName长度 250、含字母/汉字)。
// 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
}

View File

@ -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)
}
}

View File

@ -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 文件里按内容定位并删除。
//
// 为什么按内容而不是 IDloadExisting 读回记录时会重新分配连续 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

View File

@ -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 之后应处于已停止态")
}
}

582
internal/memory/scene.go Normal file
View File

@ -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 而不是 LIKELIKE 对 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:<source>)——存一份
// 冗余的 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()
}

View File

@ -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,
}

View File

@ -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")
}
}

View File

@ -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 命中 %dwant 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 标注 %dwant 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("场景数 %dwant 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)
}
}

View File

@ -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

View File

@ -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..L3L4 内核独占,见 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
// 函数本身不进 JSONhas_cleaner 只声明其存在,实际执行回到插件进程。
cleaner, err := h.cleanerProxy(CleanerScopeTool, p.Name, p.HasCleaner)

View File

@ -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
// ---- 多模态注入 ----

View File

@ -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

View File

@ -35,14 +35,14 @@ func TestClampExternalPriority_RejectsL4(t *testing.T) {
// 贯穿 pubSdkInjectOptsRPC 报文里的 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)
}
}

View File

@ -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)
}
}
}

View File

@ -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"`

View File

@ -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 = "下载失败,文件可能已过期"

View File

@ -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 时既无法整体 marshalfunc 不支持),又会诱使调用方手写字段白名单——
@ -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.