mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 14:57:38 +00:00
* feat(kernel): 模块化内核 + 编译期依赖图定序
把单体 init 序列拆成「模块自声明依赖 + 编译期生成依赖图 + 运行期只读图」。
## 为什么
原 cmd/homed/main.go 里 17 步 init 是**手写顺序**,依赖关系只存在于
注释和人脑里。实测踩到的坑(见 docs/zh/kernel-modularization-design.md):
· nlp.defaultParser 被 initONNXParser 写、被 distiller 读,两者相距 60 行,
只靠 ticker 10min 才没炸(隐式依赖,无声明)
· 加一个模块要人肉找"该插在哪",插错的表现是运行期 nil 解引用
· 关停是 defer ks.CloseAll(),而热更新路径 os.Exit(42) **跳过 defer**
⇒ docStore/distiller/textMem 的 flush 全部丢失(每次热更新都发生)
## 怎么改
- internal/kernelboot/:KernelState(一个结构体收全部包级共享变量,字段
nil 安全)、Module 接口、graphfile(依赖图编解码 + 与二进制 hash 对账)、
scheduler(按 BFS 层并行 Init)、syncbus(层边界写通知)、
lifecycle(Quiesce/Resume 可逆伪关机,区别于一次性 CloseAll)
- internal/kernelmod/<name>/:每个模块一个文件夹,Requires/Provides 自声明;
all/all.go 是唯一的 import 清单(Go 循环导入约束)
- cmd/homed-depgraph:编译期生成依赖图,Makefile 注入 digest
- 运行期只**读**图,不计算顺序(顺序是编译期决定的,源码里没有中央清单)
## 判据
kernelboot 8 条 + 图失败模式 7 条(全部拒绝并给准确错误)+ 模块目录数
与注册表一致性。变异测试 15/15 咬住,含:
· 删掉 AddCloser 的锁 → -race 报数据竞争
· Scheduler 同批跑去重 → 203 vs 200
· 图 hash 不匹配 → 拒绝启动
## 顺带修的缺陷
热更新路径漏调模块关闭 ⇒ 显式 stopKernelModules(),并用 AST 判据钉住
"必须在 os.Exit 之前"(字符串检查在本仓已骗过我三次)。
实测:冷启动 1630ms(5 层/17 模块,原 17 步串行);生产 22 插件全部建链。
* feat(security): 密钥防火墙 + 配置加密 + close 纪律
## 密钥防火墙
模型只见别名、不见明文;插件工具调用入口(StageHost.ExecuteTool)做入向
回填。三维能力:
· 出向:工具输出里的密钥被替换成 [[secret:name]] 别名后才进上下文
· 入向:模型传别名,入口处填回真值(插件完全无感知)
· 存储:config.db 里不存明文(0644 的库可读),敏感值独立 0600 文件
模式库「宁漏勿误伤」:宁可漏掉一次替换,也不把正常文本误当密钥。
## 配置加密
- internal/security/{codec,key,keyring,store,global}.go:AES-256-GCM,
enc:v1: 前缀,密钥轮换(OldKeys 解码 + 新钥加密)
- harden.go:数据目录权限收紧
- embedded.go:支持内嵌主密钥(可选,仍保留外部 key 文件路径)
- EncryptExistingSecrets / AuditSecrets:启动时幂等迁移 + 审计
## close 纪律(用户明确要求:只要调 close 就要有前置检查)
internal/safeclose:Safe / SafeFunc。全仓 37 处裸 close(chan) → 0。
关键教训(都写进注释和判据):
· `select { case <-ch: default: close(ch) }` **不是**并发安全的
(两个 goroutine 可同时命中 default)—— healthcheck 原先是这种写法
· `sync.Once` **不可重入**:X.Do(func(){ Safe(&X, ...) }) 会自锁,
症状是 go test 直接挂住(3 个包 600s 超时),栈里只有嵌套的 Once.Do
· map 里按请求持有的 channel(mcp/stdio.go、clawhubadapter/sidecar.go)
改成 *pendingRPC 包装,每通道一个 once
## 判据
security 全量 + TestNoBareCloseOutsideSafeclose(AST 级扫描,含注释剥离)
+ TestNoNestedSelfOnceDeadlock(正则修正:\w+ 不匹配点号,捕获组要含 '.')。
变异 2/2 咬住。全量 52 包 ok。
* feat(plugin+deploy): 插件三态生命周期、数据目录迁移、发行包修复
## 插件三态生命周期(用户要求:自动启动 / 启用但不自动启动 / 禁用)
- Manifest 新增 requires_facilities / auto_start(*bool,用于区分
"未声明"与"显式 false")
- plugin-host 模块(internal/kernelmod/pluginhost):用 kernelboot.Scheduler
按依赖时机拉起,**不再手写两阶段**
- 运行态停启 StopPlugin/StartPlugin **可逆**:不写 disabled_plugins
(不改用户决定)、不置 shuttingDown(之后还能加载)、会摘掉注册面
- 与 StopAll 的区别写进注释 —— StopAll 置 shuttingDown 后**永久无法加载**,
那是进程退出用的,误用于"暂停"就是关了就死
## 数据目录迁移(伪关机 → 迁移 → 唤醒)
- kernelboot/lifecycle.go:Quiesce/Resume(可逆,区别于一次性 CloseAll),
逆序暂停、逆序恢复、Pause 失败回滚
- kernelboot/migrate.go:BeginMigration/End 编排(先停插件 → 再停内核写者
→ 等在途归零)
- distiller/textmem 增 Pause/Resume 门(不是 cancel —— cancel 不可逆,
恢复要重建整个对象)
- 模块 migration:体检 + 生成可执行迁移脚本
★ 迁移脚本的三个实测教训(都写成了判据):
1. 无条件改 /etc/systemd/system ⇒ 演练把**生产** -data 改成了演练目录。
加 --dry-run(零改动)+ 强制校验 systemd 当前 -data == OLD
2. 本机没装 rsync,脚本在"服务已停、数据未搬"处硬失败 ⇒ cp -a 回退,
并明确警告不保硬链接/ACL(生产 plugindev 有大量 git 硬链接)
## 发行包修复(全部经实测确认)
1. package-linux.sh **完全漏打 depgraph.bin** ⇒ 内核 exit 1
("生产构建(内嵌 hash 非空)但读不到依赖图")
2. build_homed 不走 Makefile ⇒ 单独修 Makefile 无效,必须同时生成图+注入
3. build.sh 的 LDFLAGS 用**历史模块路径 gitcode.com** ⇒ -X 静默 no-op
⇒ --version 显示 unknown
4. Makefile -ldflags 用**单引号** ⇒ 注入了字面 $GRAPH_HASH
## ★ build.sh 的版本号必须校验(真实事故)
原 `VERSION="$(git describe --tags --dirty)"`。无 tag 分支上它返回**分支名**
`backup/pre-identity-rewrite-20261005-51-g03d87a4e-dirty`,被注入 meta.Version
⇒ 插件握手判据 MajorVersion() 把整串当主版本 ⇒ **22 个子进程插件全部
握手失败**("主版本不匹配")。内核活着、systemd active、日志每条都像
插件的问题 —— 实际是发行包带着非法版本号。
修法:git describe 只在形如 vX.Y.Z 时采用,否则回退 meta.Version(权威源),
并对最终值做形态守卫。deploy/packaging/version_guard_test.go 3 条判据,
变异 2/2 咬住。
## deb 的 systemd unit 改为 guard 模式
ExecStart=/usr/bin/homed --role=guard --data /var/lib/homeagent
(worker exit 42 → guard 重拉,不经过 systemd、不计失败)
含 RestartSteps/RestartMaxDelaySec/TimeoutStopSec + 硬化
(NoNewPrivileges/ProtectSystem/ProtectHome/DevicePolicy/ReadWritePaths)。
## 其他
- internal/plugin/proc:非法 JSON 帧的报错**可定位**(内容摘要 + 可能原因 +
修法)。生产实测 1038 条非法帧(插件往 stdout 打日志 = 污染协议通道)
- 示例插件 4 个 14 处 fmt.Printf → log.Printf
- hmapdev 模板同步修复;scaffold 判据剥离注释(模板注释里就含 fmt.Printf)
* chore: 收尾(主密钥轮换命令、IPC/知识库加固、SDK 对齐)
- cmd/homed-key-rotate:把 security 里的 RotateKey/RotateKeyFile/RekeySecrets
串成一条**可执行**的运维路径。没有它,E-1 就只是库里的能力而不是
"出事时能做的事",而轮换的价值恰恰在于能执行。
- cmd/homed:kmod_runtime(模块装配入口)、plugins_scheduled(failback
白名单)、memcandidate、bootstrap_order_test / shutdown_flush_test
- internal/ipc、internal/devicebridge、internal/knowledge:配套加固
- third_party/homeagent-sdk/sdk:PluginMgrAPI 增三态能力(mgrStop/mgrStart/
mgrIsRuntimeStopped)+ settings 对齐
判据:bootstrap_order_test(启动顺序不依赖手写序列)、
shutdown_flush_test(停机的两条路径必须对称 —— exit 42 会跳过 defer,
热更新路径下模块关闭/flush 曾全部丢失)。
* test(security): 测试夹具改用不可误认的占位值(修 push protection 拦截)
GitHub secret scanning 拦住了推送:三处测试夹具用了"看起来像真密钥"的
字面量:
internal/security/prefilter_test.go:39
internal/security/store_test.go:177
internal/security/survey_test.go:20
它们全是**假密钥**(`AKIAIOSFODNN7EXAMPLE` 是 AWS 官方文档的标准示例,
其余是 `abcdefghij...` 这类可读占位符),但扫描器无法区分 —— 它只看形态。
★ 不绕过(不用 unblock URL、不加 allowlist):直接改夹具。
原因:一旦允许"假密钥可以通过",真密钥混进来时就没有最后一道门了。
改法:保留**模式库匹配所需的结构**,把可识别内容换成 FAKE:
glpat-abcdefghijklmnopqrst → glpat-FAKE-TEST-TOKEN-NOT-REAL
sk-abcdefghij...012345 → sk-FAKE-TEST-KEY-NOT-REAL-0000000
AKIAIOSFODNN7EXAMPLE → AKIA0000FAKE0000TEST (保持 20 字符)
ASIAIOSFODNN7EXAMPLE → ASIA0000FAKE0000TEST
xoxb-123456789012-abcdefghijklmnop → xoxb-FAKE-TEST-TOKEN-NOT-REAL
★ 中途踩到一次:AWS 键的模式是 `\b(AKIA|ASIA)[0-9A-Z]{16}\b`(共 20 字符),
我第一次替换成 19 字符的 `AKIAFAKETESTKEY0000` ⇒ 模式匹配不上 ⇒
TestSurvey_WhatIsCaught / TestRecognizesCommonShapes /
TestPrefilterNeverBlocksRealMatches 三条当场变红。
判据正确地拒绝了"为了过扫描器而削弱覆盖"。
顺带核实:全仓 grep 无任何真实密钥泄漏。
* fix(packaging): package-linux.sh 的版本解析与 build.sh 统一
上一版只修了 build.sh,漏了 package-linux.sh —— 它仍只看 git describe:
VERSION="${VERSION:-$(git describe --tags --dirty)}"
无 tag 分支上 git describe 返回**分支名**
(backup/pre-identity-rewrite-20261005-56-g98850e1),守卫会正确拒绝,
但要求人**手动传 VERSION** 才能打包。而 build.sh 早已能自动回退 meta.Version
⇒ 同一个版本,走 build.sh 打得出来、走 package-linux.sh 打不出来。
危害不只是"要手动传参":运维为了跑通会去掉守卫,而分支名一旦进
meta.Version 就是**插件协议主版本**的来源 ⇒ 22 个子进程插件全部握手失败
(这是真实发生过的事故,见 3efa4e5)。
修法:两个脚本用同一套规则(git describe 仅在 vX.Y.Z 形态时采用,
否则回退 meta.Version),并对最终值做形态守卫。
判据:TestBothPackagingScriptsShareVersionRules —— 逐个读两个脚本,
要求都能从 meta/meta.go 取权威版本、都校验 git describe 形态。
变异(把 package-linux.sh 改回旧规则)咬住并准确指出是哪个脚本。
实测:不传 VERSION 跑 package-linux.sh,输出
[PKG] warn: git describe 给出非版本串(...),改用 meta.Version=1.4.0
Version: 1.4.0
* fix(test): 跨仓判据在 CI 上应 skip 而非 fail(修 PR 双红)
CI 上 "Go build / vet / test" 与 "Build linux/amd64" 双红:
--- FAIL: TestHmapdevTemplateImplementsAllPluginMgrMethods
bridge_sync_test.go:170: 读模板: open
../../../third_party/homeagent-sdk/tools/hmapdev/templates/proc_main.go.tmpl:
no such file or directory
## 根因:判据依赖了一个**被 gitignore 的路径**
`.gitignore:33` 有 `third_party/homeagent-sdk/tools/`(SDK 仓自治,本仓不跟踪)。
于是:
· 本机(完整 SDK 检出)有该文件 ⇒ 判据通过
· CI(只 checkout 本仓)没有该文件 ⇒ Fatalf ⇒ **必红**
我提交时只在本机跑测试、没看 CI,所以这个问题拖到 PR 才暴露。
## 修法
文件缺失时 `t.Skipf` 而不是 `t.Fatalf`。
★ 这不是"放弃检查":
· 跨仓一致性由 **SDK 仓自己的判据**守着
(tools/hmapdev/scaffold_stdout_test.go 等),那里才是模板的真实归属地
· 本判据在本机开发时**仍然生效**(已验证:有文件时 PASS,缺文件时 SKIP)
## 验证
· 本机(有文件) → PASS(判据真跑)
· 模拟 CI(移走 tools/)→ SKIP(不再 fail)
· 全量 53 包 ok / 0 fail
## 顺带
同目录还有 e2e_template_test.go 也读 tools/,但它是 `//go:build` 门控的
e2e 判据,CI 默认不跑,无需处理。
---------
Co-authored-by: JianFeeeee <JianFeeeee@users.noreply.gitcode.com>
359 lines
15 KiB
Go
359 lines
15 KiB
Go
package memory
|
||
|
||
import (
|
||
"crypto/sha256"
|
||
"database/sql"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// SentenceBlockID 由**原句文本**派生原句块的 ID。
|
||
//
|
||
// ★ 为什么在 memory 包而不是 distill
|
||
// -------------------------------
|
||
// 两条路径都需要它:迁移(MigrateLegacyTextEntities)与
|
||
// 蒸馏(distill.WritePayload)。而 distill 已导入 memory,
|
||
// 若放在 distill 会形成 memory → distill 的导入环。
|
||
//
|
||
// ★ 为什么 ID 必须由内容派生
|
||
// --------------------------
|
||
// 方案 A 要删 sentences 表,原句就得由块承载,而块 ID 必须**幂等**
|
||
// —— 迁移与蒸馏都会重复跑同一条原句。
|
||
// 迁移块自己用的是 blk_ent_<entityID>_<hash>(依赖行号,不是内容派生),
|
||
// 不能复用;这里用 sha256(TrimSpace(text))。
|
||
func SentenceBlockID(text string) string {
|
||
h := sha256.Sum256([]byte(strings.TrimSpace(text)))
|
||
return "blk_src_" + hex.EncodeToString(h[:12])
|
||
}
|
||
|
||
// SentenceBlockSource 是原句块的 source 标记。
|
||
const SentenceBlockSource = "sentence"
|
||
|
||
// NewSentenceBlock 构造承载原句的块。
|
||
//
|
||
// ★ 刻意**不带向量**
|
||
// ------------------
|
||
// 它是溯源锚点,不参与向量召回。理由是实测过的:
|
||
//
|
||
// 「13000端口」(7字) 容易被召回
|
||
// 「13010/13011 而非 12011」(20字) 召不回
|
||
//
|
||
// 长整句的句向量会把关键值稀释掉 —— 让原句进召回只会引入噪声。
|
||
// 省一次 embedding,也省一次无用的向量存储。
|
||
func NewSentenceBlock(text string, createdAt, updatedAt time.Time) MemoryBlock {
|
||
return MemoryBlock{
|
||
ID: SentenceBlockID(text),
|
||
Modality: BlockText,
|
||
Text: strings.TrimSpace(text),
|
||
Source: SentenceBlockSource,
|
||
CreatedAt: createdAt,
|
||
UpdatedAt: updatedAt,
|
||
}
|
||
}
|
||
|
||
// TripleBlockID 由三元组内容派生「值块」的 ID。
|
||
//
|
||
// ★ 为什么三元组要变成**两个块 + 一条边**,而不是一个块
|
||
// ------------------------------------------------
|
||
// 三元组是「主语 --关系--> 宾语」。若压成一个块(`<主语>|<关系>=<宾语>`),
|
||
// 就无法回答「X 的关系有哪些」「Y 被哪些主语指向」这类图查询 ——
|
||
// 而那正是 Recall 的主要用法(`docs/zh/recall-capability-tiers.md`)。
|
||
//
|
||
// ⇒ 主语块、宾语块各自可独立被召回(它们是独立的事实),
|
||
//
|
||
// 关系由边承载。
|
||
//
|
||
// 幂等:ID 完全由内容派生,重复提交同一三元组得到同一对块 ⇒ 天然去重。
|
||
func TripleBlockID(name string) string {
|
||
h := sha256.Sum256([]byte(strings.TrimSpace(name)))
|
||
return "blk_ent_" + hex.EncodeToString(h[:12])
|
||
}
|
||
|
||
// putTripleBlocksTx 把一条三元组写成「主语块 --关系--> 宾语块」。
|
||
//
|
||
// ★ 不带向量
|
||
// ----------
|
||
// Commit 这条路径上**没有 embedding provider**(Commit 的签名里就没有)。
|
||
// 而编造零向量比不写更坏:零向量块会参与召回并永远排在最后,
|
||
// 那是「静默的错误记忆」(见 ToMemoryBlock 的注释)。
|
||
//
|
||
// ⇒ 块先入库、无向量;召回侧按「有无向量」分别处理
|
||
//
|
||
// (有向量的走语义召回,无向量的靠符号路按内容串匹配 ——
|
||
// 实测跑分里 `[exact]` 那 14 条正是这类)。
|
||
//
|
||
// ★ 时间戳:不写 now,而是**留空**
|
||
// --------------------------------
|
||
// 第一版给块打了 time.Now(),结果被 putBlockTx 的 ON CONFLICT 覆盖了
|
||
// 迁移块的时序 —— 实测迁移测试直接抓到:
|
||
//
|
||
// blk_ent_edb5a71b11d814fdc36e8332 应继承实体时间 2026-01-01 10:00
|
||
// 实际 2026-10-04 08:04(时序被抹平)
|
||
//
|
||
// 而时序是**仲裁的前提**(internal/memory/arbitration.go 靠
|
||
// CreatedAt 判断「谁取代了谁」)。抹平时序会让仲裁失效。
|
||
//
|
||
// ⇒ 留空的语义是「时间未指定」:putBlockTx 不会用它覆盖既有值,
|
||
//
|
||
// 也不该被仲裁当成时间依据(仲裁跳过无时间的块 —— 见其规则 1)。
|
||
// 真正需要时序的块由迁移/蒸馏显式传 CreatedAt。
|
||
//
|
||
// putTripleBlocksTx 把一条三元组写成两端块 + 一条关系边。
|
||
//
|
||
// ★ 返回两端块 ID 与边 ID(2026-10-04)
|
||
//
|
||
// 调用方需要它们来挂场景引用 —— 场景引用的端点必须是**块 ID**
|
||
// (kind='block')与**边 ID**(kind='edge')。此前这些值无处可取,
|
||
// 于是 Commit 只能传旧表的 relationID/entityID,
|
||
// 写出一批退场后会悬空的引用。
|
||
//
|
||
// 返回值顺序:sourceBlockID, targetBlockID, edgeID, createdBlockIDs(无块时为空/0)。
|
||
//
|
||
// ★ sessionID / turnID 来自 commit() 的参数而非 Triple ——
|
||
//
|
||
// Triple 是「三元组内容」,会话是「这次写入的上下文」,
|
||
// 两者本就不该混在一个结构里。
|
||
func putTripleBlocksTx(tx *sql.Tx, t Triple, sessionID string, turnID int) (string, string, int64, []string, error) {
|
||
// created 收集本次**真正新建**的块 ID(已存在则为 UPDATE,不算新建)。
|
||
//
|
||
// ★ 为什么要细分到每个块:星图的「生长」动画只该在真·新块上播。
|
||
// 若按「本次 commit 有写入」就算新建,重复提交同一事实(LLM 高频重试)
|
||
// 会让旧块反复「长出来」—— 看着像新记忆,实际是同一个东西。
|
||
var created []string
|
||
// ★ SemanticType 必须带(2026-10-04)
|
||
//
|
||
// Triple.SubjectType / ObjectType 此前只写旧 entities.type,
|
||
// 块侧完全丢 —— 于是 Commit 标注的「这是 Person / 那是 Animal」
|
||
// 在召回时读回来是 ("block","block")。
|
||
// 判据 TestGraphCommit_CarriesAllFields 抓的就是这个。
|
||
//
|
||
// ★ 留空是合法的(很多三元组不带类型),block.go 的 ON CONFLICT
|
||
// 对该列用「空值不覆盖」语义,后写的不带标注不会抹掉已有的。
|
||
src := MemoryBlock{
|
||
ID: TripleBlockID(t.Subject),
|
||
Modality: BlockText,
|
||
Text: strings.TrimSpace(t.Subject),
|
||
Source: "triple",
|
||
SemanticType: strings.TrimSpace(t.SubjectType),
|
||
}
|
||
dst := MemoryBlock{
|
||
ID: TripleBlockID(t.Object),
|
||
Modality: BlockText,
|
||
Text: strings.TrimSpace(t.Object),
|
||
Source: "triple",
|
||
SemanticType: strings.TrimSpace(t.ObjectType),
|
||
}
|
||
for _, b := range []MemoryBlock{src, dst} {
|
||
isNew, err := putBlockTx(tx, b)
|
||
if err != nil {
|
||
return "", "", 0, nil, err
|
||
}
|
||
if isNew {
|
||
created = append(created, b.ID)
|
||
}
|
||
}
|
||
// ★ status 必须显式写 'active'(2026-10-04)
|
||
//
|
||
// 边表的 status 有 DEFAULT,但那个默认值是**空串**。
|
||
// 而所有读取路径都按 status='active' 过滤 ——
|
||
// 于是写进去的边**永远召不回来**,且没有任何报错。
|
||
//
|
||
// 这是典型的「写入成功但静默失效」:只有端到端判据能发现,
|
||
// 而 scene 的 4 个测试全部同时变红,才把它逼出来。
|
||
//
|
||
// ★ 用**关系边**写入器(addBlockEdgeTx 是迁移专用的去重插入器:
|
||
// INSERT OR IGNORE 会把同对节点的第二条同类型边静默吞掉,
|
||
// 而关系边按设计允许并存多条 —— 52e4596 已把全局 UNIQUE 移除)。
|
||
// ★★ 幂等:同 (source, target, edge_type, session_id) 必须收敛为一条(2026-10-04)
|
||
//
|
||
// 旧 relations 表靠 UNIQUE(source_id,target_id,relation_type,session_id) 保证;
|
||
// 而 52e4596 为支持「同类边并存」把边表的 UNIQUE **全部移除**了。
|
||
//
|
||
// ⇒ 后果:重复提交同一三元组会产生**重复的边**。
|
||
// 实测(判据 TestCommitDedupSameSession):同会话提交两次 → 2 条边。
|
||
// 而记忆写入是**高频重试**的(LLM 反复提交同一事实),
|
||
// 重复边会让召回刷屏、场景权重虚高。
|
||
//
|
||
// ★ 去重口径必须含 session_id:
|
||
//
|
||
// 同会话内重复 = 同一条事实(收敛)
|
||
// 跨会话重复 = 多次陈述(并存,见 TestCommitDedupDifferentSession)
|
||
//
|
||
// 若不含 session_id,跨会话的多次陈述会被错误压成一条。
|
||
var existingEdge int64
|
||
err := tx.QueryRow(`SELECT id FROM memory_block_edges
|
||
WHERE source_kind = 'block' AND source_id = ?
|
||
AND target_kind = 'block' AND target_id = ?
|
||
AND edge_type = ? AND COALESCE(session_id, '') = ?
|
||
ORDER BY id LIMIT 1`,
|
||
src.ID, dst.ID, strings.TrimSpace(t.Relation), sessionID).Scan(&existingEdge)
|
||
if err == nil {
|
||
return src.ID, dst.ID, existingEdge, created, nil
|
||
}
|
||
if err != sql.ErrNoRows {
|
||
return "", "", 0, nil, err
|
||
}
|
||
|
||
res, err := tx.Exec(
|
||
// ★★ session_id / turn_id 必须写进去(2026-10-04)
|
||
//
|
||
// Recall 的 sessionFilter 依赖边表的这两列。缺了它,
|
||
// 任何按会话过滤的召回都返回空 —— 而测试若不显式带
|
||
// sessionFilter 就发现不了(无过滤时召回照常工作)。
|
||
// ★★ confidence 必须写进去(2026-10-04)
|
||
//
|
||
// 它不只是「插件标注的置信度被丢弃」那么局部:
|
||
// confidence 是边表里**唯一的质量信号**,被三处依赖 ——
|
||
// ① RecallSorted 的 SortRelevance 排序(`ORDER BY confidence DESC`)
|
||
// ② 场景排序(tagSceneTripleTx 拿它当 weight)
|
||
// ③ 蒸馏时的置信度传播
|
||
//
|
||
// 不写 ⇒ 全部为 0 ⇒ 排序退化成插入序、场景权重恒 0,
|
||
// 而**没有任何报错**。
|
||
//
|
||
// ★ 同时写 updated_at 不可行:边表**没有**这一列
|
||
// (升格成独立边时只加了 confidence/session/turn/status/merged_into)。
|
||
// 旧 relations 表有,所以照抄会报 no such column。
|
||
`INSERT INTO memory_block_edges
|
||
(source_kind, source_id, target_kind, target_id, edge_type,
|
||
confidence, status, session_id, turn_id)
|
||
VALUES ('block', ?, 'block', ?, ?, ?, 'active', ?, ?)`,
|
||
src.ID, dst.ID, strings.TrimSpace(t.Relation), t.Confidence, sessionID, turnID)
|
||
if err != nil {
|
||
return "", "", 0, nil, err
|
||
}
|
||
edgeID, err := res.LastInsertId()
|
||
return src.ID, dst.ID, edgeID, created, err
|
||
}
|
||
|
||
// ── 媒体块 ──────────────────────────────────────────────
|
||
|
||
// MediaBlockID 由**媒体内容字节**派生媒体块的 ID。
|
||
//
|
||
// ★ 为什么媒体块的 ID 也必须内容派生
|
||
// ---------------------------------
|
||
// 块 ID 是本仓的记忆身份,既有约定一律内容派生:
|
||
// - 原句块 blk_src_<sha256(text)[:12]>
|
||
// - 实体块 blk_ent_<sha256(name)[:12]>
|
||
// - 蒸馏块 blk_<sha256(sentence+subject+dim+value)[:12]>
|
||
//
|
||
// 而媒体块此前用时间戳+序号(blk_<unixnano>_<n>),是唯一的例外。
|
||
//
|
||
// 淘汰 CAS 之后,块自己承载内容,于是「同一份内容只应有一个块」这条
|
||
// 不再由外部的内容寻址层保证,而必须由**块 ID 本身**保证:
|
||
// 同内容 ⇒ 同 ID ⇒ PutMemoryBlocks 的 ON CONFLICT 天然去重。
|
||
// 顺带让跨插件边界的引用(只带 ID 前缀)也能唯一定位。
|
||
//
|
||
// ★ 为何把 MIME 也掺进去:同样字节在不同 MIME 下是不同的媒体
|
||
// (同一段字节按 image/png 与 application/octet-stream 解读得到不同结果),
|
||
// 且 MIME 参与向量计算,掺入可避免「先入者定 MIME」的静默错配。
|
||
func MediaBlockID(mime string, data []byte) string {
|
||
h := sha256.New()
|
||
h.Write([]byte(strings.TrimSpace(mime)))
|
||
h.Write([]byte{0})
|
||
h.Write(data)
|
||
return "blk_media_" + hex.EncodeToString(h.Sum(nil)[:12])
|
||
}
|
||
|
||
// BlockByIDPrefix 按块 ID 的**前缀**取块(前缀唯一时命中)。
|
||
//
|
||
// 用途:跨进程/跨层的轻量引用(如插件只带块 ID 的前 12 位)。
|
||
// 前缀匹配到多条时返回 false —— 宁可不解析也不猜一个(猜错=挂错媒体)。
|
||
func (g *GraphDB) BlockByIDPrefix(prefix string) (MemoryBlock, bool, error) {
|
||
prefix = strings.TrimSpace(prefix)
|
||
// 过短前缀不解析:块 ID 形如 blk_media_<12hex>,
|
||
// 一个一两字符的前缀会把一大批块都 LIKE 中,猜一个等于挂错媒体。
|
||
// 至少要求 12 字符(内容派生 hash 的常用短引用长度)。
|
||
if len(prefix) < 12 {
|
||
return MemoryBlock{}, false, nil
|
||
}
|
||
g.mu.RLock()
|
||
defer g.mu.RUnlock()
|
||
rows, err := g.db.Query(`SELECT `+blockColumns+`
|
||
FROM memory_blocks WHERE id LIKE ? LIMIT 2`, prefix+"%")
|
||
if err != nil {
|
||
return MemoryBlock{}, false, err
|
||
}
|
||
defer rows.Close()
|
||
var got []MemoryBlock
|
||
for rows.Next() {
|
||
b, err := scanBlockRow(rows)
|
||
if err != nil {
|
||
return MemoryBlock{}, false, err
|
||
}
|
||
got = append(got, b)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return MemoryBlock{}, false, err
|
||
}
|
||
if len(got) != 1 {
|
||
return MemoryBlock{}, false, nil // 0 条或歧义(≥2)都不解析
|
||
}
|
||
return got[0], true, nil
|
||
}
|
||
|
||
// BlockData 取单个块的负载字节(按需加载)。
|
||
//
|
||
// ★ 为何单独一个方法:blockColumns 刻意不含 data(召回只比向量,
|
||
// 带上负载会让每次全表召回读入几十 MB)。需要字节的路径显式调本方法。
|
||
func (g *GraphDB) BlockData(blockID string) ([]byte, error) {
|
||
g.mu.RLock()
|
||
defer g.mu.RUnlock()
|
||
var data []byte
|
||
err := g.db.QueryRow(`SELECT data FROM memory_blocks WHERE id = ?`, blockID).Scan(&data)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return data, nil
|
||
}
|
||
|
||
// StaleBlockVectors 返回向量为空、或向量 fingerprint 与当前空间不符的块。
|
||
//
|
||
// 用途:启动时把历史媒体块/文档块的向量重算到当前多模态空间(换模型、
|
||
// 换维度、首次接入嵌入服务、嵌入服务离线后重上线)。
|
||
//
|
||
// 为何需要它:向量是召回的唯一依据,而历史块可能是在另一个空间里算的。
|
||
// fingerprint 不一致的向量不能参与余弦比较(两个坐标系的值比大小无意义),
|
||
// 必须重算而不是留着当"看起来还行"的噪声。
|
||
//
|
||
// 只取非文本块:文本块的向量由文本路径维护,不在多模态空间迁移范围内。
|
||
func (g *GraphDB) StaleBlockVectors(currentFP string) ([]MemoryBlock, error) {
|
||
g.mu.RLock()
|
||
defer g.mu.RUnlock()
|
||
// 只取元数据(blockColumns 不含 data):负载按需由 BlockData 单取。
|
||
// 迁移路径逐块处理,不需要把全部字节一次性读进内存。
|
||
rows, err := g.db.Query(`SELECT `+blockColumns+`
|
||
FROM memory_blocks
|
||
WHERE modality != ?
|
||
AND (vector IS NULL OR vector = '' OR vector = 'null' OR fingerprint != ?)
|
||
ORDER BY created_at`, string(BlockText), currentFP)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
var out []MemoryBlock
|
||
for rows.Next() {
|
||
b, err := scanBlockRow(rows)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, b)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// SetBlockVector 给单个块写入新向量与指纹(迁移用)。只改向量列,不碰负载。
|
||
func (g *GraphDB) SetBlockVector(blockID string, vec []float64, fp string) error {
|
||
vectorJSON, err := json.Marshal(vec)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
g.mu.Lock()
|
||
defer g.mu.Unlock()
|
||
_, err = g.db.Exec(`UPDATE memory_blocks SET vector = ?, fingerprint = ?, updated_at = ?
|
||
WHERE id = ?`, string(vectorJSON), fp, time.Now(), blockID)
|
||
return err
|
||
}
|