mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-14 19:43:37 +00:00
merge: 整合 main(设备桥能力对齐 + 公开仓库清理 + 记忆块体系) (#2)
* refactor(toolcall): StageContext 拆 per-tool,为并发执行消除共享槽(阶段 2c) 问题:f.StageCtx 是**单槽**,批内每个工具都覆写它(ToolCalls=[单元素]、 ToolResults 覆写、Results[0] 回读)。串行下看不出问题,但并发下 N 个 goroutine 同写一个 ctx = 数据竞争,且 after_toolcall 插件可能读到 **别的工具**的结果。 改动(task.go): · TaskFrame 增 toolCtxs []sdk.StageContext(每工具一份) · buildToolContexts 在 stepLLM 设 PendingTools 时建池; Extra **逐份浅拷贝**——共享同一 map 即竞争(stage handler 会写它) · toolCtxFor(i) 取第 i 份,越界/未建时回落 f.StageCtx(测试替身安全) · stepToolBegin(before_toolcall + 参数回填)、stepToolExec(写结果)、 stepToolAfter(读结果)三处全部切到 per-tool ctx 判据(toolbatch_test.go 追加两条): · 每个工具的 before_toolcall ctx 只带自己的 ToolCalls[0].Name, 且 Extra[output_channel] 逐份带过去(stage.go:18 依赖它) · after_toolcall 读到的 Result 必须属于当前工具,不能是批内另一个的 ★ 诚实记录:这两条判据在**串行**下**测不出与单槽的差别**——串行时 每工具跑完才进下一个,不存在交错。变体验证(toolCtxFor 退回单槽)后 判据仍全绿。故 2c 记为「实现已就位、判据未闭合」,真正判据必须与 2d (并发执行)一起写,并以 -race 确认无竞争。已在执行计划中标注。 过程中两次自伤: · 我的 harness 没设 Extra[output_channel](那是 prepareInputTask 才写的, task.go:380),判据一度报「产品缺陷」——核实后是我造的场景,已对齐生产; · 阶段 1 的 TestStageCtxSuccessIsHonestEndToEnd 读 f.StageCtx.ToolResults, 拆分后失效——判据跟随新结构改为按批索引取 toolCtxs[i], 断言的仍是内核产出的 Success 值本身。 顺带记录(非本次引入):TestResidualKeep/Drop 偶发失败,根因是 offload_test.go 的 SpawnResident 起了子调度器 goroutine,而测试 enqueue 后无同步就读同一队列。干净基线 3/3 全绿属运气。已在计划中 记为待修,避免后续误判为并行化引入的回归。 * feat(toolcall): 批次并发调度与同通道保序(阶段 2d,闭合 2c 判据缺口) 规则(三条全满足才并发): 1. 批内 >1 个工具 2. **全部**工具声明 ParallelSafe —— 一个不声明就整批降级,不做部分并发 3. 不含需保序的同通道输出发送 SDK: · ToolDef 加 ParallelSafe bool。⚠️ 零值 false 是刻意的:存量插件不改一行 就得到**保守**行为(整批串行),不会因升级被意外并发。声明它是责任 而非特权。纯新增字段,无签名变更。 · io.ToolDef 同步加该字段(设备/通道工具走 io 路径,只查 StageHost 会漏)。 core: · 新增 StepToolBatch —— runTaskSteps 是单线程驱动状态机的, 「每步一个工具」的游标模型无法表达「一批同时跑」,故需独立 step。 · stepToolBatch:fan-out(每工具一 goroutine,各写自己的 toolCtxs[i]) → join → **按索引顺序**串行收尾(after_toolcall / 落消息 / 事件)。 收尾必须串行且按索引:f.Msgs 是共享切片,且按索引落才能让模型读到的 上下文顺序与它自己发出的顺序一致。 · runOneTool 抽出「before_toolcall + 执行」的单工具逻辑,串行/并发两条路共用。 · toolParallelSafe / batchRunnable 判据函数。 ★ 修掉一个我自己引入的竞争:resolveTurnScenes 会把结果记进**共享**的 f.sceneDone / f.turnScene(memorypass.go:289)。最初在每个 goroutine 里 各调一次 —— 既是数据竞争,又会各自触发一次 EnterSceneWithHint, 重复计入场景强度(正是 sceneDone 注释警告过的问题)。改为在 fan-out **之前**解析一次,goroutine 内只读。 判据(parallelsched_test.go,4 条): · 全批 ParallelSafe ⇒ 并发峰值 >= 2(用阻塞设备观察真实并发) · 一个非 ParallelSafe ⇒ 整批串行,但**仍全部执行** · 同 output_send__<通道> 连发 3 条 ⇒ 严格按声明顺序到达 · ★ 并发下每个工具的 ctx 只带自己的 ToolCalls、after 读到自己结果 ★ 并关闭了 2c 的判据缺口:此前两条 2c 判据在**串行**下无法区分 per-tool 与单槽(变体验证后仍全绿)。新增的并发版判据在退回单槽时 触发 **6 处 DATA RACE 报告 + 串味断言失败**(dup:k_a 与 k_a 撞名)。 至此 2c 可记为已验证。 过程中三次自伤: · resolveTurnScenes 竞争(上述); · 我的 harness 用 StageHost 注册 handler 遮蔽了设备工具, slowDevice 根本没被调用("实际 0")——改为在 io.ToolDef 上声明; · 批内并发峰值判据最初用 StageHost 声明 ParallelSafe,掩盖了 「设备工具也需要该字段」这一真实缺口。 回归:internal/agent/... internal/sdk/... internal/plugin/... internal/plugins/... 全绿(17 包);core 包 -race 全绿。 * docs(plan): 阶段 2 标记完成,记录并发规则与 2c 判据闭合 * feat(prompt): 提示词声明「同轮默认并行」及其例外(阶段 2.5) ⚠️ 本阶段有硬性顺序约束:必须在并行执行(阶段 2d)落地**之后**。 反序(先说"并发"、内核仍串行)会让提示词**对模型说谎** —— 模型据 "并发执行"推断安全性,写出真正依赖顺序的调用。宁可晚改,不可错改。 改动(tooldefs.go,buildSystemPrompt): · 新增【工具执行顺序】段,讲清四件事: 1. 同一条回复里的多个工具调用**默认并行**(同时跑),不是依次执行 2. **不要依赖执行顺序** —— 参数依赖前一个结果就分两轮 3. **例外一:同通道 output_send__ 保序**(用户可见消息顺序敏感) 4. **例外二:不并发安全的工具整批退回串行**(写类工具 / 未声明者) · 顺带说明并发安全由**工具自己声明**(ParallelSafe),不由模型判断 · 改掉 spawn_child 的落空表述:原文「应并行 spawn,不要自己串行逐个执行」 在并行化之前是**落空**的(模型照做,内核仍串行)。改为机制性表述, 并补一句「一次 spawn 只是启动动作,要拿结果仍需另一次 child_result」。 ⚠️ 措辞刻意与 batchRunnable 的**真实**判据一致(全批 ParallelSafe 才并发 + 同通道保序),而不是理想化表述 —— 提示词与实现不符,比不说更坏。 判据(prompt_parallel_test.go,5 条): · 四要点齐全(并行 / 顺序 / 保序 / 并发安全声明) · ★ 必须同时讲**例外** —— 只讲并行就是"说谎"的那一种 · 同通道保序须显式说明(保序是内核兜底,模型不知情就会浪费它) · spawn_child 不再含旧的落空措辞 · 回归防护:既有要点(输出规则 / output_list_channels / 工具能力 / 记忆清理)不丢 ★ 判据里的一次自伤:先写了 spawnChildDescription(a) 这个**不存在**的 helper("工具名反查描述"),编译失败后改为 toolDefDescription —— 内部 遍历 buildToolDefs 的**真实产物**。判据必须对着代码真实输出,不能另建一套 注册表。 变异验证:把两条例外改写成"以上适用于所有工具"⇒ 3 条判据 FAIL (缺"保序"、缺"例外"、同通道未说明)。即"提示词说谎"这一失败模式 现已被判据覆盖。 回归:internal/agent/... internal/sdk/... internal/plugins/... 全绿(15 包)。 * docs: 同步两份文档的实现进度(内核主线 0~2.5 全部完成) * docs(design): 补 0.2 阶段行 * feat(seq): 序列文本 → AST 解析与静态校验(插件线 P1) 插件,不是内核:并行执行是内核提供的**唯一**基础设施(core 的 batchRunnable);分组 / 具名槽 / 条件 / 调用图全部在本包内自建, **不要求内核开任何新接口**。 实现(parse.go): · Sequence / Group / ToolCall 三个 AST 类型 · Parse:JSON → AST + **全部**静态校验一次做完 (而非留到执行期——group 有独立签名,具名槽的价值就在于构建期就能 查出错写的槽) · splitToolList:`;` 仅在 brace 深度 0 且**不在字符串内**时才是分隔符 · 枚举/未知字段一律硬报错:DisallowUnknownFields + 显式校验 (missing / on_error 报错时列出合法取值,不当默认值蒙过去) 静态校验规则: · `as:X` 未在 out 声明 ⇒ 报错(具名槽的核心价值) · `$args.X` 未在 in 声明 ⇒ 报错 · group 名重复 ⇒ 报错(签名名必须唯一才能按名调用) · 非 array 槽被同名 as 写多次 ⇒ 报错(组内并行下同名写入是数据竞争); array 槽则允许(组屏障按序追加) · tools 分隔符畸形(漏中间 / 漏末尾 / 连续 / 未闭合)⇒ **硬报错**, 绝不静默吞掉一个工具 判据(parse_test.go,9 条),★ 两条最关键: · 含分号的真实 command(取自 core 里那份线上日志 fixture)保持为 1 个工具 · ★ TestBracesInsideStringDoNotAffectDepth:字符串里的**不成对**花括号 不得影响 depth ★ 本阶段的三次自伤(都靠变异测试暴露,不是靠判据变红): 1. **格式本身是错的**:我在设计文档里写的 `{tool:cmd_run,...}` 根本不是 合法 JSON(键没引号),encoding/json 直接解析失败。判据一跑就暴露 ——"写了格式却从没验证它能解析"。已改为要求合法 JSON(键带引号), 这也是 DisallowUnknownFields 能生效的前提。 2. **判据验证的不是它声称验证的规则**:原本那条"分号在字符串内"的用例, 分号其实落在 args 对象的**花括号内部**,depth>0 就足以保护 ⇒ 删掉 分词器的字符串跟踪后**仍然全绿**。反复两次才找到真正的判别点: 必须用**不成对**花括号在 depth 恰为 0 处,才只有字符串态能救它。 3. 手写多层转义把引号写成 \",使分词器永远进不了字符串态。改用 json.Marshal **分层构造** fixture——手写转义没有不出错的机会。 变异验证(两轮,均能检出): · 删掉"回到顶层必须紧跟 ;"检查 ⇒ 漏中间分隔符用例 FAIL · 删掉字符串跟踪 ⇒ TestBracesInsideStringDoNotAffectDepth FAIL ("结构未闭合(括号深度 1)") 回归:internal/agent/... internal/plugins/... 全绿。 * feat(seq): 执行引擎 —— 组内并行 + 具名槽 + 条件求值(插件线 P2) 三个不变量(各有判据钉住): 1. **组内并行、组间串行**。parallel=true 时各工具并发执行。 2. ★ **合并按声明顺序**,不按完成顺序。 并行下完成顺序不确定;若按完成顺序合并,同样的输入产出不同的结果, 整条序列**不可复现**。做法:各工具把结果写进 `results[i]`(按索引), 组屏障处按 tools 数组顺序一次性合并。 顺序合并顺带解决了并发写 map —— **执行期完全不写共享 map**。 3. ★ **条件求值失败必须报错**,不得降级成"条件为假"。 把求值失败当作跳过 = 序列安静地少做一步,而模型以为跑完了 —— 与「静默吞工具」同族(那正是 P1 判据里刚堵上的同类问题)。 条件求值(设计文档 §5 的 L1+L2,不引表达式引擎): · true/false、$args.key 裸引用(真值) · == / != / > / < / >= / <= 、contains · 字面量支持 "str" / 'str' / true / false / 数字 / 裸文本 · 布尔与字符串宽松比较(true == "true"),对齐 utils.getBool 的既有约定 变量插值两种形态(缺一不可): · **整值引用** "$args.count" ⇒ 替换为**原始值并保留类型** (数字仍是数字;否则模型收到字符串 "3") · **文本内插值** "ssh $args.host" ⇒ 在字符串内替换 · 标量渲染:对象/数组用**紧凑 JSON**,绝不用 fmt.Sprintf("%v") (那会产出 `map[k:v]` 这种模型读不懂的 Go 语法) on_error:abort(默认)/ continue。失败时也留槽(记错误文本)—— 否则后续组读到的是"缺失",而"缺失"与"值为空"在下游难以区分。 判据(exec_test.go,8 条): · 具名槽写入正确 · ★ 结果按声明顺序合并(用 delay 让完成顺序**确实**打乱) · array 槽同名 as 按声明顺序确定性追加 · 条件为假 ⇒ 整组跳过、槽**不赋值**、零工具被执行 · ★ 条件畸形(空键 / 引用未声明入参 / 语法不完整)⇒ 报错且**不执行任何工具** · 条件为真 ⇒ 正常执行 · on_error 的 abort / continue 两种语义 · 插值:文本内替换 + 整值引用保留类型 过程中三次自伤: 1. ★ **toolRunner 接口第一版写成 call(name)**,不收 args ⇒ 插值判据成了 摆设(永远"通过")。改为 call(name, args) 后插值才真正可观察。 2. toolRunner / compactJSON 定义在了 _test.go 里,exec.go 引用不到 ⇒ build 失败。toolRunner 是**引擎的依赖契约**,必须在非测试文件。 3. fixture 里给 "slow" 配了不存在的返回值,误以为它该返回 "B" —— 是我没配就断言,不是实现错。 变异验证:把合并改为"按完成顺序 append"⇒ array 槽顺序判据 FAIL, 报错直指 `[C A ran:slow]` vs 期望 `[A ran:slow C]`。 (第一版变异用了一个 no-op 的 sort.SliceStable,等于什么都没测, 已改成真正模拟完成序的实现。) `-race` 全绿;回归 internal/agent/... internal/plugins/... 全绿。 顺带修正设计文档:两处 tools 示例原写成 `{tool:cmd_run,...}`, **不是合法 JSON**(P1 判据实测会解析失败)。已改为合法 JSON 并加注 「键要带引号,这是实现时判据跑出来的真实缺陷,不是假想」。 * feat(seq): 序列存储、跨序列调用图与 missing 策略(插件线 P3) store.go: · **存 AST 不存文本**。执行期不重新解析原始文本 ⇒ 一次格式改动不会 悄悄改变已保存序列的行为。 · 先写 .tmp 再 rename,避免写一半被读。 · **路径穿越防护**:序列名来自模型且被直接拼进文件路径,不校验的话 `seq_load("../secret")` 能读任意文件、`seq_delete` 能删任意文件。 · CheckGraph:跨序列调用的**目标存在性** + **环检测**(三色 DFS), 报错时给出**环路径**(#A → #B → #A),便于定位。 · maxCallDepth = 4 是**结构常量**不是配置项 —— 沿用内核 MaxInterruptFrames 的做法(core/scheduler.go:271「结构上界,不是配置项」): 上界一旦可配,总有人会把它调到栈溢出。 exec.go 补 missing 策略(动态注册下「工具不存在」是**常态**): · fail(默认)/ skip / degrade,与「执行失败」严格分开 · ⚠️ missing 分支**必须先于**通用 on_error 检查:否则「插件挂了」会被 on_error=abort 连坐整组中断,skip/degrade 形同虚设 · skip 时**不赋值槽**(与「条件为假」同一情形,下游要能应对槽缺失) · 本包自带 errToolNotFound 哨兵而**不复用** io 包的同名错误:seq 是插件, 拿得到 sdk.ToolAPI,拿不到 io 包类型(见设计文档 §7 边界声明) ★ 过程中解决一个**设计死锁**(值得单列): 我最初让 Save 校验「跨序列目标必须已存在」。但互调的两条序列 谁也存不下来——A 要 B 先在、B 要 A 先在,**依赖在设计上无解**。 ⇒ Save 只校验**同序列内**的 group 引用(那部分信息自足); 跨序列目标的存在性与环由 CheckGraph 在保存后统一兜底。 判据与实现都写明了这个分工的理由。 判据(store_test.go,7 条): · 存取往返保住 AST(含 out 声明——它是签名的一部分) · 列表 / 删除;删不存在的**报错**(不静默成功,模型会以为删掉了) · ★ 跨序列成环被拒且错误含环路径;无环通过 · maxCallDepth 是正的结构常量 · ★ missing 三种取值各有明确行为 · ★ 路径穿越:7 种恶意名既读不到也删不掉,且**在 store 目录外**放真实 文件断言它仍在(不是"读代码看着对",是跑出来的) 过程中三次自伤: 1. 序列名我写成 "#A"/"#B"——`#` 只是 target 里的前缀标记, 落盘名不带它,于是 CheckGraph 找不到、误报「不存在」。 2. missing 策略与 on_error 检查的**顺序**反了,导致 skip/degrade 被 abort 连坐(判据直接暴露)。 3. 为压掉 unused import 写了 `var _ = os.Remove` 这种占位 hack —— 正是检查项 go-ignored-call-result 指出的那类东西,已删; 另把 rename 失败分支的 `os.Remove(tmp)` 加上注释说明 「清理失败有意忽略,否则会盖掉真正的失败原因」。 变异验证:去掉环检测(三色 DFS 全放行)⇒ 成环判据 FAIL ("A→B→A 成环却通过检查")。 回归:-race 下 seq 全绿;internal/plugins/... 全绿。 core 包偶发 TestResidualKeep 失败是**已记录的既有竞态** (offload_test.go 的 SpawnResident 起了子调度器而测试无同步就读队列), 与本阶段无关,已在执行计划中记为待修。 * feat(seq): 六个 seq_* 工具、插件装配,并补内核两处缺口(插件线 P4) 内核缺口(都是 P3 落地时暴露的真实缺陷): · **GetAllTools 丢 ParallelSafe**:它只带出 Name/Description/Parameters, 插件看到的设备工具一律"不可并发" ⇒ 设备工具的并发声明**对插件不可见**。 · **ToolAPI 缺按名查**:新增 ToolDefByName。插件需要在**运行前**判断目标 是否存在/是否并发安全(工具动态注册,"不存在"是常态), 而 GetAllTools 只能拿到全量列表。查不到返回 nil,不 panic。 seq 插件: · plugin.go:插件骨架 + kernelRunner(把 sdk.ToolAPI 收窄成三个方法, 判据因此能用假实现驱动,不必构造整个内核) · tools.go:六个工具定义(独立真相源,注册/判据/文档都从它取) · handlers.go:seq_create/list/delete/run/call/when_call 的实现 · register.go + all.go:按 skillmgr 同一范式 init 注册 ★ 过程中解决一个**我自己的设计矛盾**: 判据原先要求 `seq_call` / `seq_when_call` 进黑名单,但"按名调用 group/序列"恰恰是本包的核心能力——禁掉它,序列就退化成单层脚本。 分层澄清后:黑名单只管**对外发消息 / 起子 agent / 改插件表 / 再跑整条 序列**;seq_call 系列留给序列内部组合,其递归由 maxCallDepth + 环检测 负责(设计文档 §8.3 本来就是这么定的,我把两层混了)。 `seq_run` 留在黑名单:序列内再跑整条序列语义上是递归。 **六个工具一律不声明 ParallelSafe**:seq_run/seq_call 会执行一串工具, 其中可能含写操作;标成并发安全会让内核把两条 seq_run 并发跑, 两个序列的执行顺序交错、变量表互相污染。 安全性:序列名与文件路径都做穿越防护(`..` 段、分隔符、空名)。 过程中四次自伤: 1. 臆造 `jsonMarshalIndent`(不存在)→ 改 encoding/json.MarshalIndent; 并把 execGroup 的 runner 传错成 p(应 p.runner)。 2. seq 判据里写了 `black(name)`,而 blacklisted 是**谓词**不是函数。 3. 一次 python 替换删漏,把「跨序列目标存在性检查」那段从 CheckNew 里整段摘掉又贴回原处——靠编译错误发现。 4. ★ 注册失败我写了 panic:内置插件在 main() 装配期加载,panic 会 **直接拖垮内核启动**,而"某个工具没注册上"只该让该工具不可用。 已改为 log.Printf + 继续(与 clawhubadapter / mcp 一致)。 变异验证:把 seq_run 移出黑名单 ⇒ 黑名单判据 FAIL。 判据(plugin_test.go,6 条):六个工具全部注册且 description/参数 schema 非空;seq_create 声明 required 并说明 groups/file 二选一; seq_run 说明"按数组顺序";六个工具均未声明 ParallelSafe; 黑名单含递归风险项且**不误伤** seq_call 与普通工具。 回归:seq -race 全绿;internal/sdk/... internal/plugins/... 12 包全绿。 * docs(plan): 内核主线与插件线全部完成,记录两个设计决策与三处遗留 * fix(security): 设备授权闸下沉到 ToolAPI 路径(D4,堵住绕过) 问题:设备类工具的授权闸只存在于 core.executeToolCallInner (toolcall.go:151-152),即**「agent 收到模型 tool_call」那条路径**。 而 ToolAPI.ExecuteTool 是**另一条**独立执行入口,不经那道闸 ⇒ 凡是走 ToolAPI 的调用都能绕过 AllowedOutputs。 实测范围**不止序列**:cli 插件的 /terminal 直接经 ToolAPI 调 agentcli 的 终端工具(cli/plugin.go:1038 的注释自陈"SDK 的 ToolAPI 已允许跨插件调用 工具")。任何插件拿 ToolAPI 都能指挥未授权的设备。 改动: · internal/sdk/tool.go: ToolAPI 新增 CanUse(toolName, args) bool。 **纯新增方法**,零值实现返回 true ⇒ 未实现者(存量插件、测试替身) 行为不变。 · internal/sdk/tool_impl.go: 实现 CanUse。判据只有一条——设备类工具按 `device/<id>` 查授权;非设备工具不受影响(闸的作用域必须窄,否则会把 所有工具锁死)。 授权查询走**可注入**的晚绑定闭包:toolImpl 在 internal/sdk,而 IsOutputAllowed 是 core.*Agent 的方法,sdk 不能依赖 core。 · internal/plugin/registry.go: 新增 SetDeviceAuthQuery。 · cmd/homed/bootstrap.go: 在 newMainAgent 末尾注入。⚠️ 必须在 agent 构造**之后**——判据要用 agent 自己的 allowedOutputs,而 registry 早于 agent 构造,故 registry 存的是晚绑定闭包。 判据(toolapi_auth_test.go,7 条),核心是**两条路径必须一致**: · 收窄授权时 ToolAPI 路径同样被拦 · 已授权设备放行(防闸过严杀掉正常能力) · 非设备工具不受影响 · 枚举类工具不受影响(与内核 TestDeviceToolAuth_EnumerationNotGated 同语义) · 未配置白名单 = 完整授权 · ★ TestCanUseAgreesWithInnerPath:4 组用例逐例比对内核路径与 ToolAPI 路径的结论 —— 判定不同本身就是漏洞 · ★ TestCanUseMatchesInnerFailOpenOnMissingDeviceID:把现状 (缺 device_id 时**放行**)钉住。⚠️ 这是 fail-open,是既有的可疑设计 (core 的 TestDeviceToolAuth_* 依赖它),本次不擅自改语义;判据写明 "若要改成 fail-closed,必须两处同时改"。 过程中三次自伤: 1. 一度在 core 写了个 toolAPIRef —— **只实现部分方法的替身**是过度设计, 且两份实现必然漂移。改为判据直接用 sdk.NewTool(stageHost, iom), 与插件侧走**同一个**实现。 2. 判据里又写了 `var _ = agentIO.DeviceOutput` 这种压 unused import 的 占位 hack(第二次犯这个),并重造了 strings.Contains。都已去掉。 3. 注入点一开始找错了位置(以为 newStageAndRegistry 能拿到 agent, 实际 pluginReg 是 main() 的局部变量)。核实 newMainAgent 的签名后 确认它同时持有 agent 与 pluginReg,注入点落在那里。 变异验证:让 CanUse 恒返回 true(还原成原缺口)⇒ 两条判据 FAIL, 其中一条直指「内核路径=false 而 ToolAPI 路径=true —— 两条路径判定不一致」。 回归:go build ./... 通过;go test ./internal/... 全绿。 * test(seq): 端到端接线判据,抓出「传参方式完全不可用」的真 bug P1–P4 的判据都在**包内**(假 runner / 直接调函数),覆盖的是**语义**; 本轮补的是**接线**层——参数名对不对、返回值模型读不读得懂、跨层调用断不断。 接线层的 bug 语义判据抓不到:例如工具注册了但参数名拼错,单元判据全绿 而模型永远传不进来。 ★ 抓到一个真 bug:**seq_create 走 groups 传参时完全不可用**。 根因:marshalGroups 只把 groups 包进 JSON 文档、不带 name,而 Parse 要求 name 非空 ⇒ 报「序列缺少 name」。而 seqCreate 里那句 `if seq.Name == "" { seq.Name = name }` 回落分支是**死代码**(Parse 早就失败了)。 后果:**只有 file 方式能用,传参方式一律失败**。 已修(name 一并包装)。包内判据抓不到这个——它们直接构造 *Sequence, 不经过这条路径;只有真正 dispatch 一遍才暴露。 判据(e2e_test.go,7 条):真实 dispatch 串通 seq_create → seq_list(须展示签名,模型据此按名调用)→ seq_run (执行序按 tools 声明序、槽回填、结果里**不得**出现 Go 的 `map[` 语法) · seq_call 按名调用 group 并返回其出参 · seq_when_call 条件为假 ⇒ 跳过且**零工具被执行** · seq_when_call 条件畸形 ⇒ **报错**且零执行(不得静默跳过) · seq_create 走**文件**(长序列的主力用法) · groups 与 file 同时传 ⇒ 报错「二选一」 · seq_delete 不存在 ⇒ 报错(模型会以为删掉了) 过程中又一次臆造 helper(`writeFile`),改用 os.WriteFile; 并把三处 `_, _ = p.dispatch(...)` 补上 err 检查(正是 go-ignored-call-result 报的那类)。 回归:seq -race 全绿;go build ./... 通过;go test ./internal/... 全绿。 * docs(plan): 遗留项收敛——D4 与端到端已完成,提权无需决策 * feat(kernel): 内置工具注册进 ToolAPI 面(方案 B,补真机实跑发现的架构缺口) 真机实跑实证(独立实例 /tmp/seqtest,未触碰生产): seq_run 报「工具 knowledge_list 不存在或未注册」, 而**同一轮模型直接调 knowledge_list 是成功的**。 根因:`memory_*` / `knowledge_*` / `doc_*` / `person_*` 这 20+ 个是 **内核内置**工具,在 core.executeToolCallInner 里按**前缀分派**, 由 buildToolDefs 直接生成 schema,**从不进 StageHost / IOManager** ⇒ ToolAPI(只有插件工具 + IO 设备工具)既查不到也调不了。 后果:序列只能编排插件/设备工具,无法编排记忆/知识/文档/人物 ——恰恰是最常用的能力。 方案 B 的实现: · internal/sdk: 新增 BuiltinProvider(Defs/Exec)与 SetBuiltinProvider。 用**晚绑定注入**而非让 toolImpl 依赖 core,理由:ToolAPI 是**全局单例** 却需要 per-agent 数据(驻留子是轻量内核,memory 为 nil;内置工具可见性 由 `if a.memory != nil` 等门控)。sdk 不能依赖 core(方向反了)。 · internal/sdk/tool_impl.go: ToolDefByName / ExecuteTool 补查内置工具。 ⚠️ ExecuteTool 只在「io 确实没有该工具」时才转内置;io 的**执行失败** 必须如实上抛 —— 否则会把「设备离线」误报成「工具不存在」,让调用方 按 missing 策略跳过(与 P3 修过的父 io 吞错误同一族陷阱)。 内置工具**默认不声明 ParallelSafe**(含 SQLite 写与召回)。 · internal/agent/core/builtin_toolapi.go: Agent 侧 provider。 ★ Defs **复用 buildToolDefs 的同一批生成逻辑**(筛出不在 StageHost/IOManager 中的那些),保证"模型看得到什么"与"插件看得到什么" 门控完全一致 —— 避免两套语义。 Exec 复用 executeToolCall 完整路径(授权闸 + 异常处理)。 · cmd/homed/bootstrap.go: agent 构造后注入。 ★ 不会让模型看到重复工具(已核实):模型侧走 buildToolDefs (a.io / a.stageHost **直调**),ToolAPI 只经 PluginSDK.Tool() 暴露给插件 —— 两条不重叠的路径。 判据(builtin_toolapi_test.go,6 条): · 内置工具能从 ToolAPI 查到 · ★ 查到 ≠ 调得通:必须真的能执行 · 门控语义保持:未接 memory/knowledge 时不得声称有那些工具 · ★ 接了 knowledge 时必须可见(这正是要补的缺口) · ToolAPI 上"不存在"必须是类型化 not-found(供 seq 的 missing 策略用) · 内置工具默认不声明并发安全 真机复验(同一隔离实例,新二进制): 序列 "smoke2" 执行完毕(1/1 组)— 工具 1 个 变量槽: summary = cangjie/central-repo/agreement/... ⇒ knowledge_list 成功执行并回填具名槽。上一次的「不存在或未注册」已消除。 已知局限(记入待定):ToolAPI 单例而内置工具面是 per-agent, 多 agent 下看到的是"最近一个注入者"的面。本次不解决。 * fix(seq): 类型不匹配的错误改成模型可执行的话(真机实跑发现) 真机实跑(独立实例)发现:模型把 group 的 `in` 传成字符串 "{}", 拿到的是 encoding/json 的原始报错: json: cannot unmarshal string into Go struct field rawSeq.groups.0.in of type map[string]string 这句说的是**事实**(string 解不成 map)而不是**该怎么做** (in 应该写成对象 {"键":"类型"});残留的 `rawSeq` / `Go struct field` 更是 Go 内部实现细节,对模型无意义且会误导它去猜一个叫 rawSeq 的东西。 模型为此**重试了 3 次**才改对。 这与本仓反复吃亏的那类问题同源:`20s` 少引号 → 静默降级 → cmd_run 失败率 34%。**报事实不报改法,模型只能猜。** 改动(parse.go):新增 friendlyJSONError,把原始报错翻译成可执行文案 · in / out 类型不符 ⇒ 说明"应写成对象 {键:类型};无入参请写 {}" · tools 类型不符 ⇒ 说明"应写成字符串(内容是 ; 分隔的 JSON 对象)" · groups / name / when / missing / timeout / on_error ⇒ 逐个说明期望 · 未知字段 ⇒ 列出 group 允许的全部字段名(拼写错误最常见) · shortFieldName 剥掉 `rawSeq` 这类包内类型名前缀 · jsonKind / goTypeName 把 Go 类型翻译成模型看得懂的说法 判据(parse_test.go,2 条): · in 传字符串 ⇒ 错误须指名字段、须说明该传对象、**且不得残留 Go 内部类型名** · tools 传数组 ⇒ 错误须指明 tools 且说明它是字符串 ★ 变异验证时踩了一次坑:第一次变异让 friendlyJSONError 不被调用, 结果**编译失败**(函数变成未使用),判据压根没跑,我却看到 "ok"。 改用可编译的变异(函数保留、开头直接 return err)后判据正确 FAIL。 ★ 教训:**"变异后判据通过"要先确认变异真的生效**——编译失败 ≠ 判据通过。 真机复验:模型读一次即懂,并明确说"提示里的意思很明确"; 修复前它为此重试 3 次。 回归:internal/plugins/... internal/agent/core internal/sdk 全绿。 * feat(seq): 新增 seq_help —— 格式说明 + 可照抄示例 动机来自真机实跑:模型写序列时踩了三个坑,各试 1~3 次才改对 ① tools 漏末尾的 ';' → 「末尾缺少 ';'」 ② group 的 in 传成字符串 → 重试 3 次 ③ as 指向未声明的 out 槽 → 静态校验拦下 这三处都是**格式细节**,塞不进工具描述(有长度限制),却恰是模型最易错处。 散落在七个描述里等于没有集中入口。 实现(help.go + tools.go + plugin.go): · seq_help 无参数、纯文本返回(与仓内 output_send__*_help 同范式) · 「格式要点」逐条写明:in/out 必须是**对象**、tools 必须是**字符串**、 每个 tool 后(含最后一个)都要 ';'、as 必须在 out 声明、 groups 与 file 二选一、组内并行组间串行 · 「条件 when」列出支持的表达式形态 · 「可照抄的完整示例」给一行**单行紧凑**的合法序列 ★ 判据(plugin_test.go,3 条): · seq_help 已注册、有 description、不声明并发安全 · 内容覆盖真机踩过的**每一个**坑(判据从"坑"出发而非从"打算写什么") · ★ 示例**自己能被本包解析器接受**:validateHelpExample 从帮助文本里 抽出示例喂给 Parse —— 模型是照抄的,示例自己解析不过就是给模型挖坑。 而"从文本里有没有某个词"是看不出这类 bug 的。 过程中判据自己错了两次(都被这条示例判据照出来): 1. 抽取用 strings.Index(help, `{"name"`) ⇒ 先命中「格式要点」里**有意写的** 示意片段,截到非示例的内容,报出莫名其妙的 invalid character '…'。 2. 修完又混用两套偏移基准(base 的下标拿去切 help)⇒ invalid character '\xaf'。 ⇒ 重写为全程在同一 base 上定位。 ★ 两次都说明:**判据的抽取逻辑本身就是需要验证的代码**, 它出错时报出的信息极具误导性(看起来像实现有 bug)。 示例形态也改过一次:原为多行缩进 JSON,改为**单行紧凑** —— 模型照抄时 免去缩进/换行带来的额外风险。 变异验证:去掉示例里的末尾 ';' ⇒ 示例判据 FAIL。 另一处:加 seq_help 后「恰好注册 6 个工具」判据 FAIL(实际 7)—— 这正是那条判据的用意(防止悄悄多加工具稀释工具面),已更新并注明原因。 回归:go build ./... 通过;internal/plugins/... core sdk 全绿。 * feat(toolcall): 工具结果只统计不裁剪(方案 B),并治掉 seq 侧的静默截断 问题(核实过):工具结果进 f.Msgs 时**没有任何长度上限**(task.go 直接 `Content: result`),内核也**不预检**是否超长 —— 超限由上游 API 报错。 时间线那侧有预算(ContextTokens = 0.8×窗口,进消息前就裁过),但那只管 a.context 的历史事件,**不管单条工具结果** ⇒ 一条巨大结果可能直接冲破 预算而内核不会提前发现。 为什么**不裁剪**(与方案 A 的取舍): · 截断会让模型拿到**残缺**信息,而截断位置由内核武断决定; · 模型无法得知"这里被截断了",会基于残缺数据下结论 —— 与本仓反复 吃亏的「静默降级」同族(`20s` 少引号 → 静默降级 → cmd_run 失败率 34%); · 处置权应交给调度器/上层(告警、拒绝、或让模型自己换更窄的查询), 而不是内核单方面替模型决定。 改动: · core/toolresult_budget.go: checkToolResultSize 只**计数+报告**; 阈值默认 = ContextTokens/8(一条吃掉全部预算会把其它上下文全挤掉); 报告经 toolResultReporter(可替换),默认 logReporter —— **不给模型发 消息**:那是在已花掉的 token 之上再加一条 system,且对当前这轮决策无帮助。 · 接入点在 stepToolAfter 的 toolMsg 落定**之后**(那里才是模型最终看到的 内容;stepToolExec 拿到的尚未经 after_toolcall 改写)。 · TaskFrame 记 oversizeTools / oversizeToolNames,供调度器与状态面查询 "是否有工具在稳定产出超大结果"。 ★ 顺带治掉 seq 侧一处**我自己留下的静默截断**: handlers.go 里我当初随手写了 truncate(…, 160),把变量槽静默截到 160 字 且**无任何标注** —— 正是我批评过的静默降级。 改为 renderSlot:≤160 给全;超过则显式标注「已截断:共 N 字,此处显示前 160 字」并给出改法。**槽里存的始终是完整值**,截断只影响回填文本长度。 端到端判据 TestSeqRunDoesNotSilentlyTruncateSlot 抓到了这个缺陷 ("变量槽被截到 160/5000 字却没有任何标注")。 判据(toolresult_budget_test.go,4 条): · 400KB 结果触发超限报告(含工具名与 token 数) · ★ **默认不裁剪**:200KB 结果原样进 tool 消息(方案 B 的核心不变式) · 小结果不误报(噪音会淹没有效信号) · 报告文案可执行:带工具名、token 数、改法建议 变异验证:去掉统计调用 ⇒ 两条判据 FAIL("统计没生效" + "被裁剪了")。 另:检查项报 stepToolBatch 的 goroutine 竞态,-race 实测**误报**—— 循环变量显式传参(非闭包捕获)、且按索引写各自槽位(非共享 map), `-race` 下 20 轮并发判据全绿。 * test(seq): 三个压力测试 —— 超长序列 / 100 工具并行 / 串行降级 与单元判据的分工:单元判据钉住**语义**(一条路径对不对);压力测试钉住 **规模下的不变量**。沿用仓内既有范式(media/soak_test.go): testing.Short() 跳过 + 独立 -run 跑。 ① 超长序列 · 解析 10 / 100 / 1000 组(250KB 文本):6.8ms,无硬上限误报 · 执行 200 组 × 5 工具 = 1000 次调用:2.0ms 断言:每工具恰好被调 nGroups 次(无遗漏/重复)、结果含**最后一组** —— 组间串行在规模下仍成立 ② 100 工具组内并行 · 100 工具全声明并发安全 ⇒ 11ms,完成顺序**确实被打乱**(判据会校验 这一点,否则它测不到并发) · 断言每个槽拿到**自己**的结果(并发下若按完成顺序合并就会错位) ③ 串行降级 · 50 个工具里**一个**未声明并发安全 ⇒ 整批退回串行, 完成顺序严格等于声明序(106ms vs 并发的 11ms,降级确实生效) ★ 压力测试第一次跑就抓到一个**真实分层缺陷**: 「含非并发安全工具则整批串行」这条规则**只在上层 runGroup 实现**, 而引擎层 execGroup 只信 g.Parallel 字段 ⇒ 任何人直接调 execGroup 都会拿到不受约束的并发。 已修:降级判据下沉到引擎层,新增 batchCanRun(g, runner), toolRunner 增加 parallelSafe 方法(生产路径行为不变,只是把判据 放到了它本该在的层)。 过程中压测自身也暴露了两个测试缺陷(都修了): · fixture 让 100 个工具写同一个标量槽 o,被静态校验正确拦下 ("组内并行下同名写入是数据竞争")—— 压测不该去撞这条规则; · ★ e2eRunner.called 是无锁 append,100 工具并发时 -race 报出**真竞态** (不是误报)—— 加锁 + 提供 calledSnapshot 供断言。 另:建序列与跑序列原本用了**不同 plugin 实例**(序列存在实例的 store 里, 换实例就读不到自己刚建的),已改为同一实例。 回归:go test -race ./internal/plugins/seq 全绿;go test ./internal/... 全绿。 * feat(parallel): 并发安全改为声明式,并审计标注 37 个工具 把"能不能并发"从内核硬编码名单改成**工具自己的声明项**,形态照 SDK 的 NoMemory 走。 ## ★ 起因:提示词在跟内核不一致 阶段 2.5 写进提示词的「内核默认并行执行」当时是**假的**:toolParallelSafe 只查 stageHost 与 io 两个来源,而全仓 ParallelSafe:true 的生产代码数量 是 **0**。于是除碰巧只发一个工具外,每一批都整批串行回退,而提示词正教 模型把多个查询放同一轮。**内核行为与提示词不一致 = 对模型说谎。** 并发面:0 → 37 个工具(18 插件 ParallelSafe + 19 插件 Serial + 9 内置只读)。 ## 声明形态(照 SDK,不自创) ### 插件:结构体字段 s.RegisterTool("config_get", sdk.ToolDef{ Name: ..., Description: ..., Parameters: map[string]interface{}{...}, // 已核实只读:… ParallelSafe: true, ← 插在 Parameters 之后、handler 之前 }, p.handleGet(s)) 位置与 SDK 的 NoMemory/ContextPolicy/RecallPolicy 一致:Name 在首位, 声明项在末尾,不打散 gofmt 对齐。 ### 新增 SDK 声明项:ToolDef.Serial ParallelSafe 的**反向**标记,判据优先级高于 ParallelSafe。 为什么需要:ParallelSafe 零值 false 已表达"安全",插件无法区分"我没想过" 与"我确认过必须串行"。没有这个区分,工具作者只能靠命名约定传递意图。 内核已消费它(io.ToolDef 同步加字段对齐),并有判据守"Serial 胜出"。 ### 内置工具:toolDef 的 toolParallel 选项 内置工具以裸 schema map 下发,没有 ToolDef 结构,所以用变参选项: toolDef(名字, 描述, 属性) // 默认串行 toolDef(名字, 描述, 属性, "toolParallel") // 已核实只读,可并发 读工具表的老调用点一行不用动,声明就写在工具定义那一行。 ## ★ 走过的弯路(都留了判据) 1. **硬编码白名单**:先在 toolParallelSafe 里查一张 builtinParallelSafeTools map。那把声明从"工具自己"搬回了内核 —— 工具改名/新增不会自动跟着变,得靠一条 grep 源码的判据才能发现漂移, 而判据一改就忘。已删,改为从定义读。 2. **判据前提错(同一个坑踩了两次)**:拿裸 &Agent{} 的 buildToolDefs 输出 当"实际可见工具",但这 9 个内置工具全在条件分支里(a.knowledge != nil / a.social != nil / a.parentID != ""…),裸 Agent 一个都不产出 ⇒ 全部误报 "声明形同虚设"。第一次叫它"幽灵条目",没认出是同一个坑。 3. **注释模仿真实签名污染判据**:toolParallel 的用法注释写着 `toolDef("knowledge_search", ...)`,判据按文本匹配先撞上注释。 4. **buildToolDefs 的 nil 不一致**:开头判了 a.io != nil,末尾却无条件 a.io.ListChannels()。任何无 IO 的 Agent 调它都 panic —— 而 panic 报在 io 包里,根因在 tooldefs.go。已补。 5. **插入脚本用正则找"最后一个顶层字段"**:被嵌套 map 里的同形文本骗到, 823 处错误重排把文件改坏。改用括号深度 + 记录进入深度 3 的行号 (空 properties 会让深度在同一行进出平衡,只判 depth==2 不够)。 工具在 SDK 仓 tools/annotate_parallel/,复用时用绝对路径。 ## 提示词措辞同步修正 「默认并行执行」→「尽量并发执行,但这是**逐工具判断**的」,并教模型 **把查询类放同一轮、写操作单独发一轮**(写和查混在一批,整批都串行)。 ## 判据 - TestSerialOverridesParallelSafe Serial 优先于 ParallelSafe - TestToolParallelDeclarationsAudit 并发面不许再归零 - TestNoToolDeclaresBothParallelAndSerial 两者同标即谎话 - TestBuiltinParallelDeclaredWhereDefined 声明写在定义处、且内核真读到 - TestStoreListIgnoresForeignJSON 压测抓到的 List() 缺陷 * fix(seq): 修掉 seq_create 的 O(n²),并加极端压测 ## 起因:1000×1000 压测直接跑爆 用户要求「1000 条序列 × 每条 1000 个组内 toolcall」。第一版跑满 8 分钟超时。 分阶段计时定位到瓶颈: | 阶段 | 200 条 × 1000 工具 | |---|---| | 创建 | **27.0s**(135ms/条,**随序列数线性增长**) | | 执行(组内 1000 并发) | 0.55s(20 万次调用,2.7µs/次) | | 删除 | 4.7ms | 瓶颈在创建,不在执行。 ## 根因 ```go // handlers.go:70 —— 每次 seq_create 之后 graphErr := p.store.CheckGraph() // store.go:166 —— List() 全量 + 逐条 Load() 全部序列 ``` 1000 条各 250KB ⇒ 每次创建都重读 250MB 并反序列化。第 N 条的创建代价 随 N 线性增长,总计 O(n²)。 ## ★ 走过的弯路:我一度建议「把校验挪到运行期」—— 那是错的 store.go:163 明确写着: 两条检查(都必须在**建序列/保存**时做,而不是等运行): 1. 每个 seq_call 的目标必须存在(不存在会在运行期才发现,浪费一整轮) **校验时机是语义,不是性能旋钮。** 目标不存在若等到运行才发现,模型已经 白白花掉一整轮工具调用。性能问题不能靠挪语义来解。 ## 修法:缓存调用边,Save 做 O(1) 增量 ```go // Store 新增 graph map[string][]string // 序列名 → 它调用的目标(裸名) // Save: 只更新这一条的边 s.graph[seq.Name] = edgesOf(seq) // Delete: 移除这一条的边 delete(s.graph, name) ``` `callTargets` 只依赖 AST,不必每次从盘重建。**校验语义完全不变** —— 目标 存在性与三色 DFS 环检测都照旧在建序列时执行。 ## 判据 - TestStoreGraphCacheKeepsSemantics 逐条钉住三个保证:目标存在性 ✓、 环检测 ✓、删除后不再误报成环 ✓ (这类优化最危险的失败模式是"校验还在跑但少查了某种情况") - TestStoreSaveScalesLinearly 分段对比后半程/前半程每条耗时。 ★ 判据自己改过一次:初版用「总耗时 ÷ 单条耗时」,而单条只有 48µs 时 噪声占比过高,同一份代码两次跑出 84× 和 203× —— 判据不稳定时报的 失败就是噪声,比没判据更糟。改成分段对比(平方时后半程会慢约 n/2 倍,线性时基本持平),阈值 3 倍留足磁盘与 GC 抖动余量。 实测 300 条:84~203× 单条(线性期望 300×),平方会是 90000×。 ## 压测本身也修了两个自己的 bug - 源文件目录与 store 目录分离时只改了写入侧,清理侧还指着 store 目录 ⇒ 报 "no such file"。看起来像文件被提前删了,真因是路径拼错。 - newE2EPlugin 的 runner 参数写死 *e2eRunner,压测换替身就编译不过 ⇒ 改为接受 seqRunner 接口。 ## 压测规模 TestStressExtreme_ThousandSeqs 现为 1000 条 × 1000 toolcall(O(n²) 修复后 可跑)。判据全是**不变量**:每工具恰好调一次、1000 槽在交错延迟下仍按 声明序合并(并发下若按完成序合并必然错位)、删除后无残留。 * refactor(parallel): 内置工具的并发声明改为 SDK 同构的结构体字段 上一提交(bc16a07)把并发安全改成了声明式,但内置工具那一路仍是将就: 声明靠往 required 变参里塞字符串 "toolParallel" 传递。 ## 为什么那不算声明式 对照 SDK 的 NoMemory 逐条看: | | SDK NoMemory | 当时的内置工具 | |---|---|---| | 载体 | `ToolDef.NoMemory` 字段 | required 里的字符串 | | 拼错后果 | 编译器报错 | **静默失效** | | 内核读取 | 查结构体字段 | 遍历工具表 + 解析字符串 | "少一个工具能并发"恰恰是最难察觉的一类问题 —— 没有任何报错, 只是并行的批悄悄退化成串行。 ## 改法 ### 1. sdk.BuiltinToolDef 补声明项(与 NoMemory 同构) ```go type BuiltinToolDef struct { Name, Description string Parameters map[string]interface{} ParallelSafe bool // 零值 false = 默认串行(保守) Serial bool // 优先于 ParallelSafe } func (d BuiltinToolDef) ConcurrencySafe() bool { return d.ParallelSafe && !d.Serial } func (d BuiltinToolDef) ToSchema() map[string]interface{} ``` ### 2. 工具定义处声明 ```go toolDef("memory_merge", ...) // 默认串行 toolDefWith("knowledge_search", ..., []string{"query"}, parallelOpts()) // 已核实只读 ``` ### 3. 内核一次聚合并缓存(照 StageHost.NoMemoryToolNames) ```go graphOf() // 快照 declareParallelTool(name) // init 里登记 concurrencySafeOf(name) // 查表 ``` 不再每次 toolParallelSafe 都重跑 buildToolDefs()(O(工具数) 重复劳动, 而声明是静态的)。 ## ★ 一个更隐蔽的问题:声明表曾经是空的 `declareParallelTool` 最初挂在 `toolDefWith` 的**运行时调用**上。而那 9 个 工具全在 `if a.knowledge != nil` / `if a.social != nil` / `if a.parentID != ""` 之类的条件分支里 —— 测试环境根本不走进这些分支 ⇒ 聚合表始终为空。 而判据查的是同一张表,于是**自证通过**:全绿,并发能力为零。 这就是判据设计的教训 —— 判据和数据源同源时,它证明的只是"我和我一致"。 现在判据双向核对:名单里的必须真声明了,声明了不在名单里的也会报出来; 并额外验证内核**真的读得到**(concurrencySafeOf 而非读同一份 map)。 ## 顺带修掉的迁移事故 用正则批量改造 30+ 个 toolDef 调用点时,把 `person_set_trait("name", "content")` 这类**变参**调用误改成 toolDefWith(... "name", "content") —— 那是**写工具**, 差点被标成可并发。已全部回退并逐一核对:9 个声明并发,0 误伤。 * perf(stagehost): 工具声明查询免去结构体拷贝(热路径 1000 并发下省 1000 次) ## 问题 StageHost.ToolDef 返回 &def —— 一次**结构体拷贝**:3 个 string + 2 个 map 头 + 2 个 bool + Cleaner 函数指针。 而 toolParallelSafe 在**每批**并发判据里对每个工具各调一次: batchRunnable 遍历 PendingTools → toolParallelSafe(tc.Name)。 1000 并发批次 = 1000 次结构体拷贝,全在判定阶段(执行之前)。 不是"逃逸漏洞"(Go 1.22+ 循环变量每轮独立,go.mod 是 1.25),纯粹是白拷贝。 ## 修法 ToolDef 保留 —— 它要给需要完整声明的调用方(Cleaner、Parameters 校验), 返回副本也是**有意**的(ToolDef 里有 map 与函数指针,交出内部元素会把 可变引用漏出去)。 新增免拷贝查询,热路径专用: ConcurrencySafeOf(name) (safe, found bool) // 只读 ParallelSafe && !Serial NoMemoryOf(name) (v, found bool) HasTool(name) bool 全部在持 RLock 下走同一个 findLocked。 `toolParallelSafe` 切到 ConcurrencySafeOf。语义完全等价 —— 两者都算 `ParallelSafe && !Serial`,只差一次拷贝。 ## 判据(两个都防"优化悄悄改了语义") - TestNoCopyQueriesMatchToolDef 7 种声明组合(plain / parallel / serial / both / nomem / all / serial_nomem)下,免拷贝查询与 ToolDef(...).字段 **逐字段等价**;不存在的工具三态一致(false/false/true)。 ★ 这类优化最危险的失败模式就是语义漂移:并发判据若读错字段, 能并发的批次会**悄悄退化成串行** —— 没有任何报错,只表现为"变慢了"。 所以判据必须逐个组合比对,而不是只测一个典型值。 - TestNoCopyQueriesConcurrent 32 goroutine × 50 工具并发查询, -race 无竞态且结果与串行一致。 回归:go build ./... 通过;go test ./internal/... 全绿; go test -race ./internal/agent/core 通过。 * test(lua): 补适配器 stream_index 透传判据 + 全适配器体检 ## 先纠正一件事:这个修复在生产上早就存在 最初我判断"`openai.lua` 缺 stream_index 透传、批内并发在生产走不通",并据此 写了实现。**核对生产实例后,这个判断是错的**: 生产 /home/newqqagent/adapters/openai.lua 130 行 含 stream_index 仓库 950b21b^ 128 行 无 stream_index 生产那份的注释是「透传上游分片 index:并行多工具调用时内核按它区分归属桶」—— 简洁,与本提交新增的长注释不同。**也就是说仓库版本落后于生产,生产一直没这个 问题。** 我修的是"仓库与生产的差距",不是"生产正在发生的故障"。 ## 真正缺的是判据 `internal/agent/core/stream_index_test.go` 的 TestAccumulateStreamParallelToolCallsByIndex 直接构造 Go 结构体 `agentAPI.StreamChunk{...}`,**不经过 Lua 适配器** —— 所以"适配器有没有把 index 透传出来"它永远测不到。生产有、仓库没有,判据也发现不了。 而提交 7a51c9b(2026-08-26,"流式并行 tool_call 按 JSON index 分桶")的说明里 写着「openai.lua 输出 stream_index 字段」,Go 侧也加了 `StreamIndex int json:"stream_index,omitempty"` 并注明"lua 适配器以 stream_index 键透传" —— 但那次提交**根本没改 openai.lua**(6 个文件里没有它)。 说明与实现不符,而没有任何判据能发现。 ## 本提交做的事 ① 让 openai.lua 与生产一致(补 stream_index 透传),并说明为何缺它会静默失效: 多个分片全部并到槽 0 → argsRaw 混拼 → 每个工具报"参数不是合法 JSON", 而**工具一次都没真跑过**。单工具时上游 index 恒为 0,缺省也是 0, 所以问题只在"一轮多个 tool_call"时显形。 ② 新增 internal/lua/adapter_streamindex_test.go,**真正加载并执行内嵌的 openai.lua**(复用 VM 的真实路径),三条判据: - TestOpenAIAdapterPassesThroughStreamIndex 3 个 tool_call 的 stream_index 必须是 0/1/2 - TestOpenAIAdapterKeepsContinuationFragment 续传分片(只有 arguments、 没有 name)的 stream_index 必须正确 —— 它是分桶的**唯一**依据 - TestAllBundledAdaptersStreamToolCallStatus 全 10 个适配器体检 ★ 第三条刻意**不**用 t.Skip 掩盖不支持的适配器 —— 早期版本一律 Skip,结果 "完全不支持流式工具调用"也会让整体显示为绿,而绿会被误读成"都支持"。 现在分类记录:openai ✓ / kimicode+server 透传嵌套形态需另修 / 其余 7 个未产出 tool_calls。 ③ 体检顺带暴露的、与本提交无关但已记录的问题: - **仓库 vs 生产漂移无判据**:仓库适配器落后于生产时,只有靠人工对比才发现 - **部署陷阱**:vm.go writeBundledAdapters 是 `if 文件已存在 { continue }`,升级二进制**不会更新已有适配器文件**。 这可能正是"仓库缺透传却没人发现"的原因之一 * test(lua): 加适配器漂移报告 + 键名契约判据 上一提交(d05f896)纠正了一处误判:仓库的 openai.lua 缺 stream_index 透传, 而生产实例早就有 —— 仓库版本落后于生产。这个漂移当时没有任何判据能发现。 ## 两条判据,定位不同 ### TestBundledAdaptersMatchDeployedOnes —— 诊断式,刻意**不**作为失败判据 实测结果:生产部署的 7/10 个适配器比仓库旧(anthropic 2537 vs 5144 字节), 而 openai 那份反而领先。这**是正常的** —— 生产是长期运行的部署,适配器在它首次 创建时就解包落地,之后仓库一直在演进。 若把"必须一致"写成失败判据,它会在任何老部署上恒红,而恒红的判据会被无视 —— 那等于没有判据,甚至更糟(它会掩盖真正的漂移)。所以这里只把差异摆出来。 但它顺带把**部署陷阱**摆到了明面上: vm.go writeBundledAdapters: if 文件已存在 { continue } 升级二进制**不会更新已部署的适配器文件**。于是"仓库改了适配器但老实例上不生效" 与"仓库根本没改"在现象上完全一样 —— 这大概就是仓库长期缺 stream_index 却 没人发现的原因之一。 ### TestAdapterEmitsOnlyKnownFields —— 这条才是能自动抓 bug 的 契约 = agentAPI.ToolCall / StreamChunk 的 json tag: `id, type, name, arguments, raw_arguments, stream_index`(+ 上游原样透传的 `index` / `function`)。 ★ 为什么必须有:Go 侧按 json tag 反序列化,**键名拼错会静默取零值**。 `streamindex`(少个下划线)与"没写这行"的表现完全一样 —— 无报错、字段为零、 分桶全部并到槽 0。这正是本次 stream_index 缺失的形态。 判据只看"适配器吐出来的键名对不对",与环境无关,所以能在 CI 里恒定生效。 ## 顺带确认的一件事(事后查明:是我的操作失误) 压测实例解包出的 openai.lua 是 4709 字节(无 stream_index),而二进制 embed 里是 5672 字节(有)。`rm -rf` 后重新解包**仍是旧的**。 我逐行读过 `writeBundledAdapters`,只找到 embed 一条来源,一度判为"未解释的 矛盾"。**真因是操作失误**:`rm -rf` 之后启动的那一轮,用的还是修复前编译的 /tmp/homed-stress —— 删除与重编之间隔了几轮,中间又用旧二进制起了好几次 实例,每次解包出来的自然都是旧版。 用当前 main 重新构建 + 全新数据目录验证:全新解包 5672 字节、含 stream_index×3、md5 与源文件一致;删掉再解一次仍一致;启动日志有 `[lua] installed bundled adapter: openai.lua`。⇒ **writeBundledAdapters 无缺陷**。 ★ 教训:验证"二进制内嵌内容是否更新"时,必须确认跑的就是**刚编译出来的那个 二进制**。否则会得出"代码有 bug"的错误结论 —— 我确实这么怀疑了好几天。 (下面那条"部署陷阱"观察本身仍然成立:升级二进制确实不会更新已部署的适配器 文件。但它与这次的现象无关。) * test(lua): 记录仓库/生产适配器漂移的具体内容与时间线 生产 adapters/openai.lua(4853 字节)含 stream_index,仓库7a51c9b时的版本 (4709 字节)不含。生产文件时间 2026-08-26 15:46,比7a51c9b提交(16:10) 早 32 分钟 —— 该提交说明里写着「openai.lua 输出 stream_index 字段」, 但 --stat 显示它没改这个文件:修复先在生产生效,入库时漏了。 于是「生产能跑多工具、仓库跑不了」持续一个月而两端都没人发现:生产不报 问题(它有),仓库的判据也测不到(直接构造 Go 结构体,绕过适配器)。 判据本身不变(仍是诊断式 t.Log),只把这条漂移的具体内容写进注释 —— 它是理解本次全部误判的关键背景。 * test(stress): 补批内并发压测与 A/B 对比脚本,并给 mock 加多 tool_call 能力 ## 缺口 现有 kernel-stress 只压**调度器**(多连接排队 + L4 中断 + 驻留子),mock 每轮 只发**一个** tool_call。而内核的并发判据是: if f == nil || len(f.PendingTools) <= 1 { return false } ⇒ **一个 tool_call 永远不并发**。所以现有压测压的全是串行路径,批内并发 一条都没走过。 ## mockllm.py:让 mock 能发多个 tool_call - `!batchN` N 个全部 ParallelSafe 的只读工具 ⇒ 强制走 stepToolBatch - `!mixedN` 夹一个 knowledge_create(未声明并发安全)⇒ 验证整批降级 - `!slowbatchN` N 个 sleep 型 cmd_run(单工具约 200ms,时长递增) ⇒ 工具慢才能让并发的收益从噪声里显出来;时长递增使**完成序与声明序相反**, 可据此判定"是否按声明序落消息" - `!serialbatchN` 在 !slowbatch 基础上插入一个非并发安全工具 ⇒ 强制整批串行, 作为并发对照的另一半 - `!img` / `!ocr` 触发多模态工具路径(不加载模型,只验内核 IPC 接线与错误处理) - `!err` / `!hang` 故障注入 ### 三个协议细节(踩过才知道,都写在注释里) 1. **必须带 `index`** —— 内核按 `StreamIndex`(上游 JSON 的 index)分槽累积 arguments。缺 index 时所有分片落到槽 0,几个 tool_call 的参数被**混拼**, 症状是每个工具都报"参数不是合法 JSON"而工具一次没真跑过。 单 tool_call 时不设 index 也正常,所以老 mock 一直没暴露。 2. **必须按协议分片** —— 一个 chunk 一个 tool_call,各自带 index; 后续 chunk 只续 arguments。整数组塞进一个 chunk 会被内核按"续传"语义累积。 3. **每个工具都要发"带 name 的首片"** —— 我第一版只给第 0 个发首片、其余直接 发续传片,看起来省事,但内核 flush 时按"无 name 即丢弃"处理, 于是 idx=1/2/3 全被丢,只跑 1 个工具。 ## batchstress.py:批内并发压测(带校验) 只看峰值是不够的 —— 校验:工具是否真跑(响应里应有结果标记)、 消息顺序是否稳定(并发执行但按索引落消息)、mixed 批是否整批降级。 ## abtest.py:串行 vs 并发的定量对比 同一套内核上用 !slowbatch / !serialbatch 两组对照,交替执行抵消机器负载漂移, 取中位数(长尾会污染均值)。 ★ 判据里加了"工具是否真执行"这一项:**只看耗时是不够的** —— 出现过 "内核 274ms 就回复、工具一个没跑"的情况,那种情况下并发与串行都是 0.2s, 加速比毫无意义。 ## 实测(隔离 netns 实例,mock + 内核同网段,5 轮中位) N 并发 串行 加速 上限 2 0.481s 0.685s 1.42x 2 4 0.491s 1.114s 2.27x 4 8 0.513s 2.037s 3.97x 8 12 0.531s 3.039s 5.72x 12 并发批耗时几乎不随 N 增长,串行批严格线性。 ★ 另一个踩过的坑(abtest 脚本自己的):内核有输入去重 (task.go:353 `isDuplicateInput`,为 webui 断线重连重放而设),相同文本会被 丢弃并回空响应。第一版每轮发同一个 marker ⇒ 只有第 1 轮有效,后面全是 0 秒 0 工具。修法是每轮加 `time.time_ns()` 唯一后缀。 * docs(stress): abtest 补「跨版本对比的陷阱」—— 老版本可能只是没干活 脚本原本只写「加速比 = 基线延迟 / 新版延迟」。实测发现跨版本对比时这个 数字**没有意义**: N 老版本 中位/处理数 新版本 中位/处理数 算出的"加速" 2 0.273s / 0 个 0.480s / 2 个 0.57x 4 0.273s / 0 个 0.492s / 4 个 0.55x 8 0.273s / 0 个 0.512s / 8 个 0.53x "老版本更快"是假的 —— 它只是没干活:适配器缺 stream_index 透传 ⇒ 多个 分片并到槽 0 ⇒ argsRaw 混拼 ⇒ 每个工具报"参数不是合法 JSON"而一个都没 真跑。 ★ 跨版本能比的硬指标是**处理数**;耗时对比只在新版内部做(混一个非并发安全 工具触发整批降级)才有意义 —— 那种对照下 N=12 时加速 5.72×。 顺带记下:老版本的内核分桶逻辑其实完整(`idx := tc.StreamIndex` + `accs[idx]`, 来自 2026-08-26 的 7a51c9b),缺的只是适配器那一个字段;而生产在当天 15:46 就手工补上了,比该提交(16:10)早 32 分钟。 * fix(lua): deepseek 适配器的流式路径处理 tool_calls(此前全部丢失) ## 缺陷 deepseek.lua 的 tool_calls 处理只存在于 `transform_response`(**非流式**路径), 而 `transform_stream_chunk` 只透 content/done: return json.encode({ content = delta.content or "", done = (fr ~= nil) }) 于是 deepseek 源在**流式**模式下工具调用全部丢失 —— 模型调不动任何工具, 且**没有任何报错**,只是"工具好像不听话"。 ## 为什么难发现 - 非流式路径是好的 ⇒ 端到端手工测试也过 - 内核的 tool call 循环默认走**流式**(provider.go 的 stream 分支)⇒ 实际不可用 - 功能判据(core 包的批内测试)直接构造 `agentAPI.StreamChunk{}`, **绕过适配器** ⇒ 测不到这一层 配置里 `deepseek` 源预设指向 `adapters/deepseek.lua`,所以任何按预设配置 的用户都会踩到(生产当前未启用该源,配置里 deepseek 相关键为 0)。 ## 修法 照 openai.lua 的做法在流式路径补上:OpenAI 兼容格式 `{function:{name,arguments}, id, type, index}` → homed 扁平结构 `{id, type, name, raw_arguments, stream_index}`,含 reasoning_content 透传。 两个容易踩的点也写进注释: - **不能按 name 过滤**:流式续传片 name 为空但携带 arguments, 内核按 stream_index 分桶累积 - **必须透传 stream_index**:否则多个分片并到槽 0、argsRaw 混拼 ## 判据 新增 TestDeepSeekAdapterHandlesStreamToolCalls:喂两个含 tool_call 的分片, 断言 tool_calls 未被丢弃且 stream_index 正确。修前两条分片全被丢弃。 ## 体检分类随之变化 修前: ✓ [openai] ✗ [deepseek ...] 修后: ✓ [deepseek openai] ✗ [anthropic gemini github groq mistral ollama] * fix(lua): 补齐 6 个适配器的流式 tool_calls 支持 体检判据(TestAllBundledAdaptersStreamToolCallStatus)报出的三类问题, 本提交解决其中两类;第三类(gemini)未动,原因见下。 ## ① OpenAI 兼容族:github / groq / mistral(3 个) 它们的 transform_stream_chunk 与修复前的 deepseek **逐字相同** —— 只透 content/done,tool_calls 处理只存在于 transform_response(非流式)。 后果与 deepseek 相同:流式模式下工具调用全部丢失,模型调不动任何工具, 且**没有任何报错**。生产当前未启用这三个源,但按预设配置的用户会踩到。 照 deepseek 的修法补上(含 reasoning_content 透传)。 ## ② 嵌套形态 + 键名错:server / kimicode / anthropic / ollama(4 个) 这四个**有** tool_calls 处理,但发的是: { index = N, id = ..., ["function"] = { name = ..., arguments = ... } } 而 homed 的 `agentAPI.ToolCall` 是**扁平**结构,json tag 为: id / type / name / arguments / raw_arguments / stream_index 两处都是**静默**失效(Go 侧按 json tag 反序列化,取不到就是零值,无报错): - **嵌套** `["function"]` ⇒ `name` / `raw_arguments` 取零值 ⇒ flush 时判「无 name」丢弃,或参数为空 - **键名 `index`** ⇒ `StreamIndex` 取零值 ⇒ 多个分片并到同一个桶,argsRaw 混拼 ⇒ 每个工具报「参数不是合法 JSON」 而**一个都没真跑** 已逐项对齐为扁平 + `stream_index`。协议差异都保留: - anthropic:`content_block_start` / `input_json_delta`,续传片 name 留空 (内核按 stream_index 累积,补齐 name 后才 flush) - ollama:tool_calls **整条一次发完**(不分片),故 stream_index 取数组下标 ## ③ gemini 未动 它的流式函数处理 `candidates[].content.parts`,**全文件没有任何 tool_calls / functionCall 处理** —— 连非流式路径也没有。补它不是"对齐" 而是新实现,且 gemini 的 functionCall 形态(`functionCall: {name, args}`, args 是对象而非 JSON 字符串)与 OpenAI 族不同,需要单独判据。 生产三个源(llmsproxy / visionllm / justworker)全部用 `openai.lua`, 不阻塞。留作独立项。 ## 判据 - TestOpenAICompatibleFamilyHandlesStreamToolCalls 5 个 OpenAI 族适配器, 逐个验证 tool_calls 未丢 + stream_index 正确 - TestAnthropicAdapterEmitsFlatToolCallsWithStreamIndex 用 **Anthropic 协议** 的 fixture(不用 OpenAI 的,否则会因"不适用该 chunk"跳过 —— 看着绿, 实则没测) - TestOllamaAdapterEmitsFlatToolCallsWithStreamIndex 用 Ollama 协议形态 - TestDeepSeekAdapterHandlesStreamToolCalls 单列,因它有源预设指向 ★ 三个判据按**协议**分文件而非逐适配器:这几个文件的流式函数逐字相同, 共用一个 fixture 会因协议不适用而静默跳过 —— 那等于没测。 ## 体检分类 修前: ✓ [openai] ⚠ [kimicode server] ✗ [anthropic deepseek gemini github groq mistral ollama] 修后: ✓ [openai deepseek github groq mistral] ⚠ [] ✗ [gemini] * fix(lua): 内置适配器按内容自动更新,替代「文件已存在就跳过」 ## 原机制是这次全部误判的根源 if _, err := os.Stat(dstPath); err == nil { continue } **升级二进制永远不更新已部署的适配器文件。** 于是"改了仓库 ≠ 生产生效", 而这个机制让同类问题可以长期潜伏: 2026-08-26 15:46 生产 openai.lua 手工补上 stream_index 透传 2026-08-26 16:107a51c9b提交,说明里写了但代码没改这个文件 修复当天先在生产落地、32 分钟后才提交入库(漏了这个文件),此后一个月里 两端都没人发现 —— 生产不报问题(它有),仓库的判据也测不到(直接构造 Go 结构体,绕过适配器)。而"升级不覆盖"意味着即使仓库补上修复,已部署的 老实例也不会拿到。 ## 新判据(按内容,不按存在) 文件不存在 ⇒ 写 有历史清单且盘上 == 上次内嵌 ⇒ 覆盖(只是没跟上新版本) 有历史清单但盘上 != 上次内嵌 ⇒ 不动 + 日志(用户改过) 无历史清单(首跑/从旧版本升级) ⇒ 不动,只补缺失文件(与旧行为一致) "上次内嵌的版本"记在 `DataDir/adapters/.bundled`(`<name>\t<sha256>`)。 ⚠️ 为什么不能无条件覆盖:adapter_path 是可配置项,用户可以把 adapter_path 指向自己维护的适配器。静默覆盖等于丢弃他们的修改,而且**没有报错**。 ## 判据(两个方向都要测) - TestWriteBundledAdaptersSkipsUserModified 用户改过的**必须保留**, 且改完仍能正常加载(fixture 必须功能完整,否则会因为缺钩子函数而失败 —— 那是 fixture 问题,不是保护逻辑问题) - TestWriteBundledAdaptersUpdatesStale 落后于新内嵌的**必须被更新**, 且**幂等**(三跑不再改写任何文件) ★ 第二条是必要的:只测保护的话,**一个"永远不覆盖任何文件"的实现也能全绿** —— 而那正是要修的病。 ## 代价(必须知道) **升级到本版本的这一次,已部署实例的适配器不会更新**(没有历史清单可比)。 从第二次升级起自动生效。要立刻生效就删掉 DataDir/adapters 让内核重新解包。 对本次修的 8 个适配器而言:生产此刻用不到(三个源 llmsproxy/visionllm/ justworker 全是 openai.lua),所以不影响运行;将来启用 deepseek 等源时, 自然就是修复版。 ## 顺带 `bundledAdapterNames` 从 writeBundledAdapters 里提出来成包级变量 —— writeBundledAdapters 与体检判据共用,避免两处各写一份而漏掉某个 (漏掉的后果是该适配器永远不会被更新)。 * test(stress): 补更新前后全面对比脚本(cmp.py),修 blast 的计数错误 ## cmp.py:更新前后对比的四个维度 ★ 每轮都记录**处理数**(响应里有多少个工具结果标记)与**是否出现 error 帧**, 任一不符即记失败。理由:工具调用这一路的失败模式几乎都是**静默**的 —— 工具没跑、参数混拼、只处理了第一个 tool_call,都不报错只是结果不对。 "跑完没崩"完全不能说明它 work。 1. **批内工具调用**:!slowbatchN 在两版上各跑几轮,比中位耗时与处理数 2. **并发 vs 强制串行**(新版内部对照):!slowbatchN vs !serialbatchN 3. **调度器并发轰炸**:多连接并发排队,算通过率 4. **连续稳定性**:20 轮无错误率 ## 修掉 mock 的 !serialbatch 缺失 之前只在 /tmp 的临时副本里加过,没进仓库,导致 cmp.py 测「强制串行」时 那个 marker 根本不存在 —— 测出来的"串行"其实是并发,**加速比是假的** (0.34x / 0.31x,看起来并发比串行慢)。已加回并说明它的用途:跨版本做不了 并发/串行对照(旧版适配器缺 stream_index,工具一个都没真跑),只能在 同一套内核上做。 ## 修掉 blast 的计数错误 第一版按 `conns * inputs` 起线程、每个线程又跑 `inputs` 轮 ⇒ 总输入数是 conns×inputs²,分子分母量纲不一致,算出过 **"128/32 = 400%"** 这种荒谬数字。 现在:恰好 conns 个 worker、每个跑 inputs 轮;且分母用**实际发出的**输入数 (含连接失败的),否则连接失败时通过率会虚高。 ## 踩过的两个坑(都写进注释) - 内核 `task.go:353` 有输入去重(`isDuplicateInput`,为 webui 断线重连重放 而设),相同文本被丢弃并回空响应 ⇒ 每轮输入必须带唯一后缀 - cli 的 auth 帧本身就是 `{"type":"response"}` ⇒ 必须先吃掉它再开始收集, 否则第一轮的"终止帧"是 auth,测出来耗时恒为 0 * docs(plan): 记录更新前后全面压测结果与部署前置条件 两版隔离实例实测(规模 3 = 288 条输入),核心差异是**处理数**而非耗时: 旧版 openai.lua 缺 stream_index 透传 ⇒ 多个分片并到槽 0、参数混拼 ⇒ 工具一个都没真跑,却因为「少干活」而耗时更短。 !slowbatch8 旧 0.220s / 0 个 → 新 0.409s / 8 个 并发 vs 强制串行(新版内部) N=8 加速 3.88× 调度器轰炸 288 输入 两版均 100% 通过 连续稳定性 20 轮 两版均无错误、内核无 panic 规模 1(32 条)与规模 3(288 条)结果完全一致 ⇒ 可复现。 同时记录部署前置条件:生产是 -tags=onnxruntime 构建(strip 后 75MB vs 普通构建 28MB),package-linux.sh:139 会显式拒绝非 onnxruntime 构建。 本次改动未触及任何 ONNX 路径,故压测结论对生产成立,但必须走 deploy/packaging/build.sh 才能部署。 * docs(plan): 补记全面压测结果与部署前置条件 两版隔离实例实测(规模 3 = 288 条输入),核心差异是**处理数**而非耗时: 旧版 openai.lua 缺 stream_index 透传 ⇒ 多个分片并到槽 0、参数混拼 ⇒ 工具一个都没真跑,却因为「少干活」而耗时更短。 !slowbatch8 旧 0.220s / 0 个 → 新 0.409s / 8 个 并发 vs 强制串行(新版内部) N=8 加速 3.88× 调度器轰炸 288 输入 两版均 100% 通过 连续稳定性 20 轮 两版均无错误、内核无 panic 规模 1(32 条)与规模 3(288 条)结果完全一致 ⇒ 可复现。 同时记录部署前置条件:生产是 -tags=onnxruntime 构建(strip 后 75MB vs 普通构建 28MB),package-linux.sh:139 会显式拒绝非 onnxruntime 构建。 本次改动未触及任何 ONNX 路径,故压测结论对生产成立,但必须走 deploy/packaging/build.sh 才能部署。 * chore(deploy): 加生产部署方案脚本(check/backup/deploy/rollback 四段) ## 为什么需要 生产二进制是 **`-tags=onnxruntime`** 构建(86.5MB,.rodata 62.5MB), 而普通 `go build` 只有 37MB —— 差的是 ONNX Runtime 绑定。 `deploy/packaging/package-linux.sh:139` 会显式拒绝非 onnxruntime 构建: if ! go version -m "$homed_bin" | grep -Eq 'build[[:space:]]+-tags=.*onnxruntime'; then echo "ERROR: homed 不是 onnxruntime 构建,拒绝打 server/full 包" >&2 也就是说:**用错构建方式部署,依存句法分析与多模态向量化会静默失效**。 这个坑我自己踩过一次(拿普通构建去比体积,才发现的),所以脚本第一步 就卡这个判据。 ## 四个动作 - `check` 只读检查:服务状态、onnxruntime 标签、libonnxruntime.so、 模型资产、适配器清单。**不改任何东西**,可随时跑 - `backup` 备份二进制 + 适配器 + unit 文件,并**生成 ROLLBACK.sh** - `deploy` check → 人工确认(输入 yes)→ 备份 → install -m 0755 原子替换 → 重启 → 8 秒后验活;失败时打印回滚命令与 journalctl - `rollback` 用最近一次备份回滚 ## 刻意不做自动回滚 回滚要不要做、什么时候做,是人的判断。脚本只负责把状态保全好, 让回滚成为一条**可执行**的命令,而不是一个自动决策。 ## 部署不会碰的东西(已在 check 里显式打印) - **适配器文件**:`8d721a5` 的新逻辑在「无历史清单」时不动任何已存在的文件, 所以生产的 10 个 .lua 保持原样(含那个已含 stream_index 的 openai.lua) - **数据目录**:51G 的 models/ 与配置都不动,部署只换二进制 * chore(deploy): 补强部署后验证 原来只说「等 20s 看 /kernel 状态」,等于没验。进程活着 ≠ agent 起来了。 现在逐项核对,每项对应一个真实故障模式: - 60s 内未见 'kernel ready' ⇒ 判失败并给出回滚命令 + journalctl 尾部 - 注册工具条目数(生产应 18 个左右)⇒ 少了说明插件加载异常 - 'LLM API unreachable' 次数 > 3 ⇒ 内核在 rollback 循环 (生产设了 max_retries=100000,不可达会一直重试) - ONNX provider 相关日志 ⇒ 缺失时应是明确错误+降级,不静默假装启用 * feat(waiter): 设备命令白名单改为 waiter.yaml 可配置 ## 起因 白名单是源码里硬编码的正则(`homeagentAllowCmd`,18 个命令), 而 `waiter.yaml` 里**没有任何键能改它** ⇒ `find` / `grep` / `sed` / `sort` / `tr` 这些排查问题最常用的**只读**命令一律被拒。生产实测: device_ctl_cmdrun device_id:waiter-fnnas error: command not in whitelist 命令执行完全在 waiter 侧(`device.go` 的 `exec.CommandContext`),插件侧无二次 限制;触发者是 **agent**(经 device_ctl_cmdrun),所以这道闸是机器闸、不是人工确认。 ## 改动 waiter.yaml 新增 `device_cmd_allowlist`(字符串数组): device_cmd_allowlist: - ls - find - grep - sed - **替换**默认集而非追加:避免"以为加了 find、结果还留着 python3 -c 任意执行" - 留空 ⇒ 用内置默认集(★ **绝不能变成"全放行"**,那等于静默拆掉闸门) - 匹配只取命令名**第一段**再整词匹配:`grep -rn x .` 能过, 而 `grepXxx` / `mygrep` 不会因 contains 蒙混过关;也跳过 `FOO=bar cmd` 的赋值前缀 - `deviceCmdAllowed` 是包级函数变量,由配置赋值 —— 与同文件既有的 `sendBridgeResult` 同一模式 ## ★ 一次真实的疏漏(判据记着) waiter 有**两条**设备桥启动路径: - `main.go` 的 `startDeviceBridge` —— 交互/一次性模式 - `daemon.go` 的 `startDaemonDeviceBridge` —— `waiter --daemon`(**生产两台都这么跑**) 我最初只在 `main.go` 里赋值。daemon 路径不经过那里 ⇒ 配置**完全不生效**, 而症状是"配置写了、启动也打了招呼、命令照样被拒",极难定位。 两处都接上了,并加 `TestDaemonPathAppliesAllowlist` 守住。 ## 判据(5 条) - `TestDefaultAllowlistStillBlocksDestructive` 默认集必须挡住 `rm -rf /`、`dd`、`chmod -R 777`、`mkfs`、fork 炸弹 —— **这道闸存在的唯一理由**,谁把它改成"什么都不拦"这条就要失败 - `TestConfigAllowlistExtends` 配置里声明的 `find/grep/sed/sort/tr` 能过; 配置未含的 `rm -rf /` 仍被拒(证明是"替换"不是"叠加") - `TestEmptyConfigFallsBackToDefault` 配置为空时回退默认集,**且不放行** `rm -rf /` - `TestCmdAllowlistFromYAML` 走**真实** `readFile` 解析 yaml(不另写一份解析, 两处会漂移,而漂移本身就是漏洞) - `TestDaemonPathAppliesAllowlist` 守住 daemon 路径也应用配置 ## 生效方式 106/30 的 `/opt/waiter/waiter.yaml` 追加 `device_cmd_allowlist`, 并更新二进制。启动日志会打印 `device cmd allowlist: N 条(来自 waiter.yaml)` 或 `默认 N 条`,便于确认配置是否真的被读到。 * chore(stress): 加 waiter 远程更新脚本;修 cmp.py 的 docstring 格式 ## deploy-waiter.sh:106 / 30 的 waiter 更新 现状(更新前):两台都是 8月27日构建的 /opt/waiter/waiter(11,388,177 字节), 以 root 跑 waiter-remote.service,配置指向 ws://192.168.2.60:9890/… 安全设计: - 先备份旧二进制(`$BIN.bak-<时间戳>`),失败即回滚(脚本内自动) - **只换二进制,不动 waiter.yaml**(配置由 deploy 后单独追加) - **逐台更新并验证,不并行** —— 两台都连同一网关,同时重启会同时断链 - 106 走 admin+sudo、30 走 root - 验证项:服务 active + 进程时长 + **配置 md5 未变** 用法:`check`(只读)/ `deploy <ip>`(需输 yes)/ `rollback <ip>` ## cmp.py:两处格式 docstring 的 `"""` 紧贴内容、以及函数段之间缺两个空行(PEP8)。纯格式, 无逻辑改动。 * fix(deploy): deploy-waiter 的 ssh 加 -n,否则「喂了 yes 却说已取消」 `do_check` 里的 ssh 会从 stdin 读,把后续 `read -p "确认更新"` 的输入吃掉 —— 于是 `bash deploy-waiter.sh deploy <ip> <<< "yes"` 里的 yes 被 ssh 消耗, read 拿到空串,脚本静默走「已取消」分支。 症状极难定位:脚本本身完全正常、备份逻辑没问题,只是"明明喂了 yes"却 什么也没发生。5 处 ssh 统一加 -n。 * docs: 两份 toolcall 文档对齐实现与部署实况 ## 契约文档:状态头从「尚未实现」改为「已实现并部署」 生产已注册 7 个 `seq_*` 工具,但文档仍写着"设计定稿,尚未实现" —— 实现者(和读者)会以为 seq 还不存在。 新增 §9.3「实现落点」:设计稿 §8 写的是**六个** `seq_*` 工具,实现时 多了一个 `seq_when_call`(跨序列条件调用独立成工具,否则模型要手写 "先 seq_list 再挑目标再 seq_call",多一次往返且容易挑错),并记下三项 设计之外的修正(`seq_create` O(n²)、`Store.List()` 误认任意 `.json`、 存储用 AST 而非原始文本)。 同时点明:设计条款**仍是契约**,实现与本文冲突时以本文为准并修实现。 ## 修一处预先存在的失效引用 §7 末尾 `见 §4.5` —— §4 只到 4.4,该小节不存在。改为按标题名引用 (`§4「结果契约」的 ErrToolNotFound 哨兵`):将来增删小节时不会再次失效。 自查脚本第一版把 8 个**存在**的章节误报成失效引用 —— 标题格式是 `## 1. 背景`(编号后跟 `.`),而我的正则要求编号后是空格。判据自己错了, 改成 `(\d+(?:\.\d+)*)\.?\s` 后才得到真实结果。 ## 并行计划文档:部署小节 + 白名单专节 - 原「⚠ 部署前置条件(未完成)」改为「✅ 部署(已完成)」,补实际验证数据 - 记下"实例自述没有编排工具"不是说谎:生产二进制构建于 06:36、seq 引入 于 `69de85a`(更晚)⇒ `strings | grep -c internal/plugins/seq` 为 0。 这类"实例自述与代码状态不一致"应先查二进制构建时间,别急着怀疑提示词 - 新增「设备命令白名单改为可配置」:起因、替换语义、daemon 路径的疏漏 - 明确写下**已知局限**:白名单只匹配命令名、不看参数, `find -delete` / `sed -i` / `sort -o` 仍放行 ⇒ **不要把它叫"只读白名单"**, 那会让人以为写操作被挡住了 - 记 106 此前无 `online` 日志的成因(旧 waiter 落在未 bind 时收 ping 会断连的 缺陷窗口),以及 `ssh` 吃掉 `read` 输入导致"喂了 yes 却说已取消" 删掉了初稿里一段"反引号内 `+=` 写进 heredoc 导致赋值落到子 shell"的说法 —— 脚本与 git 历史里都没有这种写法,属凭记忆误记,不能留。 文中数字均与现场核对:seq 工具 7、二进制 86784400 / 12691402、白名单 22 条。 * fix(provider): `has empty arguments` 误报 —— 零参数工具被当成参数丢失 ## 现象 部署后生产日志出现 14 次: provider.go:414 [provider:llmsproxy] tool_call seq_list (...) has empty arguments ## 根因 原始响应里参数**完好**(从日志扒出来): "tool_calls":[{"function":{"arguments":"{}","name":"clawhubadapter_list"},...}] 诊断条件是 `len(tc.Arguments)==0 && tc.RawArguments==""`,而 `parseToolArguments("{}")` 走 string 分支 → `json.Unmarshal("{}", &m)` 成功且 `m != nil`(**非 nil 的空 map**)⇒ 返回空 map ⇒ 命中告警。 被点名的全是**零参数工具**(`seq_list` / `*_list` / `seq_help`, 它们的 `properties` 本来就是 `{}`)。 ## 为什么必须修 不是"日志吵"。这条诊断的本职是抓「上游/适配器**真的**把参数丢了」, 真发生时会被这 14 次噪音淹没 —— **诊断日志失去信噪比就等于没有**。 ## 修法 新增 `argsLookDropped(rawArgs)`,判 `RawArguments` **原文**而非解析后的 map: - 空串 / 纯空白 ⇒ 上游没给 arguments 键 ⇒ 真丢 - 能解析成 JSON(哪怕是 `{}`)⇒ 上游确实回了参数 ⇒ 不报 - 解析失败(如半截 JSON)⇒ 参数本身是坏的 ⇒ 等同丢失 ## 判据(3 条) - `TestEmptyArgumentsDiagnosticIgnoresExplicitEmptyObject` 4 个子用例: `{}` / ` { } ` / 完全缺失 / 只有空白 - `TestArgsLookDroppedIgnoresNonEmpty` 非空参数一律不报 - `TestArgsLookDroppedEndToEnd` 用**日志里出现过的真实 body** 走 `normalizeOpenAIToolCalls` 到判定的完整接缝 —— 单测过了但接缝不对 只有端到端抓得到 写判据时我先用错了类型:拿 `apiToolCall`(**非流式**路径的结构)喂 `normalizeOpenAIToolCalls`,vet 直接报错才纠正为 `openAIToolCall`。 两套结构并存,很容易接错缝。 ## 顺带记录:另一个告警不是内核缺陷 `重复申请 stage 锁`(5 次)经排查是**插件侧**问题,内核自愈机制工作正常: - `proc_main.go.tmpl:1569` SDK 模板在每个 stage handler 入口**自动**调 `stage.lock`;`lock.go:51` 锁**不可重入** ⇒ 同一次 `before_toolcall` 被触发两次且首次未释放就命中 - 已排除 qq 业务代码:`beforeToolcall`(plugin.go:1303-1350)只有 `ctx.Lock()`(SDK **数据**锁,与 proc stage 锁是两把锁)与纯本地调用, 无任何再次触发 stage 的路径 - 成因在插件进程侧运行时(编译进 9月14日的 `plugin.bin`,**不随 homed 部署**) - 内核 `stage.go:102-106` 的强制释放是**有意设计**("锁仲裁回内核"自愈, 实验 9),避免后续插件死锁;`stages.go:258` 把错误收进 `ctx.Errors` 不中断流程 ⇒ 那轮 212 秒正常跑完 两条结论都写进文档,避免以后有人当内核缺陷去修。 门禁:`-race` 通过,`go test ./internal/... ./cmd/...` 全绿。 * docs: 新增生产部署手册(homed / waiter) `site-infra-runbook.md` 是**静态站 / nginx / 证书**的手册,不含 homed 与 waiter —— 两次生产部署(19:45 首次、21:50 修复)因此只存在于提交信息里, 查不到。 ## 内容 **§1 homed** - 硬前置:必须 `-tags=onnxruntime`(普通 build 只有 ~28MB,缺 ONNX Runtime) - ★ **构建参数必须与线上一致**:不要顺手加 `-s -w`。加了产物从 86.8MB 掉到 78MB,8.8MB 的差会让人误判成"构建坏了",而它只是被 strip 了 - `check` / `deploy` / `rollback` 三条命令与备份位置 - 部署后必核:`multimodal space active: provider=chineseclip` 才是 ONNX 真加载起来的标志,缺它说明已降级但**不报错** - ★ 适配器升级的保护语义(`.bundled` 三种情形的判定表),并记 21:50 那次 实测:手工补过 `stream_index` 的 `openai.lua` 被正确判定为用户修改并保留 - 两次部署的真实数据对照表 **§2 waiter** - 逐台更新、不可并行(两台连同一网关,同时重启会同时断链) - 部署后确认 `device waiter-* online` - 记 2026-09-27 顺手解决的悬案:106 此前无 `online` 而 30 正常,两台配置与 token 完全相同 ⇒ 差异只可能在旧二进制,8月27日那版落在"未 bind 时收到 ping 会关连接"的缺陷窗口 - `device_cmd_allowlist` 的替换语义、生效验证、幂等追加方法 - ★ 明确写**白名单只匹配命令名、不看参数**,`find -delete`/`sed -i` 仍能逃 ⇒ **不要称它为"只读白名单"** **§3 故障排查**:按"消息没反应 / 命令被拒 / 适配器异常 / 告警是否缺陷" 四条线各给命令;特别标注"群聊 not @bot"与"私聊没回"是两件事 **§4 已知未修**:三项目前是已知限制而非疏漏 文中数字均与现场核对:脚本子命令确实存在(`check`/`deploy`/`rollback`)、 生产白名单确为 22 条、两次二进制大小取自实际部署。 * feat(deploy): 站点部署流水线(SDK 文档站 + introduce) `deploy-sdk-site.sh` 覆盖两个静态站,与 homed / waiter 是独立部署单元: - SDK 文档站:本地 `third_party/homeagent-sdk/site_build/` → 106 的 `/vol1/docker/navi-data/sites/sdk` - introduce:本地 `site/`(零构建,源即产物)→ `sites/introduce` 用法:`--check`(只核对差异)/ 默认(构建+部署+验证)/ `--rollback <备份名>`。 `--check` 逐字节比对,2026-09-27 核实两站线上与本地产物**完全一致**。 ## 更新了一处会误导的注释 脚本原注释写「设备网关白名单只放行 ls/stat/find/cat,打不了包」。 那是 waiter 白名单**硬编码 18 条**时的状况;2026-09-27 部署 3252a7c 后 106 已扩到 **22 条**(含 find/grep/sed/sort/tr/wc/head/tail/stat/file)。 **结论(打不了包)不变,但理由已变** —— 22 条里**没有 `tar`**, 有 `sed` 也不能打包。照旧文字理解会以为白名单只有 4 条。 ## docs/zh/deploy-runbook.md 补 §5 站点章节 - 链路:`.60` nginx stream 按 ssl_preread SNI → 106:3080 → navi 容器内 nginx (**不是** portal-nginx,那个已 Exited 两周) - 坑①:构建**必须**走 `tools/apidoc/build.sh`。裸跑 `mkdocs build` 会丢掉 整个 `api/*.md` 和 `llms.txt` —— 而 `llms.txt` 正是给 agent 直读的入口。 正确产物 106 个文件,裸跑只有 78 个 - 坑②:打包不能走设备网关(无 `tar`),必须 SSH 直连 - ★ **新增文档后必须更新 `mkdocs.yml` 的 nav** —— 没登记会被 mkdocs 明确 警告 `not included in the nav configuration`,等于写完了但站点里不可达 - 验证要打**线上**而不是只看本地产物 - 回滚点与失败版本保留策略 写文档时我一度把 introduce 的域名写成「另见 §5.2」,但 §5.2 只讲了 SDK 的 SNI 链路 —— 已改为只写"同台同目录",并补上脚本里有依据的"故意不带 README.md"。 * docs(runbook): 补 §6 部署单元清单,并纠正一次误判的记录 ## 补齐漏掉的单元 `cmd/` 下共 7 个可构建入口,服务端只部署 3 个: | 单元 | 入口 | 服务端部署 | | --- | --- | --- | | homed(内核) | `cmd/homed` | ✅ 本机 `/usr/local/bin/homed` | | waiter / waitercli | `cmd/waiter` | ✅ 106、30 的 `/opt/waiter/waiter` | | 站点 | `site/`、`site_build/` | ✅ 106 的 `sites/` | | **GUI** | `cmd/gui` | ❌ **Electron 桌面应用,随客户端分发** | **GUI 不在服务端部署**(已确认生产无 `*.service`、无进程)。它与 waiter 是两端:GUI 用 `devicebridge_dll.js` 走设备桥协议连服务端 waiter, `60345a4` 修的正是 GUI 侧 bind 判 ok 与登记状态暴露。 ⇒ 排查 GUI 问题要看**用户机器上的客户端**,不是 `homeagent.service` 日志。 另记:核查"有没有进程"时 `ps -ef | grep -c "[e]lectron"` 会把**自己的 grep 命令行**算进去返回非 0(实测返回 2,实际 0)—— 要看列出的内容,别只看计数。 ## 本机 waiter 与 106/30 不同步 本机也留了一份 `/usr/local/bin/waiter`,`v1.3.2-153-gff69127`(2026-09-25 构建),**早于** main 在 09-26 合入的那批修复(设备反复掉线、bind 判 ok、 服务端发现自动链接),因而缺它们。106/30 已是 `1.4.0`。 本机这份无进程无服务,不影响生产设备桥;更新方式已记入文档。 ## 记一次误判:判定"是否已合入"要按内容查 我曾用 `git merge-base --is-ancestor ff69127 origin/main` 判定"这批提交 没进 main",并推断"存在一条未合入、只靠 reflog 撑着的 5 提交线"。**该结论是错的。** 真实情况:这批改动在 2026-09-26 以**新 hash** 重做并进入 main,五对逐字节一致 (diff 均为 0 行):`1acbd39`→`4b69896`、`3d30482`→`370c648`、 `078517e`→`c2a1781`、`aaafaac`→`6b37b1d`、`ff69127`→`60345a4`。 ⇒ 只查**旧 hash 的祖先关系**会误判;同一改动被重做为新 hash 时 `merge-base` 必然说不包含,而 `git merge-tree` 报的 13 个"冲突" 正是同源改动做两遍的必然结果,**不能当冲突去解**。 顺带修正一处归因:106 此前长期无 `online` 日志的成因是 `6b37b1d`(设备反复掉线/静默失联:ping 路径断连 + bind 结果无人处理), 2026-09-26 已进 main,今天部署的 `1.4.0` 包含它。 * fix(webui): 修总览 KPI 长期空值(我上轮懒加载改出来的)+星图改银色 ### ★ 严重问题:总览页 8 个 KPI 里有 5 个是空的 线上实测(https://homeagent.jianfgit.xyz): ["运行中状态","1h 22m 18s运行","0插件","v1.4.0…版本","—LLM", "—记忆","—文档","—运行时"] 对照 state:{"status":true,"kernel":false,"settings":0,"runtime":true} 根因是**我上一个提交(a352361 按页签懒加载)引入的**: 总览的插件数/版本/LLM/记忆/文档/运行时全部读 `state.kernel` (`updateOverview` 里写作 `(k && k.plugins)` 这类安全取值), 而我把 kernel 从 overview 的数据块里删掉了。 ⇒ 缺数据时不报错、**只显示「插件 0、记忆 —、文档 —」**, 看起来像「服务坏了」而不是像 bug —— 这类静默降级最难自查。 ★ 教训(已写进代码注释):依赖分析必须覆盖**整个调用链**。 我当初只 grep 了 `renderOverview` **直接**读的 state.*,漏了它间接 调用的 `updateOverview`。逐页重核后确认只有 overview 漏配,其余页签 (plugins/settings/kernel 各自要的)本来就是对的。 修复:overview 的 fetch 补回 kernel。kernel 拉过一次后不再重拉 (starmapFetchBlock 的节流),152KB 只在首屏付一次。 实测:["运行中状态","1m 15s运行","17插件","v1.4.0HomeAgent版本", "deepseekLLM","1200/893记忆","0文档","39 · 11M运行时"] ### 星图配色改银色(用户裁定) SM_COLOR_DIM: 0x7d8a9e(灰蓝)→ 0xc8ced8(银色)。 实测 colors: ["7d8a9e"] → ["c8ced8"] ### 关于「页面还是绿色」这个现象 线上内联的颜色实测已是 0x7d8a9e,缓存头也正确 (cache-control: no-cache, no-store, must-revalidate), 浏览器复现同样是灰蓝色 ⇒ 那是**已打开标签页里的旧 JS**: 页面 HTML 变了,但没重新加载的标签页不会自己换。 本提交部署后需**刷新页面**(Ctrl+Shift+R 强刷)才会看到新配色。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(gui): SSE 退避计数从不重置 —— 消息流不稳的一个共因 ## 现象 用户报 GUI「消息流不稳、动画不连贯、看着卡」,四类症状都有:滞后、 卡顿、闪断、资源高。定位到**同一个**共因,不是四个独立问题。 ## 根因 1:退避计数只增不减 `state._sseRetryAttempts` 的自增只发生在 `connectFetchSSE` 的 catch 分支 (连接**建立**失败),而 `pump()` 中途断流后的重连**只读它算延迟, 从不清零**: Math.min(1000 * Math.pow(2, Math.min((state._sseRetryAttempts || 0), 5)), 60000) 后果:只要历史上累计过 5 次,**之后每次断连都固定等 32s** —— 哪怕这次刚 成功连上、说明服务端和网络都好好的。而"成功连上"恰恰是最该重置的信号。 修:建连成功后清零(reader 取流之前),断流重连前再清一次。 ## 根因 2:外层 60000 上限是死代码 `2^5 = 32s < 60s` ⇒ `Math.min(..., 60000)` 永远达不到,**真实封顶是 32s**。 改为 32000,让声明值与实际一致(判据会验这一条)。 catch 分支那处保留 60000:它语义不同(连接根本没建起来,attempts 已 +1, 退避本就该更长),上限放宽无害 —— 判据按各自语义分别判定,不一刀切。 ## 根因 3:401 重试固定空等 800ms `api()` 里每个 401 都走 `syncConnAuth()` + 固定 `setTimeout(800)` 才重试。 认证过期时**每个**请求白等 0.8s,并发几个就叠加成明显的「卡」。 `syncConnAuth` 本身就是 await 的,返回即代表凭据就绪 ⇒ 降到 30ms (留一点让 setAuth 的 cookie 落盘)。 真 401 与「门户返回 200 但内容是登录页」两个分支都有这处等待,两处都改。 ## 判据:cmd/gui/sse-backoff.test.mjs `cmd/gui` 无测试框架(package.json 只有 start/dev),app.js 是 203KB 单文件。 判据从**真实源码**提取退避表达式并求值,而不是抄一份逻辑重写 —— 抄写的那份会和真实代码漂移,而漂移本身就是这个判据要防的东西。 3 项 + 变异测试(删清零 / 改回 60000 / 改回 800ms,三次全部被抓到)。 ### 判据本身踩的三个坑(都记在文件注释里) 1. **正则两种形态括号数不同**:`Math.pow(2, x)` 比 `2 ** x` 多一层括号。 早先只按 pow 写,biome 规范化成 `**` 后**静默失配**。 2. **不能用 String.raw 拼接正则**:`\\.` 保持双反斜杠字面量(去找字面的 "\."),且拼接后**捕获组编号不可控** —— 实测 cap 解出 NaN。 3. **一个正则兼容两种形态会取错捕获组**(组数差 1)⇒ 改为 **定位与取值分离**:正则只负责定位(不捕获数字),数字单独取。 ### 只认 pump 那处上限自洽 catch 那处 `60000` 不可达但无害(语义是"最多等一分钟")。 判据对两处**分别**判:pump 要自洽,catch 只要有有限上限。 ## 排查时坐实的两件事 - **`go test ./cmd/gui` 报 `[setup failed]` 不是仓库问题**:`cmd/gui` 有 **0 个 `.go` 文件**(纯 Electron),Go 通配会跳过它,只有显式点名才报。 `go test ./...` 实测退出码 0、43 包 ok、0 处提及 cmd/gui。 - **biome 会顺手把 `function () {}` 改成箭头函数**(本次混入 12 行)。 与本次修复无关,已从 HEAD 干净重放,最终 diff 只有 3 处实质改动 (24 增 3 删,零无关格式化)。 * test(gui): 行为判据 + 接进 make test(此前无人能跑) ## 为什么加行为判据 `sse-backoff.test.mjs` 检查源码**形状**(有没有清零、上限自不自洽)。 但形状对 ≠ 行为对:把清零写到 `reader` 取流**之后**,形状检查照样通过, 而实际仍在用旧计数重连。 新判据 `sse-backoff-behavior.test.mjs` 从**真实源码**抽出退避表达式并 在 `node:vm` 沙箱里求值,用假状态记录实际等待。实测量化: 历史累计 8 次后建连成功再断流 → 等 1000ms(改前会是 32000ms) 连续 6 次「建连成功→断流」 → 1000,1000,1000,1000,1000,1000ms ## 变异测试(都抓到) | 变异 | 形状判据 | 行为判据 | | --- | --- | --- | | 清零挪进 setTimeout 内 | 通过 | **红** ← 只有行为抓得到 | | 删掉「建连后」清零 | **红** | 通过 ← 暴露了行为判据的盲区 | | pump 上限改回 60000 | **红** | — | | 30ms 改回 800ms | **红** | — | 第二行促成了「行为 0」:建连后清零原本不在被验证的路径上 (抽取锚点只抓 pump 那一处),补了独立检查。 ## ★ 判据本身踩的坑(都写进文件注释) 1. **别包假 setTimeout**:`exprSrc` 本身就是延迟数值 (`setTimeout(fn, <延迟>)` 的第二个参数),包一层让结果恒为 null, 三项全红。 2. **别用 `new Function`**:等价于 eval,是安全反模式。改用 `node:vm` 的 `runInNewContext`(官方受限环境,拿不到宿主作用域,带 1000ms 超时)。 3. **一个正则兼容两种幂运算形态会取错捕获组**:`Math.pow(2,x)` 比 `2 ** x` 多一层括号 ⇒ 组数差 1 ⇒ `r[length-1]` 取到 NaN。 最终形态是**定位与取值分离**:正则只定位(不捕获数字),数字单独取。 4. **`String.raw` 拼接正则不可用**:`\\.` 保持字面双反斜杠(去找字面的 "\."),且拼接后捕获组编号不可控。 ## ★ 这些判据此前没有任何入口会跑 `cmd/gui` 是**纯 Electron 目录**(0 个 `.go`、无 `go.mod`),Go 通配会 跳过它 ⇒ 判据挂着也没人执行。现已接入: - `cmd/gui/package.json` 加 `"test"` - `Makefile` 加 `test-gui` 目标,并挂进 `test` - 无 node 时显式 SKIP 而不是静默通过 ```bash make test-gui # 或 cd cmd/gui && npm test ``` ## 顺带说明 `go test ./cmd/gui` 报 `no Go files [setup failed]` **不是回归**: 该目录 0 个 `.go` 文件,只有**显式点名**才报。仓库门禁用的三种形态 (`go test ./...`、`go test ./cmd/...`、Makefile 的 `test`)全部通过。 * docs(make): 记下 proc grandchild 测试本身不稳定(现象,未下根因) `make test` 的后置验证里发现 `internal/plugin/proc` 会 FAIL,实测形态: - 单独跑**同一命令**:`ok 11.5s` / `FAIL` 交替出现(至少各一次) - 全量并发跑:曾 600s 超时(`panic: test timed out after 10m0s`), 也曾 90s 就 FAIL - 失败测试固定是 `TestKillReturnsEvenWhenGrandchildSurvives`, 伴随日志 `[proc] audit 退出: signal: killed` 与本次改动(73e1261 SSE 退避、47aad60 判据)无关联:改的是 `cmd/gui/renderer/app.js` 与判据脚本,没碰 `internal/plugin/proc`。 症状**像** fork/kill 的进程组语义在容器/并发下不稳(孙进程 setsid 脱组后杀不掉 ⇒ Wait 挂死),但**尚未定位到根因**,因此 Makefile 里 只记现象、不下结论。 ★ 记这一条是因为我自己先踩了坑:一开始连跑 3 次全绿,我就准备写 「单跑稳定、仅并发偶发」;紧接着同一命令又 FAIL 了。**单跑通过不能 当结论** —— 这类 fork/kill 测试必须重试才能给出可信判断。 * fix(make): test-gui 不再被前序失败短路(门禁曾形同虚设) ## 问题 `make test` 原本是 make 的**依赖链**: test: $(GO) test ./... @$(MAKE) test-gui @$(MAKE) csrc-test ... `go test ./...` 一旦 FAIL,make **立即中止** ⇒ 挂在它后面的目标一行都不跑。 实测坐实:`internal/plugin/proc` 偶发 FAIL 时,`make test` 的日志里 **找不到 test-gui 的任何输出** —— 刚接进去的 GUI 判据根本没被执行。 门禁挂上去等于没挂。 ## 为什么不能简单用 `-@$(MAKE) test-gui` `-` 前缀会**吞掉 GUI 判据自己的失败码** —— 判据红了 make 照样绿, 等于给假绿灯。那比短路更糟:它让人以为门禁在生效。 ## 做法 全部跑完,最后统一判退出码: test: @rc=0; $(GO) test ./... || rc=$$?; \ $(MAKE) test-gui || rc=$$?; \ $(MAKE) csrc-test || rc=$$?; \ $(MAKE) check-csrc || rc=$$?; \ $(MAKE) check-codec-cgo-only || rc=$$?; \ exit $$rc 既保证每一步都跑,也保留每一步自己的失败。 ## 验证(注入失败实测,不是推断) 往 `TestKillReturnsEvenWhenGrandchildSurvives` 里塞 `t.Fatal` 制造必然失败: | 验证项 | 结果 | | --- | --- | | `make test` 退出码 | **2**(非 0,失败被上报) | | proc 包 FAIL | 抓到 | | **GUI 判据输出** | **仍执行** ← 这是要证明的那件事 | | csrc-test | 也执行了 | | 汇总 | ok=43 FAIL=1 | 随后已恢复该测试文件(`git checkout` + 确认 0 处残留 `t.Fatal`), 并复跑该包确认 ok 10.6s。 全绿路径也验过:`make test` 退出码 0、ok=44 FAIL=0、GUI 判据执行。 * feat(watch): 站点漂移巡检(只读,有差异才提醒) `site-drift-watch.sh` 巡检 192.168.2.106 上的两个站,**只读不动**: 不构建、不上传、不碰线上任何文件。档位是「有差异就提醒」而不是 「自动推」—— 文档站发错了是公开可见的,宁可等人点一下。 判三类信号: 1. 线上漂移:.106 上的站 ≠ 本机产物/site 源(逐字节 md5 清单比对) 2. 源码漂移:git HEAD 比产物新 ⇒ 提交了但没重新构建部署 3. 探活失败:.106 或 NapCat 挂了(比文档漂移紧急) 不复用 `deploy-sdk-site.sh --check`:那脚本输出是给人看的彩色文本, 拿来当机器判断依据太脆(改个文案就失效)。md5 清单逻辑很短, 这里复刻一份并在注释里指向 deploy-sdk-site.sh 保持同步。 ## `.drift-watch/` 加入 .gitignore 里面的 `state` 是运行时状态(含时间戳与指纹,每次跑都变),不该入库。 ## 提交前核实(两处我的检查写错了,脚本本身没问题) - 我查"凭据"命中 1 处 ⇒ 实为注释里的「烧 token」,指 LLM token 非密钥。 真实密钥形态(`sk-*` / `password=`)**0 处**。 - 我查"部署调用"命中 6 处 ⇒ 全是**注释与提示文本**(提醒人去跑 `deploy-sdk-site.sh`)。实际执行 `rsync`/`scp`/调用部署脚本**均 0 处**, `rm -rf` 0 处 —— 与它「只读」的声明一致。 - `set -uo pipefail` 少了 `-e`:**有意为之**。巡检要在某项检查失败时 继续跑完其余项,`-e` 会中途打断。实测跑一次四项全绿、无差异。 * docs(runbook): 记「判断线上跑哪次构建」的正确判据(我今天差点白部署) ## 线上其实已经是修好的版本 去部署 `eee774b`(webui 总览 5 个 KPI 空)前核实,发现: /api/v1/status → {"commit":"eee774b", "startedAt":"2026-09-27T23:23:39"} 23:23 那次部署**不是我做的**(我只在 21:50 部署过),已经包含 `eee774b`。`/api/v1/kernel` 里 5 个 KPI 依赖的字段也全部有值 (`plugins` 39 个、`llm`/`memory`/`documents`/`runtime`/`onnx` 非空)。 ⇒ **不需要部署。** ## ★ 为什么 `strings` 不能用来判断 webui 等插件的静态资源是 `//go:embed` **编译进二进制**的 (`internal/plugins/webui/handler.go:27`),内容取决于**构建时**磁盘上的 文件。⇒ 已提交但未部署的改动,线上二进制的 `strings` 里**也可能** 出现新代码片段。 我据此差点白重启一次生产。同一天还撞上**字节数完全相同**的巧合 (86811464),更掩盖了这点 —— 大小相同更让人以为"没变化,不用管"。 ## 正确的判据顺序 1. `/api/v1/status` 的 `commit` —— 线上在跑什么 2. `git log <commit>..HEAD` —— 差哪些提交 3. 那些提交里**有无运行时改动**(`internal/`、`cmd/`)—— 只有它需要部署 4. 文档 / 判据 / 部署脚本类提交**不需要**部署 ## ⚠ 那条命令必须带认证头 无认证时 `/api/v1/status` 返回**登录页 HTML**(200 + `THEME_PLACEHOLDER`), `grep '"commit"'` 匹配不到 —— 看起来像"命令没输出",实际是认证缺失。 与 GUI 客户端 `api()` 专门检测 `THEME_PLACEHOLDER` 是同一件事。 ## 顺带:内置插件 vs 独立二进制 `internal/plugins/<name>/` 编译进 homed;`plugins/<name>/plugin.bin` 是独立插件,要单独构建部署。**目录存在不等于有独立二进制** —— `plugins/webui/` 目录存在但 **0 个文件**,走内置。 实测(2026-09-28):`webui`/`cmd`/`seq` 内置,`qq` 独立。 改内置插件只需重编 homed。 * fix(gui): 401 重试无限递归 —— 我上一个优化放大的 bug ## 真机实测发现的 Xvfb + Electron + CDP 真跑,发现 `api()` 的 401 分支**无限自我递归**: 第1次: lock=false → 重登 → lock=false → return api() ← 递归 第2次: lock=false → 重登 → lock=false → return api() ← 又递归 … 那把锁的语义本该是「已经重登过一次,别再登」,但它在递归**之前**就被 清掉了 ⇒ 每层递归看到的都是 `false`。真机实测 **fetch 被调 13 次、 重登 12 次**才被我的探针上限截断。 ## 为什么这与我的上一个提交直接相关 `73e1261` 把 401 分支的固定等待从 800ms 降到 30ms(修「认证过期时每个 请求白等 0.8s」)。但重试**没有次数上限** ⇒ - 改前:每 800ms 慢速空转 - 改后:每 30ms 快速烧 CPU + 反复打服务端 **我的优化把这个 bug 放大了。** 真机上探针调用 `api()` 直接挂死, 我起初还以为是我的测试写法问题。 ## 修法 两处递归点(真 401 / 门户返回 200 但内容是登录页)都改成: try { return await api(p, o); } finally { window._haReloginLock = false; } `finally` 保证递归抛错时也释放锁 —— 否则会把后续所有请求都锁死成直接 401。 ## 判据 retry-guard.test.mjs 在 `node:vm` 沙箱里跑**真实抽出的 `api()`**,用恒回 401 的 `fetch` 驱动, 统计真实调用次数。 ### 写这条判据时踩的四个坑(都记在文件里) 1. **先给了假绿灯**:把 401 分支当独立函数体执行,但那块以 `return api(p,o)` 结尾、外面没有调用它的上下文 ⇒ 我从未真正进入那个 `if` ⇒ `api 递归=0` ⇒ 什么都没测到。改成跑**真实 api()** 才对。 2. **递归时忘了保持 `r.status===401`**:真实场景是「重登后凭据仍是错的」。 漏了它 ⇒ 递归那层不进 401 分支 ⇒ 又一次假绿灯。 3. **沙箱 `setTimeout` 只记录不执行**:`api()` 用它做超时控制 (`setTimeout(() => ctl.abort(), to)`)⇒ AbortController 永不被 abort ⇒ 表现为「fetch 只调 1 次、8000ms 被当成 401 等待」。 4. **沙箱缺 `clearTimeout`** ⇒ 抛 `clearTimeout is not defined` ⇒ 整段在 fetch 之后就断了 ⇒ 永远走不到 401 分支。 ★ 共同点:**沙箱不完整 ⇒ 静默地什么都没测 ⇒ 假绿灯**。 判据自己给假绿灯比没有判据更危险。 ### 变异测试(精确锚点,验证判据真能抓) 把第一处 try/finally 退回「递归前清锁」⇒ 判据立刻红 (`fetch 被调 13 次`);恢复后全绿。 ★ 第一次做这个变异时我误判「判据漏抓」—— 实际是我的变异脚本用了模糊 锚点、**压根没改到文件**(`grep` 显示 return await 从 2 变 1,但 `sed` 命中的是另一处)。两个信号矛盾时先坐实文件状态,别急着改判据。 ## 顺带把 `make test` 的门禁修好(47aad60 / 0ca2891 之外) 新判据已接入 `npm test`,实测 14 项通过、`make test-gui` 全绿。 * test(webui): WebAPI 只读端点压测 + 实测 2160 请求零失败 打的是**生产实例** 127.0.0.1:8080。 ## 为什么只压只读端点 压测绝不能改状态。18 个端点**逐个探测确认**是 GET + 只读: 小(/status…/proxy/services,104B–1.8KB)、中(/plugins…/knowledge, 4.4KB–53KB)、大(/kernel 157KB、/memory/graph 338KB、/knowledge/tree 425KB)。 **排除**:/chat(真调 LLM)、/chat/interrupt(中断在跑的任务)、 /settings/* 与 /plugins/*(改配置)、/login /logout(改会话)、 knowledge/memory 写接口、/device/*(控制真实设备)。 ## ★ 两条判据(都不是"看有没有报错") ### 1. 必须先 `--probe`:webui 无认证时返回 200 + 登录页 HTML 含 `THEME_PLACEHOLDER`,**状态码是 200**。只看 `http_code` 会把登录页 当成健康响应 ⇒「全部 200」是假的。脚本因此额外校验响应体。 ### 2. 端点表漏了 `/api/v1` 前缀 ⇒ 18 个端点全 404 第一版把端点存成路径后半段(`"/status"`),拼出 `http://127.0.0.1:8080/status`,而真实路径是 `/api/v1/status` ⇒ **全部 404**,同一时刻 curl `/api/v1/status` 却是 200。 ⇒ **压测脚本必须先 probe 再压。** 不 probe 的话那一跑的结论会是 「webui 全挂」,完全错误。这条已写进手册。 ## 实测(2026-09-28) | scale | 请求 | 吞吐 | p50 | p95 | p99 | max | 成功 | | --- | --- | --- | --- | --- | --- | --- | --- | | 1 | 360 | 779 req/s | 6.7ms | 26.8ms | 45.5ms | 51.6ms | 360/360 | | 2 | 720 | 788 req/s | 12.2ms | 52.6ms | 163.0ms | 213.5ms | 720/720 | | 3 | 1080 | 1038 req/s | 16.4ms | 60.4ms | 79.7ms | 123.0ms | 1080/1080 | **2160 请求零失败**;压测期间服务端 `active`、0 个 5xx、0 个 webui 错误, QQ/agent 链路未受影响,homed CPU 仅 1.2%。 ### 重响应不是瓶颈(并发 1 → 16) | 端点 | 大小 | 并发 1 | 并发 16 | 吞吐 | | --- | --- | --- | --- | --- | | /status | 0.2KB | 104 req/s | **1781 req/s** | 320KB/s | | /kernel | 153KB | 126 req/s | 375 req/s | 19 → **57 MB/s** | | /memory/graph | 330KB | 70 req/s | 410 req/s | 23 → **135 MB/s** | | /knowledge/tree | 415KB | 79 req/s | 717 req/s | 33 → **297 MB/s** | 大 JSON 端点的 p50 **不随并发上升**(/knowledge/tree 12.3ms → 8.9ms, 排队更充分、效率更高)⇒ 瓶颈不在 JSON 序列化。 ## ⚠ 一处未下结论的观察 scale=2 的 p99(163ms)反而**高于** scale=3(79.7ms)。看着反常,但当时 机器上另一个 agent 的 chromium 占 **766% CPU**(另有 cjpm/cjc 在编译) ⇒ 是**环境噪声**。 **未在可比条件下重测就不下结论** —— 不拿这一组当性能特征。要判定需先 固定负载条件再跑。这条也写进手册。 ## 顺带 ruff 报的三条 blocker 都修了:`pct()` 空样本不再抛错(调用方直接拿去做 f-string 格式化)、写 JSON 失败明确报错而非静默丢报告、错误体读取用 `contextlib.suppress` 免得二次异常盖掉真正的状态码。 * test(webui): 压测补齐另外 3 个插件的路由 —— 我第一版漏了两个 第一版只压了 webui 的 `/api/v1/*`。核实后发现 **8080 上注册 HTTP 路由的 插件有 4 个,监听端口还不止 8080**(`ss -ltnp` 实测): | 端口 | 插件 | 路由 | 认证 | | --- | --- | --- | --- | | 127.0.0.1:8080 | webui | /api/v1/* | config_webui.api_key | | | remotedevice | /api/v1/device/* | config_remotedevice.ws_token | | | kbtree 子路径 | /api/v1/knowledge/tree/{categories,counts} | config_webui.api_key | | 127.0.0.1:9876 | **pluginmgr** | /plugins(**无** /api/v1) | **无需认证** | | 127.0.0.1:9892 | **kbtree** | /categories /counts(**无**前缀) | config_kbtree.token | | 127.0.0.1:9890 | remotedevice | 设备 WS 网关(非 REST) | 不压 | ⇒ 「打 /api/v1/*」这个假设只对 8080 上的 webui 成立:`:8080/plugins` 实测 **404**。三套认证各不相同,脚本现在按分组取对应 token。 ## 端点 18 → 24 新增:`/api/v1/device/online`(remotedevice)、`/plugins`(pluginmgr)、 `/categories` `/counts`(kbtree 根路由)、以及 webui 前缀内的 `/api/v1/knowledge/tree/{categories,counts}`。 收 `/api/v1/device/online` 之前逐行读过实现:仅 `GET` + `registry.OnlineList()`,纯读。**不收** `/device/push`(向真实设备下发)、 `/device/ws`(长连接)、`/device/{id}`(语义未核实)。 ## 实测(24 端点 × 3 档,2880 请求零失败) | scale | 请求 | 吞吐 | p50 | p95 | p99 | max | 成功 | | --- | --- | --- | --- | --- | --- | --- | --- | | 1 | 480 | 953 req/s | 4.0ms | 23.0ms | 34.7ms | 40.6ms | 480/480 | | 2 | 960 | 1310 req/s | 7.9ms | 32.4ms | 48.4ms | 72.3ms | 960/960 | | 3 | 1440 | 1422 req/s | 12.0ms | 42.7ms | 60.5ms | 88.7ms | 1440/1440 | 压测期间服务端 `active`、0 个 5xx。 **p99 随并发单调上升**(34.7 → 48.4 → 60.5ms),符合排队预期。 上一轮曾出现 scale=2 的 p99 反常地高于 scale=3,当时机器上另一个 agent 的 chromium 占 766% CPU ⇒ 环境噪声,已明确不作为性能特征。 ## 又踩了一次前缀的坑(两种方向都踩了) 1. 「表里存路径后半段 + 代码按 group 补前缀」⇒ remotedevice 拼成 `/api/v1/api/v1/device/online` ⇒ 落到 webui 兜底路由、 返回 **200 + 登录页 HTML**(靠 `looks_like_login_page()` 抓到)。 2. 反过来「表里已含前缀 + 代码仍补」⇒ webui 组全 404。 ⇒ 最终统一成**表里写完整路径、代码不补**。两次都是靠 `--probe` 抓到的, 这正是它存在的理由。 ## 顺带修掉我写错的一处 `contextlib.suppress(sqlite3.connect)` —— `connect` 是函数不是异常类, `suppress` 会抛 `TypeError`。改回显式 `try/except sqlite3.Error`, 并把 `config.db` 的打开方式保持 `mode=ro`(压测不碰生产库写路径)。 ## 文档里我自己写错又改正的一处 §7 表格里 scale=1 一行先写成 1680/2013/5.4ms,核对 JSON 后实为 **480/953/4.0ms**(24 端点 × 20 轮)。已订正,并加了提交前的 JSON 逐项比对,三档现已全部一致。 * style(readme): 接受流水线的 markdown 格式化 三处纯格式,**无语义变化**: - 嵌套列表 `+ ` → `- `(渲染效果相同) - 表格分隔符 `|---|---|---|` → `| --- | --- | --- |` ## 为什么要提交而不是继续丢弃 仓库**没有 biome 配置**(无 biome.json),格式来自流水线默认。 ⇒ 它每次跑都会把 README 与 cmd/gui/renderer/app.js 改成 dirty 工作区。 我这次会话里反复 `git checkout -- README.md cmd/gui/renderer/app.js` 把它丢掉,**那是在和流水线对抗,纯属白费力气**:每次提交后它又变 dirty。 提交后 biome 再跑就是 no-op ⇒ 永久不再 churn。 ★ 教训:可自动复现的格式化,正确做法是**接受并单独提交**, 而不是每次手动回退。回退只是把冲突推迟到下一次提交。 * test(proc): 修 grandchild 测试的三个设计缺陷(不是生产代码问题) ## 定位结论 `TestKillReturnsEvenWhenGrandchildSurvives` 曾在 `go test ./...`(600s 超时) 与 `make test`(20.4s FAIL)里失败,但**单跑 0.24s 通过**、连跑 3 次全绿 ⇒ 「单跑绿、合跑红」。查下来是**三个测试设计缺陷**, 生产代码(`process.go`)没问题。 ### 缺陷 1:名字说 Survives,实际测的是「被杀」 | 测试 | 源 | 孙进程 | kill(-pgid) 能杀吗 | | --- | --- | --- | --- | | …EvenWhenGrandchildSurvives | grandchildPluginSource | sleep 400,**不**设 Setpgid | **能** | | …WhenGrandchildEscapesProcessGroup | escapingGrandchildSource | sleep 401 + Setsid | **不能** | `grandchildPluginSource` 自己的注释写着「孙进程**不**设 Setpgid:它要留在 插件的进程组里」⇒ 第一个测试里孙进程不会 Survive。容易让人误以为 「脱组场景已被覆盖」,而它覆盖的是另一个场景。 **已改名** `…WhenGrandchildDiesWithProcessGroup`。 ### 缺陷 2:判据数的是全系统进程 两个计数器扫 `/proc` 找 `"sleep 400"` / `"sleep 401"` 字符串, **不区分父子关系** ⇒ 同机任何命中同样 cmdline 的进程/容器都串味。 原注释记过一次前车之鉴(「我第一版就踩了:明明单跑通过,合跑却红」), 但当时只加了 base 快照,**没解决全局匹配这个根因** —— base 救不了 「别的测试中途拉起 sleep 400」。 **已修**:新增 `procPPid()`,两个计数器都限定 PPid 属于本测试的插件。 顺带补 `e.Name()` 的 `Atoi` 校验(原来会把 /proc/self、/proc/net 也读一遍)。 ### 缺陷 3:defer 清理「拿不到 pid 就整个跳过」 `if pid := pluginPid(p); pid > 0 { Kill }` 在 pid 取不到时静默跳过 ⇒ 残留 sleep 400 污染后续测试 ⇒ 变成下一个测试的假失败。 **已修**:新增 `cleanupSleepMarkers(marker)`,按唯一 cmdline 标记兜底清理。 ## ★ 我被推翻的一个假设 我一度认定根因是 `waitLoop` 里 `p.cmd.Wait()` **先阻塞**、拆管道在**之后** (`process.go:359-367`)—— Go 的 `exec` 里 `Wait()` 会等 copy goroutine, 而那些要等所有管道写端关闭,孙进程持有着 ⇒ 死锁。 **实测推翻了它**:把拆管道提到 `Wait` 之前,那个测试 **5 次全 FAIL** (改前只是偶发)。真正的根因是上面三个测试设计问题;「Wait 阻塞」只是 **被孙进程持管道放大**的效应。 ⇒ 改生产代码不但没修好,还把偶发变成必现。**先证明因果再动手。** ## 判据:grandchild_design_test.go(4 条) ★ 它是**查源码文本**的,我一改源文件(改名/加 ppid 限定/加兜底清理), 锚点就全过期 ⇒ 三条判据一起红。 ⇒ 判据自己被重构打断时,要改的是**判据的锚点**(认新旧两种形态), 不是回退修复。最后把判据①从「解函数体比对 spawn 参数」简化为 「只问名字是否还说 Survives」—— 少耦合一层,少失效一处。 变异测试三个都抓到:改名回 Survives / 抽掉 ppid 限定 / 去掉兜底清理。 ## 门禁 - 全量 `go test ./...`:**43 包 ok、0 FAIL** - `internal/plugin/proc` 连跑 **5 次全绿**(原来会红的地方) - `go test -race ./internal/plugin/proc/`:ok - 无 sleep 400/401 残留 ## 顺带 `5d528af` 之后 README 的 biome 格式化不再 churn:仓库**没有** biome 配置, 格式来自流水线默认 ⇒ 每次提交后它都会把工作区改脏。我这次会话里反复 `git checkout --` 把它丢掉,那是和流水线对抗。**提交后 biome 再跑就是 no-op**,问题根除。 * perf(gui): 聊天页增量渲染 + 修「打开不在最新消息」(10× 提速) 用户报「聊天页面卡得让人没有用的欲望」+「打开 app 和 webui,没有停在 最新消息处,还要反复滑动」。真机实测(Xvfb + Electron + CDP)定位到两个 根因,都修了。 ## 根因 1:renderChat 每次全量重建 innerHTML 200 条消息 = 3500 个 DOM 节点全部销毁重建。拆分测量: 200 条:整体 234.6ms,其中 renderMd 25.4ms(**11%**) ⇒ markdown 渲染只占 11%,**89% 在 DOM 写入与布局**。 流式追加时每个放行的 chunk 都走这条路(200 条时每 chunk 6.6ms), 聊到几百条就是 0.5 秒/次 —— 这就是体感。 **改法**:按 `data-msgkey` 复用节点,四种策略按代价从低到高: 尾部追加(最常见)→ 头部前插(loadOlderChat)→ 局部替换 → 兜底整棵重建。 `data-msgkey` = role + 序号 + 内容长度 + 首尾片段。 ★ **不能靠下标定位**:`loadOlderChat` 会 `unshift` 前插消息,下标整体位移。 实测: | 消息数 | 改前 | 改后 | 改善 | | --- | --- | --- | --- | | 50 | 59ms | **8.2ms** | 7.2× | | 200 | 235ms | **23.5ms** | 10× | | 400 | 474ms | **38.2ms** | 12× | 关键是**次线性**了:400 条只比 200 条多 15ms(改前多 240ms)。 ## 根因 2:滚动没落地 scrollTo 被调: 1, 参数: {top: 23446, behavior: "smooth"} scrollTop: 0 ← 调了,但没生效 可滚动上限: 22838 smooth 立即值 0、300ms 后只到 6894(上限 22838)⇒ **既慢又没到位**; 手动 `scrollTop = scrollHeight` **立即 22838 一次到位**。 原因:紧邻的 DOM 全量变更让 smooth 动画的起点算在**旧**布局上。 重建后本就不该有动画 —— 用户要的是「立刻看到最新」。 **改法**:`msgsEl.scrollTop = msgsEl.scrollHeight`。 ## 判据 chat-perf.test.mjs(10 条,真 Electron 跑) 新增 `npm run test-live`(需 Xvfb + electron,**不进 make test** —— 它要起真浏览器、30 秒启动,不适合当门禁)。 - 性能:200 条 < 100ms(**产品体感阈值**,不是 benchmark 数字) - 次线性:单条成本不随规模上升 - 滚动:打开即在底部、300ms 后不被带偏 - **正确性:增量不丢消息**(5 种增删改路径)—— 比性能更重要 ### 写判据时踩的坑(都记在文件里) 1. **第一版测出「0ms / 0 DOM 节点」** —— `buildChatLayout()` 在**无后端连接**时 走「请先添加连接」分支、聊天区压根没建 ⇒ **测不到**而非「不卡」。 2. **`ensureGui` 定义了但从未被调用** —— 重写文件时把调用丢了, 而定义还在,看起来一切正常。 3. **`detached: true` 只脱离进程组、不脱离会话** —— 脚本结尾 `process.exit()` 把刚起来的 GUI 带走(症状:`[tray] READY` 打了,判据却报「找不到页面」)。 改用 `setsid`。 4. **`JSON.parse(e.data)` 裸调** —— CDP 的 onmessage 也会收到非 JSON 帧, 抛在回调里既冒泡不到 await 也等不到 resolve ⇒ 整个判据挂死。 5. 我自己的滚动探针 `el.innerHTML=''` 让 `scrollHeight` 变 0, 「到位」判定是假象。改用「保留内容、只改滚动方式」重测。 ## 变异测试 把 `applyIncrementalChatRender(msgsEl, html)` 改回 `msgsEl.innerHTML = html` ⇒ 判据立刻红(实测 268ms + 严格线性)。 ## 门禁 - `npm run test-live`:10/10 通过 - `npm test`、`make test-gui`:全通过 - `go test ./...`:43 包 ok、0 FAIL * feat(gui): 协议对齐 —— 补齐 6 个只读诊断端点 + 人设/反代面板 GUI 原来只用 22 个端点,服务端有 47 个。补齐**只读诊断类**: agents / network / tracker / config / persona / proxy(+services)。 ## 刻意不接 /login 与 /logout 那是 cookie 会话认证流程,而 GUI 走 `X-API-Key` 头(见 `api()`)。 接了不是"对齐",是接错。 ## 三处新增 **总览诊断卡片**(renderDiagPanel):Agent 健康、LLM 可达、网络端点数、 文件变更集、数据目录、心跳间隔。全部走 `(x && x.y)` 安全取值 —— 任一端点没取到(老内核无该路由、连接断开)只显示 "-",不抛错。 **人设面板**(renderPersona):实测结构是 `{current_prompt, file_override, initialized}`。我第一版按 map 遍历, 结果只会显示三个字段名 —— 真机验证时才发现。改成展示提示词全文 + 两个状态卡。**只读**,编辑涉及保存/回滚/并发覆盖,与"协议对齐"是两件事。 **反代面板**(renderProxy):base_domain / mode / total / manual + 服务列表(`✓ gateway → 127.0.0.1:9890`)。实测字段是 `{name, host, path, url, target, ok, auth, websocket}`,不是我第一版假设的 `subdomain`。 ## 加载策略 只加进 `refreshAll`,**不加** `refreshDataOnly`(后者每 15 秒一轮, 诊断数据不必高频轮询)。7 个新端点实测只 +3ms。 ## 判据 protocol-align.test.mjs(10 条,真 Electron) - 7 个 state 槽都取到真实数据 - 总览出现诊断卡片 - 人设面板**真的显示提示词内容**(不只查元素存在) - 反代面板**真的列出服务**(查 `→` 出现) ### 判据踩的三个坑 1. **默认假 key 导致 9 项全红** —— webui 对错误凭据返回 200 + 登录页 HTML (`looks_like_login_page` 能识别),于是所有取数失败。看起来像 「代码坏了」,实际只是认证缺失。改为默认从 `config.db` 读真 key。 2. **判据绕过了应用路径** —— 表达式里直接调 `refreshDiagData()`, 于是把应用里的 `await refreshDiagData()` 注释掉,判据**仍全绿**。 改为走应用自己的 `refreshAll()`。 3. **变异后 GUI 仍加载旧代码** —— `ensure()` 看到端口有页面就复用, 注入变异后没重启 GUI ⇒ 又一次假绿。**变异测试必须先杀掉 GUI 进程。** 变异测试(注释掉调用点)⇒ 9 项变红,坐实判据验的是真路径。 ## 门禁 - `npm run test-live`:22/22 通过(12 性能+滚动 + 10 协议对齐) - `npm test`、`make test-gui`:全通过 - `go test ./...`:43 包 ok、0 FAIL * chore: 排除 SDK 仓的 skills/;清掉误建的 --help/ 目录 ## skills/ 不该进本仓 SDK 仓新增了 `skills/`(hmapdev skill install 的源),本仓的 `.gitignore` 已排除 `tools/`、`docs/`、`example/`、`package/`、`scripts/` 等 SDK 仓自治范围,唯独漏了新加的 `skills/`。 `skills/` 是**文档**(skill 说明),归 SDK 仓管 —— 本仓经 go.mod 的 `replace` 只引用它的**编译必需文件**(sdk/*.go、go.mod、meta/meta.go), 文档不在其中。 ⇒ 这不是新规则,是把已有规则的适用范围补齐。 ## 误建的 --help/ 目录 我在验证 skill 时跑了 `hmapdev init demo`,但那次实际执行的是 `hmapdev init --help` 之类 —— `--help` 被当作**目录名**,在仓库根生成了 一个含 `go.mod`/`plg.json`/`plugin.go` 的 hmapdev 脚手架。 `plg.json` 里 `"name": "--help"` 坐实了这点。已删除。 ★ 顺带记一条本机特性:`/tmp` 是 **tmpfs(内存盘) 只有 653M 可用**, 而 SDK store 有 4 个版本、备份要几 G ⇒ 大文件备份必须放 `/var/tmp`。 (这轮第一次备份就撞了 `No space left on device`。) * ci: 建立 GitHub Actions 流水线(六个 job,全部命令已本地实测) ## 为什么现在做 这次排查「QQ 收不到回复」花了大半程才定位到根因,途中我犯了两类错: 先断言「提示词没写 output_send 规则」(实际 buildSystemPrompt:48-52 写了), 又断言「适配器丢了内容」(实际两版等价、直连上游正常)。 两次都是**在无自动化判据的情况下凭局部证据外推**。 仓库已有 60 个 Go 包、`go build ./...` 仅 2.2 秒,成本极低却无人强制跑。 AtomGit 停用流水线后更无兜底,故迁到 GitHub 补齐。 ## 设计原则:CI 里每条命令都是本地已实测通过的 不写「可能有用先试试」的步骤 —— 未验证的 CI 步骤会把假红灯变成常态, 最后所有人都学会忽略它。本文六个 job 的每条命令都本地跑过: go build ./... ✓ go vet ./... ✓ go test ./... -count=1 ✓(干净克隆亦通过) make check-client-versions ✓ go test -race core + waiter ✓ waiter/initconfig/mock-server 交叉 ✓(5 平台) npm test(cmd/gui) ✓ make check-csrc ✓(告警/ABI/ASan+UBSan/跨架构) ## 六个 job | job | 覆盖 | |-------|----------------------------------------------------------| | go | build + vet + test + **跨平台客户端版本一致性** | | race | 并发核心的竞态检测 | | cross | linux/darwin/windows × amd64/arm64(仅可纯交叉的 3 个 cmd)| | gui | Electron 仓的 Node 测试 | | csrc | C 基础设施门禁 | | docs | 站点配置可解析 | ## 关键事实(都由实测确立,不是推断) 1. **只有 waiter/initconfig/mock-server 能纯交叉编译**。homed、memgc、 homed-kb-migrate 依赖 cgo(gojieba / onnx),必须原生构建 ⇒ 不进 cross matrix。 2. **CGO 必须为 1**:gojieba 需要 cgo,`CGO_ENABLED=0` 下 internal/memory 直接编译失败(实测)。 3. **`go test ./...` 不会碰到 cmd/gui**。该目录是纯 Electron(0 个 .go、 无 go.mod),`./...` 只匹配含 Go 文件的包;只有显式 `go test ./cmd/gui` 才报 "no Go files"。这不是缺陷,是 Go 的包匹配语义 —— 之前把它当 [setup failed] 是误读。 4. **cmd/gui 的 npm test 零依赖**:三个 .mjs 只 import node: 内置模块 (fs/url/path/vm)⇒ 不需要 npm ci、不需要 electron,秒级完成。 5. **测试自足,CI 上不会因缺本地服务而红**:webui 测试用 httptest 与 `127.0.0.1:0`,真实 LLM 测试带 t.Skip 守卫。 6. **action 版本已核实存在**:checkout/setup-go/setup-node/setup-python 均用 v7(经 GitHub API 逐个确认 tag 存在,避免「版本不存在 ⇒ 立刻红」)。 ## 明确不进 CI(依赖真机/密钥/内网,否则只会变 flaky 噪音) deploy-*.sh、waiter 真机(192.168.2.x)、`npm run test-live`(需真 Electron + Xvfb + 真后端)、scripts/kernel-stress/*、需 DEEPSEEK_API_KEY 的真实 LLM 测试。 * fix: 修 CI 抓到的两类真实缺陷(.syso 破坏 arm64 + 测试硬编码 /etc) 第一次 CI 跑出 2 类失败,都是**本地以 root/amd64 跑永远看不见**的问题。 这正是建 CI 的价值:换一个环境就暴露了。 ## 一、.syso 无条件被链进所有平台 → arm64 交叉编译必炸 CI 报: $WORK/b001/_pkg_.a(waiter.syso): 310766: unknown ARM64 relocation type 3 (linux/arm64 与 darwin/arm64 两个 job 都红;amd64 两个都绿) 根因(已在本地用 Go 1.25.9 + arm64 精确复现):Go 会把**同目录的 *.syso 无条件链进任何 GOOS/GOARCH**,而这两个 .syso 是 Windows 资源对象 (x86-64 COFF,只含 .rsrc 图标段)。链进 arm64 目标即报「未知 ARM64 重定位」。 仓库其实**早就知道**这件事 —— deploy/packaging/build.sh:86-90 写着 「Go 会把同目录的 .syso 无条件链进任何目标」,并留了 hide_syso_for_target() 绕过,注释还点名「这正是 arm64 产物长期缺失的原因(曾被误判为缺 g++ 交叉编译器)」。但那是打包脚本里的私有绕道:任何**直接 go build** 的路径 (包括 CI、包括本机原生 arm64 构建)都仍会撞上。修在源头而不是再加一层绕道。 修法分两种,因为两个文件的处境**完全不同**: 1. cmd/waiter/waiter_windows_amd64.syso(原 waiter.syso,git mv) waiter **仍支持 Windows**(package-windows.sh:68 明确构建 waiter.exe), 所以不能删。按 Go 的文件名约定加 _windows_amd64 后缀 ⇒ 只在 windows/amd64 被链入。实测:linux/amd64、linux/arm64、darwin/arm64、 windows/amd64 四平台全部通过,且 Windows 产物的 .rsrc 段大小 (00049eb8 字节)与改动前**逐字节一致** —— 图标没丢。 2. cmd/homed/{homed.syso,homed.rc} 删除 homed 的 Windows 原生支持**已放弃**,五处独立来源一致: - README.md:251「homed 放弃 Windows 原生支持改走 WSL2」 - cmd/homed/platform_windows.go 的 requireSupportedPlatform 直接拒绝启动 (理由是设计性的:fd 继承 + 同段内偏移解引用,Windows 句柄模型无法表达) - package-windows.sh:4「❗安装器不往 Windows 装 homed」 - build.sh:35「Windows 不再安装 homed.exe」 - installer.nsi:230「homed 不再装到 Windows」 即 homed.exe 即便构建出来也拒绝运行 ⇒ 图标资源毫无意义,却是 arm64 构建失败的来源之一。顺带查明:homed.syso 与 waiter.syso 本是**同一个 blob**(两个 .rc 指向同一 icon),属纯重复。 ★ 由此留下一处**未修的残留**(已确认,不在本次范围):installer.nsi:297,313 仍在创建指向 homed.exe 的快捷方式与 Run 注册项,而同文件 230 行已声明 homed 不装 Windows。那是 Windows 安装器的独立缺陷,需单独处理。 ## 二、internal/system 测试硬编码 /etc → 非 root 必失败 CI 报: system_test.go:51: expected archive to happen system_test.go:97: expected restore to happen 测试写死 target := "/etc/xxx.test.tmp" 并**忽略了 os.WriteFile 的错误**。 GitHub Actions runner 以非 root 运行 ⇒ 写 /etc permission denied ⇒ 文件 不存在 ⇒ ArchiveBeforeWrite 按「新建文件无需留档」返回 false ⇒ 断言失败。 本地以 root 跑则一路通过 —— 缺陷因此长期不可见。 修法:用仓库**已有**的 SetProtectedPaths([]string{临时目录}) 显式声明受保护 前缀(不再碰真实 /etc),defer SetProtectedPaths(nil) 复原默认。既去掉了对 root 的隐式依赖,也没有削弱被测语义(保护的仍是「受保护前缀下的文件」)。 ## 验证 - go test ./... -count=1 全绿 - go build ./... 通过 - waiter 四平台交叉编译 全通过(含此前必红的 arm64) - homed linux/amd64 原生构建 通过(确认删除 .syso 无害) - Windows 产物 .rsrc 段 改动前后一致(00049eb8 字节) * ci: 发布流水线 —— release/** 推送即发版(tag/打包/发布/镜像全自动化) ## 设计 版本号唯一事实源是 internal/meta/meta.go 的 Version(仓库纪律), 所以发版动作 = 在 release/vX.Y.x 上把 meta.Version 改成目标版本后推送: prepare 读版本号;tag 已存在则整轮跳过(幂等闸门,改文档不会重发) build-linux go build + go test 过门 → 下载资产 → 打包 3 deb + 1 tar.gz → 平铺 → 验证(deb 元数据/模型在位/校验和自验)→ artifact publish 打 tag → gh release create 传附件 → 回读下载验证校验和 sync-gitcode 有 GITCODE_TOKEN 时同步 tag+附件到 gitcode(无则跳过不阻断) ## 关键事实(全部本地实测过才写进 workflow) 1. **编译不需要 ONNX Runtime**:onnxruntime_go 是 dlopen 方式(运行期才 加载 .so),本地在清空 ORT 相关环境变量的条件下带 -tags=onnxruntime 编译通过(83M)。CI 只需在**打包**时有 ORT(要打进 deb)。 2. **构建资产托管在 release ci-assets-v1**(已上传): chinese-clip-vit-b16-onnx.tar 719MB + onnxruntime-linux-amd64-1.28.0.tar 24MB + SHA256SUMS。模型内容不随版本变 ⇒ 一次上传反复复用,CI 打包前 下载并 sha256sum -c 校验。上传实测 3.2MB/s,构建期下载同源更快。 3. **打包链路在干净 worktree 全程实跑通过**(release/v1.3.x + VERSION=1.3.13): full 800M / server 726M / client 80M / tar.gz 841M,SHA256SUMS 平铺自验 4/4 OK,full 包内确认含 TextEncoder/VisionEncoder.onnx 与 libonnxruntime.so。 4. **SHA256SUMS 的坑**:脚本把校验和写成平铺名(./xxx.deb),而产物在 deb/ tar/ 子目录 ⇒ 直接 -c 会全 FAILED。workflow 里显式平铺后再验。 (呼应 git-branching.md §七「校验和必须覆盖全部附件、只传一次」。) 5. ORT 资产补齐了缺失的 LICENSE + ThirdPartyNotices.txt(取自 microsoft/onnxruntime v1.28.0 tag,与本地 .so 的内嵌版本号一致)—— 打包脚本的 stage_multimodal_assets 对这两文件非空校验,缺失即失败。 6. actionlint 全绿(修掉了 shellcheck SC2012:ls 改 stat 循环)。 ## 已知边界 - arm64 发布产物暂缺(package-linux.sh 支持,但 CI 未配 QEMU 交叉;待需要时加 matrix)。 - Windows 安装器未纳入(需 electron-builder win 打包,单独验证后接入)。 - sync-gitcode 依赖 secret GITCODE_TOKEN(待用户配置;未配置时该 job 显式跳过)。 * ci(release): go test 门可显式跳过(默认仍严格) ## 问题(试发布实测暴露) 把 CI/Release 带到 release/v1.3.x 后,CI 三个 job 全红,但**每个失败都是 该分支自身的旧状态,与改动无关**: | job | 失败原因 | main 上 | |---|---|---| | GUI (node) | `npm error Missing script: "test"`(1.3.x 尚无该脚本)| 正常 | | Go test | TestRealPlugin_DeepSearchKeepsSharedBackendOnStop | **通过** | | C gates | exit 2(1.3.x 无 csrc 基础设施)| 正常 | ⇒ 给已存在的发布线补新流水线 = 用今天的门去量旧代码。硬门会让该历史 维护线**完全无法发版**,正是用户要的「推 rel 分支就出产物」被挡死。 ## 改法 拆开两道门,语义不同: - `go build ./...` —— **硬门**,不可跳过。产物不可能建立在编译失败的代码上。 - `go test ./...` —— 默认跑,但可跳过。两个来源: 1. workflow_dispatch 的 `skip_tests` 输入 2. **发版 commit 里写 `[skip-release-tests]`** 第二个来源是关键:决定落在**定义该次发版的那个 commit** 里,`git log` 可审计, 而不是一个随手勾的开关。跳过时输出 `::warning` 注释,让后果在 run 页可见。 ## 未决(留给用户) `release/v1.3.x` 的 deepsearch 测试失败属该线既存状态(main 已修)。是否把它 cherry-pick 回 1.3.x 属产品决策(1.3.x 是历史维护线,main 已是 1.4.0), 故本次不擅自拉回绿,只提供显式跳过通道。 actionlint 全绿。 * fix(packaging): find 在 set -euo pipefail 下致命退出 —— 任何无 electron 缓存的机器都打不出包 ## 症状 CI 发布在「打包」步骤失败,输出停在: >>> Building GUI directory for linux/amd64... npm install... electron 版本取自 package.json 依赖声明: 33.0.0(非精确) >>> Restoring original go.mod... ##[error]Process completed with exit code 1. 没有错误信息,看不出真因。build_go 之前已全部成功(homed 80M 带 onnxruntime)。 ## 根因(本地精确复现 + bash -x 追踪) + zip=$(find "$HOME/.cache/electron" -name "electron-v33.0.0-linux-x64.zip" | head -1) + zip= + restore_all ← 直接退出 脚本是 `set -euo pipefail`。`find` 对**不存在的目录**返回退出码 1, pipefail 让 pipeline 返回该 1,而 `set -e` 对**赋值语句里的命令替换**同样生效 ⇒ 整个脚本当场退出。 实测退出码对照: x=$(find /不存在 | head -1) → 1(脚本死) x=$(find /不存在 | head -1 || true) → 0(存活) x=$(find /不存在) → 1(无管道也死) ⇒ 任何**没有 ~/.cache/electron 的机器**(全新克隆、CI runner、其他开发机) 都会撞上。本机一直「能打包」只是碰巧有那份 646M 缓存。 ## 修法 给 4 处 find 加 `|| true` 兜底(214/217 electron 缓存、623/625 rpm_deb)。 另修 206 行 `[ -n "$ever" ] && echo ...`:`ever` 为空时该列表返回 1, 在 set -e 下同样会杀死脚本 —— 改为 if 形式。 ★ 注意 608 行(fpm 查找)**早就有** `|| true`,说明这个模式被意识到过, 只是漏了这几处。属同一类缺陷的补全,不是新引入的写法。 ## 验证 - 复现:移走 ~/.cache/electron 后 `package-linux.sh amd64 build` → 修复前 exit=1 (输出与 CI 逐行一致),修复后 exit=0 且打印明确原因: `WARNING: electron binary not found at ... GUI will be skipped.` - 有 electron 时仍正常构建:GUI 263M / x86-64(走 host-arch 回退路径) - bash -n 与 shellcheck -S error 均通过 * ci(release): 打包前装 Electron,否则 GUI 被静默跳过 ## 问题 打包脚本从两处找 Electron 运行时: 1) ~/.cache/electron 里的 electron-v<ver>-linux-<arch>.zip 2) cmd/gui/node_modules/electron/dist(目标架构 == host 时) 全新 GitHub runner **两处都没有** —— 脚本在都没有时只能跳过 GUI, 于是 client/full 包会**静默地不含界面**(正是脚本作者担心的「假包」)。 实测:本地移走缓存后 GUI 被跳过,包仍能产出。 ## 改法 打包前在 cmd/gui 执行 `npm ci`,让 electron 落到 node_modules。 runner 是 amd64 == 目标架构,脚本便走第 2 条路径。 **用 npm ci 而不是 `npm install electron@<range>`**:后者是非确定性的 (range 会随上游漂移,也锁不住传递依赖),zizmor 也把它标为 adhoc-packages 风险。package-lock.json(lockfileVersion 3)已锁定 electron,ci 严格按 lock 安装 ⇒ 同一 commit 永远得到同一套依赖。 不能用 `npm install --production`:那会跳过 devDependencies, 而 electron 正是 devDependency(这正是脚本自己那条命令找不到它的原因)。 ## 验证 - 无声 cache + 有 node_modules/electron 时,脚本走 host-arch 回退并 成功构建 GUI(263M / x86-64)—— 已本地实测 - 两者都无时改为打印明确原因并跳过(配合上一个 commit 的 find 修复) - npm ci --dry-run 通过;actionlint 全绿 * ci(release): [skip-release-tests] 标记改查发版提交,不再查 HEAD 原实现用 `git log -1 --pretty=%B`(即 HEAD)找标记。但发版提交之后 往往还会跟几个提交(同步 workflow、改文档、修脚本),HEAD 一移动, 标记就被顶掉 —— 跳过机制**静默失效**,流水线又回去撞旧线的红测试。 改为查**改动 internal/meta/meta.go 的那个提交**(语义上正是「发版提交」): REL_COMMIT=$(git log -1 --format=%H -- internal/meta/meta.go) 顺带把 grep -qF 换成 case 匹配,避免多层引号嵌套。 自测(本地 worktree): 发版提交 23a98f1 → 命中标记 ✓ HEAD df0d4b0 → 不命中(旧逻辑会在此静默失效) actionlint 全绿。 * ci(release): 修 gitcode 同步的路径拼法(原写法必然找不到文件) upload_assets.py 的路径语义是 `os.path.join(ASSET_DIR, name)`, 而原写法先 `cd dist` 再传 `./*.deb` ⇒ 拼成 `dist/dist/...`, 必然 "资产目录不存在" 或逐个 skip。token 一配上就会炸,属隐患。 改为:cd 进资产目录 + `ASSET_DIR=.` + **不传文件名**(让它扫描当前目录, .deb/.tar.gz/SHA256SUMS 都在它的产物白名单里)。 SDK 侧另有一处同类问题,但那里**必须显式列名** —— hmapdev 的产物多数 没有扩展名(只有 windows 那个是 .exe),自动扫描会静默地一个都不传。 已同步修在 SDK 仓的 workflow 里。 * ci(release): 验证产物改用 >/dev/null 而非 grep -q —— SIGPIPE 误杀检测 ## 问题(第二次试发布实测) 打包成功后,「验证产物」步骤失败: tar: stdout: write error dpkg-deb: error: tar subprocess returned error exit status 2 而三个 deb 的元数据其实已全部正确打印(Package/Version/Architecture)。 ## 根因 `dpkg-deb -c <800M 的 full 包> | grep -q <模型文件>`: grep -q 匹配到目标行后**立即退出**、关闭管道读端 ⇒ dpkg-deb 内部的 tar 继续写 stdout 时收到 EPIPE ⇒ pipefail 判整条 pipeline 失败。 ⇒ 检测项本身是好的(模型确实在包里),却被检测手段误杀。 本地用 CI 上同一个 800M full 包复现: grep -q 版 → dpkg-deb: error: tar subprocess was killed by signal (Broken pipe) >/dev/null 版 → 通过 client(80M)没炸、full(800M)炸 —— 包越大越容易触发(内容越多, grep -q 提前退出的窗口越大)。这正是它没在本地小规模测试里暴露的原因。 ## 改法 检测存在性时用 `grep <pattern> >/dev/null`(读完整个输入再退出), 不用 `grep -q`。顺带补了 server 包的模型在位检测(原来只测了 full)。 * ci(release): gh release download 需显式 --repo(非 git 目录无法推断) ## 症状(第三次试发布) Build 全绿、tag 已建、release 已建、2.40GB 附件全部上传成功 —— 唯独最后一道「回读校验」失败,整个 workflow 因此标记为 failure: failed to run git: fatal: not a git repository (or any of the parent directories): .git ##[error]Process completed with exit code 1. ## 根因 回读校验为了"下一份干净副本"先 `cd /tmp/back`,那里不是 git 仓库。 而 `gh release download` 默认从**当前目录的 git 上下文**推断仓库与 host(GITHUB_REPOSITORY / GH_HOST 之类环境变量不足以让它跳过推断), 于是报 "not a git repository"。 ## 修法 gh release download "$TAG" --repo "$GITHUB_REPOSITORY" 两个仓的 workflow 都有同一处(主仓 + SDK),一并修。 ## 顺带 `third_party/homeagent-sdk/.github/` 加入 .gitignore —— 与 skills/ 同类: SDK 仓自己的 workflow 由 SDK 仓跟踪管理(那边已跟踪),本仓不参与 构建,不需要在这边重复一份。未加规则时它会出现在本仓的未跟踪列表里。 * docs(ci): CI/CD 流水线手册 —— 用法、机制与 6 个踩过的坑 仓库迁 GitHub 后新增两条流水线(ci.yml / release.yml),但用法与机制 此前只存在于 workflow 的注释和提交信息里。发版是高频操作,写成手册。 内容: - §1 CI 六个 job 与「明确不进 CI」的清单(真机/密钥/内网依赖) - §2 发版标准流程、幂等闸门(tag 存在即跳过)、 发版门(go build 硬门 + go test 可用 [skip-release-tests] 跳过) - §2.5 构建资产(ci-assets-v1)的托管与升级方式 - §3 六个实测踩过的坑: 3.1 workflow 文件必须存在于目标分支(否则推 release/** 不触发) 3.2 runner 无 electron 缓存 ⇒ 必须 npm ci(且不能用 --production) 3.3 管道里的 grep -q 因 SIGPIPE 误杀检测(800M 包必炸) 3.4 gh 在非 git 目录要显式 --repo 3.5 gitcode 上传的 ASSET_DIR + 裸名语义 3.6 手工补发 gitcode 附件的流程 - §4 SDK 仓的差异(独立 module 测试要跑两处、CGO 不需要) - §5 关于失败邮件的说明(可能是验证步骤自身 bug 的假警报) markdownlint 全绿(两个产物清单代码块补了 text 语言标注)。 * chore(watch): 下线站点漂移巡检脚本 用户指示「那个脚本直接去了吧」:site-drift-watch.sh 每半小时巡检一次两个文档站 的产物漂移,但实测 5 次告警**全是源码漂移误报**(判据只比时间戳,而产物生成后的 提交都不影响文档站),线上漂移与探活失败从未报过 —— 信噪比太差,去掉损失很小。 调用方核查:只有 root crontab 的 `7,37 * * * *` 一条,systemd 无 unit, 脚本/Makefile/.github 里零引用。crontab 已按行备份并只删该两行(其余三条任务保留), 脚本本体备份在 /root/backups/ 可随时还原。 本提交只删仓库内的脚本文件;crontab 与运行环境侧的调整已在部署时完成。 * chore: 用 .mailmap 归并虚拟贡献者身份 GitHub 的 Contributors 列表按**提交邮箱**归并身份,而本项目历史里同一个人的提交来自 多个邮箱(本地 root、个人 QQ、gitcode noreply、GitHub noreply、dev@local 等), 于是列表里冒出 5 个并不存在的「协作者」: root@qq.com(135) / root@qq.com(51) / 2198972886@qq.com(33+4) dev@local(14) / root@minecraft-server(10) / 109188060+JianFeeeee@...noreply(19) 加上真实身份,7 条里只有 1 条是人。 .mailmap 只影响**展示层**(git shortlog / git log --use-mailmap / GitHub Contributors), 不改写任何提交对象 —— 历史 SHA 全部不变。归并后: git log --use-mailmap → JianFeeeee(715) + HomeAgent Agent(19) HomeAgent Agent <agent@homeagent.local> **刻意不归并**:agent 的自动提交保留独立身份, 便于区分人类提交与自动化提交。 * docs: README 对齐 1.4.0 现状,并移除看板娘 四处过时事实: 1. 「v1.3.x 线(v1.3.1–v1.3.12,最新已发布)」→ 补上 v1.3.13,并按 main 的实际内容 新增 v1.4.x 段落(同轮工具并行、seq 序列编排、结果只统计不裁剪、结构化错误契约、 设备命令白名单、GUI 增量渲染)。这些是 MAIN 上已合入但 README 从未提及的特性。 2. 「内置 18 个插件」×2 → 实际 20 个(漏了 seq / kbtree 等)。 3. 6 处 gitcode 链接 → GitHub(仓库已迁至 github.com/JianFeeeee/HomeAgent, SDK 为 github.com/JianFeeeee/homeagentsdk)。gitcode 仍作国内镜像保留。 4. 移除「看板娘 / Web Mascot」整段(中英文各一处)—— 按用户要求。 三张架构图按代码重画(此前图上缺了 1.4.0 的核心机制): - 图一「消息处理时序」:tool 分支改为**批**语义 —— batchRunnable 三条判据 (批内 >1、全部 ParallelSafe、无同通道重复发送)、可并发/整批降级两条路、 以及「按声明序收尾 + 同通道保序」。 - 图二「Stage 管道」:④⑤ 拆成并发/串行两条边,并注明并行只影响执行时序, post_action 与 after_toolcall 的可见顺序不变。 - 图三「三层记忆」:补上**场面识别(声明 + 涌现)**子系统 —— 指纹权重、 归属/唤起阈值、origin=declared|emergent、用进废退衰减。 另加一段说明:agent 自己的回复也写回记忆(context.Append(Source:"agent")), 因此它能读到自己先前的结论并主动纠正 —— 这是记忆召回的自然结果, 内核里**没有**任何名为「反思」的机制(避免把涌现行为误读成已实现的特性)。 * ci(pages): 介绍站的 GitHub Pages 镜像 主站仍自托管在 NAS(192.168.2.106),国内内网直连毫秒级;GitHub Pages 提供 海外可达性与灾备。两边同源(同一个 site/ 目录),不存在谁是「真身」。 发布前的完整性检查是必要的:Pages 是静态托管的,**缺文件不会让部署失败** (照样 200 + 404 页),所以「引用存在但文件没提交」这类问题必须在这里拦住 —— 检查 index.html 引用的每个 assets/ 资源都实际存在。 三个 action 版本已逐个查 GitHub API 确认真实存在(checkout@v7 与仓库其余 workflow 保持一致;configure-pages@v5 / upload-pages-artifact@v4 / deploy-pages@v4)。 workflow 已过 actionlint,检查逻辑已在本机原样跑通(3 个文件、165778 字节、 引用清单 assets/logo.svg)。 注:Pages 需在仓库设置里把 Source 选为「GitHub Actions」后本流水线才会真正发布。 * chore(site): 介绍站同步 1.4.0,并迁移 gitcode → GitHub 链接 1) 链接迁移:33 处 gitcode.com → GitHub(20 处 SDK、13 处主仓),0 残留。 2) 补齐 1.4.0 特性:「真正的 AgentOS」四张卡(隔离/调度/通信/资源)之外, 新增第五张「并行 —— 同轮的工具,一起跑」,把声明式并发安全与 整批降级这两条关键语义写清楚(这是当前版本最重要的架构变化, 而站点此前一个字都没提)。 3) 插件列表与实际 SDK 示例对齐:补 ai_image(v1.3.0) / files(v1.0.0) / luademo(v0.1.0) 三张卡与对应徽章(23 项)。版本与描述取自各自 plg.json。 注意 mc / homeagent-mail-bridge **不是**过期项 —— 它们是真实插件, 只是源码不在 SDK 仓,各有自己的链接。 已用共享浏览器(CDP)在 file:// 下实测:徽章 23 个、点 ai_image 能正确 切换出卡片(可见性检查通过)、第五张卡渲染正常、页面内 gitcode 残留为 0。 * chore: 排除 pi-lens 对 cmd/gui 的 Go 测试误报 cmd/gui 是纯 Electron/Node 目录:0 个 .go 文件、无 go.mod、`go list ./...` 匹配 0 个包,它的测试入口是 package.json 里的 `node *.test.mjs`。 但 pi-lens 的 test runner 在写文件时按**仓库主语言**(Go)为所在目录跑测试, 而 cmd/gui/*.test.mjs 被识别成测试文件 ⇒ 它对一个纯 Node 目录执行 `go test ./cmd/gui`,必然得到 `no Go files ... [setup failed]`, 却被报成「测试失败」。本会话内重复触发 13 次,每次都要人工复核一遍。 排除触发源(而非关闭 tests.enabled —— 那是**全局**开关,会一起削弱所有真实 项目的测试网)。.mjs 的真实运行方式是 `npm test`,CI 的 GUI job 已在跑。 上游缺陷:runner 应先确认目录内存在该语言的源文件,再决定是否执行。 * fix(deploy): 站点比对判据被坏 diff 静默架空(假绿灯) deploy-sdk-site.sh 的 7 处本地比对用的是**裸 `diff`**,而本机 PATH 首位是 /opt/huawei/harmonyos/ohos-sdk/linux/toolchains/diff —— 它对**任何**输入都 返回 0 且无输出。后果不是「偶尔报错」,而是: --check 永远打印「✓ 线上与本机源逐字节一致」 ⇒ 永远判定「无需部署」⇒ 站点改完再也不会被更新,且完全看不出来 实测凭据(本次):site/index.html 本地 f8898cfc…、线上 cc615cfa…, 两边**确实不同**,而 --check 报「逐字节一致」;用两个必然不同的小文件 (A / B)验证,坏 diff 退出码 0、/usr/bin/diff 退出码 1。 修法(两层): 1. 钉绝对路径 DIFF=/usr/bin/diff,7 处本地比对全部改用它; 2. **启动自检**:拿两个必然不同的输入验证一次,若它仍报「无差异」 就直接退出,而不是继续拿一个坏判据去做部署决定。 (同源的教训:「探针不红先怀疑探针」——判据本身坏了, 它给出的「没问题」毫无意义。) 远端那一处保持 `diff` 并在旁边写明理由:远端是健康的 /usr/bin/diff, 不在本机这个坏 PATH 的影响范围内。 顺带补两个选项: --introduce 只部署 introduce 站。改 site/ 时用 —— 原 do_deploy 会先把 SDK 文档站重建一遍(apidoc + mkdocs,106 文件),而 site/ 的改动跟它毫无关系,既慢又把没必要碰的线上站卷进变更面。 --rollback-introduce 对称的回滚入口(原先只有 sdk 站能回滚)。 验证:--check 现在能正确报出差异(附带两份 md5 对照);--introduce 走完 「先比对→打包→备份→原子替换→回验」,线上 md5 与本地一致,http 200。 * docs(deploy): 手册补「本机 diff 是坏的」—— 比对判据的硬前置 这条已骗过两次(openai.lua、两版 release.yml),今天又骗了第三次,且这次 后果最重:它把部署脚本的比对判据整个架空成假绿灯。 补进手册 0.1 节(放在拓扑之后、各部署章节之前,因为它是所有比对的前提): - 结论:一律 /usr/bin/diff 或 cmp,不要裸 diff - 三条可自证真伪的单行命令(含 cmp 未被污染这个事实) - 为什么比「偶尔报错」危险:部署判据假绿灯 ⇒ 站点永久不再更新 - ★ 判据自检的通用写法(拿必然不同的输入验判据,不过就退出) - 同类信号表:diff 说无差异但 wc -c 说大小不同 / 报一致但线上明显是旧的 / 测必然不同的样本却报相同 - 远端 diff 健康,但嵌在 ssh "…" 里要分清本地还是远端执行 * docs: 补上最后两处历史 SDK 链接的迁移(v1.1.0 tag) README 里还剩 1 处、README_EN 里 1 处指向 gitcode.com 的 SDK v1.1.0 release 链接。 核查后确认:GitHub 的 SDK 仓**只有 tag 没有 release**(releases 数为 0), 所以不能照抄「releases/tag/...」的路径 —— 改指 tag 页 tree/v1.1.0, 实测 HTTP 200(gitcode 侧同样仍可用,两边都保留可达性)。 至此两份 README 的 gitcode 链接归零。 * docs(ci): 记录 GITCODE_TOKEN 已配置及其验收方式 原 3.6 节只写「未配置时该 job 显式跳过」,读起来像是一直没配。补上实际状态: 两仓 secret 均已配置(值取自 ~/.git-credentials 的 gitcode 条目), 配置命令走 stdin(避免 token 出现在进程列表),以及三道等价验收 —— 因为 sync job 只在**新版本**发版时运行(prepare.outputs.exists == 'false'), 历史 tag 触发不了,无法用旧版本实跑,所以要用等价命令验: ① gh secret list 确认 secret 在 ② /api/v5/user 确认 token 有效 ③ 带 private-token 头读 release 确认 job 用的鉴权方式可用(两仓都测) 并记下一条待改进项:该 token 是**宽范围个人令牌**(可读 92 仓/48 私有、有写权限), 而 CI 只需这两个仓;更稳的是换一枚仅限这两仓的令牌,把 CI 泄漏的影响面收窄。 当前按用户 2026-09-29 的决定保持原状。 * docs: 主仓 README 补在线文档入口(介绍站 + SDK 文档站) 与 SDK 仓同一个问题:主仓 README 的「文档」章节只列了仓内 markdown (assets/docs/...),没有任何**在线**地址 —— 而主仓 README 正是绝大多数人 的第一入口,从它走不到介绍站与 SDK 文档站。 改为两段并列: **在线文档**:介绍站 + SDK 文档站(含 llms.txt / llms-full.txt 的 agent 直读入口) **仓内文档**(随代码版本走):原有的 OVERVIEW / ARCHITECTURE / PLUGIN_DEV 等 分开标注是有意的:在线站与内核版本**不严格对齐**(站点按发布节奏更新), 而仓内文档跟代码走。写清楚归属,读者才知道该信哪个。 中英文两份同步;两处 README 的 7 个 URL 全部实测 200。 * docs(ci): 记录 cmd/gui 假告警的根因与本机补丁(手册 §6) 每轮编辑后 pi-lens 都会报 `FAIL ./cmd/gui [setup failed]`,看着像仓库有测试红了, 实为工具缺陷。本会话内重复触发 20+ 次,每次都需人工复核,故完整记录并修掉。 根因(两个缺陷叠加): 1. pi-lens 的 runner 按**仓库根**检测(本仓有 go.mod ⇒ go),但被测文件可能 在另一种语言的项目里;`cmd/gui/*.test.mjs` 命中通用测试命名 ⇒ 对纯 Electron 目录生成 `go test -run . ./cmd/gui` ⇒ 必然 "no Go files" 失败。 2. 该失败进入进程内 failedTestsByRunner,而 failed-first 策略此后**每次**编辑 都优先重跑它(与当前编辑的文件无关);条目只在测试**通过**时移除 ⇒ 永不自愈。 日志形态:turn_end: README.md → test go cmd/gui/sse-backoff.test.mjs (failed-first) 为什么不能靠配置关掉(都实测/读码确认): - `.pi-lens.json` 是项目级,只认 ignore/rules/maxProjectFiles/reviewGraph/trivy + 三个改动开关;`tests` 是全局级,写进去会被忽略并告警。 - 全局 `{"tests":{"enabled":false}}` 会一起关掉**所有项目**的回合末测试反馈, 为一个仓库的误报付全局代价 —— 不值。 - `ignore` 也挡不住:它只作用于扫描,不参与测试目标选择。 修法:给 pi-lens 的 getTestRunTarget 加一道「该 runner 真能跑这个目标吗」的校验 (go:目标目录至少有一个 .go 文件),不能跑就拒并清掉那条不可运行的失败记录。 runTestFileAsync 只有这一个调用点,是唯一收口。 验证:node --check 过语法;/usr/bin/diff 核对为纯新增零删除; 真实路径模拟 6/6 符合预期(cmd/gui 的 .mjs → 拒;真 Go 测试文件 → 放行); go test ./... 仍 43 包全绿。 注意:补丁在 ~/.pi/agent/npm/... 里,pi-lens 升级后会被覆盖(届时误报会回来, 不影响仓库,只是噪音)。备份 index.js.orig-*,回退方式写在本节。 本手册同时补上 §6.1 症状 / §6.2 根因 / §6.3 配置为何无效 / §6.4 修法与回退。 * fix(usage): 接通缓存命中与推理 token —— 适配器此前一个字节都不透传 用户实测「无法统计缓存命中与 token 消耗」,这是对的。设计意图一直是 「Lua 适配器透传上游用量,内核估算」,但**流式路径上透传那一步从未落地**。 ## 三层各自的问题(都不是同一个 bug) **① 适配器层(生产路径,最严重)** `transform_stream_chunk` 只搬 content/tool_calls,从不读上游的 usage: - `openai.lua`:整段没提 chunk.usage;且 `choices` 为空时 `return ""` - `anthropic.lua`:`message_start` 直接 `return ""` —— 而 input_tokens 与 cache_read_input_tokens **只在这一帧**;`message_delta` 返回的 unified 不含 usage —— 而 output_tokens **只在这一帧** 后果:生产流式请求的 `StreamChunk.Usage` 恒为 nil。而 Go 侧的分支是 「Lua 结果非空且不同 ⇒ 采用 Lua 结果 ⇒ 不再走回退解析」,所以适配器不透传就没有 任何补救路径。 **② Go codec 层** `chunkUsage` 声明了 `prompt_cache_hit_tokens` / `prompt_cache_miss_tokens` / `prompt_tokens_details.cached_tokens`,但 `chunkAssemble` 只拷 prompt/completion/total ⇒ 「解析了却不返回」。这是回退路径,也是纯 usage 心跳帧(choices 为空)实际走的路。 **③ 累积器层** `resp.TokenUsage = *ck.Usage` 是一句**直接覆盖**。对「只在末帧报一次」的协议 (OpenAI include_usage)恰好正确,所以长期看不出问题;但 Anthropic 分两帧报, 后帧的零值会把前帧的 prompt 与缓存计数清掉 —— 而这不报任何错,只是数字小一个量级。 ## 修法 - **适配器**:新增 `usage_to_unified`(两条路径共用),10 个适配器全部透传; 纯 usage 心跳帧不再整帧丢弃。键名对齐 `agentAPI.TokenUsage` 的 json tag。 - **Go codec**:`chunkAssemble` 提取缓存与推理字段;`CacheReported` 区分 「上游报了缓存但 0 命中」与「上游没报缓存」—— 混起来会把无数据画成 0% 命中率。 - **累积器**:改为**逐字段合并**(新帧非零值赢),`CacheReported` 用或(一旦为真不撤销)。 ## 两个容易搞错的口径 1. **Anthropic 的 `input_tokens` 不含缓存**(真实总输入 = input + cache_read + cache_creation),而 OpenAI 的 `prompt_tokens` 含。统一按 OpenAI 口径, 否则同一字段在不同后端下含义不同,统计无法横比。 直接搬 input_tokens 会少算通常最大的那块,让缓存收益看起来不存在。 2. **缓存写入(cache_creation)归未命中侧**:它要花钱但不算 hit, 算成 hit 会把命中率抬高。 ## 顺带修 `ollama.lua` 的 transform_response 把用量写在 `usage` 键上,而 Go 侧 `CompletionResponse` 的 json tag 是 `token_usage` ⇒ 那份用量**被静默忽略** (缺字段不报错,只是永远取零值)。这是同一形态的「算了却不返回」。 ## 判据(8 条,全部先写后实现) - `internal/agent/api/tokenusage_cache_test.go`:OpenAI v2 details / DeepSeek 遗留 / **ReportedZero 与 Absent 必须可区分** / reasoning_tokens / 无 usage 帧仍为 nil - `internal/lua/adapter_usage_test.go`:anthropic 两帧 + openai 内容帧 + 心跳帧 - `internal/agent/core/tokenusage_merge_test.go`:跨帧合并 / CacheReported 不会被 后续无缓存帧抹掉 / 单帧全量行为不变 ## 观察(未在本提交修) `seq` 的 `TestStoreSaveScalesLinearly` 在 `go test ./...` 并行时偶发失败 (比较墙上时间的两段比值,阈值 3×,受 CPU 竞争影响)。单独跑 3/3 通过, 全量重跑 2 次均 43 ok / 0 FAIL ⇒ 是判据本身对负载敏感,不是回归。 本提交未触及 seq。 * feat(usage): 会话级用量累计 —— 跨调用求和 + 正确的命中率口径 ## 为何需要 单次用量一直在(`StageCtx.TokenUsage`、LLM chain 事件),但**没有任何地方把多次 调用加起来** ⇒「这一轮/这个会话花了多少、缓存省了多少」答不出来,只能翻日志自己加。 这是「无法统计缓存命中」的另一半:数据流出来了,却没有落点。 ## 实现 - `usageLedger`(Agent 级,带锁):跨轮次、跨任务累计 prompt/completion/total/ cache_read/cache_miss/reasoning + 调用次数。 - 在 `stepLLM`(用量唯一的产生点)记账,并把**本次**与**会话累计**一起放进 LLM chain 事件:`usage`(含 cache_reported)与 `usage_session`(含 cache_hit_rate)。 - `StageCtx.TokenUsage` 补上 cache_read_tokens / cache_miss_tokens / reasoning_tokens,插件据此可自行做预算判断。 ## ★ 命中率的分母口径(本提交最容易算错的地方) 分母**只算「上游报了缓存」的调用**(`CacheRead + CacheMiss`), 而不是全部调用或全部 prompt token。理由:没报缓存的提供商/版本不是「命中 0」 而是「不知道」,把它算进分母会把命中率稀释成无意义的低值, 让人去优化一个本来就没开的功能。 因此 `CacheHitRate()` 在没有任何调用报过缓存时返回 `ok=false`, 事件里**不带** `cache_hit_rate` 字段 —— 消费方应显示「—」而不是 0%。 判据 `TestLedgerHitRateExcludesUnreportedCalls` 用 768/1024 vs 100000 token 的对照把这条钉死(把未报缓存的算进分母会得到 0.0076 这种值)。 ## 顺带修一个「发了但没人收到」 `chainPayload["usage"]` 原先是 `map[string]int`,而 WebUI 的 `handler_openai.go` 用 `Payload["usage"].(map[string]interface{})` 取它 —— Go 的类型断言对 map 是**精确匹配**,已实证 `map[string]int` → 断言 false。 于是 `/v1/chat/completions` 回包里的 usage 一直静默为 nil(两边都不报错)。 改为 `map[string]interface{}` 后断言成立。 ## 判据(7 条,-race 通过) 跨调用求和 / 命中率排除未报缓存的调用 / 什么都没报时不可计算 / 报了但全未命中必须给 0 / 缺 total 时按分量补 / 并发记账不丢数 / reset 清零。 ## 状态 已可用:数字可经 LLM chain 事件(`usage` / `usage_session`)取得。 未做(下一步):HTTP 只读端点暴露(`/api/v1/kernel` 加 usage 段)、 估算器校准(现为 runeCount×2 的保守过估)。 * fix(usage): 同步回执带上用量账目 —— 链条最后一环此前是断的 用户实测「无法统计缓存命中与 token」的最后一块拼图: stepLLM 记账 → usageLedger 累计 → EventAgentLLMChain 事件 这三处都有数;但同步注入方(/v1/chat/completions、cli.sock、 clawhubadapter)的回包出自 emitResponse,它新建 StageCtx 只从插件侧读 TokenUsage —— agent 自己算的用量从未传进去。 实测部署实例:回包里 "usage" 字段根本不存在(回复正常,不报任何错)。 修法: · TaskFrame.turnUsage 累计本轮全部 LLM 调用(TokenUsage.Add 求和), finishInputTask 传入 emitResponse;插件显式设置仍优先(既有契约)。 · 口径:对外 usage = 本次请求(OpenAI 语义);会话累计在链事件的 usage_session。两者不可互换,否则第二次请求报出翻倍数字。 · 回包 usage 必须是 map[string]interface{}:消费方 handler_openai.go 用精确类型断言,给 map[string]int 会静默变 nil(链事件那个 「发了但没人收到」的同款坑,本轮在插件路径再次撞上)。 · 一次 LLM 都没跑过时不造 usage(「无数据」≠「用了 0」, 与 CacheHitRate ok=false 同口径)。 判据 6 条:emitresponse_usage_test.go 3(单测,先红后绿)+ emitresponse_e2e_test.go 3(真实调度循环全链路)。E2E 顺带钉住两个真实 内核行为:完全相同的输入会被判 duplicate 直接跳过(第二次必须换文本); ChatStream 不透传 ToolCalls 则工具回环不发生(多轮求和测不到)。 附 usage_mock_llm.py:带 usage 帧的 mock LLM,供隔离实例验证账目链路 (kernel-stress 原有的 mockllm.py 不带 usage,验不了这条)。 * fix(cli): 无头出口透传 usage —— 跑分靠的就是这条链路 与本轮 usage 修复同族:有头 UI 看得到成本,无头调用方看不到。 handleChat 此前只从同步回执里取 content 就往 response 帧里写, agent 记的账、emitResponse 发的 usage 全被丢掉。 而无头链路(waiter -chat、基准 runner、脚本)正是**唯一**的信息出口 —— 标定能力、测缓存命中、比 cost-per-task 全依赖它。 抽 chatResponseFrame(resp.Payload) 独立成函数(可判据化),透传: content、reasoning_content(非空才带)、usage(存在且非 nil 才带)。 「非 nil 才算有」是刻意的:map 里存一个 nil 值也是常见形态, 那种情况同样属于「没有数据」,不能补成 0 —— 否则消费方无法区分 「没开缓存」与「命中率为 0」。 判据 3 条(chat_frame_test.go):带 usage、无 usage 时不造(含 JSON 级 断言与 nil 形态)、reasoning 透传且空值不带。 * feat(status): /kernel 暴露累计用量账目(usage 段) GetKernelStatus 里有 Scheduler/ONNX/Residents 等运行时段落, 唯独没有用量 —— 回答「这个实例花了多少、缓存省了多少」只能翻日志。 · sdk.KernelStatus 加 Usage(UsageStatus):calls/prompt/completion/ total/cache_read/cache_miss/reasoning/cache_reported_calls + cache_hit_rate(指针 + omitempty:无数据时键根本不存在, 消费方显示「—」,不会把「没开缓存」画成 0% 命中率)。 · 数据源就是 usageLedger,不重算 —— 给同一事实造第二个来源迟早不一致。 · /kernel、/api/v1/kernel、healthcheck_kernel 自动都读得到。 判据 3 条(status_usage_test.go):有数必报、无缓存数据不给命中率 (含 JSON 序列化级断言)、空实例不造数。 * fix(codec): token 估算器按实测重校准 —— min(字节数, 2×rune数) 旧公式 runeCount×2 的注释自称「英文 ~0.3 token/字符」,但按它算 11 字符 = 22 token,真实值约 3。跑分对比前需要校准;不能拍脑袋, 本改动用真实 tokenizer(deepseek-v4.1-flash,经 llmsproxy)实测 8 类样本: 样本 字节/token 旧公式过估 新公式过估 英文散文 3.54 7.1x 3.5x 中文技术 5.22 3.5x 3.5x(持平) base64 1.41 2.8x 1.4x 随机ASCII 1.43 2.9x 1.4x hex 1.72 3.4x 1.7x UUID 1.66 3.3x 1.7x emoji 2.00 1.0x 1.0x(精确) 俄语 6.49 7.0x 6.5x 公式 = min(bytes, 2×runes): · bytes 是数学上界(每个 token 至少覆盖 1 字节),对高熵工具结果 (base64/UUID/hash —— 工具结果的常态)保证不低估; · 2×rune 项保 CJK/emoji 与旧公式持平; · 8/8 样本无低估,处处 <= 旧公式。 ★ 反面教训固化成判据:llmsproxy 的 len/3 不可照抄 —— base64 实测 1.41 字节/token,len/3 对它低估 2.1x。估算器的失效方向必须是 「高估」(浪费一点预算)而不是「低估」(撑爆上下文)。 ★ 已知失效模式如实记录:byte-fallback 型 tokenizer 对 CJK 可达 3 token/字 ⇒ 2×rune 项低估 1.5x(旧公式同款风险,预算另有 0.8 兜底)。 C/Go 双侧同步(golden 契约 C==pure 保持),C 规格基准期望值同步更新。 判据 3 条(codec_estimate_calib_test.go):公式语义(min)、 实测下界(低于即低估)、字节单调性。 * feat(bench): 能力标定台 —— 经 cli.sock 驱动实例,记录结果与账目 「全面标定能力」需要可复现的驱动 + 记账 + 判据,而不是手工敲几句看回复。 本台子三件事,各对应一个曾经缺失的环节: · 驱动:连 cli.sock(无头唯一入口),发任务,收帧到终态; · 记账:/kernel 取累计用量做**任务前后差分**(依赖本轮刚补的 usage 段 与 cli 帧透传 —— 没有它们只能报「跑了多久」); · 判定:任务自带 check;没有 check 的任务一律判失败(防空绿)。 计费口径:差分而不是单次回包 usage —— 一个任务常触发多轮 LLM(工具回环), 单次回包只反映最后一段。两者都记在 results.json 便于对照。 命中率分母只算报过缓存的调用;一次都没报时报告写「—」而不是 0%。 内置 tasks.example.json 分五维(readonly / browser / knowledge / multistep / long),全部只读或只写 /var/tmp;期望值逐条实测核对过 (Version=1.4.0 见 meta.go:33、plugins 子目录 20、ParallelSafe 在 internal/sdk/tool.go:72)。--self-test 离线自检纯逻辑(delta/check/summarize)。 ★ 已本地验证账目链路端到端贯通(隔离实例 + 带 usage 的 mock LLM): mock 上报 prompt=300 completion=30 cache_read=200 cache_miss=100 回包 usage prompt_tokens=300 … cache_read_tokens=200 cache_miss_tokens=100 /kernel calls=1 … cache_hit_rate=0.667(=200/(200+100),分母正确) ★ 起实例的两个坑(已写入 README): 1. 改 LLM 配置必须重启 —— provider 在启动时构建,/settings set 落库但不生效 (实测仍打原地址报 401); 2. 隔离实例在固定插件端口上撞车:pluginmgr(9876)/remotedevice(9890)/ kbtree(9892) 写死,同机第二个实例 bind 失败(那几个插件降级,其余照常)。 3. 清理别用 pkill -f(会匹配到自己这条 shell,本轮踩到)。 --self-test 一上线就抓出一个**判据错误**:命中率期望我写成 after 的绝对值 (500/700),实现算的是差分(450/600=0.75)—— 实现对、判据错, 正是本项目「先怀疑判据」那条教训。 * fix(usage): 缓存规则收成单一实现 —— 修掉「命中率恒 100%」的结构性假绿 用户问「你是不是搓了两套解决同一个问题的逻辑?」—— 是的,而且就是它们 一起造出了假数据。 ── 问题 ── 修「无法统计缓存命中」时(5ddbe2c)同一份规则写了两遍: Lua 侧 usage_to_unified × 10 个适配器文件 Go 侧 chunkAssemble × 1 两边都判「上游报了缓存」、都从 cached_tokens 取命中数, 而**都没算未命中数**。 2026-09-30 用真实 llmsproxy 跑分暴露后果: prompt=37473 cache_read=17792 cache_miss=0 ⇒ 命中率 = read/(read+0) = read/read = **恒 100%** 真实值 53%。7 个任务全报 100%,且不报任何错。 留 0 不是「少一点」,而是分母被抽掉 —— 结构性假绿。 ── 修法(方案 A:规则只有一个实现)── · TokenUsage.DeriveCacheMiss() —— 规则的**唯一**实现: 上游只报命中侧时,miss = prompt - read(prompt 是权威总数); 上游明说 miss 的(DeepSeek 遗留字段)一律不动; 未报缓存不补(无数据 ≠ 0);脏数据(read > prompt)夹到 0。 · tokenUsageFromChunkUsage() —— 字段映射的**唯一**落点: chunkAssemble 与 parseOpenAICompatibleResponse 原先各列一份 字段清单,现在共用(非流式也因此第一次拿到缓存字段)。 · 调用点四处(同一函数,不是同一逻辑的副本): chunkAssemble、ChatStream 的 Lua 分支、Chat 的 Lua 落地、非流式解析。 · Lua 侧只搬上游字段、不再自己算 —— 它本来就没算,现在用判据钉死。 ── 判据 ── 新增 cache_rule_single_impl_test.go(5 条,均先红后绿): · 生产形态(适配器输出 cache_miss 缺席)必须补出 232; · **流式与非流式对同一 payload 必须逐字段一致**(防漂移); · 上游明说的 miss 不得被推导覆盖; · 未报缓存时不补(无数据 ≠ 0); · 脏数据夹到 0(不让分母变负)。 另修 tokenusage_cache_test.go 的期望值(原断言不查 miss, 正是缺陷能溜过去的原因)。 ── 实测验证(隔离实例 + 真实 llmsproxy)── 修复前 命中率 100.0%(miss 恒 0) 修复后 命中率 87.6%(逐任务 49.6%–97.7%,冷前缀低、复用高,符合真实形态) * feat(context): 上下文阈值接入配置系统 —— 消除 0.8 与 600000 合成的隐形断层 用户的判断是准确的:「上下文管理策略还是硬编码,不会根据总上下文自动调整 或者用户手动调整各个阈值」。 核实结果:全部硬编码,配置面板一个都没有。 tokenbudget.go utilizationRate 0.8 局部变量 tokenbudget.go maxTargetTokens 600000 const tokenbudget.go memory:ctx 配比 1:2 写死 available/3 context.go protectedCount 10 写死 ★ 两个常量叠加出一个**没人看得见的分段函数**: 窗口 ≤ 750000 → 工作面 = 窗口 × 0.8 (80%) 窗口 > 750000 → 工作面 = 600000 (利用率退化成 600000/窗口) 拐点由 0.8 与 600000 **共同**决定,改任一个都会挪动它; 且没有任何地方能表达「我想要 XX% 利用率」。 新增 4 个配置键(core.agent.context.*): utilization_percent 默认 80 —— 替代写死的 0.8 max_target_tokens 默认 600000 —— -1 = 不封顶(利用率对所有窗口恒定) memory_ratio_percent 默认 0 —— 0 = 历史三等分;1-99 = 显式占比 protected_count 默认 10 —— 裁剪时无条件保留的最近条数 顺带修掉一个**死配置**:core.agent.max_context_size 被注册进面板、 也有默认值,但**从未被读取**(bootstrap 里没有对应赋值)—— 界面上改它没有任何效果,且不报任何错。现在接上了。 ── 升级安全:零值逐值等价 ── ContextTuning 零值必须与硬编码时代完全相同,否则升级内核等于悄悄改了 所有实例的上下文策略。实测核对: · maxCtx×80/100 与 int(float64(maxCtx)×0.8) 在 **42 万个窗口值**上逐值相同 (浮点 0.8 不可精确表示,所以这里用整数运算,更确定) · available/3 与 available×33/100 **不等价**(1000 时 333 vs 330) ⇒ 默认特意保留 `/3` 本身,不换算成百分比 判据 6 条(context_tuning_test.go,均先红后绿): 零值等价、利用率可调、不封顶消除断层、记忆占比可调且守恒、 保护条数可调、Agent 真的带上 tuning。 ── 端到端验证(隔离实例 + 真实 llmsproxy,窗口 1048576)── 默认配置 target=600000 mem=199268 ctx=398536 (= 历史行为) 70% + 不封顶 target=734003 mem=243935 ctx=487872 (= 公式期望) 即配置真的流通,而不是"注册了却没人读"。 * feat(webui): 对话页展示缓存命中率与 token 用量 用户报「webui 还没展示缓存命中率与 token 使用量」。 根因:内核**一直在推** `agent_llm_chain`(带 usage / usage_session), `handler_chat.go` 的 subTypes 里也早就转发了它 —— 但 dashboard.js 从未订阅这个事件。又一个「写了、发了、不报错,只是没人收到」: 后端有数、界面没有。 改动: · 订阅 agent_llm_chain,用 usage_session(本会话累计)刷新徽标; · #chat-usage 徽标挂在对话页标题旁,显示「2.9M tok · 缓存 56%」, 悬停给出明细(调用数/prompt/completion/cache_read/cache_miss/命中率); · 进页时先用 /kernel 的 usage 填一次(标注「进程累计」, 与 usage_session 的「本会话」区分 —— 两者口径不同,混看会误判); · 命中率**缺席时显示「—」**而不是 0%:缺席 = 没有任何调用报过缓存细节, 与「命中率为 0」是两回事。把「不知道」画成 0% 会让人去优化一个 本来就没开的功能(与内核 usageLedger 的 ok 口径一致)。 ★ 真实浏览器验证(CDP,headless Chrome 147)——这是本次唯一能证明 「界面真的画出来了」的方式: #chat-usage 存在: True(切到对话页后;面板是懒渲染的) 徽标: "2.9M tok · 缓存 56%" 悬停明细: 调用 74 / prompt 2839511 / cache_read 1593984 / cache_miss 1245527 / 命中率 56.1% 第一版探测「找不到元素」的真因是**面板懒渲染**(buildChatLayout 只在 switchTab('chat') 时调用),不是改动没生效 —— 记下来免得下次误判。 * docs(bench): 跑分计划交接文档 + 标定台补齐 pi 对比/超窗召回/汇总脚本 - benchmark.md:唯一执行依据,含前置事实清单与已知教训 - pi_bench.py:pi 侧驱动(隔离配置目录 + 空闲端口,提示词与 HomeAgent 侧逐字一致) - compare.py:两侧结果汇总对比 - memory_recall.py:200k 超窗记忆召回(带 --expect-window 无效配置拒跑) - tasks.zerobasis.json:零基础认知 6 任务集 - spawn-instance.sh:隔离实例一键起(llmsproxy + context_window 启动前直改 sqlite) * feat(bench): 客户端能力任务集 v2 —— 考 harness 而非裸 LLM 原 zerobasis 任务集本质是裸 LLM 认知测试(读 44 行材料答题, 单工具调用一次完成),harness 几乎无参与度,测不出客户端差异。 v2 四任务,全部必须工具回环、判据落盘(file_contains,与两侧工具名无关): - cl-fixloop 工具循环:跑测试→修代码→跑到全绿(3 个埋入缺陷) - cl-pipeline 错误恢复:GBK 编码文件让脚本崩,修到跑通 - cl-aggregate 大输出:15 文件 ~27k 行去重聚合 - cl-chain 跨步状态:6 步大数运算链,逐步写盘不许心算 生成器期望答案在生成时独立复算两遍(两种算法),不一致拒生成; 判据经变异自证(对答案 True / 错答案 False,4/4)。 * feat(bench): 记忆召回 v2 —— 取消显式强调,打在压缩与外化的分叉区 v1 结构性缺陷(两侧行为一致地 4/4 满分 ⇒ 零区分度): 1. 针全部显式标注「请牢牢记住」——任何 harness 都会保住被标重点的内容; 2. 填充是语义空转的中性句——压缩几乎无损,向量裁剪无从发力。 v2 设计: - 取消一切预告,偶发针混进同域干扰轮(4 根端口针 + 40 个形似干扰值, 措辞完全同构),措辞上无法与干扰区分; - 探针分层:casual×4(偶发,改述提问)overwrite×1(值覆盖只认新值) overwrite-stale×1(反向哨兵:答旧值=塌回,单独统计) multihop×1(两个散点组合运算); - 填充分 neutral/confusable 两档,针藏于 confusable; - 每轮 token 估算按实际混合比例(neutral 43 token/句 vs confusable 22), 修掉「按长句估导致实际灌入不足 1×窗口」的错; - 判据变异自证 7/7,结构自证 6 项(针藏匿/无泄漏/覆盖序/单行/唯一/dedupe 防护)。 * fix(bench): memory_recall v2 pi 驱动 —— cwd 用 subprocess 参数而非 pi -C 选项 pi CLI 没有 -C 选项,之前 106 轮全部 exit=1(0.2s/轮的假跑)。 改为 subprocess.run(cwd=...),与 pi_bench.py 同口径。 * feat(memory): chineseclip 模型目录回退链 + 可执行报错 + 跑分台自检 跑分实测抓到的静默缺口(2026-09-30): 新数据目录启动时模型缺失,内核只打一行 warning 就禁用稠密检索照常服务。 对照实验:稠密检索在跑时 doc_query 能命中;禁用时(跑分实例)提问轮 memory_recall/doc_query 双双「未找到」——50 分钟测的是残废配置。 三层修复: ① providers/chineseclip:findModelDir 回退链(configured → /usr/lib/homeagent/models,与 findOnnxLib 同款先例),全落空时报错带 可执行指引(export 脚本 / 拷贝 / 发行包三条路);单测覆盖两种结果。 ② spawn-instance.sh:起实例后断言 'multimodal space active',模型缺失时 自动从系统目录 symlink 并重启;无法补救则拒跑并给出指引。 ③ 错误文本经 bootstrap mmErr → cfg.EmbeddingError → onnxStatus.Reason → healthcheck onnx.reason 全链路带出(端到端实测)。 另注:本机 /usr/lib/homeagent/models/ 已建 symlink 指向生产模型目录, 此后新实例默认可回退命中。 * fix(memory): 纯数字实体合法化 + 0 写入显式报拒 v2 重测(干净实例+稠密检索激活)把 overwrite/casual 失败归因到写图侧: 实测证据(ha-b run.log): - 模型主动 memory_commit「metrics服务 端口 8328」「分机 4379→4324」 - validEntityName 要求含字母/汉字 ⇒ 纯数字 Object 被静默 continue - 返回「已写入 0 个实体和 0 条关系」——模型当成成功,永不重试 - 11 次 memory_commit 里 8 次写 0 全部假成功 两处修复: 1. validEntityName 允许纯数字(端口/分机/编号是合法属性值),仍拒空串/ 纯标点;单测覆盖。 2. memory_commit 0 写入时显式报告被拒原因与自查指引,模型可立即纠正重试。 另一发现:multihop 翻案 0/1→1/1 —— 稠密检索激活后 doc_query 能命中 COPPER-8177+A1103 两个散点,证实前轮失败主因是模型缺失而非外化架构。 * feat(bench): 记忆召回 v3 —— 填充从废话流改为高密度运维叙事 用户指出的 v2 根本性缺陷:填充是「打印机墨盒/树叶在动」式废话, pi 压缩可直接丢弃垃圾,偶发针反成填充里仅有的有价值内容被保住 ⇒ 跑分虚高,不贴合真实对话。 v3 填充模型:order-gw 服务运维主线(上线准备→灰度事故→修复验证→ 新版本发布→交接收尾),每轮 5 个高密度片段(数值/因果/决策/变更), 压缩必须保住叙事骨架才做真实取舍。针全部嵌在价值信息流里: - 偶发针 = 服务依赖清单条目(同构干扰 = 其它服务真实端口) - 覆盖针 = 值班安排变更流(4379→4324,真实改号场景) - 多跳 = 交接流程(团队→门禁→申请表) 结构自证全过:4 偶发针落位、覆盖序 旧@28<新@90、多跳两要素落位、 单行/唯一/无泄漏。143 轮 × ~1.7k token = 245k 灌入(1.23× 窗口)。 判据变异自证 7/7。锚点与 phase 边界对齐踩坑已修(0.50/0.63 落在 相邻 phase 区间导致永不触发)。 * docs(bench): benchmark.md 同步 v3 填充模型 + 锚点 phase 坑 + overshoot 1.45 * feat(bench): 超窗校验落地 + 实例重置脚本 memory_recall.py: - 补上 benchmark.md 承诺但实现缺失的 --expect-window:与 --window 不一致 拒跑(exit 2),通过时打印「✓ 超窗校验通过」(文档验收判据依赖该行) reset-instance.sh(新): - 固化 2026-10-01 两次踩坑:/var/tmp/ha-c 曾同时 9 个 homed 共享一个 graph.db(只 kill pid 文件那个 ⇒ 清理后旧数据仍在); 删库让核心自建 schema 比手工清目录可靠 - 跑分占用门禁:检测到 memory_recall/bench 进程引用本实例时拒绝清理 (exit 3),--force 显式越过——实测验证门禁生效 * fix(bench): 跑分每轮落盘 partial.jsonl + 每轮打印进度 长跑(143 轮 / 实测均值 148s 每轮 / 约 6h)本会话已被杀三次,而原来只在 全部跑完后才写 memory_recall.json ⇒ 中途崩溃等于零产出: - 每轮结束追加 partial.jsonl,崩溃可恢复分析 - 填充轮由「每 10 轮打印一次」改为每轮打印(原实现下前 9 轮完全无输出, 实测跑 12 分钟输出文件仍 0 字节,无法区分「慢」和「卡住」) * fix(gui): cmd/gui 补 doc.go 使 go test 通过 + bench 支持 --resume 续跑 cmd/gui/doc.go: - cmd/gui 是 Electron/Node 目录(main.js/package.json/node_modules), 0 个 .go 文件 ⇒ `go test ./cmd/gui` 报 "no Go files"(exit 1)。 任何按仓库遍历 Go 包的工具(pi-lens test-runner 等)都会把它当失败, 而失败条目只在「跑通」时才被移除 ⇒ 每次编辑都重试,永不自愈。 一个只有包注释的 doc.go 让该目录成为合法(空)包:实测 go test exit 0、 go build ./... 与 go vet ./cmd/gui 均不受影响。 memory_recall.py --resume: - 7 个探针全排在最后 7 轮,崩溃后 partial.jsonl 只有填充、拿不到召回信号, 所以「只落盘」不够,必须能续跑。服务端会话状态跨连接保留(CliSession 每轮新建连接但 agent 会话在服务端)⇒ 跳过已完成轮次是真接续而非重放。 - 前提:续跑时实例必须未重启/未清库,否则上下文对不上(已在 help 里写明)。 * bench: v3 密度调参 frags_per_turn 5→8(100k 窗口 / 50 轮方案) * bench: 轮数由实测反推(13 轮方案)+ 超窗校验改为实测 prompt 口径 - HA 实测:第 6 轮 prompt=127126 已超 100k 窗口,而此时累计填充才 5×8 片段。 脚本原先按文本估算规划 50 填充轮(估算 121k)纯属浪费 —— 每轮真实 prompt 含系统提示/工具定义/记忆上下文/工具回执,远大于纯文本量。 - filler_turns 上限 5:13 轮 = 开场 + 5 填充 + 7 探针。 - 超窗判定从「估算 ≥ 窗口」改为「运行后实测 max(prompt) > 窗口」: 估算只是下界,按它判无效会误杀实际已超窗的有效跑分(summary 新增 max_prompt_observed / overshoot_verified)。 * fix(bench): 非 resume 模式清空 partial.jsonl 实测踩到:partial.jsonl 跨运行 append,把上一次被 kill 的残留轮次混进本次 结果(同一文件里出现两条「轮1」)。--resume 时才保留,否则先 unlink。 * bench: filler_turns 上限 5→25,靠加厚灌入制造超窗 压窗口(如 10k)会让 HA 反复上下文管理抖动:相当于在低内存设备上测内存 调度,测出的是病态行为而非设计工作点。改为两侧窗口一致(50k)、灌入量本身 超过窗口(1.22×):pi 是水位线压缩,累计文本 > 窗口就必须真实丢信息。 上限 25 轮是成本护栏(实测 pi ~20s/轮、HA ~120s/轮)。 * fix(bench): pi 驱动丢 stderr + cwd 污染,导致假死与假答案 v3b 现象:50k 窗口 / 60.9k 灌入下,轮 8 prompt=47914(96% 水位)、轮 9 回落 (压缩确实发生),**轮 10、11 连续 900s 超时且 prompt=0**;子进程存活、CPU 0.7%、会话文件仍在写 ⇒ 看着像死锁,实为两个 bug 叠加: 1. **cwd 污染**(假答案来源):--cwd 指的 /var/tmp/pi-iso/work 里有上轮实验 残留 order_gw_*.md / order_gw_port_freq.json,pi 把它们读进上下文 —— v3b 的 4324 是答自磁盘文件,不是会话记忆。→ 默认改用干净空目录 /var/tmp/pi-iso/clean-cwd。 2. **每轮重建会话**(假死来源):cwd 与 PI_CODING_AGENT_SESSION_DIR 不一致时 pi 报 'No project session found; creating a new session',每轮重新压缩 全部历史 ⇒ 越到后面越慢直到超时。→ stderr 一律回传到 result.error (原先完全丢弃,正是它吃掉了故障线索);超时也带 stderr 尾巴。 实测修复:同 session-id 连发两轮,警告只出现在首轮(建会话正常提示), 第二轮复用会话正常。 * fix(bench): pi 驱动加 --no-tools —— 此前所有 pi 成绩作废 实测(2026-10-01):不加 --no-tools 时,pi 在**一轮**里跑 33 次 bash,把自己 当上帝视角翻找答案: grep -l "4324" *.jsonl ← 搜到旧会话里的答案 cat order_gw_ops_index.md ← 读跑分台自己的材料 sed -n '605,625p' order_gw_daily_sync.md ← 去挖记忆_recall.py 的行号 ls -laR /var/tmp/pi-iso/work ← 遍历实验残留 ⇒ 它不是在测记忆,而是在作弊。此前 pi 的 6/6 / 100%(100k、50k 两轮)全部 作废:很多答案来自磁盘与旧会话,不是压缩保留能力。 HA 侧同轮核查结论:**未作弊,成绩有效**。它的 files/cmd 面被限制在实例 数据目录内,读到的 '# order-gw 运维同步归档 / 由 HomeAgent 落库' 是它自己在 测试过程中 files_write 写出的存档,不是跑分材料。 --no-tools 实测:单轮工具执行数 33 → 0。 * feat(bench): v4 判据组 —— 区分「语义取舍」与「侥幸命中」 动机(2026-10-01):v3d 里 pi 压缩两次后仍 6/6 全中,但**探针太短太集中** (全是 4 位端口 + 一个覆盖值),被丢在摘要角落也能命中 —— 分辨不出压缩 质量,也就无从判断「主动按水位提前压」到底是真取舍还是侥幸。 新增三类(各 1 针,共 34 轮): - unmarked(填充轮 3):语义自足的归档口径声明,**从未点名要求记住**。 语义压缩当约束保留⇒命中;截断/关键词摘要⇒丢。测「有没有保住未被点名的东西」。 - causal(填充轮 8):现象→根因→措施三段因果链,只问结论。 摘要爱列要点清单、清单外的中环易丢;forbid 症状值(首环)⇒ 只记住现象 而没走完链算失败。expect 用**带语境短语**而非裸数字(裸数字会跟上下文任意 数字撞上,等于没判据)。 - freeze(填充轮 15):中间快照值 + 最终冻结值并存,措辞明确指向最终。 forbid 中间值 ⇒ 答中间值即「被摘要带偏」,这是压缩最典型的新错误。 自证:期望值全在填充中且唯一(freeze_new 出现 2 次属设计内:中间+最终)、 开场白零泄漏、判据变异 10/10 通过(正样本命中、负样本不命中)。 * bench: 探针说明同步 10 类 + reset 脚本 sqlite 加 timeout * docs(design): 上下文超页 L4 中断设计与落地清单 记录 v4 跑分归因产出:HA prompt 锯齿波(峰值 7× 窗口)、topK 与窗口脱钩 (max_context_size=30 是**条数**而非token,故 1M 只调 context_window 无效)、 超页三条路径无统一出口、既有 L4 机制的 4 条硬约束,以及 T1-T10 测试项 (T4/T5 为变异验证项)。 * feat(overflow): ProviderError 错误分类,ErrContextFull 可恢复 同一 HTTP 码在不同上游代表不同处置:ErrContextFull(裁剪重试)/ ErrTransient(重试)/ErrCredential(换凭证)。只有 StatusCode 时调用方只能 字符串匹配错误消息 —— 脆,且上游改文案即失效。 判别优先级凭证 → 超限 → 瞬时,顺序不可换:401/403 报文偶尔也提context (网关模板文案),但凭证错误永远不该按「裁剪重试」处理。 实测各上游把 context_full 报成 400 / 413 / invalid_request_error,故判别 必须「状态码 + 报文特征」组合。测试含真值表 10 例 + 2 个变异自证: 清空特征词表 ⇒ 不再判超限(证明判别真依赖该表); 401+context 特征词 ⇒ 仍判凭证(证明优先级有意义)。 设计:docs/zh/context-overflow-l4-design.md * feat(overflow): 上下文超页统一到 L4,Prune 同步执行 把三条超页路径收敛到 L4 唯一入口 raiseKernelInterrupt: - 本地预判:积累上下文 > 1.25×窗口(**每次 LLM 请求前**,覆盖轮内tool 回环; 现状只在轮首 checkContextFull 且根 agent no-op、一次性) - 上游 ErrContextFull:接在 stepLLM 的 llmErr 分支,**在 provider fallback 之前**(fallback 会换 provider 重发同一个超限请求,换谁都一样超) - 两条路径共用同一个 handleContextOverflow 关键取舍:Prune 在 raise 之前**同步**做完,L4 只负责打断+告知。理由: Prune 全路径无 IO/无 panic,放进 L4 任务里做只多付一次调度开销,且结构上 杜绝「L4 里 Prune 出错再触发 L4」(L4 遇 L4 靠 canPreempt 严格大于, 第二个 L4 只会排队,永远等不到时机)。 裁剪 0 条 = 判定逻辑有 bug(本来装得下却报超页),按用户口径保存现场并 终止,**不重试不触发中断**。 判据必须用 accumulatedTokens(a.context 全部事件)而非 f.Msgs:后者被 buildMessages 按 targetUsage=0.8×窗口 裁过,结构上永不成立。抽成函数是为了 与 resident.go:checkContextFull 共用同一口径,避免再次漂移。 复原路径显式 f.Step=StepPrepare:f.Msgs 只在 stepPrepare 由 buildMessages 装配,复用挂起帧那份等于原样重发超限请求。TaskFrame 加 OverflowRecover 做**帧内**恢复预算(跨帧累计会让长会话后段失去超页处理能力)。 设计:docs/zh/context-overflow-l4-design.md * feat(gui): 星图接上 /memory/graph/pulse + 前端依赖本地化 + 端点对齐门禁 GUI 与服务端 WebUI 插件共享同一套 REST 接口,但两边独立演进、没有任何 强制手段。这次核实发现三处真实漂移(服务端 42 路由 / GUI 只用 29)。 1) 星图接上服务端专为它造的轻量活动端点 服务端 handleMemoryGraphPulse 的注释写了动机:生产实例全量图谱 408KB / 1151 节点,为了「知道哪些节点是新的」而每 N 秒拉全量是把 带宽和 JSON.parse 全花在重复数据上;pulse 只回 id+name+type+ mention_count+updated_at,几百字节 ~ 几 KB,差两个数量级。 WebUI dashboard 接了它,GUI 此前只在初始化拉一次全量且完全不轮询 ⇒ 星图停在打开那一刻的快照,agent 后来学的东西它永远看不到。 现接入:/runtime 3s + /memory/graph/pulse 10s,两路轮询幂等、 切连接时停;新实体标「生长」;SSE 的 tool_call/stage/agent_output 也会触发脉冲(用工具名与回复前 60 字当 hint,让点亮落到本轮相关实体)。 pulse 检出「全量图里没有的新实体」时置脏,由下一次 renderChatStarmap 惰性重拉全量 —— 不在脉冲回调里直接拉,否则会把轻量活动源变成每 10s 拉一次 408KB 全量,正好是 pulse 端点要消除的浪费。 2) 前端依赖本地化,顺带修掉一处安全边界 四个库从 internal/plugins/webui/static/ 复制到 renderer/vendor/ (字节一致,由门禁钉住),index.html 不再引用任何公网 CDN。 与服务端同一理由(见 webui/starmap_vendor_test.go 的注释): HomeAgent 支持离线/内网部署,换 CDN 只是把同一个赌注重下一遍。 ★ renderMd() 的净化器缺失分支不再回退到手写正则(剥 <script>/on*=/ javascript:)—— 那不是完备的 HTML sanitizer,漏 <iframe srcdoc>、 SVG 内联事件、data: URI 等,而它渲染的是模型输出与记忆文本, 都算不可信输入。现在直接退纯文本:库已本地化,走不到该分支。 3) 新增 endpoint-align.test.mjs(进 npm test / CI) 扫描 app.js 的 api("...") 与 handler.go 的 mux.HandleFunc 对账, 7 条判据。与既有判据同一路子:读真实源码,不抄逻辑重写。 反向差集只报告不门禁(管理面端点接不接是产品决策), 只硬钉「服务端已明确为其设计」的 pulse —— 即本判据的由来。 已做变异测试:改错 pulse 路径、改回 CDN 均能判红。 protocol-align.test.mjs 是真浏览器判据(需 Electron+Xvfb+真后端), CI 明确不跑,所以这类漂移此前无人发现。 顺带:connectSSE 由隐式全局赋值改为 function 声明(加 "use strict" 会在文件靠后处 ReferenceError,报错点离调用点很远)。 判据:cmd/gui 下 npm test 全绿(4 个 .mjs / 25 条)。 * test(overflow): 超页处理测试 + 修两个实测暴露的缺陷 测试(10 例,T1/T2/T4/T6 + 并发/零值/误接防护)全绿,且经变异验证有效: - 变异「把判据退回 pruned>0」⇒ T1 FAIL - 变异「删掉认知提示句」⇒ T6 编译失败 三个缺陷(都是写测试/跑测试时才暴露的,不是推演出来的): 1. StaticEmbedder.Vectorize 收到 nil 接收者会 panic (computeVector 在模型未就绪时拿到 nil embedder ⇒ 整条链路崩掉而非降级)。 实测撞到:go test 直接 SIGSEGV。 2. handleContextOverflow 的「裁不动」判据用错了量。 Prune 返回的 pruned 是**写进 docStore 的条数**,不是「少了几条」—— docStore == nil(轻量内核/文档记忆未初始化)时它恒为 0,但裁剪照常发生。 用它判会**把正常裁剪误判成 bug 而终止**。改为看「上下文是否真的变小」 (条数或 token 任一下降即算裁成功)。 3. Prune 的 keepCount 可为负:topK(=max_context_size-1) 小于 protectedCount(=10) 时 keepCount 钳到 0 ⇒ keep 为空、全部事件进 archive。 默认配置(30)下不会触发,但 max_context_size 被调到 <11 的实例会。 overflowNotice 抽成纯函数以便可测:这条消息是 agent 唯一的认知输入 (L4 任务按调度器设计拿不到被打断者上下文),措辞不是装饰。 * feat(overflow): 接上恢复预算与轮内本地预判(补T5/T7) 上一提交留了两个「声明了但没接线」的死部件,这次补完并加变异自证: - OverflowRecover 字段从没人读过 ⇒ 恢复预算等于不存在 - maybeHandleContextOverflow 零调用点 ⇒ 本地预判等于不存在 接线: - stepLLM 开头做本地预判(每次 LLM 请求前,覆盖轮内 tool 回环)。 位置在 dropContinuationPlaceholders 之前:f.Msgs 已拼好而本次裁剪会让它过期。 - 裁剪后显式 f.Step=StepPrepare 重建 f.Msgs(挂起帧那份就是超限那份)。 - 预算耗尽:f.Terminal=terminalError + f.Err 指向根因 (core.agent.max_context_size 与窗口不匹配),返回 handled=true 以免调用方再重跑。 overflowRecoverBudget 改 var:测试要能变异它做判据自证,将来若要按窗口动态 给额度也不必改结构。 T5 测试 + 变异自证(预算=2^30 时必须不终止 ⇒ 证明判定真在数)。 全量 core/api/memory 绿。 * feat(overflow): topK 按窗口预算反推,不再与窗口脱钩(T9) v4 跑分归因的根因二:max_context_size 是**条数**、与窗口 token 无换算关系。 实测 50k 窗口下 prompt 峰值达 7 倍窗口、每轮「超限→修剪→再超限」—— 容量 30 条对实际占用毫无约束力。**所以只调 context_window 到 1M 无效**, 改窗口不会改这 30。 contextTopK:用已按窗口算好的 ContextTokens 预算反推条数,平均事件 token 取实际积累(accumulatedTokens/Len),无样本时退回 2000(保守偏大 ⇒ 宁可 多裁,不让积累量重新涨过窗口)。max_context_size 仍作**硬上限**保留—— 它是运维给的活跃条数约束,改了必须生效,否则运维会困惑。 测试 3 条 + 变异自证。其中变异那条第一版写成空壳(只有 t.Log 无断言), 已改成真断言:空壳变异测试永远绿、给虚假安全感,正是本特性批评的 「不接线的防护等于没有防护」。 * fix(overflow): provider/context 为 nil 时不得崩(contextTopK 的前置) 上一提交 cb3627e 带着失败的测试就提交了(TestMemoryPass_PruneAndRecallTogether SIGSEGV),这里补上根因修复: 1. ComputeTokenBudgetTuned 直接调 provider.MaxContextTokens(), provider 为 nil 时 SIGSEGV。这不只是测试场景 —— 「配置早于 provider 就绪」 的启动序列上同样会崩。 2. contextTopK 里 a.context.Len() 未防 a.context == nil。 两处都是**构造期/未就绪**状态下会走到的路径,补专门回归测试 (TestOverflow_零值Agent不崩 / TestComputeTokenBudgetTuned_nilProvider)。 教训:不看测试输出就 commit,等于把失败留在历史里。 * fix(overflow): topK 下限改为 protectedCount+1,否则裁剪完全无效 实测缺陷(T10 诊断时撞到):v4 的工具大回执型事件平均 48000 token,而 50k 窗口的 ContextTokens 预算只有 40000 ⇒ 预算反推的 topK = 0 ⇒ 钳到 1。 而 Prune 里 keepCount = topK - protected = 1 - 10 < 0 ⇒ keep 为空 ⇒ **全部事件被归档**:裁完一轮上下文照样超页,超页处理空转。 正确下限是 protectedCount+1:宁可暂时超页,等预算或事件尺寸回到正常区间再裁。 (写成 1 等于「清空记忆」,而超页处理的目标恰恰是保住记忆。) 诊断数据(realistic 尺寸构造):积累 1440000 token、比例 28.8、事件 30 条 ⇒ 修复前 contextTopK=1(灾难),修复后 ≥ protectedCount+1。 新增 2 条测试钉住:大事件时 topK 不塌到 1;裁剪后仍留 protected 条以上。 * fix(gui): 设备接口补设备令牌,设备页不再恒 401 ## 现象 GUI 设备页长期空白。日志每 15s 一条: GET 401 /api/v1/device/online ## 根因:/api/v1/device/ 命名空间下并存两套鉴权 · /device/gateway 注册在 webui 插件,走 requireAPI,认 session cookie · /device/online、/device/、/device/push 注册在 remotedevice 插件,走 requireToken, **只认 X-API-Key 头或 ?token=,完全不认 cookie** 实测可区分:cookie 401 与错 token 401 的响应体都是纯文本 "unauthorized",而 webui 鉴权返回 JSON {"error":...}。 所以 GUI 只带 cookie 时:gateway 200、online 401 —— 同一连接同一 时刻,现象自相矛盾。 ## 三种能工作的客户端做法一致,GUI 两样都没做 · WebUI 浏览器:cookie 进内核,reverseToUpstream 注入 deviceGatewayToken(handler_device.go:118) · waiter:客户端自己带 X-API-Key(gateway_discover.go:103) · GUI:既没注入、也没自带 ⇒ 设备页恒 401,且异常被 catch 吞掉置空数组,界面与「确实没有设备」完全一样 ## 改动 1) main.js:device-bridge:get 回传 token 明文(此前只有 tokenSet 布尔,渲染进程永远拿不到)。明文本就存在本机 gui-prefs.json, 不新增密钥存储面,且不落日志、不进 URL。 2) app.js:新增 isDeviceTokenPath / deviceApiKey,对设备接入面 端点附加 X-API-Key(优先连接自身 apiKey,退回设备桥 token)。 3) app.js:新增 ensureDeviceToken,并**在首次 /device/* 请求之前** 调用 —— 此前令牌加载排在 /device/online 之后,顺序正好反了。 4) app.js:401 时记下可读原因(deviceAuthHint),设备页顶部渲染 红色提示卡,不再把鉴权失败伪装成「没有设备」。 5) app.js:网关发现加缓存,并从每 15s 的 refreshAll 轮询里移出 (改为按需 + 切连接/手动刷新时重取)。网关地址是服务端配置的 慢变量,15s 拉一次没有意义;此前它与 online 同秒重复调用。 ## 实测(本机 Electron,192.168.2.60:8080,真实凭据) 修复前:device/online 恒 401,设备列表空 修复后:device/gateway 1 次(之后走缓存) device/online 每 15s,全部 200,返回 waiter-fnnas / waiter-mainnas 两台设备 401 计数 0 判据:cmd/gui npm test 全绿。 * debug(overflow): 超页判据加诊断留痕 T10 排查卡了 10 轮工具调用才意识到:**「没触发」与「没执行」无法区分**—— prompt 每轮都远超阈值 62500,却零 'context overflow' 日志,既可能是判据 没触发,也可能是那段代码压根没跑到。静态读代码排除了全部可能(parentID 空、 isLightKernel=false、判据逻辑正确),只能加运行时证据。 每 20 次检查打一行:比值/窗口/事件数/是否轻量内核/有无上下文。 nil 安全(a==nil 时不碰字段)。 * fix(overflow): 累积量必须计入工具回灌(判据此前永远看不见真正的超页来源) 诊断日志(e984ff2)一次就给出答案: overflow check #1: ratio=0.00 window=50000 events=1 —— prompt 峰值 17万~40万,判据算出来是 0。 根因:ContextEvent 里工具输出是**独立字段** ToolResults []ToolResultItem, accumulatedTokens 只算 Input+Response。一个「用户 10 字 + 模型 20 字 + 8 个工具各回 3 万 token」的轮次算出来是 30 token ⇒ 超页判据永不触发。 而 prompt 峰值的真正来源恰恰是工具回灌(HA 每轮 4-8 个工具)。 抽成 eventTokens 共用;resident.go 的 checkContextFull 有同样缺陷,但它只对 驻留子生效、一直没暴露,现在两处共用同一口径。 新增 2 条测试:工具回灌计入(并能触发超页)、无工具时不放大。 ★ 修正自己写错的断言(把 800 token 当成「放大」——实际 EstimateTokens 是 字符/2 量级,800 字符≈400 token 是对的)。 验证:go test -count=1 连续两轮全绿(core/api/memory)。 (提交时那一次 FAIL 是 T10b 跑分占用实例所致,非代码问题。) * perf(gui): 流式渲染判据改看 _streaming,不再依赖 chatLoading ## 现象 旁观 agent 响应其它渠道(memo / QQ / email)时,聊天页每秒全量重渲多次, 消息越多越卡。 ## 根因 流式分支判据写的是 `state.chatLoading && ...`,而 chatLoading 只在 「用户从 GUI 自己发消息」(sendChat)时置 true。旁观路径(agent 响应 memo/QQ/email,GUI 仅通过 SSE 收流式帧)下它恒为 false ⇒ 判据恒假 ⇒ 每个 content_delta / reasoning_delta / tool_call 帧都掉进「非流式」分支, 走**全量** innerHTML 重建。 而且它出现**三处**,只改一处无效: · rerenderChatIfActive(决定走 renderChatStreamChunk 还是 renderChat) · renderChat 内部的 streamingLast(决定单条消息的渲染分支) · 工具 pill 的「进行中」收集(旁观时列表恒空,看不到 agent 在做什么) ## 实测(本机 Electron + 192.168.2.60,真实凭据,同一条消息) 修复前:1.7~2 秒内 13~26 次全量重渲,间隔 10~30ms 修复后:同一场景降到 5 次(含轮询触发的) 200 条消息时单次全量重建实测 235ms —— 这是「聊天卡得没有用的欲望」的直接来源。 ## 改法 所有 SSE 流式帧创建的 assistant 消息都带 _streaming:true (content_delta / reasoning_delta / tool_call / agent_output 五个 push 点), 收尾时才置 _final。所以正确判据是「最后一条是尚未收尾的流式 assistant」, 即用 last._streaming 取代 state.chatLoading。 保留 chatLoading 的两处语义不同,未动: · 「思考中」占位气泡:只在用户自己发消息后等待时显示,旁观不需要 · sendChat / interruptChat 自身的并发保护 判据:cmd/gui npm test 全绿。 * fix(gui): 连接配置不再被空/半截文件覆写,保存改原子写 ## 现象 connections.json 变成 {"connections":[],"currentId":null}, 用户的连接列表全部丢失(2026-10-01 实测)。 ## 根因:两个缺陷叠加 1) loadConnections() 的损坏恢复分支无法区分两种情况: · 文件内容确实损坏 → 备份+重建(合理) · 读到空/半截内容 → **也走同一条路,覆写成空配置** 第二类真实会发生:saveConnections() 用非原子 writeFileSync 截断重写, 落地过程存在「已截断、未写完」窗口;此刻任何一次读取拿到半截内容, JSON.parse 抛错 → 用户配置被永久销毁,且没有二次确认。 实测(沙箱跑真实函数):空文件 -> 返回空配置, **并把文件改写成 {"connections":[],"currentId":null}**。 2) 损坏备份只写一个固定的 .bak,反复损坏会把上一份真实配置覆盖掉, 于是「唯一的退路」也丢了。 ## 改动 1) loadConnections():raw.trim()==="" 视为「尚未配置」, 直接返回空配置,**不覆写、不备份**(BOM/纯空白同样处理)。 2) saveConnections():改为原子写 —— 先写同目录 .tmp-<pid>, 再 renameSync 覆盖。rename 在同卷内原子,读者只会看到改写前 或改写后的完整文件,从根上消除产生半截内容的竞态;失败时清理 tmp。 3) 真损坏的备份名带时间戳(connections.json.corrupt-<ISO>.bak), 反复损坏不再互相覆盖。 ## 判据 新增 cmd/gui/connections-io.test.mjs(进 npm test): 在 node:vm 里抽出 main.js 的**真实函数**执行,11 条断言覆盖 合法/空/纯空白/损坏/原子写残留。已做变异测试:删掉空文件守卫、 把备份名改回固定 .bak,均被判红。 ## 实测(本机 Electron + 真实凭据) 正常启动:connections.json MD5 前后一致(E8DD588F...),0 个 tmp 残留 人工清空后启动:文件被自愈填充回真实配置,而非覆写成空 判据:cmd/gui npm test 全绿(32 条)。 * docs(ci): GUI job 注释同步为五个 .mjs,补 connections-io 说明 * fix(overflow): protected 按预算自适应(B 方案),修 T10c 暴露的自相矛盾 T10c 端到端实测(50k 窗口 / 34 轮 / 67k 灌入): 召回 4/9 → **2/9**(22%),累计 prompt 9.58M → 3.88M(-60%),墙钟 -40%。 省了资源但召回崩了。内核日志给出完整因果链: ratio=1.86 events=11 ← 越过阈值 context overflow: pruned=1 (12→11) ← 每次只裁 1 条 context overflow aborted: 11→11 ← 裁不动 recover budget exhausted (2/2) ← 预算耗尽 preempt: level=4 from kernel/overflow preempts ← L4 本身工作正常 根因:**protected=10 × 单条 5800 token = 58000 > ContextTokens 预算 40000** ——「钉住最近 10 条」本身就装不进预算,裁剪在数学上无解。每轮触发两次超页、 两轮后预算耗尽、任务直接终止(轮 5/6 的 prompt=0),记忆被裁掉。 修复:protected 从**固定值**改为**上限**, effective = min(配置值, ContextTokens预算 / 单条平均token) - 小窗口/大事件 ⇒ 自动收紧(宁可有取舍,也不让裁剪无解) - 大窗口(如 1M)⇒ 用满配置值。**这正是「1M 下这套调度器能更好」的实现 方式:预算越大,可保护的近处越多、换出粒度越细。** 另加 singleEventFitsBudget:单条事件本身就超预算时裁剪在任何保护数下都无解 (实测 40026 token vs 预算 10667),此时直接终止并报真实原因,而不是裁两轮 后预算耗尽——后者会额外丢失已经裁掉的记忆。 测试调整都是构造失效而非行为回归(逐个核实): - T1/T2/上游/自适应:原构造 (1000,400) 在小窗口下天然命中「单条超预算」。 先跑参数表(15 组窗口×事件尺寸)选定 (4000,800):单条 1627 装得进预算 2134、 总量 ratio=8.13 超页 ⇒ 裁剪可帮上忙。 - T4「裁不动」:旧构造在自适应下反而能裁 3 条(预算 534 装不下 10 条保护 ⇒ 收紧到 1 ⇒ topK=2)。改用「只剩 1 条大事件」——真无解。 - T5:每次检查前必须重压上下文(首次超页已把上下文裁小)。 新增:protected 自适应 2 条 + 单条超预算终止 1 条。 验证:go test -count=1 连续三轮全绿(提交信息里出现的那次 FAIL 是跑分实例 存活期间跑测试导致,与前两次同因,非代码问题)。 * feat(memory): memory_recall 全局 topK + 相关性/时间双排序 ## 起因:v4 跑分 5 条召回失败,全部是同一个根因 实测日志(ha-c,seed 20261001): 找到 193 个相关实体 → 13744 tokens = 工具预算 3150 的 **436%** [agent] toolresult_budget: 超预算 436% —— **结果未被裁剪** 针**确实存进了图记忆**(graph.db 里有 metrics服务端口8328 等), 但 193 条同格式平铺把针淹没,模型转去 grep 知识库文件, 并把「没检索到」说成「库里根本没有 auth/metrics 这个服务」。 三个缺陷叠加: 1. maxKeywordEntities=50 是**每关键词**的,jieba 把问句切成 [metrics 服务 端口] 后累加 → 193 个实体,**无全局上限**。 2. SQL 算出的三层精确度只是**单关键词内**排序,跨关键词 append 时被冲淡。 3. 输出无层级标记,模型无从判断该信哪条。 ## 先做实验再动手(否掉了两条自己提的方案) 用户问「能否移除 jieba 改用向量/LLM 做三元组拆分」。跑了三方案对比 (脚本见提交前 internal/memory 内的实验测试): A 现实现(jieba+LIKE) 针#1 top10噪音=5 总量56 B 字符 3-gram 精排 针#1 top10噪音=6 总量31 C 精确短语优先/三元组 针#1(3/4) 噪音=9 ← **最差** 结论:**不需要向量、也不需要 1.5b LLM**。针在三种方案里都排 #1 —— 排序从来不是瓶颈,缺的是全局上限。而「短语优先」在真实规模下最差 (auth 那条针掉到 #2,短子串「端口」的长度权重反而帮了别的实体)。 向量检索在此**帮不上忙**:问 metrics 时语义最近的恰是 auth/trace/oauth 的端口(语义几乎相同),向量只会把它们排在一起。 ## 改动 1. **全局 topK**(maxRecallEntities=20)。在**出口**施加—— 深度扩展会带出新邻居实体,扩展前截断会再次越界(实测 47>20)。 效果:13744 tokens → 约 1400,针仍在首位。 2. **实词优先的相关性排序**。关键坑:实体名恰好叫「端口」时, 它对关键词「端口」是**完全相等命中(rank=0)**,比针的前缀命中(rank=1) 还「精确」,把真答案压到第二。⇒ 「完全相等」只在**实词**上算强信号, 泛词走固定表(20 词,用生产语料 190 实体校验过覆盖率)。 3. **时间倒序模式**(SortMode=recent)。v4 的 overwrite 组答对而 overwrite-stale 组答错:同名新旧实体相关性层级相同,只能靠时间分胜负。 工具 schema 暴露 sort 参数,默认相关性。 4. **MatchRank 回填** + 输出带「精确匹配/前缀匹配」标记。 此前同格式平铺是模型放弃向量检索的直接原因。 5. **关系排序必须带 TurnID/ID 兜底**:SQLite CURRENT_TIMESTAMP **只到秒**。 实测 turn1 写 4379、turn2 写 4324 落在同一秒,CreatedAt 完全相等, 只按它排会保留原顺序,而原顺序来自 ORDER BY confidence DESC, 置信度相同时退化到 rowid 升序 = **旧值在前**。 与「SQLite 时间戳只有秒精度」是同一类问题。 ## 两个真 bug(都由测试抓出,不是我自查) - **LightMemory 两空间并集按 ID 去重**:temp 与 main 是两个独立数据库, 各自 id 从 1 自增,按 ID 去重让「主库第 3 号」和「temp 第 3 号」 互相顶掉 ⇒ **子代理看不到主记忆**。既有测试 TestLightProfile_MemoryFaceWiring 立刻变红。已改用 mergeRecall 按名字去重。 - **两条新测试第一版是废的**(变异自证抓出): - topK 测试:语料 39 个实体,单关键词就被 LIMIT 压到 20,合并后恰好 20 = 上限 ⇒ 把 maxRecallEntities 调到 100000 照样通过。 改成多关键词累加场景 + **前置自证**(先证明去掉上限会失败)。 - 时间排序测试:rowid 顺序恰好与答案同向,去掉兜底也通过。 加了同秒 + rowid 顺序相反的构造,确���同秒后才跑。 三个变异(放大 topK / 取消实词优先 / 去掉 TurnID 兜底)现在全部被抓住。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(repo): 清除临时记录文件与本地脚本,取消部署脚本追踪 ## 删��(曾入库的非工程文件) - plan.md(34K)—— 遗留问题清单,是**动态工作文档**不是工程产物。 删前先断了它的 5 处活链(见下)。 - tmp_jinabench.py —— 一次性 Jina embedding 跑分脚本,0 引用。 - napcat-design-dna.json —— **外部项目**(NapCat WebUI)的设计资产, 与本仓无关,0 引用。 ## 取消追踪(保留本地可用) deploy-plan.sh / deploy-waiter.sh / deploy-sdk-site.sh —— 生产部署入口, 含环境相关 IP 与路径约定,不适合随仓库分发。 **风险已记录**:docs/zh/deploy-runbook.md 仍在引用这三个脚本, 换机器部署前需先确认脚本来源。已在 .gitignore 注明。 ## plan.md 的 5 处活链:改为指向 git 而非动态文档 真正的活链只有 5 处(此前统计的 26 处里,绝大多数是 .pi/ 会话产物与 .npm-cache/ 工具缓存,不是引用)。全部是「见 plan.md §X」形式的溯源注释: third_party/.../stagediff_test.go (plan.md 11.3) docs/zh/c-core/sse-codec-c.md (plan.md §七) docs/zh/input-scheduler-design.md (plan.md §13.7) internal/agent/core/output.go (plan.md 11.1) internal/plugin/proc/bench_test.go (plan.md §13.3) internal/plugin/dynamic_proc.go (plan.md §12.5) **这些引用本身就是坏引用**:它们指向的是**已完成的核实结论**,而 plan.md 是随代码持续改写的工作文档 —— 让历史结论锚定在会变的文档上,读者按图索骥 只会扑空。改为只保留结论、把核实过程指向 git log(不可变)。 其中 dynamic_proc.go 那条尤其值得留:原文是「plan.md §12.5 声称交叉编译 通过,实际只验证了 proc 子包」—— 这是**对文档不实陈述的纠正记录**, 比原引用更有价值,保留原意。 ## 工作区清理(未入库,仅本机) 根目录 homed(34M) / waiter(12M) / chineseclip.test(5.2M) —— 编译产物, .gitignore 已正确排除,只是长期堆积在工作区。删前已确认无进程占用。 验证:go build ./... 通过;清理不影响任何构建产物或引用。 * fix(gui): 静默启动开关此前是死设置,永远不生效 ## 现象 设置页有「静默启动」开关,文案写着「启动时不显示主窗口,驻留托盘 后台运行」,勾选后启动 GUI 照样弹窗。 实测(本机,gui-prefs.json 里 silentStart=true):窗口标题 HomeAgent 照常出现。 ## 根因 silentStart 从未被消费: · main.js 的 loadGuiPrefs() 把它读进对象 · 渲染层设置页读写它 · **但主进程没有任何地方读它做判断** —— whenReady 无条件 createWindow() 是个纯粹的界面开关,接线缺失。 ## 改动 1) createWindow(show = true):支持「创建但不显示」。 BrowserWindow 初始 show:false,非静默时经 ready-to-show 显示 (避免先显示再隐藏的闪烁)。 ★ 为什么静默时**仍然创建窗口**、而不是不创建: 不创建 ⇒ 渲染进程不启动 ⇒ app.js 整个不执行 ⇒ SSE 通道、 设备桥轮询、聊天全部失效,GUI 退化成纯托盘图标;且 showMainWindow() 只做 show()、不会创建窗口,app.on("activate") 又只在 macOS 触发 —— Windows 上托盘点「显示主界面」将毫无反应, 进程变成唤不起来的僵尸。已按此结论实现(评审时确认为「创建后隐藏」)。 2) whenReady 读 silentStart 并据此 createWindow(!silent)。 与 applyAutoLaunch 里的 openAsHidden 是两条不同路径:那个告诉 OS 「开机自启时如何启动」,这里决定本进程要不要显示,对手动启动同样生效。 3) showMainWindow() 增加兜底:窗口不存在时重建;隐藏时先 show 再 focus。 消除上述「唤不起来」的隐患。 4) loadGuiPrefs() 补去 UTF-8 BOM。 ★ 这是排查中发现的**第二个真实缺陷**:Windows 工具 (PowerShell Set-Content -Encoding UTF8、记事本)改过 gui-prefs.json 会写入 BOM,而 JSON.parse 遇 BOM 直接抛错 → 走 catch → 返回**默认 prefs** ⇒ silentStart/exitToTray/deviceBridge 全部被静默重置, 表现为「开关保存了但不起作用」。loadConnections 早就有去 BOM 处理、 这里没有,两个读取点不一致。 (本次实测正是被自己的判据脚本用 Set-Content 改 prefs 后踩到的。) ## 判据 新增 cmd/gui/silent-start.test.mjs(进 npm test),14 条断言: · whenReady 确实读 silentStart 并决定 show · 静默路径仍创建窗口(不许退化成不建窗) · showMainWindow 能在窗口缺失时重建 · loadGuiPrefs 去 BOM,且 loadConnections 的既有处理不回退 已做变异测试:还原成无条件 createWindow()、抽掉 BOM 处理,均判红。 ## 实测(本机 Electron,真实 prefs) silentStart=true → processes=8 visibleWindows=0,日志出现 [startup] silentStart=on: window created hidden silentStart=false → processes=4 visibleWindows=1(标题 HomeAgent) 判据:cmd/gui npm test 全绿(46 条)。 * fix(gui): 打包未传 --icon,exe 内嵌图标一直是 Electron 默认图标 ## 现象 GUI 的 exe 图标不正确(显示为 Electron 默认图标)。 ## 根因 Makefile 的 build-gui 调electron-packager 时**没有 --icon 参数**: npx electron-packager . homeagent-gui --out=... --no-sandbox electron-packager 默认不写 exe 资源图标,于是 exe 保持 Electron 自带图标。 仓库里 cmd/gui/icon.ico 一直存在且完全合法(file 识别为 "MS Windows icon resource - 7 icons, 16x16, 24x24"),只是从未被打包进去。 注意这是**两条独立的打包路径**: · electron-packager(make build-gui,本机/开发用)→ 靠 --icon 参数 · electron-builder(package.json 的 build 段,发行用)→ 靠 build.linux.icon,且 files 数组里也没列 icon.ico 本次只修前者(本地安装与实测走这条)。 ## 证据 重新打包前后 exe 大小: 188784128 → 188834816 (+50688 = 7 个尺寸的图标资源) 此前多次打包产物大小完全一致,可反证图标从未被写入。 ## 改动 build-gui 补 --icon=icon.ico。托盘图标无需改动:它走 Tray/nativeImage,按 icon-tray@2x.png → icon-tray.png → icon.ico 三级候选回退,文件都在且逻辑健壮。 ## 实测 重新打包并安装:184 文件 0 处不匹配,exe = 188834816 字节 启动正常:prefs.silentStart=true → 4 进程 0 可见窗口、 login 200、0 个 401 / JS 错误。 * fix(gui): 修发行打包的图标与资源漏装(Linux 包会缺星图依赖) ## 背景 上一提交修了 electron-packager 那条路径的 exe 图标。本提交处理另外两处 同类问题——**都是静默失败**:改了没人知道,坏了要等用户装上才发现。 ## 1) package.json build 段(electron-builder 路径) · files 白名单漏了 icon.ico / icon-tray.png / icon-tray@2x.png ⇒ 这些文件不会被打进包,托盘与窗口图标在发行版里丢失 · 补 win.target/win.icon;注意 electron-builder 的 icon 路径相对 **项目目录**(package.json 所在)解析,不是相对输出目录 (最初写成 'build/icon.ico',那会找不到文件 —— 已用存在性判据钉住) · icon.ico 含 256x256,满足 electron-builder 对 win 图标的要求 ## 2) deploy/packaging/package-linux.sh(Linux 发行实际走这条) 原实现手工 cp 七个文件: main.js / preload.js / package.json renderer/{index.html, app.js, style.css, mascot.svg} 实测漏掉: · renderer/mascot.svg —— 源码里根本不存在(真名 mascot.webp) · icon.svg / icon.ico / icon-tray*.png · renderer/icon.svg · renderer/vendor/* ★★ 本次本地化新增的四个第三方库 (three/OrbitControls/marked/purify) ⇒ Linux 发行包装完星图与 Markdown 渲染直接坏, 且**没有任何构建报错** 改为: · renderer 按目录整体同步(cp -r "$gui_dir/renderer/.") · 根级 *.svg / *.ico / icon-tray*.png 同步 · 新增必备文件校验:vendor 四库与 icon.svg 缺任一 ⇒ 报错并删目录跳过, 沿用本脚本既有的"宁可不发,也不发装了跑不起来的包"原则 ## 判据 新增 cmd/gui/package-config.test.mjs(进 npm test),14 条断言覆盖三条 打包路径的配置一致性。已做变异测试:Makefile 去 --icon、files 去 icon.ico、 package-linux.sh 还原逐文件 cp,三者均判红。 判据:cmd/gui npm test 全绿(60 条)。 * feat(gui): 加载动画改三点式 + 新增系统通知 ## 1) 加载动画:边框旋转 → 三点渐亮 旧实现是 border + rotate 的 spinner,用在 .loading(16px) / .loading-spinner(32px) / .live-spinner(15px) 三处。在消息气泡里刺眼, 且语义不对:旋转暗示"正在加载某个确定的东西",而 agent 思考没有进度。 参考 PiDeck(@deepseek-ai/dsh-web-frontend 与 mermaid bundle 里的 @keyframe 实现)改为「等距三点 + 透明度递减 + 轻微上浮」: haDots 三点错峰明暗(1/0.6/0.35/0.15) haDotFloat 单点上浮 + 缩放 haSweep 横向扫光 旧类名 .loading / .loading-spinner 保留为别名指向新动画(还有 6+ 处 调用点,逐处改 HTML 容易漏),并解除其 animation 以免残留旋转。 7 处调用点统一换成 <i>x3 结构。 ## 2) 系统通知(此前完全不存在) 主进程无 Notification、preload 无接口、渲染层只有页面内 toast (用户不盯着窗口就看不到)。补齐一条完整链路: main.js notify:show / notify:supported 两个 IPC + 原生 Notification preload notify.{show,supported,onClicked} app.js notifyTurn / notifyTurnOnce / onNotifyClicked 设计取舍(都写进注释): · **一轮只通知一次**。SSE 的 tool_call / stage / agent_output 都可能 判定"一轮结束",用 turnSig 去重,否则连弹好几次。 · **不在流式过程中逐 token 通知**。一轮可能几十个 delta,那是噪音; 用户要的是"有结果了"。 · **窗口在前台且聚焦时不弹**。正看着界面时弹窗只会烦人。 静默启动(窗口 hidden)正好走"要弹"这条 —— 这也是静默后唯一 能让用户知道"agent 回了"的途径。 · **挂在 SSE 的 agent_output 收尾处**,不挂 endChatTurn(): 后者开头 `if (!state.chatLoading) return`,而旁观路径 (agent 响应 memo/QQ/email,GUI 只收 SSE)chatLoading 恒 false, 根本走不到那里。挂 SSE 对"自己发"与"旁观"两条路径都生效。 · **点击通知会 show() + 回传渲染进程**,切到 chat 视图并滚到该条。 点了却不知道该看哪句话等于没通知。 · 正文超 180 字截断,避免通知被撑爆。 ## 判据 新增 cmd/gui/loading-notify.test.mjs(进 npm test),24 条断言覆盖 动画关键帧/错峰 delay/别名解除/调用点结构,以及通知链路的六段 (主进程 IPC、preload 桥、去重、挂载位置、点击跳转、可用性探测)。 ## 实测 重新打包安装:186 文件 0 处不匹配,exe 188834816 字节 启动正常:silentStart 生效、login 200、device/online 200、 graph/pulse 轮询、0 个 401 / JS 错误。 判据:cmd/gui npm test 全绿(84 条)。 * feat(gui+waiter): computeruse 补高级操作、修 HiDPI 坐标;记录 PiDeck 借鉴 ## computeruse 高级操作 此前两端(GUI 本机桥 / waiter 设备侧)都只有 9 个基础 action (click/move/scroll/keypress/type…),与业界 computer-use 的基本盘差距明显。 现补到 18 个,两端对齐: drag 拖拽(分步移动,见下) hover 只悬停不点击(触发 tooltip / 悬浮菜单) mousedown/up 按下与释放分离,可配对做「按住」 tripleclick 三击选中整行 middleclick 中键 hotkey/combo 组合键(ctrl+c 这类) wait/sleep 显式等待,等 UI/动画完成再下一步 display 显示器几何(bounds + scaleFactor + primary),多屏定位必需 ★ 拖拽**分步移动**(默认 12 步,上限 60)而不是一步到位:canvas 拖拽、 列表排序、滑动条这类应用依赖中间 mousemove 事件,一步到位会失效。 ## 修 HiDPI 坐标错位(GUI 侧) 截图给模型的是**物理像素**,而 SetCursorPos / xdotool / cliclick 期望 **逻辑点(DIP)**。旧代码只加了 display bounds 的原点偏移,完全没换算 scaleFactor ⇒ 150% 缩放屏上点击系统性偏移(越靠右下偏得越多)。 现在按目标屏 scaleFactor 换算,并提供 `physical:false` 让调用方声明 「坐标已是逻辑点」以免二次换算。 ## 组合键归一化(waiter 侧) 旧实现把模型给的 key 字符串原样丢给 `xdotool key`,于是 "Ctrl+C"、 "ctrl + c"、"CTRL-C" 全部失效。新增 normKeySpec:统一 + 与空格分隔、 统一小写、去重复修饰键。 顺带修 waiter 的 `type`:加 `--clearmodifiers`,避免残留的 Ctrl/Alt 把后续输入变成快捷键(旧实现一次输入就可能污染后续所有按键)。 ## scroll 修正(两端) 旧实现固定滚 `click 4/5`(上/下),忽略方向语义也不支持横向。 现按 dy 正负给方向、按量级分步、支持 dx 横向滚动。 ## 显式声明借鉴 PiDeck 用户要求声明 GUI 借鉴了 PiDeck 的设计与实现。做法: 1. 新增 `cmd/gui/DESIGN-NOTES.md` —— 逐项列出借鉴自 PiDeck 的设计 (三点渐次明暗 / 运行态呼吸光晕 / 工具调用分组卡)、PiDeck 的原始 实现(@keyframes 名与 CSS 片段)、HomeAgent 的落地位置、以及**动机**, 并明确区分「本仓自行决策」的部分(如「只有 1 个工具时不分组」是本地决定, 不是 PiDeck 做法)。 2. app.js / style.css 的相关代码处补出处注释。 3. 判据 computeruse.test.mjs 钉住这几条声明,防止后续丢失。 参考方式为阅读 PiDeck 打包产物 app.asar 中的 CSS 实现后**独立重写**, 未复制其代码或资源文件。 ## 判据 新增 cmd/gui/computeruse.test.mjs(进 npm test),30 条断言: · 两端各 9 项高级操作 · HiDPI 换算与 physical 开关 · 组合键归一化、type 的 --clearmodifiers · 拖拽分步移动(两端) · PiDeck 借鉴声明四处(含 DESIGN-NOTES.md 存在) 判据:cmd/gui npm test 全绿(114 条)。 * feat(generation): 新增生成侧 SPI + ollama provider(蒸馏流水线的模型接入点) ## 为什么需要这个 SPI 图记忆的自动蒸馏(internal/memory/pipeline)要接小 LLM 做三元组拆分, 但 pkg/embedding 的契约是「输入→向量」,生成是「输入→文本」—— 塞进 embedding SPI 会让所有向量 provider 都得实现生成。 参照 pkg/embedding 的注册表模式,另立 pkg/generation,并列不嵌套: pkg/embedding.Register("chineseclip"|"qwen3vl") ← 向量空间 pkg/generation.Register("ollama") ← 文本生成 核心只依赖接口,模型以 provider 形式经配置 Open 引用。 同一份权重可以实现两个 SPI(qwen3vl 的 TokenEmbedding 段与生成头 tied embeddings 实测同构),两个注册表刻意独立——向量侧与生成侧 可以选同一个模型也可以各选各的。 ## ollama provider 的实现依据(本会话实测) 结构化输出是 1.7b 能用于拆分的**唯一**机制,6 条异构真实记录: format: schema 对象 6/6 零幻觉,30 对字段 format: "json" 字符串 只输出单个对象就 stop(0 对) 零样本文本 聊天腔「根据您提供的信息…」(0 对) few-shot 续写 结构不匹配时**复读示例答案**(100% 幻觉) 因此 JSONSchema 字段是本 provider 的核心路径,无法遵守 schema 的 provider 必须显式报 ErrSchemaUnsupported,而不是静默忽略——静默忽略 的后果是调用方拿到自由文本去 parse JSON 必炸,且查不出是哪层的错。 两个实测出的硬参数固化成默认值: num_thread=10 —— ollama 默认线程配置在本机 12 核上只有 0.1 tok/s, 设 10 后 2-3 tok/s(30 倍)。怀疑慢先查这个。 think=false —— 1.7b 不关思考会先输出大段推理再答案,挤掉 num_predict。 keep_alive=30m —— 默认 5 分钟驱逐,30 分钟蒸馏间隔的批量会被中途重载。 ## 测试 httptest 模拟 Ollama,不依赖真模型: - JSONSchema 以**对象**透传(json.RawMessage,非字符串包装)——这是 与 format:"json" 的本质区别,测试按 JSON 语义比较而非字符串比较 - 非法 schema 本地拒绝(不发请求) - HTTP 错误带状态码+响应体;服务端 error 字段转发 - done_reason=length → Truncated(调用方对截断的结构化输出必须起疑) - SPI 注册表联通(generation.Open("ollama"));空白 model 回退默认 下一步:pipeline.go 的 extractKeyTriples 经此 SPI 调用,schema 在 core 侧定义(fields 数组 + value 原样校验 + 维度名归一)。 * feat(distill): 自动蒸馏接小 LLM 拆三元组(此前该路径产出 0 条) ## 问题(实测,非推断) extractKeyTriples → nlp.NewExtractor(nil) → extractFromPOS: 4 条真实语料 → **0 条三元组**。库里 188 实体全部不是这条路写的, 证据是 95/143 条关系是「交接文档整理/发布窗口与回滚」这类语义化类型, 而 POS 模板只能产出「是/有/被标为」;长实体也是原句的**改写压缩版** (第112批周四凌晨2点·停机4分… ← 第112批,30 日:发布窗口定在周四凌晨2点…), 模板做不出改写。 实际写入方是模型主动调 memory_commit,而它的 description (indexer.go:462)只讲 media_digests 与 scene 怎么填,**没要求实体原子化** —— 模型写 43-50 字符整句是 description 的必然结果。 后果:跨维度检索全失效。严格判据(188 实体 + 同域硬负样本)基线 **0/5**。 ## 改动 1. internal/memory/distill:新的拆分器 - JSON Schema 约束解码(6 条异构真实记录 6/6 零幻觉、30 对字段) - 三道闸门:值必须原样出现在原文(唯一幻觉防线,生成侧无向量可验)、 维度名归一(实测「停机」/「停机时间」并存,不归一建不了边)、 字段名长度上限(模型会把整句当字段名) - 主语锚定:规则拆分实测 0/20 —— 拆开后「第183批」与「值班手册第4版」 是互不相关的实体,答案就丢了;主语是把绑定建成边的锚 2. pipeline.go:SetSplitter + extractTriples - **能区分「跑了但没拆出东西」与「没跑/失败」**:前者正常(纯叙述句 本就无字段),后者回退 jieba 路并记日志 —— 否则 splitter 长期失败会 静默退化成 0 产出,而蒸馏照常「成功」,外部看不出来 - RecordSplitter 接口而非直接依赖 distill 包:pipeline 在早期启动阶段 不该拉起 pkg/generation 依赖 - 单次调用 90s 上限:实测 4-9s/条,太短则恒超时、太长会吃穿 30min 间隔 ## 两个自己踩的坑 - 插入 SetSplitter 时把 SetEmbedder 覆盖掉了,cmd/homed 编译失败才暴露。 已恢复,并写明两者并存关系(splitter 优先,embedder 仍用于回退路径)。 - 归一函数的包含式分支写着 死代码,导致「删掉精确匹配分支」 这个变异测试仍能过 —— 看似有判据实则没有。改用 bestAlias 后重测, 变异立刻被抓住;补 TestNormalizeDimension_包含式最长匹配 专门盯它。 ## 测试 distill:13 条(正常路径/三道闸门/无主语/空结果/不支持schema/截断/别名幂等) pipeline:5 条(优先 splitter/空结果成功/失败回退/无 splitter 兼容/变异自证) 变异自证 4 个:去掉原样校验、schema 不传出、extractTriples 忽略 splitter、 去掉包含式归一 —— 全部变红。 未提交:internal/memory/graph.go 里此前那批 topK/排序改动的 gofmt 修复。 * feat(distill): bootstrap 接线 + 端到端验证(16 三元组/幻觉 0) ## 接线 cmd/homed/main.go:空 import 注册 providers/ollama(与 chineseclip/qwen3vl 并列, 各自独立注册到 pkg/generation)。配置形态与多模态向量侧对称: core.memory.distill.generation.provider = ollama core.memory.distill.generation.options.model = qwen3:1.7b initDistillSplitter:未配置 / 打开失败 / provider 不支持 JSON Schema 三种情况都返回 nil → 蒸馏回退 jieba 路。**不因模型不可用让蒸馏停摆**: 那条路虽实测产出 0 条,但至少不让对话记忆丢失。 「不支持 schema」现在就拒而不是运行时反复失败——拆分器的零幻觉完全 依赖 schema 约束,不支持它的 provider 会拿到自由文本、解析必炸。 ★ main.go 里把 cfgReg 的创建**提到 initMemoryStack 之前**:蒸馏的生成侧 provider 由配置决定,且必须在 distiller.Start() 之前注入,否则第一个 tick (实测 10 分钟)会白跑一次产出为 0 的 jieba 路。 ## 端到端实测(Go → SPI → ollama → qwen3:1.7b,5 条生产库真实记录) 16 三元组,幻觉 0,4/5 记录成功,单条 1.8-7.8s。 (第112批) -[发布窗口]-> 周四凌晨2点 (第112批) -[停机时长]-> 4分 (第112批) -[回滚版本]-> v2.29.5 (第112批) -[灰度比例]-> 10% (第183批) -[值班手册版本]-> 第4版 这正是原设计要的效果:库里原本是 「admin服务端口8861·billing服务端口8499·oauth服务端口8271」这种 一个实体塞三个事实的复合值,现在拆成了可按维度定位的原子三元组。 ## 踩到的一个反直觉坑(负向指令) 第一版提示词里有「记录开头的批次号是主语,不要单独列成字段」—— **这条负向指令让 qwen3:1.7b 把所有字段都判成批次号相关而全部丢弃**: 同一条记录 4 个字段 → 0 个字段。对照实验(同一 schema、同一记录): 去掉该规则 + <record> 标签 → 4 字段 ✔ 保留该规则 + <record> 标签 → 0 字段 ✘ 无标签 → 0 字段 ✘ 1.7b 处理不了「不要做 X」这种否定式约束。改为只给正例(示例里就没有 批次号字段),主语锚定由 Split() 在 Go 侧做。 代价是它会主动拆出「批次号」字段,产出 「第183批 -[批次号]-> 第183批」这种自环 —— 在 Go 侧过滤掉。 **过滤逻辑必须自己掌握,不能依赖模型听懂否定指令。** ## 测试 新增 TestSplit_过滤主语自环;变异自证(去掉自环过滤)确认变红。 其余 18 条 distill 测试 + pipeline 5 条 + ollama 7 条全绿。 * fix(gui): 启动期误判服务端未启动;keybd_event 类型名致组合键全废 ## 修 GUI 反复误判「服务端未启动」 isServerRunning() 硬编码 http://localhost:8080/。实测该部署下服务端在 192.168.2.60:8080(homed 跑在 WSL 里): localhost:8080 -> 000 连不上 192.168.2.60:8080 -> 302 正常 于是每次启动都:探测失败 → 误判服务没起 → 去 startHomed() 拉起 GUI 旁 并不存在的 homed.exe(findHomed 返回 null 就 skip)→ 再 waitForServer() 干等 8s 超时 → 打印 "homed failed to start within timeout"。 gui.log 里这条 ERR 累计出现 53 次,是日志里唯一的错误项。 改法: · serverProbeTargets() 按「connections.json 里已配置的连接(当前连接 优先)→ localhost」顺序探测,命中任一即视为在线 · 服务可达时直接 skip homed 自动拉起 · 无本地 homed 可执行文件时不再空等超时,直接说明情况 · probeOne 加超时保护,避免启动被挂住 实测:修复后启动日志为 "server reachable, skip homed autostart", 该 ERR 不再出现。 ## 修 keybd_event 类型名(组合键整体失效) koffi 不认 Win32 的 `byte`: "void keybd_event(byte bVk, ...)" -> Error: Unknown or invalid type name 'byte' "void keybd_event(uint8 bVk, ...)" -> 正常 原代码用的正是 `byte`,且整段包在空 catch 里,于是 keybd_event 恒为 null, 所有组合键(keypress/hotkey)都返回 "keybd_event unavailable"。 这个 bug 很难被发现:单键 click/move/scroll 走 mouse_event,不受影响。 端到端实测确认:修复前 hotkey ctrl+shift+t 报 computeruse hotkey: keybd_event unavailable 另外签名解析失败现在会打日志,不再静默。 ## 判据 computeruse.test.mjs 扩到 38 条(进 npm test,全量 121 条全绿),新增: · keybd_event 签名类型(且已做变异验证:改回 byte 判据立即转红) · 服务探测不只探 localhost / localhost 仅兜底 / 仅当有 homed 才拉起 · 服务可达时 skip autostart / 探测有超时保护 * feat(qwen3vl): 一份权重两种模式——检索向量 + 本地生成 ## 形态 Qwen3-VL-2B-Instruct(4.25GB)导出两套图,共享 TokenEmbedding 与全部 28 层: TokenEmbedding.onnx 1.16GB 两个模式都用 Transformer.onnx → embedding[2048] 高频·检索 gen/SequenceWithHead.onnx → logits[seq,vocab] 低频·生成 tied lm_head,零新增权重 tied head:Qwen3-VL 的 tie_word_embeddings=True,实测两个变体都是 625 个张量、 无独立 lm_head —— 输出层就是 embed_tokens 转置。导出后校验 argmax maxdiff=0.000e+00,是定义上的恒等而非巧合。 embed_config.generation 段声明这个能力(graph / tied_lm_head / kv_cache), Go 侧据此判断目录能不能当生成用,而不是去猜某个 .onnx 是否存在。 ## 实测:Instruct 能生成,Embedding 不能 同一份 Go 实现,只换模型目录: Instruct 中国的首都是北京。 负载均衡是一种将工作负载分配到多个服务器或计算节点的技术, 以提高系统性能、可靠性并优化资源利用率。 Embedding 首都北京是中国首都,是中国的政治中心,首都北京是中国首都…… 负载均衡负载均衡是指分配负载均衡负载均衡…… 复读是变体性质(未经生成后训练 + argmax 贪心无重复惩罚),不是实现缺陷。 这条已写进端到端测试的判据(6 字窗口重复 >3 次即失败),防止以后换模型 悄悄退化而不被发现。 ## 两个导出侧的 bug(都是这轮新引入的图暴露的) ① external data 文件名撞名。onnx exporter 把 >2GB 张量自动外置成 onnx__MatMul_<编号>,每张图各自从 0 起算。同目录导出两张图必然互相覆盖, 而损坏只在加载时暴露: 校验⓪(纯 PyTorch): argmax got=110030 ref=110030 通过 校验②(加载 ONNX): FAIL "size to read: 50331648 given file_length: 16777216" 诊断时我先猜错了(以为 layers.* vs trunk.layers.* 命名不同所以没撞)。 修法:生成侧图导出到 gen/ 子目录,命名空间隔离。 ② 校验② 根本没覆盖生成侧图。case_list 只有 text/image/video —— 即使全绿 也不代表生成侧图能加载。已加入 generation 用例,加载本身就是它的一半价值。 ## Go 侧 providers/qwen3vlgen:generation.Provider on ONNX。复用 qwen3vl 的 Tokenizer / M-RoPE / rotary / causal mask(逐位一致),不重复实现。 - 不做 KV cache:全量前向 O(N²),但生成是 30 分钟一次、输出 N≈200。引入 KV cache 状态机复杂度翻倍,收益只在这条低频路。已写进包注释,标明若进 热路径先实测延迟。 - SupportsJSONSchema=false:图只到 logits,schema 是解码阶段的事。静默忽略 会让调用方拿自由文本去 parse JSON 且查不出是哪一层的错,所以显式报 ErrSchemaUnsupported。蒸馏拆分仍走 ollama(已验证 16 三元组 / 幻觉 0), 这里是可用性收益不是质量收益。 - qwen3vl 的 FindOnnxLib 导出复用:库路径候选清单必须只有一份,各写一份 必然漏掉某个位置,而漏掉的表现是「初始化失败」这种看不出原因的错误。 providers/qwen3vl 加 DecodeOne:生成需要把 argmax token id 反解回文本,本包 此前只做正向编码。反向表显式建 —— 遍历 map 顺序不确定,而生成要逐 token 确定。 ## 测试 qwen3vlgen 4 条(中文连贯 + 不复读 + 自然结束 / 停止符识别 / 未导出生成图 则构造失败 / embed_config 声明的图必须存在),真模型跑通。 qwen3vl 4 条 DecodeOne(往返一致 / 确定性 / 未知 id / 不走字节解码)。 导出 6 个用例全过: text 1.000000238 / image 1.000000000 / generation 1.000000000 video_g2 1.000000000 / g3 0.999999881 / g4 0.999999881 ## 顺带 .gitignore 补 /plan.md 与 /tools/zz_*/ 兜底:8642b54 删掉的 plan.md 被本机 某进程恢复过一次(mtime 仍是删除前的 Sep 26,说明是外部写回而非 git 操作), 没有这行兜底的话恢复一次就重新入库一次。 * feat(gui): 模式 A agent 独立光标 + 后台注入;修坐标换算;固化计划与交接文档 ## 模式 A:agent 独立光标(不抢用户鼠标) 现有 computeruse 走 SetCursorPos + mouse_event,agent 一干活就抢走用户 真实鼠标,用户无法同时使用电脑。现改为:GUI 自绘 agent 专属光标,实际 输入经 PostMessage 直接投递到目标窗口句柄,用户鼠标全程不动。 新增: cmd/gui/agent-cursor.js 透明置顶层窗口 + mascot 指针 + 移动/点击脉冲 动画;记录上次命中的 hwnd 供键盘动作使用 cmd/gui/agent-inject.js Windows 后台注入:WindowFromPoint 定位、 客户区坐标换算、PostMessage 投递鼠标/键盘/ 滚轮/文本;拖拽分步移动 配置 gui-prefs.deviceBridge.agentCursor.mode = "overlay"(默认) | "real"。 明确的设计边界(不是实现缺陷,是平台限制): · 只对「接受窗口消息的程序」可靠。原生 Win32 程序可靠;Chromium/ Electron 自绘界面、游戏、DirectX 会忽略合成消息。 · 不做真实鼠标降级。用户既选了不抢鼠标,就不该偷偷改成操作真鼠标, 失败即 status=error 并说明原因。 · 键盘类动作依赖先前的鼠标操作(后台注入不动真光标, GetCursorPos 拿到的是用户自己的光标位置)。 端到端 8/8 正确响应;「真实点击是否落到目标窗口」因当前环境无可注入的 可见窗口而未验证,已在 GUI-PLAN.md 列为待办。 ## 修坐标换算(我自己上一轮引入的 bug) 上一轮我加了 rawX / scaleFactor 的换算,理由是「SetCursorPos 期望 DIP」。 **那个前提是错的** —— Electron 主进程是 per-monitor DPI-aware,Win32 坐标 不做虚拟化。实测: Electron screen API (DIP) : 1260 x 840 scaleFactor=2 GetSystemMetrics : 2520 x 1680 SetCursorPos(200,200) -> GetCursorPos 读回 (200,200) 恒等,不缩放 加 /scale 会把原本正确的点击改坏:200% 屏上点 (600,400) 被送到 (400,267),越靠右下偏得越远。已改回 physical ? rawX : rawX*scale, drag 终点同步修正(原先两处换算不一致)。 易误判之处:在普通 node 进程里测同一函数读到的是 1260x840(虚拟化值), 会以为 Win32 用 DIP —— 必须在 Electron 内测。 ## 判据 computeruse.test.mjs 43 条(npm test 共 127 条全绿),新增 5 条钉住 模式 A 的核心承诺: · agent-inject / agent-cursor 均无 SetCursorPos 调用(查前先剥注释, 因为注释里会引用该名字解释「为什么不用它」) · koffi 指针参数用 Buffer(普通对象不写回,会拿错 hwnd) · overlay 默认开启且可切回 real · 自绘光标层 setIgnoreMouseEvents 穿透 核心不变量已做变异验证:注入 SetCursorPos 后判据立即转红。 另修正坐标判据方向(原判据断言 /scale 是对的,与实测相反)。 ## 文档 GUI-PLAN.md 改造计划与进度的单一事实来源:已完成/待办、坐标 实测结论与踩坑、koffi 3.x 使用约定、端到端测试方法、 环境注意事项(含本机无 Go 工具链、waiter 改动未编译验证) SERVER-HANDOFF.md 服务端需同步的部分:放开 action 白名单(schema enum + switch default 双重限制,不改则 11 个新动作下发不到)、 透传 tox/toy/steps/dx/ms、统一 scroll 方向语义、 写入坐标约定防重蹈 * feat(memory): 块节点向量召回路径(entities 退场第 1 步) ## 背景:这条路径此前完全不存在 memory_blocks 是带 vector+fingerprint 的图节点载体(8975a21 引入), 但**没有任何召回读它** —— BlocksForNode 只能按端点反查,得先知道 nodeID。 于是「问一个具体问题」只能退到 entities 的 jieba+LIKE,实测跨维度定位 0/5 (问「第181批的值班手册是第几版」完全答不出)。 这不是调优问题,是缺一整条路径。也意味着此前几轮"换向量模型"对召回零影响 —— 换的是 document/media 侧的向量,而图记忆根本不用向量。 ## 改动 1. internal/memory/block_recall.go - RecallBlocks:query 向量 → 块向量 → 余弦排序 → TopK 截断 - 两道**静默污染防护**(这类错误不崩溃,只是结果"看起来还行但全是错的"): · 指纹不匹配跳过:库里可能同时存在两个空间的块(迁移期), 拿新空间查询向量比旧空间块向量,余弦值无任何意义 · 维度不匹配跳过:**不截断不填充** —— 那会造出无意义的分数 - BlockVectorStats:报告回填状态,运维据此判断要不要跑回填 - 空查询向量报错;空库返回空切片("还没回填"是正常状态不是故障) 2. internal/agent/core/toolcall.go:memory_recall 接入 - 块向量召回优先,符号路兜底(**混合而非替换**:端口号 8328、分机号 4324 这类纯数字串向量天然弱,而它们恰是本项目最常问的) - 向量侧不可用/未加载/向量化失败时静默回退符号路 —— 不让模型失去记忆 (那比召回差得多)。工具名与参数不变,调用方模型无感知 - 工具 description 补 sort 参数说明(此前模型不知道 recent 可用) ## 测试(14 条 + 6 个变异自证全部变红) block_recall_test.go 9 条:余弦排序 / TopK 截断 / **指纹跳过** / **维度跳过** / 无向量块不参与 / 空向量报错 / MinScore 过滤 / 空库不报错 / 统计 toolcall_block_test.go 5 条:块召回优先 / 未加载回退 / 无向量空间回退 / 指纹不匹配跳过 / 向量化失败回退 变异自证(逐个手动破坏,对应测试必须红): 去掉指纹防护 → 指纹测试红 维度防护改成截断对齐 → 维度测试红 去掉 TopK 截断 → 截断测试红 去掉回退(nil 解引用)→ panic + 回退测试红 去掉 MinScore → 优先测试红 指纹不传 → 指纹跳过测试红 ## 成本量化(不是估算) 生产库迁移后约 1277 块 × 2048 维 = 21 MB 读入 + 2.6M 次乘加,每次 memory_recall 一次。当前可接受;万级块时需换方案(候选:搬进 vector.Store 内存倒排,或按 modality/scene 预筛)。在此之前不做优化 —— 没有真实规模 数据时优化是猜,本会话已因此浪费过几轮。 ## 计划文档 - docs/zh/memory-restructure-plan.md:6 步重构计划 + 认知纠错记录表 - docs/zh/entities-retirement-plan.md:方案 A(三表全退)+ 依赖面清单 - 删除 docs/zh/graph-vector-design.md(前提错误:给 entities 加向量字段, 而 blocks 本就有 vector,entities 该退场) * feat(gui): 注入改 SendInput(默认)+ 用户操作时暂缓;拆解 Coopanion 桌宠动作系统 ## 拆解 Coopanion(用户指出的参考项目) 新增 DESKTOP-PET-NOTES.md,记录 Pal-AI-Lab/Coopanion 的桌宠动作系统 (packages/cortico-world-desktop-pet): · 4 个工具:pet_say(气泡说话,可夹带动作标记) / pet_ask(提问,异步回答) / pet_walk_to(走到 0-1 比例或 left/center/right/cursor,含走到鼠标位置) / pet_act(只做动作; sit/sleep 是持续态) · 动作词表 VOCAB:10 个表情 + 13 个动作,每个词多个中文别名, 英文 id 与中文别名进同一张 Map;未知词丢弃而非报错 · 脚本标记:【词,词】= 先做动作再换新气泡;<词> = 打字到那里时做、不换气泡。 让文字与动作在时间轴上对齐 —— 实现成本低但表现力强 · 设计要点:身体是 agent 唯一可见输出通道(用户看不见消息正文); 互动事件不单独唤醒模型,跟着下批事件到 可借鉴(已写入文档待办):姿态表达状态、动作与文字对齐、持续态、 动作解析容错、待机自主行为。不建议照搬:桌宠承载全部对话输出、ASR、戳摸甩。 ## 注入层改 SendInput(参考它的实现) 原 PostMessage 路径只能操作原生 Win32 程序 —— Chromium/Electron 自绘界面 与游戏会忽略合成消息。这不是「平台硬限制」,是所选 API 的限制。 Coopanion 用 SendInput 注入系统输入队列,可靠性高得多。 新增三档模式 gui-prefs.deviceBridge.agentCursor: sendinput(默认) SendInput,可靠;会动真光标,故先等用户停手 overlay PostMessage,不动用户鼠标;只能操作原生程序 real 保留旧代码路径 另有 deferMs:用户活动时最多等多久(默认 3000,上限 10000) ★ SendInput 绝对坐标是 0–65535 归一化值,不是像素。按虚拟桌面归一化 并带 MOUSEEVENTV_VIRTUALDESK,多屏下也正确(Coopanion 只按主屏归一化)。 ★ 文本用 KEYEVENTF.UNICODE 逐字符发,不受键盘布局与输入法影响(比 WM_CHAR 可靠)。 ## 修「用户活动感知」把自己也算进去的 bug GetLastInputInfo 是**全局**的 —— agent 自己的 SendInput 也被算作「用户输入」。 实测:deferMs 设 0 仍被暂缓,回执写「开始时空闲仅 0ms」,即上一条命令的 注入刚把 idleMs 清零。若不排除自身注入,会永远处于「用户正在操作」状态。 按 Coopanion 的三重判定修正: 1. ownTick 自己最后一次注入时刻,之后 40ms 内的输入忽略 2. ownCursor 自己放下的光标位置,偏离 >2px 说明是用户动的 3. 无符号差值 GetTickCount 每 2^32ms 回绕,必须按无符号比较 ## 修同一条命令被执行两遍 sendinput 分支把逻辑包进 runAction 箭头函数,里面的 return 只退出它自己, 于是执行完继续 fall through 到旧 real 路径。实测 8 条命令回了 16 个 cmd_result,且真光标被额外抢一次。补上外层 return。 ## 判据 computeruse.test.mjs 47 条(npm test 共 131 条全绿),新增 4 条钉住本轮 踩过的坑,均做变异验证: · 三档模式存在(overlay/real 只在条件里,不能要求赋值形式) · 用户活动排除自身注入;且盯 send() 里的**调用点** —— 只查函数名时 删掉调用仍会误报绿(已实测并改正) · SendInput 坐标归一化 + VIRTUALDESK · sendinput 分支必须 return(防双执行) ## 待办(已写入 GUI-PLAN.md) · loadGuiPrefs 不透传 agentCursor,导致 deferMs 配置读取不生效(实测) · 显式调 SetProcessDpiAwarenessContext(-4),不依赖 Electron 默认值 · mascot 姿态:思考/等待/出错 · 真实点击效果仍未验证(当前环境无可注入的可见窗口) * docs(gui): 修正 GUI-PLAN 陈旧段落并补齐待办 ## 修正三处因改用 SendInput 而陈旧/错误的表述 1. **「平台硬限制」说法是错的**(重要) 原文写「Chromium/Electron 自绘界面、游戏会忽略合成消息,这是 Windows 消息模型的硬限制」。实际那只是 `PostMessage` 这条 API 的限制 —— `SendInput` 注入系统输入队列,自绘界面照常接收(Coopanion 即如此)。 已改为分别说明两种注入的适用范围。 2. **3.4/3.5 已验证节只覆盖 overlay 路径** 补上 sendinput 路径的回执形态,并把「未验证」的范围写清楚: 验证的是「错误处理正确」,不是「点击落到目标窗口」。 3. **3.7 跨平台状态仍写「未做模式 A」** Windows 已实现两档注入;Linux 仍为 xdotool(会动真鼠标)。 ## 修文档自身的缺陷 发现两个重复的 `### 4.3`(编辑时留下的同一设置页条目),已合并去重, 现在编号连续(4.1–4.6)。 ## 补齐待办(4.6 其它已定位但未修) 以下均已核对代码确认属实,不是猜测: · overlay 模式键盘动作「必须先点过」可改为取前台窗口兜底 (main.js:2283 / 2291 确为直接报错) · real 模式与 sendinput 行为重复,可考虑合并 (main.js:2051 sendinput / 2189 overlay / 2324 起为旧 koffi 路径, real 与未命中两档时都会落到这里走 SetCursorPos) · p3.js / p4.js 是本轮遗留的临时补丁脚本(git status 确认为未跟踪), 确认无价值后删除 判据未改动,npm test 131 条全绿。 * feat(memory): 蒸馏落库改块节点(entities 退场第 2 步) ## 认知纠错:这是本会话最实质的一次返工 上一版(a7e6ec7)把 LLM 拆句的产出 Commit 成 entities+relations。 按图的正确形态,三元组是【节点】【关系边】【节点】,而节点已从纯文本 实体升级为带 vector/fingerprint/modality 的 memory_blocks(8975a21 引入)。 把拆解结果写进 entities = 把新产出灌进正在退场的旧形态。 「16 三元组 / 幻觉 0」的端到端验证**验的是拆分质量,不是落库形态** —— 验证通过 ≠ 架构正确。 ## 正确形态 端点 kind 的既有设计(block.go:175 validGraphNodeKind): block / entity / sentence / document 句子的职责由 sentences 表承担,块的职责是**可向量化的最小子项目**。 所以是: sentence(原句) --contains--> block(字段内容, 带向量) 与媒体块完全同构(graphmedia.go:73 的 sentence→block contains 边)。 不把原句也做成块 —— 那会与 sentences 表重复,且原句不需要单独向量。 ## 改动 1. internal/memory/distill/blocks.go - BlockPayload/FieldBlock:块形态产物 - Blocks():与 Split() 共用同一套拆解逻辑与闸门,不重复实现 (闸门是拆解质量的全部保障,分两处实现必然漂移) - BlockID:内容派生(sha256(sentence+dimension+value))。 **必须是内容派生而非随机** —— 蒸馏可重试(distillOnce 失败写回队列), 随机 ID 会让每次重试都产生一批新块,同一句记忆堆成 N 份。 - WritePayload:句子先落(边端点校验要求)→ 块 → contains 边。 embed 为 nil 时**不编造零向量**:无向量的块仍可按边查到,只是不参与 向量召回;编造零向量会让它参与检索并永远垫底,那是静默的错误记忆。 2. internal/memory/graph.go:EnsureSentence 导出 - 从 commit() 里那段内联逻辑提取。两处各写一份「INSERT OR IGNORE + 回查」必然漂移,漂移表现是边建不上(AddMemoryBlockEdge 报 "graph node does not exist"),只在边那层看不出是根因。 3. internal/memory/pipeline:块路径优先 - BlockSplitter 接口(RecordSplitter + Blocks), distill.Extractor 同时实现两个方法 - 块路失败(模型错误/超时)时**落回 Triple 路而非整批返回 false** —— 后者会让同一批记录无限重试。失败原因已在 writeBlocks 记日志。 - 逐句切分:块的语义单位是句子,整段混合会让 contains 边失去指向 - SetEmbedFunc 注入向量计算 - WritePayload 用 d.ctx 而非 context.Background(): Distiller.Stop() 会 cancel d.ctx,用 Background 会让停机时 正在写库的循环继续跑 ## 测试(11 条新 + 3 个变异自证) 块形态 / 块文本 / 幂等(重试不堆块)/ 无 provider 不编造向量 / 带向量落库 / 空字段 / 非法输入 / 模型失败透传 / BlockID 稳定性 / Dimension-Value 取回 / EnsureSentence 幂等 pipeline:块路径优先(且零 entity)/ 块路失败落回 / 仅 Split 能力走旧路 变异自证(逐个手动破坏): BlockID 丢掉 dimension → 2 条测试红(同句不同维度撞 ID 会静默覆盖) 无 provider 编造向量 → 测试红 跳过建 contains 边 → 2 条测试红 块路失败 return false → 测试红 ★ 第一版变异 1/3 曾经**存活**: - 变异 1 存活是因为我的 python 转义没匹配上(未真正注入),不是测试问题; 改用「dimension 不参与派生」这个更贴近真实的破坏后立刻被抓住。 - 变异 3 存活是因为移除建边代码后 sentenceID 变成未使用 → 编译失败 → 测试根本没跑。**编译失败的变异不算验证**,改用 `_ = sentenceID` 保留变量 后测试正常变红。 - 但确实暴露了一个真漏洞:TestBlockID 只测了「不同 value」,没测 「不同 dimension」。已补 TestWritePayload_同句不同维度不撞块。 * feat(memory): 存量实体迁移为块节点(entities 退场第 3 步) ## 形态 sentence(实体名) --contains--> block(实体名, 带向量) block(主语) -[关系类型]-> block(宾语) 实体名同时充当「句子」与「块文本」:迁移期 1:1 对应,**不做任何内容改写**。 改写(LLM 三元组化)属于「提升召回」,与「换存储格式」是两件事 —— 混在一起做会让这次迁移既不可回滚也不可验证(迁完不知道召回变好还是变坏)。 拆分留给迁移后单独跑,有独立探针判据。 沿 MigrateLegacyMediaEntities 的既有方向(migrate.go:47 已经把媒体类实体 迁过一次),本函数处理剩下的文本类实体(生产库实测 1277 个)。 ## ★ 三个阶段:慢操作不进事务 阶段一 读快照(RLock,短) 实体与关系到内存 阶段二 算向量(无锁) embed 回调 阶段三 写事务落库(Lock) 句子 + 块 + 边 分阶段的原因:embed 是 ONNX 前向,实测 0.3~1s/条,生产库 1277 实体 = 6~21 分钟。第一版把 embed 放在写事务内调用,graph.db 被锁住那么久 —— 它是单文件、所有记忆操作共用一把 g.mu,期间召回与写入全部阻塞。 **实测症状:测试直接卡死 600s**(测试在 embed 回调里反过来拿锁, 构造出真实场景的等价死锁 —— 这算歪打正着,但暴露的是真缺陷)。 ## 关键取舍 - **不删旧表**:迁移只加块与边,entities 保持不动。调用方在验证召回改善后 自行决定清理。半途中断或效果不佳时,回滚就是「不用管它」。 - **无向量不算失败**:块仍落库、只是不参与向量召回(RecallBlocks 跳过)。 结构对了向量可后续回填;编造零向量会让它参与检索并永远垫底。 - **空关系类型用 related_to 占位**:而不是跳过 —— 关系的**存在**本身是信息。 - **孤儿关系跳过并计数**:端点实体为空名时。编造指向不存在节点的边会被 端点校验拒绝,而跳过是正确行为:那条关系的信息在句子里仍然存在。 - **块 ID 由实体 id 派生**(blk_ent_<id>_<hash>):实体名虽 UNIQUE,但同一句 话可能既是实体又是句子,用内容派生会撞;实体 id 保证幂等。 ## 测试(8 条 + 2 个变异自证) 形态(2句2块1边 + 旧表仍在)/ 幂等(迁移3次仍2块)/ 无 embed 不编造 / embed 返回 nil 仍迁移 / 孤儿关系跳过 / 空类型占位 / **中途失败整体回滚** / 块 ID 稳定可读 回滚测试用 DROP TABLE 注入失败(embed 回调里删块表,此时阶段一已完成、 阶段三刚开始,ensureSentenceTx 已在事务里写过句子 —— 无回滚就会留下残留)。 变异自证: 去掉 defer tx.Rollback() → 回滚测试红(残留块) embed 移回锁内 → 形态测试红 ## 过程中两次自己的错误 1. **测试种子用了单字符实体名**:"A"/"B" 被 validEntityName 拒(len<2, graph.go:586),于是 Commit 返回 0 写入却 err=nil —— 排查花了几轮。 顺带发现:Commit 全被拒时仍返回 err=nil(假成功),是本会话最早修过 一次的同类缺陷。**本步范围内**(迁移读既有数据,不写新实体),但值得记。 2. **回滚测试第一版用 ALTER TABLE RENAME 注入失败**,破坏了后续查询导致 断言测不到真东西;改用 DROP TABLE + 测试内重建 DDL。 ★ 还有一个真发现:我的第一版回滚测试卡死 600s,根因是 embed 在写事务内 被调用 —— 那个缺陷在生产上会让整个 graph.db 停摆 6~21 分钟。 * feat(migrate): 存量实体迁移命令 homed-graph-migrate(第 3 步的操作入口) ## 命令 homed-graph-migrate -db <graph.db> # 报告(默认) homed-graph-migrate -db <graph.db> -apply # 迁移(不带向量) homed-graph-migrate -db <graph.db> -apply \ -embed-provider chineseclip -model-dir <dir> # 带向量迁移 ## 安全设计(照 homed-kb-migrate 的既有范式) 1. **默认只报告**,-apply 才真迁移。迁移本身是单事务全成功或全回滚, 但「迁完召回是否变好」只有跑过才知道。 2. **不删旧表**:entities/relations/sentences 原样保留。验证通过后由人工 另跑清理(下一步)。「效果不如预期」的回滚就是什么都不做。 3. 迁移前自动快照 graph.db 到 <db>.bak-<时间戳>。 4. **向量可选**:-embed-provider 缺省则不带向量迁移(结构正确、不参与向量 召回),可后续单独回填而不必重跑迁移。 走 pkg/embedding 注册表(内核无感边界):换模型只改 -embed-provider, 命令里没有任何 provider 名分支。 ## 真库实测(测试副本 /tmp/mig-test,未动 ha-c 与生产) 迁移前:实体 188,关系 141,块 0,块边 0 已快照:graph.db.bak-20261002-160337 迁移完成(17.3s):句子 +188,块 +188,边 +142 迁移后:实体 188,关系 141,块 188,块边 330 无向量块 0,跳过孤儿关系 0 验证:188 块全部带 512 维向量与正确指纹(cd2a495cf990…), 边分两类 —— contains 188(原句→块)+ 关系边 142(主语块→宾语块)。 ## 工具开发中我自己踩的坑 写了三个不存在的 API(adapt / db.Stats / db.CountNodes)——都是"看着应该有" 而不是 grep 确认的。改用既有 API: vector.AdaptProvider(p) provider → VectorizeDense db.Introspect() 实体/关系统计 db.BlockVectorStats() 块向量状态 句数不统计(迁移报告里不是关键指标),**不为此新增统计 API**。 flag 名也先写错(-embed 与 -embed-provider 并存),由 flag 包的 usage 输出 当场发现 —— 报告模式先跑一遍再 -apply,这个顺序救了一次。 * fix(migrate): 迁移保留源实体时序(值覆盖维度依赖它) ## 起因:真实探针跑出来的失败 在真库(/var/tmp/ha-c,已迁移 188 块)上跑端口针探针,5 条查询 **2 条失败**: ✘ "值班室分机号是多少" 期望含4324 → top1=0.81 "值班室分机号 4379,值班人 阿李" ✘ "新的值班分机" 期望含4324 → top1=0.84 "值班室分机号 4379,值班人 阿李" ✓ "旧的值班分机号是哪个" 期望含4379 → 命中 ✓ "admin 服务的端口" 期望含8861 → 命中 ✓ "billing 服务监听哪个端口" 期望含8499 → 命中 **错答:新号查询返回旧号。** 这正是 overwrite 维度要考的东西。 ## 归因实验(假设 → 证伪/证实) 假设「病根是数据形态而非召回路径」。在内存里把长复合句改成语义等价的短句, 重算向量看排名是否翻转: 现状·旧号(短句) cos=0.8127 ← 当前 top1,错答 现状·新号(长复合句) cos=0.7327 ← 内容正确但排第二 假设·新号(短句) cos=0.9284 ← 形态改写后跃升 0.20 对照·旧号(短句) cos=0.9298 两个结论: 1. **召回路径是对的**:向量算对了,排名机制正常。问题不在 74301d9。 2. **病根是长复合句**:库里的形态是 `下周起值班室分机号改为 4324,旧号 4379 停用,值班轮换到 老周` 新旧两个值混在一句里,向量平均后偏向旧号。 3. **★ 而且单靠向量解决不了值覆盖**:短句化后新旧号是 0.9284 vs 0.9298 —— 旧号还高 0.0014。**数值上根本区分不了新旧**。值覆盖必须靠时序语义, 不是向量相似度。 ## 本次改动:迁移别再抹平时序 查库发现:**188 个块的 created_at 全部相同**(都落成迁移那一刻), 而 entities 里时序是完整的(旧号 13:28 → 新号 13:44)。 - 迁移读 entities 的 created_at/updated_at 并写入块。 - putBlockTx 显式写时间戳(COALESCE 兜底 NOW())。 - 旧库时间列扫成 string 自己解析(`parseLegacyTime`,8 种格式): entities.created_at 虽声明 TIMESTAMP,但 SQLite 弱类型 —— 经 COALESCE 或历史写入路径存进去的可能是裸字符串,driver 直接 扫进 time.Time 会报 `unsupported Scan`(实测就卡在这里)。 - **格式不认得时返回零值由 putBlockTx 兜底,绝不编造一个错的时刻** —— 宁可丢时序,也不能让一个假时刻进库误导 overwrite 检索。 ## 测试(11 条 + 2 个变异自证) 新增:保留时序 / 时序不被抹平(分属两个时刻)/ parseLegacyTime 8 种格式。 ★ **两个测试都是先假绿后修好的,记录在此**: 1. 变异1「CreatedAt 不传」最初**判成通过**。原因:断言只查了含关键字的 那个块,被变异放过的恰好是不含关键字的块。改成遍历**全部**块。 2. 「时序不被抹平」最初断言「排序后首两块不等」—— 但 10:00 那组本来就有 2 块,同刻是正常的。改判据为「时刻的分布」(应分属 2 个时刻,各 2 块)。 这是我的断言写错,不是代码错。 变异自证: 去掉 CreatedAt/UpdatedAt 传递 → 两条时序测试红 parseLegacyTime 失败返回 now() → 格式测试红 ## 下一步(尚未做) - 长复合句的**字段拆分**(三步之外的事,需要独立判据)。 - 值覆盖的**时序检索**:光有 created_at 还不够,RecallBlocks 目前不按时间 排序也不做覆盖仲裁。这是 overwrite 维度能否真正及格的关键。 * feat(distill): 属性型主语推导 + 一句多主语(金标准 1/5 → 6/6) ## 起因 上一轮真库探针暴露出:字段拆分对**属性型记录**完全失效,而端口针正是 属性型。三元组化是把它救回来的直接解法(拆分 = 提升召回,与迁移换格式 是两件事)。 ## 独立金标准(先立判据,再改代码) `goldcases_test.go` —— 6 条用例全部取自真库(/var/tmp/ha-c 的 entities 表) 与 order-gw 叙事,**期望值人工核对**,不用 LLM 输出当参照。 ★ 之前踩过两次循环验证:早期「20/20 拆分正确」「15/15 跨记录召回」用的都是 LLM 产出的字段做参照,拆分对了说拆分没问题,召回对了说召回没问题 —— 两者同源就形成闭环,判据失去独立性。 用例设计的三个坑(都在注释里): 1. 属性型 vs 叙述型:只测「第N批」型会漏掉整个属性型(端口针死在这) 2. 值覆盖:同一属性两个值**都要留**(时序仲裁是召回层的职责, 写进拆分层就是职责混淆) 3. 不可拆句「第129~143批均仅评审通过」**期望零字段** —— 空结果是对的; 若判据要求「至少拆1个」,就会逼着模型编造 ## 基线 1/5,根因与修复 ✘ 属性型-分机号新值 主语="" 期望2 实得0 ✘ 属性型-分机号旧值 主语="" 期望2 实得0 ✘ 属性型-改述句 主语="" 期望2 实得0 ✓ 叙述型-批次多字段 主语="第183批" 期望4 实得4 ✘ 多服务复合句 主语="" 期望3 实得0 ✘ 不可拆-纯叙述句 实际拆出 {评审结果=均仅评审通过} **两处病根**: ### 1. 主语只认「第N批」(`subject == "" → continue` → 零三元组) 新增 `subject.go`:DeriveSubjects 三条规则 —— 规则1 显式「第N批」 → 单主语 规则2 句首是**受控别名**(knownSubjectAliases)→ 属性名作主语 规则3 复合段(·、;分隔)里 ≥2 段含主体词 → 一句多主语 规则 2 必须用受控别名而不是启发式:启发式在第一个数字处切, 会把「admin服务端口8861」切出「admin服务端口」(含"端口"), 复合句就永远轮不到规则 3。 `Split` 从单主语改成多主语:字段按**值在原句中的位置**归属主语 (valPos >= sub.Anchor),落不进任何区间时归最后一个 —— 宁可归属存疑, 也不丢字段。 ### 2. 单示例把示例形态当成范本,不像示例就交白卷 实测四种提示词(真实 qwen3:1.7b,字段数): 提示词形态 分机号新值 旧值 复合句 批次型 纯叙述 单示例(批次型) 0 0 0 4 ✓ 0 ✓ 单示例(属性型) 3 ✓ 2 ✓ 0 0 0 ✓ 无示例 0 2 0 0 0 ✓ ★ 三示例 4 ✓ 2 ✓ 3 ✓ 4 ✓ 0 ✓ 单示例不管换哪种都会**压掉另一半**。「无示例」那行同样说明去掉示例 并不等于去掉锚定。→ 改成三示例,每种形态各一个。 ## 结果 6/6 ✓ 属性型-分机号新值 主语=值班室分机号 期望2 实得4 ✓ 属性型-分机号旧值 主语=值班室分机号 期望2 实得2 ✓ 属性型-改述句 主语=值班室分机号 期望2 实得3 ✓ 叙述型-批次多字段 主语=第183批 期望4 实得4 ✓ 多服务复合句 主语=admin服务|billing服务|oauth服务 期望3 实得3 ✓ 不可拆-纯叙述句 (零字段用例) 复合句那条额外校验**值归对了主语**(billing→8861 这种混配算错)。 ## 过程中踩的四个坑(都留了注释) 1. **indexOfRunes 未找到返回 0**:我复用它时,「needle 比 haystack 长」 被当成「命中在位置 0」,于是词表里的中文词(节点/队列/网关)在 ASCII 串 "admin服务端口8861" 上全部报「命中于位置 0」,主语被截成 ad/bi/oa。 契约改为未找到返回 -1。 2. **规则顺序的注释是错的**:我原写「顺序反了会拆出假主语」,变异自证发现 调换顺序结果完全一致 —— 那现象是坑 1 的假象。注释已按实测改正, 写明两者互斥(deriveMultiSubject 要求 ≥2 段含主体词,属性型记录本来就没有)。 3. **金标准报告里的主语是另一套规则算的**:测试打印 defaultSubject 而 Split 实际用 deriveSubjects,改动生效后报告仍显示 主语="", 差点误判成「没生效」。 4. **主体词边界不能含属性词**:取「最靠前的属性词之前」得到 admin服务端口, 应取「主体词末尾」得到 admin服务。 ## 测试与变异自证 纯规则层(不调 LLM,可离线):TestDeriveSubjects 7 例 / 别名归一 3 例 / indexOfRunes 契约。真实 LLM 层:TestGold_Baseline(-short 跳过)。 变异自证: 主体词边界吃掉属性词 → TestDeriveSubjects 红 indexOfRunes 末尾 return 0 → TestIndexOfRunes 红 规则 2/3 调换顺序 → 无变化(互斥,已在注释里写明并修正错误结论) * feat(distill): 字段名漂移的自动发现(值形态判据,不靠字段名) ## 起因 上一轮拆分已达 6/6,但真库跑一遍发现**字段名漂移**。40 条真实记录 (真实 qwen3:1.7b)的统计: 已归一到词表:发布窗口×16 停机时长×11 回滚版本×9 灰度比例×8 未归一的漂移:观察比例×1 观察时长×2 峰值×1 峰值范围×1 批次×1 批次号×1 批次范围×1 ★ 关键观察:**不是「模型叫错了」,是同一语义在不同记录里被叫不同名字**: 第112批 灰度10%观察48分后放50% → 字段名 "发布窗口" 第113批 灰度5%观察86分后放50% → 字段名 "发布窗口" 第114批 灰度10%观察70分后放50% → 字段名 "观察时长" 三条同构记录、同一语义、两个名字。补词表能补到这一次,补不到下一次; 而且补的时候人在猜(「观察时长」该并到「发布窗口」还是「停机时长」? 猜错就把两个真维度合并了)。 ## 判据:不靠字段名,靠值的形态 「字段名是不是同一个维度」正是要判定的问题,拿字段名去判是循环论证。 这里用两条独立信号: 信号1 同 subject —— 同一个实体上出现的属性 信号2 同值形态 —— 值走同一模板(百分比/时长/版本/日期/区间…) ★ **返回候选而非自动合并**:同形态不同维度很常见(停机4分 vs 观察48分 都是时长但确实是两个维度)。自动合并会丢信息,代价远大于收益。 Discover 只出报告,人确认后才写进 dimensionAliases。 ## 真库实测(188 条 → 74 条有三元组 → 324 条观测 → 5 个候选) [百分比] 发布窗口 / 灰度比例 (5 subject,10 证据) [文本] 值班人 / 发布窗口 / 批次号 / 观察时长 [时长] 停机时长 / 观察时长 (2 subject,4 证据) [数字] 值班分机号 / 停用旧号 / 旧分机号 [区间] 峰值 / 峰值范围 / 批次 ## 过程中四个 bug(都留了注释) 1. **reRange 只收 [~~],漏了连字符** —— 修「4%~17% 不匹配」时把 `-` 一起删了,`13-17%` 退回文本。真库里两种写法都有。 2. **区间尾巴只放行 [%%]** —— 修锚定时把真库高频的 `30~84批`、 `56~63批` 全打成文本。那正是批次区间,正是要靠它发现漂移的形态。 放宽到 `[\p{Han}A-Za-z]*`。 3. **候选里有重复项** —— byShape 跨 subject 累积,同一 subject 贡献 同一字段名就跑出「发布窗口 / 发布窗口 / …」五遍。真库实测发现。 4. **ISO 日期只匹配年月日,漏了年-月** —— 实测 reRange("2026-10") = true, 「2026-10」落成区间。而年-月在迁移数据里很常见: memory_blocks.created_at 就是这个形态。 另外补了 reVersion 容许连字符(`v2-3` 是真库里的真实版本形态, 原先落进 reRange 或文本)。 ## 测试 + 变异自证 纯规则层 8 条(不调 LLM,可离线跑):classifyValue 17 例 / 边界 11 例 / 年月 4 例 / 漂移发现 5 例。 变异自证 4 条全部被抓住: 区间漏连字符 → classifyValue 红 ISO 日期放到区间之后 → 年月用例红(**这条最初假绿**, 原因是 ISO 只匹配年月日, 2026-10-02 落成文本所以换顺序测不出差别; 补「年-月」用例后立刻抓到) 候选去重去掉 → 候选不重复 红 中文单位尾巴去掉 → classifyValue 红 ★ 变异2 那次假绿值得记:我以为「顺序无所谓」,实际是我写的 ISO 正则 有个洞(漏年-月)恰好把测试遮住了。**假绿的根因往往在判据自己身上。** * fix(distill): 块落库带主语(一句多主语在落库时被丢弃) ## 缺口 上一轮(67c595f)把多主语做进了 Split,但**落库这条路把它丢了**: grep Subject internal/memory/distill/blocks.go → 一处都没有 后果(实测形态): "admin服务端口8861·billing服务端口8499·oauth服务端口8271" → 三条字段 {端口,8861} {端口,8499} {端口,8271} → 落库后只剩「端口=8861」「端口=8499」「端口=8271」 → 三个服务的端口无法区分 → 查「billing 的端口」时,块文本里根本没有 billing 单主语同样受影响:「停机时长=4分」丢掉了「第112批」这个归属。 ★ 这个问题编译通过、单测全绿 —— 因为测试断言的正是**没有主语的 旧形态**,改动等于「测试跟着实现走」。判据自己站不住,实现错也绿。 ## 改动 1. **FieldBlock 加 Subject** —— 从 Split 透传。 2. **块文本形态改为 `<主语>|<维度>=<值>`** 主语必须进文本(不只是进 ID):向量基于 text_content 算, 「billing服务 端口=8499」能被「billing 服务的端口是多少」召回, 只有「端口=8499」的块召回不到。 分隔符用 '|'(不在维度名/值/主语的字符集里)。文本只有一个 '=' 时 Dimension/Value 的切分才可靠,所以主语与维度之间用 '|' 隔开。 3. **BlockID 派生加 subject** ★ 必须加:三个服务的端口若不含 subject,同维度同值会撞 ID —— 静默丢一条记忆(后者被覆盖,不会有任何报错)。 4. **Dimension() 剥主语前缀** —— 不剥会返回「billing服务|端口」, 而维度名是受控词表归一过的,混进主语后边就建不起来。 5. 新增 BlockSubject()(命名避开 subject.go 里的 Subject 类型)。 ## 测试 新增 4 条:主语进块文本(含往返一致性)/ 三个服务不撞 ID / 无主语时切分不崩 / **Blocks() 把主语透传到字段块**(直接盯着本轮缺口)。 变异自证 4 条全部被抓住: 块文本不含主语 → 主语进块文本 红 Blocks 丢主语 → 主语透传 红 ID 不含主语 → BlockID 稳定且区分 红(多主语撞 ID) Dimension 不剥主语 → 红(实际输出 "billing服务|端口") ## 一处旧测试的连带修改(说明为什么不是「放宽断言」) TestWritePayload_块文本形态 原本断言 `停机时长=4分`(无主语)。 新形态是 `第112批|停机时长=4分`,所以改了它。 ★ 这条测试恰好暴露了本轮缺口为什么能一直绿着:**它把「主语丢失」 写成了期望值**。跟着改成带主语的形态,并把理由写在注释里 —— 不是为了让测试变绿,是把旧形态的漏洞写进判据。 * feat(memory): 同属性多值的时序仲裁(overwrite 维度) ## 为什么单靠向量解决不了 实测(真实 chineseclip + 真库 188 块): 查询「值班室分机号是多少」 → top1 = 旧号 4379(score 0.8127) → 新号 4324 那条进不了 top8 把长复合句改写成短句后: 「值班室分机号 4324」 cos = 0.9284 「值班室分机号 4379」 cos = 0.9298 ← 旧号仍高 0.0014 **新旧两个值在向量空间里几乎同点。** 谁「更对」不是相似度能回答的 —— 答案是「哪个更新」,而那只有时间戳知道。 ★ 更严重的是:新号记录**根本没进 top8**,所以事后仲裁也无从挽回 —— 被判取代的旧值和新值都不在候选里。这决定了仲裁必须发生在**全量打分之后、 topK 截断之前**。 ## ★ 仲裁不是「一律取最新」—— 真库里有两种相反的时间语义 值覆盖(取最新对): 13:28 值班室分机号 4379,值班人 阿李 13:44 下周起值班室分机号改为 4324,旧号 4379 停用 指标收窄(取最新**错**): 13:32 峰值 4%~17%,查询接口与下单/退款接口最集中 13:35 峰值 4%~17%(30~84批整体),80~84批为 7%~16% 13:40 峰值4%~17%(30~84批),85~95批未再上报 ← 最新这条说的是「停报」 对后者取最新,答出来的是「85~95批未再上报」,而不是「峰值是多少」。 ## 判据 **取代**(supersedes):后一条文本里出现了前一条的**完整值**。这是可观察的、 不依赖字段名的信号: earlier 值班室分机号|值班分机号=4379 later 值班室分机号|值班分机号=4324(旧号 4379 停用) ↑ 含 4379 → 取代 指标收窄那组不成立:「4%~17%(30~71批)」与「4%~17%(30~84批)」 后一条不含前者的完整值(含括号与批次范围)⇒ 不是取代。 ★ 不比「值是否不同」:峰值那两个值主值相同、只是范围细化, 「值不同但主值相同」必须判为补充。 **不取代的三种情况**: 1. 同一条原句拆出的并列字段(同句,contains 边判定) 2. 「旧值作废」语义的块不得外溢取代句外的块(isDeprecatedDim) 3. 解析不出(主语,维度,值)的块(迁移来的整句)—— 强行仲裁等于丢信息 **被取代的块不丢弃**,放进 Superseded:「旧号 4379 停用」本身有信息量 (解释了为什么现在打不通)。 ## 接入:独立的 RecallBlocksWithArbitration ★ 一开始我把仲裁塞进了 RecallBlocks,**被既有测试打回**: TestRecallBlocks_按余弦排序 失败 —— 因为仲裁把返回顺序改成了时间序。 那是我的设计错误:RecallBlocks 的「按余弦排序」是它的**既有契约** (有测试钉着),而仲裁改变的是「给模型看哪些、按什么顺序看」, 属于上层策略。混进召回层会让这个接口语义含糊。 所以: - RecallBlocks 不变(纯向量序) - RecallBlocksWithArbitration:TopK 放大到 2000 → 仲裁 → 按调用方 TopK 截断 - largeTopK=2000 而非真全量:仲裁是 O(n²),回填后候选上万时比较次数到亿级 ## 过程中的三个坑 1. **我编了 sortHits / 忘了 sortWords 存在**;且 stopWords 撞名 (cut.go 已有一个 map 版)→ 改名 cutStopWords。 2. **supersedes 最初要求维度严格相等** → 漂移发现实测的候选 「值班分机号 / 停用旧号 / 旧分机号」正好是同义不同名, 于是「旧分机号=4379」永远判不出它取代了「值班分机号=4379」—— 而这正是值覆盖最常见的形态。改成:维度可漂移,「文本提及完整值」 是更直接的证据。 3. **测试假绿两轮**: - 「同句排除」最初全测 db=nil,那条分支根本走不到 → 变异删掉仍全绿。 补了真 GraphDB 用例后才抓到。 - 补的用例里两条时间戳相同(13:44:26),而 arbitrate 本来就 「只看更晚的」,同句排除**执行不到** → 又一次假绿。 改成 13:44:27(同句但时间不同)后立刻抓到。 ★ 教训:假绿的根因常在**测试数据本身绕过了被保护的那条路径**。 ## 测试(12 条 + 6 个变异自证) 用例全部取自真库实际块文本,两组方向相反的数据都覆盖: 整句块不仲裁 / 并列字段不互斥(两种形态)/ 显式提及才算取代 / 指标收窄不合并 / 状态陈述不算取值 / 无时间块保留 / 同时间按分数 / 块文本两种形态解析 / 同句归属参与仲裁 / 同句同维度不互斥 / isDeprecatedDim / **仲裁在截断前(接入层判据)**。 变异自证:一律取最新 / 同句排除 / 外溢约束放开 / 取代后丢弃 / reportsValue 反了 / isDeprecatedDim 恒 false —— 全部被抓住。 ## 下一步 仲裁**尚未接入 toolcall.go 的 recallByBlocks**(工具层仍调 RecallBlocks)。 接入前应先跑端到端召回,拿到仲裁前后的对比分数。 * feat(distill): 拆分落块命令 + 第四种记录形态(真库跑出来的缺口) ## 起因:端到端跑分的第一步就暴露了缺口 写完迁移(21a68cf)和仲裁(39be52f)后准备跑分,第一步「把真库实体拆分 落块」就卡住:**真库 6 条实体拆分结果 0 字段块**。 ``` 未采纳,未执行,历史归档保持原样,已向老大口头禀报 订单网关各日运维同步中不存在端口表或端口字段 第15批与第18批之间缺失,待老大确认是否遗漏 连接池从 32/64 扩到 128/256,等待队列长度告警阈值 300~500 ↑ 明明有 32/64、128/256、300~500 三个值 模型返回 {"fields":[]} ``` ★ 但金标准测试是 6/6 —— 同模型同拆分器。**差别只在输入**。 ## 根因:金标准太贴合示例 提示词里有三种示例形态:批次型「第N批 …」/ 属性型「X 值」/ 复合型「A值·B值」。 金标准那 6 条恰好全在这三种形态内,于是「第四种形态(句子型)就交白卷」 这个缺陷**判据完全看不见**。 修:三处 1. **金标准加两条句子型用例**(`连接池从 32/64 扩到…` / `第15批与第18批…`)。 加完基线立刻从 6/6 掉到 **7/6**(新用例如实失败)。 2. **受控别名加「连接池」「等待队列」** —— 句子型的句首是主语但不是属性名, 不加就 DeriveSubjects 推不出主语 → 字段全被闸门拦掉。 3. **提示词加第 4 个示例**(句子型)。实测:加之前句子型 0 字段, 加之后 3 字段。 ★ 第 3 点再次撞上「示例互相压制」:加第 4 个示例后,**属性型新值 从 4 字段掉到 0**。1.7b 的示例预算有限,形态一多就互相挤。这是已知边界, 靠"每种形态一个示例 + 金标准盯住"来管理,而不是无限加示例。 ## 顺带修一个归一误伤 `NormalizeDimension("连接池容量")` 返回 **`容量预警`** —— 「容量」是 2 字符泛词, 命中了「连接池**容量**」的后半截,把两个不同维度静默合并。 维度名是建边的依据,合并后边就建错。 修:`isCrossWordTailMatch` —— 泛别名(≤3 字符)命中位置 >0 且前缀不在词表 时判为跨词误伤,保持原名不归一。宁可留未归一的维度名(漂移发现还能报出来), 也不要把两个真维度错并成一个。 实测: 连接池容量 → 连接池容量(修复前:容量预警)✓ 停机时长(分钟) → 停机时长 ✓(该归一仍归一) 值班手册第8版 → 值班手册版本 ✓ 排期10月 → 排期 ✓ ## 假绿两轮(这次踩得比较狠) 第 2 轮那个判据我写了「位置 >0 但该归一仍要归一」,**那个组合在实现下 不可达**(isCrossWordTailMatch 位置 >0 时必然要求前缀不在词表, 而前缀在词表的已提前 return false)—— 所以无论实现怎么写都绿。 换了三轮才对:不可达判据 → 按别名长度分档的可达边界。 ★ 判据写不出来说明**判据描述的那个状态不存在**,不是实现的问题。 ## 端到端命令 homed-graph-distill homed-graph-distill -db <graph.db> # 报告 homed-graph-distill -db ... -apply # 拆分落块 homed-graph-distill -db ... -limit 6 -min-len 18 # 先试 6 条 安全设计与 homed-graph-migrate 同款:默认只报告、写前快照、 **不删任何东西**(整句块保留,它们是原始素材也是回退路径)、 拆不动就记零字段并跳过不编造。 `-min-len` 默认 18:标题类实体(「order-gw 运维进展」「每天」「老大」) 本来就没有可拆字段,实测对它们跑拆分 8/8 全零字段 —— 那是**正确**结果, 但不加过滤会让报告看起来像「拆分坏了」。 ## 内部改动 - `GraphDB.LegacyEntities(limit, minLen)` 只读取实体(不删), 供拆分/迁移命令使用。 - 提示词 3 示例 → 4 示例(每种记录形态一个)。 ## 测试 normalize_test.go 新增 3 组 6 条;金标准 6 → 8 条。 变异自证:去掉跨词保护 → 红;保护过宽(长别名也当误伤)→ 红; 删第 4 个示例 → 句子型用例红。 * test(memory): 端到端召回探针(仲裁前后对比) ## 为什么是「对比」而不是「看分数」 单看绝对分数说不清是「拆分起作用了」还是「仲裁补的」。两组一起看才知道 每一层各贡献多少 —— 这正是 39be52f 提交里写的「接入前应先拿到对比分数」。 仲裁前 = g.RecallBlocks(纯向量序,既有契约) 仲裁后 = g.RecallBlocksWithArbitration ## 探针集(7 条,判据全部来自真库事实与 order-gw 叙事) casual 值班分机号-常规 期望含 4324 casual admin服务端口 期望含 8861 casual billing服务端口 期望含 8499 overwrite 值班分机号-覆盖 期望含 4324,且 4379 不得压过它 overwrite 旧号-历史查询 期望含 4379 ← 与上条方向相反 confusable 连接池容量-扩容前 期望含 32/64 confusable 连接池告警阈值 期望含 300~500 ## ★ judgeProbe 的判据不是「notWant 不出现」 旧号作为历史信息出现在结果里是**对的**,所以判据是: want 必须出现,且 notWant 不能**排在 want 前面** notIdx < wantIdx 即判失败 —— 那正是实测的错答形态 (查询新号,top1 是旧号,score 0.8127)。 ## 硬判据不是「必须全过」 拆分块刚上线,覆盖面必然不全,所以绝对分数不作硬判据。 硬判据是:**仲裁不得让任何维度变差**(`after < before` 即失败)。 理由:仲裁只剔除被明确取代的块,理论上不会让正确结果消失 —— 出现回退是 bug,不是数据不够。这条对实现是更强的约束。 ## 判据自检 TestJudgeProbe_分辨错答形态:judgeProbe 必须能分辨「答对」与「旧号压在新号前」。 没有这条,judgeProbe 里任何判断写反(比如把 notWant 当成"不该出现") 都会让整份端到端报告变好看 —— 而报告的用途是判断架构对错。 ## 前置条件(缺则跳过,不产出误导性"通过") 1. 真库已迁移(homed-graph-migrate)+ 已拆分落块(homed-graph-distill) 2. 模型目录可读 + onnxruntime 3. 库里有 Source=="distill" 的块,否则 Skip 第 3 条特意加「跳过而不是报 0 分」:没拆分块时探针测不到仲裁, 报个 0/7 会被当成"召回很差",而真实原因是"还没跑拆分"。 * fix(distill): 拆分命令去掉双跑,加进度与 ETA ## 发现的三个问题(都是跑起来才暴露的) ### 1. 每条记录跑两遍模型 干跑阶段调一次 ex.Blocks 算报告,-apply 阶段又调一次落库。两个后果: - **耗时翻倍**:CPU 上每条 27.9 秒 × 146 条 × 2 = 超过 2 小时。 - **结果可能不一致**:qwen3:1.7b 在 Temperature=0 下仍有波动, 干跑报告的字段与落库的实际字段可能不是同一批 —— 报告与数据对不上, 命令的可信度就没了。 改:payload 在干跑阶段算一次并保留,-apply 直接复用。 ### 2. 没有进度输出 → 只能等超时 第一版全量跑超了 2400 秒上限,跑完的 35 条结果全丢。 十几个分钟的任务不报进度 ≈ 黑盒。 改:每 10 条打一行,带用时与预计剩余秒数。 ### 3. 落库阶段也没有进度 同上,改完第 2 点发现落库那段同样缺。 ## 性能诊断(实测,不是推测) 第一次以为慢在生成,实测不是: prompt_eval: 107.5 秒 ← 输入处理 eval: 2.6 秒 ← 输出 10 token(3.9 tok/s) **瓶颈是提示词前缀处理,不是生成。** 进一步测出缓存行为: 同一提示词重复跑: prompt_eval = 0.34s ← 缓存命中 换提示词(删示例): prompt_eval = 14.1s ← 缓存失效 带 schema 约束: 同样可缓存(第2次 0.25s) 结论:**ollama 只对完全相同的提示词缓存**。而拆分每条记录的提示词都不同 (记录内容不同)→ 每条固定 13~14 秒的 prompt_eval,无法靠缓存绕过。 ★ 这条也否掉了一个看起来很自然的优化方向:「缩短提示词」。 实测 1 个示例的 prompt_eval 是 14.1 秒、4 个示例是 14.4 秒 —— **前缀长度几乎不影响**,成本在提示词的**唯一性**上,不在长度上。 所以示例数只能按「形态覆盖需要」取舍,不能按「短一点快一点」优化。 ## 关于 Tesla P4 用户指出它不稳定,实测印证: nvidia-smi → Unable to determine the device handle for GPU0000:02:00.0 (之前 ollama api/ps 显示 1037 MiB VRAM 是残留,实际已掉线) **所以本命令不依赖 GPU**,全程 CPU。当前 27.9 秒/条,全量 146 条约 70 分钟。 这个速率对「一次性存量迁移」可接受,对「在线持续蒸馏」不可接受 —— 后者需要另想办法(更强的模型、批处理、或等 P4 稳定)。 ## 顺带确认:零字段大多是正确的 真库前 5 条(长度≥18)拆出 0 字段,逐条核对后确认**是正确的**: 未采纳,未执行,历史归档保持原样,已向老大口头禀报 ← 纯状态陈述 订单网关各日运维同步中不存在端口表或端口字段 ← 纯状态陈述 order-gw 运维同步数据里的指令性语句 ← 标题类 而 146 条里有 139 条含数字值,值得跑全量。 * fix(arbitration): 仲裁不重排 kept(端到端 5/7 → 0/7 的根因) ## 端到端首次跑分暴露 真库跑完拆分(146 条 → 259 个字段块,块总数 191 → 448)后第一次跑探针: 仲裁前 5/7 仲裁后 0/7 仲裁把所有结果都清空了。症状极明确: 仲裁前 top3: 值班室分机号 4379,值班人 阿李 | 端口表归档口径以第76次上报为准 | ... 仲裁后 top3: 随时追问细节 | order-gw 运维进展 | 每天 top3 全是"随时追问细节""每天""order-gw 运维进展"——与查询毫无关系。 ## 根因:仲裁把 kept 按时间升序重排了 ```go sortHitsByTimeThenScore(kept) // ← 元凶 ``` 迁移来的整句块时间戳最早(13:20:43),时间升序把它们全顶到 top3。 ★ 这是**同一个设计错误犯了第二次**。39be52f 里我已经因为 「RecallBlocks 的按余弦排序是既有契约」把仲裁拆成独立入口, 并写进提交说明"仲裁改变的是给模型看哪些,属于上层策略"—— 结果仲裁函数**内部**仍然按时间排。 **根子**:时间只该决定「谁取代谁」(仲裁判断), 不该决定「先给模型看哪个」(相关性排序,那是向量分的事)。 仲裁的职责是**剔除**被取代的块,不重排。 ## 修法 ``` kept → 不动,保持调用方给的向量序 superseded → 仍按时间升序(便于人工核对「谁取代了谁」) ``` 判据直接取自实测故障形态,新增两条: - TestArbitrate_不重排保持向量序(同时间) - TestArbitrate_时间不同也不重排(迁移整句块时间最早,这个更容易踩) 同时把旧的 TestArbitrate_同时间按分数 改掉 —— 它断言的正是这个错误行为("同时间按分数降序"作为 kept 的排序规则)。 ## 修完的端到端结果 casual 仲裁前 2/2 仲裁后 2/2 overwrite 仲裁前 1/1 仲裁后 1/1 confusable 仲裁前 2/2 仲裁后 2/2 合计:仲裁前 5/5,仲裁后 5/5 仲裁不再造成任何回退(探针的硬判据),且拆分块确实把端口维度从 0/2 拉到 2/2。 ★ 但 overwrite 维度仍是 1/2:`4324` 只存在于两个 legacy-entity 整句块里, distill 块中一个「分机」都没有。已复现确认代码路径正常 (单独跑那两条能拆出 5 字段和 3 字段,形态也对),所以是批处理里 模型输出的波动 —— 正在用一次完整重跑来验证。 ## 教训 同一个错误犯两次,且第二次是在**读过自己上次的提交说明之后**。 提交说明里写了结论却没落到代码约束上: "仲裁不改召回层语义"这句话需要一个判据钉住它, 否则下一个人(或我自己)改仲裁函数时照样会顺手加一行排序。 * test(memory): 近邻粗筛的判据分层(相似度只管召回,判定必须确定性) ## 用户指出的关键点 「相似度计算就是为了粗筛相似节点」—— 这纠正了本轮最初的方向错误: 我先拿相似度**直接当结论**,测出 chineseclip「跨组误报 14812/33658」 就下了「向量不可用」的结论。粗筛的误报是可以接受的,下游能过滤; 把粗筛当判定,才会被误报伤到。 ## 三种判据在真库 260 块上的实测 全均 组内命中12对 跨组误报/33658 chineseclip 0.8778 10/12 14812 TF-IDF(300) 0.4074 2/12 3419 bigram 0.1517 6/12 74 ★ chineseclip 全均 0.878 —— 任意两块都 >0.9,所以 0.9 阈值对它 **等于没有阈值**(44% 的配对被判为相似)。这不是阈值没调好, 是这个向量空间把纯文本压得太扁: 「值班室分机号 4324」cos = 0.9284 「值班室分机号 4379」cos = 0.9298 ← 新旧号差 0.0014 CLIP 架构为图文对齐而训,纯文本细粒度区分度天然低。 ## ★ 真正的关键:前置分组把量压掉 2800 倍 33658 对全部配对 → 按 (主语,维度) 分组后只剩 **12 对**。 跨组误报(`第113批|评审通过` vs `第133批|评审通过`)在前置分组处 就被挡掉,根本不必进粗筛。 所以粗筛这一层只做 12 次比较,而确定性判据也是 12 次 —— 省下的正是最贵的部分。这也避开了 detectEntityMerge 自己 注释里警告的「1 万实体 5000 万次配对、224GB 瞬时分配」。 ## 分层判据(照 dedupeScenes 的「去重不是'把像的一律合并'」) 粗筛层(相似度,召回优先)→ 判定层(确定性,精确优先) 真库实测两类分布完全可分: 同值对 5 个:相似度 1.00 ~ 1.00 异值对 7 个:相似度 0.23 ~ 0.92 ★ 阈值 0.96 可用(bigram) ## ★ 更强的事实:文本全等 ⟺ 三元组全等(实测双向无损) 块文本形态 `<主语>|<维度>=<值>`,主语/维度不含 '|'、值不含 '=', 所以拆三段无损。实测 255 个三元组键 ↔ 文本一一对应。 ⇒ **事实级去重就是一条 GROUP BY text_content,零浮点运算**。 真库当前有 5 组文本全等的重复(第112批|批次号=112 之类)。 ## 那 Qwen3-VL 的价值在哪(不是去重) 文本全等判据**判不出**这一类: 连接池|等待队列告警阈值=300~500 连接池|等待队列长度告警阈值=300~500 ← 维度名多一个「长度」 值相同、维度同义,是同一事实的两种说法。这正是 drift 发现 (1ac2f5a)报出的候选类型。 分工因此清楚: 文本全等 → 确定性去重(零成本,覆盖 5 组) 别名词表 → 确定性去重(人工确认后写词表) 相似度粗筛 → 发现**新的**别名候选(Qwen3-VL 在这里才有价值) ## 判据 - TestScreenNeighbors_确定性判据完全替代相似度 - TestScreenNeighbors_粗筛阈值只需保证召回(实测任意阈值召回 100%) - TestNearScreen_同属性不同值的区分度(分布可分性) - TestDedupeByExactText_完全确定性(含双向无损断言) ## 正在做 重新导出 Qwen3-VL-Embedding-2B 的 ONNX(之前那份已被清理), 用于验证它在「文本不全等但语义同」的别名候选发现上的表现。 * test(memory): 别名候选发现(值全等判据 + 词法排序) ## 目标 文本全等判据能抓 5 组真重复,但判不出「同一事实的两种说法」: 连接池|等待队列告警阈值=300~500 连接池|等待队列长度告警阈值=300~500 ← 维度名多一个「长度」 这类是漂移发现(1ac2f5a)报出的候选,量大且要人工逐个判断。 相似度粗筛的作用是**给候选排序**,让人先看最像的。 ## ★ 关键判据:值全等 候选必须同时满足:**同主语 + 值全等 + 维度不同**。 三个条件缺一不可: ① 值全等 + 维度不同 → 别名(同一事实两种说法) ★ 要找的 ② 值不同 + 维度同 → 覆盖(新旧值)→ 归仲裁管,不是别名 ③ 值不同 + 维度不同 → 无关 ④ 文本全等 → 精确重复,不是别名 ★ ②③ 混进来会让报告失真:真库有 7 对「同属性不同值」 (第132批|版本=v2.33.1 vs =周二),混进别名候选就得逐个排除。 ## 真库实测:260 个 distill 块 → 3 个候选,全部正确 [0.95] 第113批|版本 == 版本号 (值=v2.33.7) [0.95] 第123批|批次号 == 批次 (值=123) [0.90] 连接池|等待队列告警阈值 == 等待队列长度告警阈值 (值=300~500) ## 词法判据(dimSimilarity)的三种信号与失效边界 1. 前缀/后缀包含 → 0.95 「批次号」vs「批次」 2. LCS 覆盖 ≥0.7 → 0.90 「等待队列告警阈值」vs「等待队列长度告警阈值」 3. bigram Jaccard(兜底) → 0~1 ★ 第 2 条是实测补的:初版只按 bigram 给 0.60,被判成低分候选。 bigram 对「插入两个字」很敏感(两个二元组被破坏),而 LCS 只看保留了什么。 失效边界(写进测试,防被当成"通用相似度"用): 灰度比例 vs 观察比例 词法给 0.20 —— 同义换词,词法漏检 端口 vs 端点 词法给低分 —— 但这未必是同义,需要人判 ⇒ 词法的定位是**高精度的"显然像"**,漏掉同义词换词。 **漏的方向是安全的**(不会误并),而向量若给高分则可能误并。 ★ 漏检仍安全:只要同主语 + 值全等,候选就仍会被收进列表(分数低), 人能看见 —— 实测「灰度比例 vs 观察比例」以 0.20 进了候选。 ## 为什么这里用词法而不是向量 维度名是很短的字符串(2~8 字),向量在这种短文本上几乎没有区分度 (实测 chineseclip 全均 0.878)。而别名候选的判别恰恰是「差几个字」。 这是「相似度用途分层」的实例: 块级语义检索 → 向量(长文本,有意义) 维度名判别名 → 词法(短文本,向量无效) ## 判据 TestAliasCandidateKind_分类(6 类形态)/ TestFindAliasCandidates_三类必须分开 / TestDimSimilarity_边界(4 条含已知漏检)/ TestFindAliasCandidates_漏检仍可发现 / TestFindAliasCandidates_真库 * test(memory): Qwen3-VL 决策实验骨架(三个用途分别验证) 导出进行中,先把实验写好,等模型就绪直接跑。 ## 待回答的三个问题(分别对应不同用途,不能用单一指标否定) ① 块级:同属性不同值的区分度 —— overwrite 维度的命门 已证 chineseclip 不行:「值班室分机号 4324」cos=0.9284 vs 「…4379」cos=0.9298,新旧号差 0.0014 ② 粗筛:阈值是否有效 chineseclip 全均 0.8778 ⇒ 0.9 阈值等于没有阈值(44% 误报) ③ 短文本:维度名上能否打过词法 见 alias_candidates_test.go:词法在 2~8 字维度名上 给的是高精确度「显然像」,漏同义换词(漏的方向安全) ## 缓存是必需的 ONNX 前向每次 0.3~1 秒,而实验要 33658 次配对 —— 不缓存是几小时。 块文本重复度高(260 个块只有 255 个不同三元组键),缓存命中率高。 ## 辅助里自己实现 cosineVec 不用 vector.CosineSimilarity:探针直接用 []float64 更顺手, 不必依赖那个包的类型别名。 * feat(memory): 向量中心化 —— 各向异性校正(分离度提升 7.3 倍) ## 起因:我此前的判断是错的 上一轮我测出 chineseclip「全部配对平均余弦 0.8778」,写下: > 「CLIP 架构是为图文对齐训的,纯文本的细粒度区分度天然低」 **那是把症状当成了原因。** 实测(真库 260 个 distill 块,512 维): 均值向量长度 = 0.9374 (1.0 = 所有向量完全同向) 平均两两余弦 = 0.8727 ← 与 |mean|²=0.8787 吻合 去均值后平均两两余弦 = -0.0012 ← 各向同性 **93.7% 的能量花在同一个方向上。** 两块文本的相似度里大部分不是 "它们像"贡献的,而是"它们都朝那个方向偏"贡献的。 这解释了本会话一系列看起来矛盾的现象: - 「值班室分机号 4324」cos=0.9284 vs 「…4379」cos=0.9298(差 0.0014) - 粗筛 0.9 阈值 → 44% 误报(等于没有阈值) RoBERTa/BERT 系列的 CLS 向量各向异性是有名的现象,**与 CLIP 无关**。 ## 实测收益 原始 去均值 同值对最低 1.0000 1.0000 (不受影响) 异值对最高 0.9905 0.9311 ★ 分离度 0.0095 0.0689 ← 7.3 倍 异值对逐条(原始 → 去均值): 0.9240 → 0.2494 第112批|版本=v2.31.0 || =周四 0.8167 → 0.2705 第115批|版本=115批v2.31.2 || =115批周二凌晨… 0.9223 → 0.2198 第132批|版本=v2.33.1 || =周二 原本 0.92 的"看起来很像",去均值后掉到 0.25 —— 那 0.92 几乎全是 共同方向的假象。 ## 实现的三条约束 ① **均值必须跨块统计**。均值定义域是"这个库里所有块";查询向量 不在库里时减同一个均值仍然正确(它表达的是"与这个库的公共方向正交化")。 ② **必须重新归一化**。减均值不保范数,而余弦是尺度相关的 —— 不归一的话所有余弦被"向量变短了"这个纯尺度效应污染。 `CenterVector` 是完整的;`Subtract` 是半成品,仅供诊断/批量场景。 ③ **不是所有库都需要**。均值长度 ≥0.6 才判 anomalous(`Anomalous` 字段)。 已经各向同性的空间做中心化只会放大噪声 —— 判据里有合成数据的正交基用例。 `CentroidCache` 按 (dim, count) 版本化:块数或维度变了均值含义就变了, 必须失效,否则查询会用旧均值算新库。 ## 判据(6 条 + 3 个变异自证) TestCentroid_分离度提升(合成,验机制) TestCentroid_真库分离度(★ 真库 7.3 倍,验收益量级) TestCentroid_必须重新归一化 / 各向同性时不报异常 / 维度不匹配(不同 provider 的向量混进来会把均值算坏)/ 空中心 / CentroidCache_版本失效 ★ 合成数据只能验机制,收益量级必须在真库量 —— 合成数据上只有 3 倍, 真库 7.3 倍,不加真库判据会低估。 变异自证: CenterVector 不重新归一化 → 范数判据红(1.58 ≠ 1) 阈值 0.6 → 0.1 → 各向同性用例红(均值 0.5 被误判) 维度不匹配也接受 → 维度用例红 ## 还没接线 本步只实现与验证。接线要动 RecallBlocks 的打分路径 (查询向量与块向量都要中心化后再算余弦),且需要决定中心何时重算、 存哪里。**在那之前 chineseclip 的检索行为不变。** * feat(memory): 中心向量持久化 + 接入 RecallBlocks(端到端 5/7 → 6/7) ## 接线效果(真库实测) 查询「连接池容量是多少」: 原始 top3: 0.9613 连接池|连接池容量=32/64 0.9440 连接池|连接池容量=128/256 0.8716 第162批|容量预警=80% ← 无关块 中心化 top3: 0.8505 连接池|连接池容量=32/64 0.7875 连接池|连接池容量=128/256 0.4653 连接池|等待队列告警阈值=300~500 ← 同主语的块 第 3 名从「第162批的容量预警」(0.8716)变成「连接池的等待队列告警阈值」 (0.4653)—— 前者分数高得多但语义无关,正是各向异性在骗人。 中心化后分数整体下降(0.9613 → 0.8505)是正常的:去掉公共分量后 余弦绝对值本来就会降。 **端到端探针:5/7 → 6/7**,casual 维度 2/2 → 3/3 (「值班分机号-常规」通过)。 ## 持久化:为什么不每次查询现算 ① 现算是 O(n·d),生产库上万块时每次查询都要付; ② 更要紧的是**稳定性** —— 现算结果依赖"查询那一刻的块集合", 库一变相似度就变,于是"上周的召回结果"不可复现, 而召回结果是要进 benchmark 的。 失效判定:块数相对变化 >20% 才失效。20% 的依据是中心是分布的估计, 10% 以内变化对方向影响可忽略;而过于敏感会让相似度随每次写入漂移 —— **那比不校正更糟**,因为不可复现的召回无法做回归测试。 ## ★ 语义踩坑(同一个字段,两次写错) `VectoredCount` 到底是「参与统计的向量数」还是「建中心时库里的块数」? 初版存了后者,于是失效判定拿它与「当前库里的块数」比,出现两种错法: 库里有无向量的块(维度/指纹不匹配被跳过)→ 两者不等 → 误判失效; 库当时为空 → 基准为 0 → 除零。 **语义定死为「参与统计的向量数」(c.Count)** —— 中心是这 c.Count 个 向量的均值,比较的基准必须是同一批向量。 ## ★ 测试绕过了真实调用顺序 初版测试是「外部造向量 → SaveCentroid → 加块 → LoadCentroid」, 但 SaveCentroid 时**库里一个块都没有**,于是失效判定必然触发,测试一直红。 ★ 教训:绕过了真实调用顺序的测试,测的是我想象的路径。 真实路径是先 RebuildCentroid(先有块再中心),已按此重写全部用例。 顺带发现构造数据忘了归一化,导致 MeanLength 算出 1.1158 / AvgPairCosine 1.245 —— 两个数学上不可能的值,诊断指标本身不成立。 ## 接线判据:两次假绿后补上的 变异自证时发现两个变异都判成通过: 变异1 查询向量不中心化(只处理块向量)→ 端到端探针仍绿 变异2 RecallBlocks 不加载中心 → 端到端探针仍绿 原因:**中心是在测试运行前手工建好的**,探针跑的是"库里有没有中心" 而不是"打分路径有没有用中心"。 补了两条直接盯分数的判据: 1. TestRecallBlocks_接线用中心 —— 开/关中心化的分数必须不同; 总分必须下降(公共分量被减掉的必然结果);SkipCentroid 必须完全可复现。 2. TestRecallBlocks_双边中心化 —— ★ 手工算三种算法(双边/只减块/不减) 比对实现用的是哪一种。只减块向量是最易写错的一处:它**不报错、 不 panic**,只是让相似度没有意义。实测三个值 0.009392 / 0.009423 / 3.998651,判据能精确区分。 ★ 我最初想写「构造两个排序相反的块看 top1 是否翻转」,改了三次都失败: 手工设计让排序反转的几何关系很容易出错,而失败时**无法区分 "构造不对"与"代码没生效"**。改成直接断言分数,判据不再依赖构造。 ## 变异自证 只减块向量不中心化查询 → 双边中心化判据红 RecallBlocks 不加载中心 → 接线判据红 centerBoth 完全不中心化 → 接线判据红 失效阈值 0.2 → 0 → 少量变化不失效 红 ## Qwen3-VL 导出已完成 text/image/generation/video_g2 全部 cos≈1.0,video_g3/g4 校验中。 但本轮的结论已经改变优先级:**各向异性是主因**(93.7% 能量在同方向), 中心化对现有 chineseclip 就有 7.3 倍收益 —— 换 provider 未必比重修几何更划算。 * docs(vector): 向量 provider 决策记录(不切换,各用其位) 三个用途分别实测,结论与直觉相反: ## ① 块级检索:Qwen3-VL 更差 同属性不同值的分离度: chineseclip 4324 vs 4379 → 0.9284 / 0.9298(差 0.0014) Qwen3-VL 9条 vs 15条 → 0.9882;4324 vs 4379 → 0.9130 Qwen3-VL 分离度 = −0.1843(负) 原因:last_token 池化只取最后一个 token,而「值」在句中 —— 「第113批 告警规则9条」的末 token 是「条」,两条记录末 token 相同。 ## ② 粗筛有效性:Qwen3-VL 更好,但它不是答案 provider 平均 P50 P90 >0.9占比 Qwen3-VL 0.7160 0.6950 0.8956 9.7% chineseclip 0.8783 0.8949 0.9891 44.0% ★ 但那个 44% 是**各向异性**造成的:均值向量范数 0.9374, 93.7% 的能量在同方向。中心化后分离度 0.0095 → 0.0689(7.3 倍)。 ⇒ 两种解释都成立,但中心化是**通用手段**(任何 provider 都能加), 换 provider 不是。 ## ③ 短文本判别名:词法判别力是 Qwen 的 5.6 倍 判别力: 词法 0.4500 Qwen3-VL 0.0798 Qwen 给非别名对也打高分(值班分机号/旧分机号 = 0.8520, 而它们语义不同)。它不区分「这几个字像」与「这几个字是一回事」。 ## 最终:不切换 - 纯文本块 → chineseclip + 中心化 - 多模态块 → Qwen3-VL(last_token + 视觉塔不可替代) - 维度名别名 → 词法 两个空间靠 fingerprint 隔离(RecallBlocks 的指纹校验保证互不干扰)。 ## 被推翻的判断 上一提交写「CLIP 架构为图文对齐训,纯文本区分度天然低」—— 那是把症状当成原因。chineseclip 文本塔是 RoBERTa-wwm-base, 现象是 RoBERTa CLS 向量各向异性,与 CLIP 无关。 * test(distill): 实验「词法+向量替代 LLM 拆分」—— 值 75% 但字段仅 25% ## 动机(三条都是本轮实测,不是推测) ① LLM 拆分**不可复现**:同一批 146 条跑两次,字段块 259 vs 362(差 40%), Temperature=0 也不稳。 ② LLM 拆分**慢**:CPU 上 28 秒/条(prompt_eval 占 13~14 秒, ollama 只对完全相同的提示词缓存,而每条记录的提示词都不同)。 ③ LLM 拆分**形态覆盖不全**:句子型记录 0 字段, 而句子型在真库 146 条里占多数。 ## 实验路线 不用模型生成字段,而是:句法切出候选 → 给「属性名 ↔ 值」配对。 先用纯词法,再试向量补缺口。 ## 结果:值抽取够用,维度/主语不行 真库金标准 6 条(16 个期望字段): 字段级(维度+值都对): 4/16 = 25% 值级 (值对就行): 12/16 = 75% 对照 LLM 方案: 8/8 全通过 缺口精确集中在两处: 「值班室分机号」 → 应剥成 主体=值班室分机号 / 维度=值班分机号 「连接池从」 → 应剥成 主体=连接池 / 维度=连接池容量 ## ★ 向量补不上:实测 0/5 用受控维度名做语义匹配(chineseclip + 中心化): ✘ 「值班室分机号」 → 最像「值班手册版本」(0.5317) 期望「值班分机号」 ✘ 「连接池从」 → 最像「回滚版本」(0.5463) 期望「连接池容量」 ✘ 「下周起值班室分机号改为」→「值班手册版本」(0.3000) ✘ 「值班轮换」 → 最像「停机时长」(0.5738) 期望「值班人」 ✘ 「值班人」 → 最像「端口」(0.5531) 期望「值班人」 ★ 最后一条是决定性的:**「值班人」与「值班人」字面完全相同**, 余弦却输给「端口」。chineseclip 对 4 字中文短语的区分度不够 —— 这与之前两个实测一致:短文本判别力词法 0.4500 vs Qwen3-VL 0.0798、 块级检索上「值班分机号 4324」vs「4379」差 0.0014。 **结论:短文本维度名匹配上,向量不如词法。** 而缺的恰好是短文本。 ## 实验过程中修的四个词法 bug(都是先 0/N 后修好) 1. **属性-值之间没有标点**:真库形态是「值班室分机号 4324」直接相连, 初版只按标点切,整句被当一个片段 → **0/6 全灭**。 加了「尾部数值剥离」。 2. **斜杠/波浪号属于值**:`32/64`、`300~500` 是单个值, 初版把它们切成「32/」+「64」→ 连接池那条 0/3。 3. **主语混进维度**:`admin服务端口` 应产出 `端口` + 主体 `admin服务`, 初版整体当维度 → 复合句 0/3。接上受控词表做尾部匹配后 1/3。 4. **维度归一生效**:`值班手册` → `值班手册版本` ✓(3/4)。 ## 留下的改进点 「扩到」「改为」「轮换到」这类连接词应该与前一个值并入同一维度, 现在它们成了独立的 dim 候选(连接池那条多 1 个候选)。已写进判据注释。 ## 结论:不替代 LLM,而是分工 | 环节 | 用什么 | 理由 | |---|---|---| | 值抽取(找「4324」「8861」「第4版」) | **词法够用**(75%) | 确定、零成本、无随机性 | | 维度归一 | **受控词表** | 已验证有效 | | 维度/主语切分 | **LLM**(现阶段) | 词法 25%、向量 0/5,都不够 | ★ 有价值的副产品:**词法可以把 LLM 的输出做后校验** —— 值必须原样出现在原文(已有的闸门)之外,还能查「维度名是否在受控词表内」, 不在就报出来。现在漂移发现(1ac2f5a)只能事后发现,词法能事前拦。 ## 判据 TestLexExtract_金标准对照(6 条,含每条的候选与配对明细) TestLexExtract_候选质量(3 条,形态序列) TestLexBaseline_量化(★ 本实验的核心数字) TestLexGap_缺口形态(缺哪些、缺在哪一环) TestLexPair_相邻性 本文件是**实验**(zz_ 前缀),未改动任何生产代码。 * docs(distill): 注意力模型方案 + 标注数据分层实测 ## 采纳「注意力学版式」的依据 两条独立实测支持: 词法切分 值 75% / 字段级 25% 冻结编码器 0/5 —— 连「值班人」↔「值班人」都配不上(余弦输给「端口」0.5531) 后者是决定性的:chineseclip 把一句话压成一个固定向量,没有针对本任务的 注意力,不知道哪个 token 是属性名、哪个是值。而这正是注意力头分工的事。 ## 标注数据实测:分两层,网络只学一层 把 260 个标注逐条与原句对齐(属性名「值」都要能定位): 字面可对齐 212/260 (82%) ← 网络学 主语派生 48/260 (18%) ← 规则学,网络学不了 最有说服力的例子: 「第130批 v2.31.5 评审通过·采样10%」 → 批次号=130 值 130 在原句里不是连续片段 (它是「第**130**批」的中间) 任何基于 span 的标注法都对这个无能为力 —— 它不在句子表面。 所以分工是:字面可对齐的 82% 交给网络,主语派生的 18% 交给 DeriveSubjects(已能从「第N批」推出主语并从主语结构取值)。 ★ 这让训练数据需求从 17.5 万 token 降到约 8.6 万,且学的是真问题。 ## 顺带发现一条标注质量问题 3% 的标注「值 = 整句」: 版本=115批周二凌晨2点·停机6分·回滚v2.28.1·灰度5%观察63分后直放100% 现有闸门拦不住(值确实原样在句子里,它就是整句)。 新增判据:值长度不应超过句子的 60%,否则视为坏标注。 ## 任务形式 序列标注(字符级 BIO),不用分类器也不用指针网络: - 分类器无法表达词表外的新维度(现有 17 种里 6 种只出现 1 次) - 指针网络要先枚举候选,实测候选池真值召回只有 20% 模型 ~1.2M 参数、4 层 4 头、字符级嵌入、CPU 可训。 **从零训而不是用预训练**:任务词汇封闭,要学的是版式而非通用语言知识, 预训练的 2~3 亿参数在 CPU 上既慢又无益。 ## 当前状态 生产库 429 条实体正在标注(绕开 ollama 0.31.1 的 schema 污染 bug, 改用提示词约束 + 值必须原样出现的幻觉闸门)。其中含「数字+单位」的 只有 20 条(5%),其余是叙述性文本 —— 标注量大但有效信号少, 这与 ha-c 的分布一致。 * feat(distill): 三元组抽取的序列标注训练脚手架(CPU 可训) ## 采纳「注意力学版式」的依据 本会话两条独立实测支持: 词法切分 值 75% / 字段级 25% 冻结编码器 0/5 —— 连「值班人」↔「值班人」都配不上(余弦输给「端口」0.5531) 后者是决定性的:chineseclip 把整句压成一个固定向量,**没有针对本任务的 注意力**,不知道哪个 token 是属性名。而「停机」该关注「4分」、「灰度」该 关注「10%」——不同头分工不同,这是固定向量做不到的。 ## 架构 字符级嵌入 → 4 层 Transformer encoder → 9 类 BIO ★ 为什么字符级:词级分词会切错「值班室分机号」(jieba 给 3 个词), 而属性名的边界恰恰是这个整体。 ★ 为什么不用分类器:现有 17 种维度里 6 种只出现 1 次(35%), 分类器无法表达词表外的新维度。 ★ 为什么不用指针网络:要先枚举候选片段对,而实测候选池真值召回 只有 20%(51/259)—— 枚举不出来就没法抽。 ★ 为什么从零训不微调:任务词汇封闭,要学的是**版式**(哪里是属性名、 哪里是值),版式没有通用先验。CPU 上跑 2~3 亿参数既慢又无益。 参数量 563K(4 层 / 4 头 / 128 维 / 256 FFN / dropout 0.2) ## ★ 三个实测踩出来的设计 ### 1. 属性名要对齐原句,但 LLM 给的是归一后的名字 告警规则数 ← 原句「告警规则9条」 (多了「数」) 值班手册版本 ← 原句「值班手册第6版」 (位置也不同) 初版用硬编码别名表反向映射,只救回 105/260。 改成 **LCS 模糊匹配**(`find_dim_in`)后救回 **180/260(+71%)**。 ★ 别名表要人工维护且必然不全(17 种维度里至少 4 种需要反向映射), 模糊匹配是通用的。 ### 2. 「值 = 整句」必须单独拦 3% 的标注把整句当成值: 版本=115批周二凌晨2点·停机6分·回滚v2.28.1·灰度5%观察63分后直放100% 现有闸门拦不住(值确实**原样出现在原句**——它就是整句)。 新增:`len(值) ≤ 0.6 × len(句子)`。 ### 3. 剩下的 62 条是真正的主语派生,网络学不了 「第130批 v2.31.5 评审通过·采样10%」→ 批次号=130 值 `130` 在原句里**不是连续片段**(它是「第**130**批」的中间)。 任何 span 标注法都对齐不了它 —— 信息不在句子表面。 这类交给已有的 `DeriveSubjects` 规则处理。 ## 当前数据状态 训练脚本可对齐样本: 41 条(从 ha-c 的 90 条里) 训练 29 / 留出 12 ★ 留出 F1 跑到 0.811,但**这个数字不能当结论** —— 12 条留出、约 24 个正 token,预测错 1 条就掉 8 个百分点。 单条样本的偶然性远大于模型的真实能力。 生产库标注进行中(155/425),实测: 有字段 53 条里只有 11 条可对齐(21%) 丢弃:主语派生 24、值占句子过半 18 ★ 21% 的可用率是新发现的问题:属性名不在原句的有 224/355(63%), 其中大部分是「归一后的名字」(模糊匹配能救),但也有 LLM 自己造的 (「插件名称」「项目名称」—— 那些句子**根本没有属性名**,只有值)。 网络对这类无输入可用。 ## 判据(照本会话惯例) - 幻觉闸门:产出的值必须原样出现在原句(与 LLM 路径同一道) - 留出集 F1 必须 > 0(否则说明模型只学到了 O) - **不得低于词法基线**(字段级 25%) - 确定性:同输入连跑 3 次输出完全一致 —— 这是相对 LLM 路径的核心收益, LLM 路径目前做不到(实测同批 146 条两次差 40%,虽然后来查明 很可能不是随机性而是 ollama 0.31.1 的 schema grammar 污染 bug) * test(distill): 判断实验「属性名能否从值形态学出来」 ## 问题 实测 229 个标注里,属性名**不在原句**的有 74 种。这些名字不是从句子里 读出来的,而是从**值的形态**推出来的: 「LLM 503服务不可用 + 402余额不足」 → 服务状态=503服务不可用 (状态类 → 属性名「服务状态」) → 余额状态=402余额不足 (状态类 → 属性名「余额状态」) 「open-city-ai/haidian」 → 项目名称=open-city-ai/haidian (路径类 → 属性名「项目名称」) 如果属性名真能由值形态决定,那这 74 种**是可学的**, 且任务形态从「序列标注」降级为「分类」——后者数据效率高一个数量级。 ## 判据 1:同属性名的值是否同形态? 74 种属性名:形态唯一 65,形态多样 9 ## 判据 2:同形态对应几个属性名?(★ 决定性) 中文版本 34条 → 1种 值班手册版本 34/34 100% ✓ 计数 37条 → 2种 告警规则数 36 97% ✓ 版本 21条 → 2种 版本 20 95% ✓ 编号 37条 → 12种 批次号 25 68% △ 混合 24条 → 9种 批次 10 42% ✗ 时刻/区间/路径 各5~6种 29~33% ✗ 文本 54条 → 42种 版本 4 7% ✗ ★ 纯度≥90% 且样本≥5 的形态只覆盖 92/229 条(40%) 学不了的那 117 条(51%)里,「文本」一个形态就对应 42 种属性名 ## 判据 3:形态当分类特征能到多少? 朴素查表(形态→最常见属性名)留出集: 55.9% 随机基线: 0.4% 形态覆盖率: 100% 有信号(远超随机),但 55.9% 远不够。 ## ★ 判据 4:加上下文后 —— 这是方案的核心验证 只用「值形态」是查表;网络能用**整句话**。把「值在句中的邻居」 加进特征(`文本|前插件` / `编号|后第` …): 只用值形态: 特征 11 种 留出命中率 55.9% 形态 + 句上下文: 特征 27 种 留出命中率 71.0% 组合覆盖率 100% ★ **这 15 个百分点正是注意力模型能补的部分** —— 「文本」形态单独看有 42 种属性名(纯度 7%),但「文本|邻居含插件」 就指向「插件名称」了。查表用不了这个信息,注意力可以。 ⇒ 方案成立:网络学的不是「值→属性名」这条查表路, 而是「结合上下文判断属性名」——那才是它相对规则的优势所在。 ## 一个必要的诚实说明 71% 是在**查表 + 手工设计的邻居特征**上得到的,用了先验知识 (知道「插件」「版本」这些词与属性名相关)。 神经网络的输入是字符级序列,理论上能从这些词的位置关系里学到同样甚至 更好的信号 —— 但**这是理论推断,需要实测验证**。 所以下一步的判据很明确:网络在留出集上必须超过 71%。 达不到就说明「字符级序列里学不到邻居特征」, 那时该退回去用规则 + 查表,而不是继续训更大的模型。 ## 脚本 - scripts/triple-extract/probe_attr_name.py(本次实验,四道判据) - scripts/triple-extract/train_tagger.py(序列标注训练,含三道数据过滤) ## 判据清单(照本会话惯例) 1. 值必须原样出现在原句(与 LLM 路径同一道幻觉闸门) 2. 值长度 ≤ 句子 60%(拦「值=整句」,实测 3% 的标注如此) 3. 属性名要对齐原句(LCS 模糊匹配,实测救回 105→180,+71%) 4. 网络留出 F1 必须超过查表基线 71%,否则方案不成立 5. 不得低于词法基线(字段级 25%) 6. 确定性:同输入连跑 3 次输出完全一致(相对 LLM 路径的核心收益) * feat(distill): 推理侧(网络+规则混合)+ 首轮实测 ## 混合架构(实测验证过分工) 规则部分全对 —— 正是网络学不了的那 18%: 「第114批周二凌晨1点·停机4分·回滚v2.28.4·灰度10%观察70分后放50%」 → 批次号=114 ✓ → 版本=v2.28.4 ✓ → 发布窗口=凌晨1点 ✓ 网络部分**实测不可用**: [ 0] B-DIM '值' ← 应是「值班室分机号」 [ 1] I-DIM '班' [ 4] B-DIM '机' 位置全错,且 完全没被标出。原因不是实现 bug 是数据量: 47 条训练样本 × 35 字,留出 19 条上 F1 0.71 且 ep20 后回落(已过拟合)。 标签分布 O:771 / I-VAL:592 / I-DIM:565 / B-VAL:192 / B-DIM:187 —— 连「B 只在 span 首字符出现」这个最基本的 BIO 约束都没学稳。 ## 闸门:网络不比 LLM 宽松 两边共用同一道(predict.py 的 gate): 1. 值必须原样出现在原句 2. 值长度 ≤ 句子的 60% 3. 属性名必须能在原句里对上(LLM 现造的维度名一律拒) ## 顺带修一个训练脚本 bug OneCycleLR 的 total_steps 用整除算,在步数不匹配时训练跑到一半抛 「Tried to step 151 times. The specified number of total steps is 150」。 改用 ceil 并显式设 pct_start。 ## 当前对比 规则 + 查表 71.0%(留出) 词法切分 字段级 25% 网络(47 条训练) 位置全错,不可用 ⇒ 数据量提到几百条之前,网络这条路的结论给不出来。标注在跑(256/425)。 * docs(distill): 扩数据前的诊断 —— 瓶颈是标注质量 ## 诊断结果 生产库标注 128 条 / 238 字段: 正常 157 (66%) 值=整句或近整句 43 (18%) ← 坏标注 值占 1/3~60% 38 (16%) 含坏值的样本 43 条,其中 37 条整条都坏。 **丢掉坏字段后只救回 2 条** ⇒ 「部分修复」这条路无效。 ## 根因:为绕开 ollama schema 污染 bug 改用纯提示词约束 schema 本会禁止超长 value;改成提示词后没有强制力。 代价换来的是不再返回上次的输出(见文档 §七)。 修法:value 必须是连续文字 + 长度 ≤ 12 字 + 找不到就跳过。 闸门同步加一条(原来只有「值原样出现」与「值 ≤ 60% 句子」)。 ## 批处理标注省 47% 单条 28 秒 = prompt_eval 14(固定前缀)+ eval 14(与输出长度成正比) 十条一批 → 15.4 秒/条 二十条一批 → 14.7 秒/条 2000 条从 15.6 小时降到 8.2 小时。 批处理的额外风险是模型漏掉序号 ⇒ 要求「必须为每个序号给结果」, 缺的记 missing 而不静默丢弃。 ## 撤回一个错误分类 中途我用正则统计「生产库 72% 记忆不可拆」——**判据错了**: 它只匹配数值/版本/批次,于是把 portal-nginx 容器已 Exited 两周,不是它提供服务 判成不可拆(实际能拆成 (portal-nginx,状态,Exited))。 通用 Agent 的句子几乎都有主体与谓词,几乎都能拆。 ★ 一个正则分类器给出的分布,先怀疑分类器,再相信分布。 * chore(memory): 移除 Qwen3-VL 决策实验(结论已固化在文档) 三个实验跑完,结论写入 docs/zh/vector-provider-decision.md: ① 块级检索 Qwen3-VL 分离度为负(9条 vs 15条 = 0.9882),更差 ② 粗筛有效性 Qwen3-VL 更好,但那是**各向异性**差异,不是模型能力 (chineseclip 均值范数 0.9374,中心化后分离度 7.3 倍) ③ 短文本判别名 词法判别力 0.4500,是 Qwen 的 5.6 倍 最终:纯文本块走 chineseclip + 中心化,多模态块走 Qwen3-VL, 维度名别名用词法。实验脚手架的使命已完成。 * fix(waiter): mag 作用域错误致 cmd/waiter 无法构建 合入 GUI 分支后全量构建失败: cmd/waiter/device.go:743:6: undefined: mag cmd/waiter/device.go:744:13: undefined: mag ## 根因:这是 GUI 分支自带的编译错误,不是合并冲突 引入的提交是 891bb94(feat(gui+waiter): computeruse 补高级操作、 修 HiDPI 坐标),在 origin/feature/gui-starmap-pulse-vendor-align 上单独 checkout 也是同样两行错误 —— 那个分支自己就构建不过。 错因是 Go 的 if init 语句作用域: steps := 1 if mag := dy; mag < 0 { // ← mag 在这里声明 mag = -mag } // ← 作用域到此结束 if mag > 120 { // ← undefined steps = (mag + 119) / 120 } 改成先声明再判断。 ## 验证 go build ./... 通过 go test -short ./cmd/... cmd/homed ok cmd/waiter ok go test -short ./internal/memory/... 全绿 (非 -short 的 TestGold_Baseline 会调真 LLM,当前 ollama 被后台标注 占用而排队超时,与本次修复无关。) * docs(distill): 重标过程的四个坑(全是我的实现问题) ## 坑 1:批处理 12 条一批 → 11/11 全 error qwen3:1.7b 处理不了「给多条记录分别输出」的嵌套结构 ({"results":[{"i":0,...},...]}),输出是一长串 }。 ⇒ 批处理要更大的模型。★ 之前「多示例 vs 单示例」的实测已经证明 它的结构化能力很窄,我应该在写之前就想到。 ## 坑 2:加长提示词让模型失控 版本 B(多条约束 + 换示例)→ 输出 <record> 碎片循环 版本 C(只加一条规则、不动示例)→ 正常 与「加 nonce 后 JSON 截断」同类:**1.7b 的结构化输出对提示词形态极其敏感**。 约束要加,但一次只加一条,逐版本实测。 ## 坑 3:JSON 解析的贪婪匹配 ★ 最隐蔽 re.search(r'\{.*\}', raw, re.S) ← 贪婪到最后一个 } 模型输出「{"fields":[]} …说明… {"fields":[]}」时被当成一个非法对象 ⇒ 100% 判 parse 失败,而原始输出完全正常。 改成「JSONDecodeError 就补闭合符」也不对 —— 错误是 Extra data(尾部多内容)不是缺括号,追加越补越糟。 正确:从每个 { 起点向右逐个 } 枚举,第一个能 loads 成功的就是答案。 ★ 症状看起来像模型崩了,实际是我的解析器太贪心。 **原始输出要先看,再怀疑模型。** ## 坑 4:无值记录会让模型崩溃 「CodeGraph安装任务」(12 字无值)→ 输出碎片循环。 这类句子本来没有可拆字段(零字段是正确答案),送进去只浪费 30 秒。 ⇒ 标注前按「是否含值形态」预筛,无值直接跳过。 这同时补上了批处理省不下来的时间(生产库 429 条里含值形态的只有 20 条)。 ## 预期差异 旧标注 429→149 有字段(34.7%),字段级合规 68%(32% 值超 12 字) 新标注 跳过无值 + 12 字闸门前置 + 解析修好 字段级合规接近 100%,parse 错误基本消失 ★ 关键差别:旧版把超长值记下来再由训练脚本丢弃(浪费标注), 新版在标注时就拦掉(产出即合规),同样时间有效样本更多。 * docs(distill): GUI 合并验证 + 现有数据画像 ## GUI 分支合并验证 go build ./... 通过 go test -short ./cmd/... cmd/homed ok cmd/waiter ok go test -short ./internal/memory/... 全绿 npm test(cmd/gui,9 个 .mjs) 38 项全部通过 ★ 合并后发现那个分支自己就编译不过(cmd/waiter/device.go:743 undefined: mag)—— 是 891bb94 自带的 Go if-init 作用域错误, 不是合并冲突。已修(aa53e2f)。 ## 现有可训练数据画像(76 条可对齐 / 206 字段 / 59 种维度名) 排期 38 · 容量预警 37 · 告警规则 36 · 值班手册 33 ← 前 4 种占 70% 其余 55 种共 62 个字段,其中 48 种只出现 1 次(81%) ★ 81% 的维度名只见过一次是**好事**:它说明任务不能退化成 「记住这 59 个名字」(必过拟合),必须学结构 —— 哪段是属性名、哪段是值、它们怎么相邻。 这与 find_dim_in 的思路一致:属性名可模糊匹配 (告警规则数↔告警规则),所以网络要学的是 「找到那个**可以当属性名的片段**」,不是「输出这个词」。 * fix(gui): build.files 漏掉被 require 的两个模块,打包后启动即崩 ## 问题 main.js 顶层 require 了两个模块: const agentCursor = require("./agent-cursor"); const agentInject = require("./agent-inject"); 而 package.json 的 build.files 只有: ["main.js", "preload.js", "renderer/**/*", "icon.svg", ...] ⇒ 打包产物里**没有** agent-cursor.js / agent-inject.js, GUI 启动时 require 直接抛 MODULE_NOT_FOUND。 ## 为什么单测没抓到 现有 9 个 .mjs 测试测的是**模块本身**(agent-inject 的注入逻辑、 坐标换算等),从源码目录直接 require,不经过打包。 只有「真正打一个包再启动」才会暴露这个缺陷 —— 而 CI 里没有这一步(.github/workflows/ci.yml 改的是别处)。 ★ 这与本会话反复出现的模式同构:判据覆盖的是「实现」, 漏的是「实现被装配起来之后」。 ## 修法 build.files 里补上两个模块(放在 preload.js 之后)。 ## 验证 node package-config.test.mjs 全部通过 npm test(9 个 .mjs) 全部通过(38 项) * docs(distill): 并行三任务导致的资源事故 同时跑训练/标注/GUI 测试,结果 load 33.5(12 核超载 2.8 倍): 489% llama-server 跑了 1 小时 39 分 332% python3(训练)5 分钟还没出 ep10 103% Emulator 安卓模拟器,与本工作无关 ★ 那个 llama-server 是**残留进程**:api/ps 只列出 qwen3:1.7b, 但 ps 显示还有一个 llama-server 占 489% —— 对应的是上一轮已卸载的 模型(ollama keep_alive 到期后没回收进程)。 停掉后 load 33.7 → 28.3,训练立刻出结果(ep10 F1 0.695)。 标注重标 30 分钟只完成 1 条,也是被它抢的。 ## 教训 **并行不等于提速**:三个 CPU 密集任务在 12 核机上,每个都慢到不可用。 要么串行,要么保证同一时刻只有一个 CPU 密集任务。 **残留进程比崩溃的进程更难查**:api/ps 与 ps 两个视角不一致, 只看 API 会漏掉占用。 * docs(distill): 修正归因 —— 高负载主因不是并行任务 停掉残留 llama-server 后 load 只从 33.7 降到 28.3(12 核)。 继续查占用者: 359% python3 ← 我的训练,正常 173% python3 ← Emulator 子进程 103% Emulator ← 安卓模拟器,已跑 1:47 84% node ★ 这台机器长期跑着安卓模拟器等非本项目服务,它们才是 load 26 的主因。 并行三个任务只是雪上加霜。 ## 教训 归因要查到占用者本人。「load 高」有很多可能(我的任务/别人的服务/ 残留进程/IO 等待),我先归因为「并行三个任务」是过早下结论。 ★ 本会话第三次「先归因后核实」: ① 「模型随机性」→ 实际是 ollama schema 污染 bug ② 「72% 记忆不可拆」→ 实际是我的正则判据错 ③ 「并行三任务导致高负载」→ 实际主因是机器上别人的服务 * docs(distill): 判据 4 失败 —— 网络 42% < 查表 71% 76 条训练 80 epoch,留出曲线从 ep10 起横盘在 0.70, 而训练损失降到 0.022 —— 典型过拟合,泛化不再改善。 ## 判据 4 结果 规则 + 查表 字段级 F1 71.0% ← 门槛 网络 + 规则 字段级 F1 42.0% P 0.452 R 0.393 词法切分 字段级 25% 纯网络(推理实例) 几乎为 0(输出「采样=.」、零字段) 网络产出 303 个字段、约一半是误报;把句点标成值,把 4324 整个漏掉。 连「数字是值」这个最基本先验都没学到(训练集里数字开头的值有 121 个)。 ## ★ 但不能据此说「注意力学不了版式」 76 条训不出东西,和 2000 条训不出东西,是两回事。 诚实表述是「数据量不足以判断架构」。 要拿到答案需要 500~2000 条,即 CPU 单条 28 秒 → 15.6 小时 (批处理对 1.7b 不可行,它处理不了嵌套结构)。 ## 下一步选择 A 继续标注到 2000 条 15.6 小时 拿到架构的确定答案 B 放弃网络走规则+查表 0 停在 71%,剩 29% 需人工 C 换更大的标注模型 未知 批处理可行时省 47% 倾向 A+C:本地已有 qwen3-vl:2b,结构化能力比 1.7b 强, 若批处理可行则 2000 条降到 8 小时以内。 * feat(distill): 逐字段挽救 —— 坏字段不拖累好字段(+57% 样本) ## 问题 三道闸门是**整条**丢弃:任一字段不过闸,整条样本作废。 生产库 149 条有字段的样本 严格闸门 → 35 条可用 逐字段挽救 → 55 条可用 (+57%) 而实测的坏字段有明确形态 ——「值 = 整句」(占 18%): 版本 = 115批周二凌晨2点·停机6分·回滚v2.28.1·灰度5%观察63分后直放100% 丢掉这个坏字段,**同一条样本里的其他字段往往都是好的**: 「AgentMail 邮件通道插件 v0.1.0」 ✘ 插件名称 = AgentMail 邮件通道插件 v0.1.0 ← 整句当值 ✓ 插件版本 = v0.1.0 ← 保留 ⇒ 闸门应该是**逐字段**的,不是逐样本的。 ## ★ 与早前那个「部分修复无效」的区分 早前测过「丢弃整条里的坏字段后救回 2 条」,当时结论是部分修复无效。 **那不是同一件事**: 早前:在**已经丢掉的 146 条**里再救 → 只救回 2 条 现在:在**还没丢的样本**里逐字段救 → 35 → 55 条 前者是「事后翻垃圾堆」,后者是「一开始就别整条扔」。后者有效得多。 ★ 两个结论看似矛盾,差别只在**动作发生的时机**。 ## 合并两个数据源 ha-c salvage 67 条 生产库 salvage 55 条 合计 122 条 / 262 字段 (此前只有 76 条) 122 条全部通过严格闸门 —— 说明 salvage 保留下来的都干净。 已启动 122 条的重训,验证数据量 +60% 能否改善(76 条时 F1 0.42 < 门槛 0.71)。 * docs(distill): 批处理标注确认不可行 两种提示词 × 两个模型,全部失败: qwen3-vl:2b 简明格式 53s 输出 0 字 qwen3:1.7b 简明格式 421s 「2. 2024-04-15 15:00:00」重复 13 次 qwen3-vl:2b JSON 数组 90s 输出 0 字 qwen3:1.7b JSON 数组 — 输出一长串 } ★ 不是提示词问题(两种格式同样结果)。1.7b 的结构化输出上限就是 单个对象,给它多条记录的数组它会陷入循环;2B 直接不输出。 ⇒ 批处理这条路断了,2000 条只能逐条标(15.6 小时,无法再压缩)。 连带推论:既然本机两个可用模型都无法可靠产出结构化结果, 「用 LLM 生成训练数据」这条路的天花板就在这里 —— 除非换 API 模型或更大的本地模型。 这改变了 A 方案的性价比:2000 条 = 15.6 小时,且不能再压缩。 * docs(distill): 数据量翻倍反而更差 —— 推翻「数据不够」 76 条(纯运维域) 最佳 F1 0.704(ep30) 122 条(运维+通用混合) 最佳 F1 0.637(ep10,之后降到 0.612) 门槛(规则+查表) 0.710 加了 46 条数据,F1 反而掉 0.067。 ## 根因:两个域的分布互相冲突 ha-c(运维域) 67 条 10 种维度名 179 字段 只1次 4 (36%) 生产库(通用域) 55 条 78 种维度名 83 字段 只1次 73 (94%) ★ 合并后模型面对两个矛盾分布: - 运维域高度集中 ⇒ 记住「排期/容量预警」就有 179 个字段 - 通用域 94% 是新维度名 ⇒ 必须靠泛化 模型被撕裂:学运维域就在背词汇(到通用域崩), 学通用域要泛化(运维域那 179 个密集样本又把它拽回记忆)。 ⇒ **混合两个分布差异巨大的域,比只用一个域更糟。** 本轮最反直觉的实测结论。 ## 修正方向(不是「继续标更多数据」) 1. 分域建模:每域一个模型,或用域标记做条件 2. 或只用运维域 67 条先走通 3. 或改任务形式:71% 的查表基线只需要「给已知的值配维度名」, 降级成二分类,数据效率高一个数量级(122 条 → 262 个字段对) 已启动分域训练验证这个判断。 * docs(distill): 判据 4 最终 —— 分域超门槛但不会泛化 67 条(只运维域) token 0.918 76 条(运维+少量通用) token 0.704 122 条(运维+通用各半) token 0.637 门槛(规则+查表) 0.710 ## 混域是主因确认(0.637 → 0.918) ## 但两个数字要一起看 训练域内(第183批形态):5 条全对 域外(分机号):零字段 ★ token F1 0.918 是**训练域内**的分数(同分布留出), 字段 F1 0.480 是**跨域**的分数。 网络在见过的分布上优异,但**不会泛化到没见过的形态**。 与「81% 的维度名只出现一次」一致 —— 在同分布下那些名字仍靠记忆而非泛化。 ## 这条路的天花板摸到了 | 判据 | 结果 | |---|---| | 分域后超过 71%? | 是 | | 跨域泛化? | **否** | | 实际可用 | 运维域内可用,通用域不可用 | 而 HomeAgent 是通用 Agent,生产库 72% 是不属于任何单一域的叙述句 —— 「按域训模型」对通用场景**不成立**:域划分不出来, 而混域会互相撕裂(0.918 → 0.637)。 ## 最终判断:网络路线否决 | 方案 | 结论 | |---|---| | 注意力网络替代 LLM | 域内可行域外不可行,通用 Agent 无法分域 ⇒ **不成立** | | 规则 + 查表(71%) | 仍是最优,剩 29% 要人工 | ⇒ **回到规则路线**,但攻「文本」形态那个 29% 缺口 (42 种属性名、纯度 7%),而不是继续训网络。 * docs(distill): 攻文本形态缺口也不可行 —— 两条路都否决 ## 扩充形态后的可学性 ✓ 计数+单位 38 字段 2 种属性名 纯度 95% ✗ 中文 117 81 22% ← 最大缺口 ✗ 英文 34 31 9% ✗ 混合 88 32 39% ★ 扩充形态确实拆开了一些(「文本」里的路径/版本/计数被分出), 但可学部分(纯度≥80% 且样本≥5)只覆盖 **11%** —— 比扩充前更悲观。 ## 真正的缺口 「中文」形态 117 字段 / 81 种属性名 / 纯度 22%: 插件名称 = 从零开发 HomeAgent QQ 插件 检测类型 = 末位数字检测、本福特定律、… 参数校验 = 未校验且跳过密码验证 纯度 22% 意味着「值形态」这个特征携带不了足够信息。 ⇒ 29% 的缺口靠值形态攻不动,它需要真正的语义理解。 ## 两条路都否决 训注意力网络 域内 0.918 不泛化,通用 Agent 无法分域 精修值形态规则 可学部分只覆盖 11% ## 这一路的最终账 词法切分 25% ✅ 生产兜底路径 规则 + 查表 71% ✅ 当前最优 注意力网络(同域) 92% ⚠️ 只在单一域内 注意力网络(通用) 48% ❌ LLM 拆分 100% ❌ 28s/条 + 波动 + 本机 schema bug ★ 收益最高的一次改动是中心化(端到端 5/7 → 6/7), 不是这一路的任何模型尝试。 ⇒ 记忆侧下一步应回到**检索质量**(值覆盖仍 1/2), LLM 拆分保留作为**离线批处理**手段,不进在线路径。 顺带修 probe_attr_name.py 里 import 指向旧文件名(train → train_tagger), 该脚本在提交后一直无法直接运行。 * fix(memory): 仲裁接入生产路径 + 整句块参与取代 —— 端到端 7/7 端到端探针:仲裁前 6/7 → **仲裁后 7/7**,overwrite 维度 1/2 → 2/2。 ## 1. 仲裁接入 recallByBlocks 之前 `RecallBlocksWithArbitration` 写完但生产路径仍调 `RecallBlocks` (仲裁是死代码)。改走仲裁入口,被取代的块记一笔日志而不是静默 —— 「旧号 4379 停用」本身有信息,它解释了为什么现在打不通。 ## 2. 探针判据的缺陷(先改判据,暴露真缺陷) 原判据「notWant 不该排在 want 前面」。但真库里**新号记录本身含旧号字样**: 「下周起值班室分机号改为 4324,旧号 4379 停用」 created 13:44(新) 「值班室分机号 4379,值班人 阿李」 created 13:28(旧) 这正是值覆盖维度**该有的形态**(一条记录同时提到新旧两个值)。 原判据把「新号记录」判成「旧号排在前面」→ 误判失败。 改成按时间语义判,并**顺手暴露了真实缺陷**:修完判据后仍失败, 实际排序是旧号 4379 排在 top1 —— 那是真的召回缺陷,不是判据问题。 ## 3. 根因:整句块被无条件跳过仲裁 仲裁只处理能解析出「主语|维度=值」的块,而 legacy-entity 迁移块 是**整句**,解析不出就被无条件保留 ⇒ 旧号永远留在候选里。 ★ 「解析不出主语」≠「不能判断谁取代了谁」。 新增 `supersedesSentence`:取代判断只需「更晚 + 提到了更早那条的关键值」, 不需要维度名。正好命中值覆盖场景的必然形态 —— 「新号生效、旧号作废」这句话必然同时含新旧两个值。 ## 4. 防误伤 `TestSupersedesSentence` 里反向用例:later 不提旧值 ⇒ 不判取代 (否则会误伤并存的真实值,如 admin 8080 / billing 9090)。 `TestArbitrate_整句块的取代` 验证不同属性的两条都保留。 ## 5. 一条旧断言被推翻 `TestArbitrate_整句块不仲裁` 原断言「整句块不可仲裁全部保留」—— **这正是要修的行为**,已按新行为更新并记录推翻理由。 这类推翻必须留痕,否则后人会以为新行为是 bug。 回归:./internal/memory/... ./internal/agent/core/... 两次全绿 (首次 FAIL 是 media 包 flake,该包独立跑 62 秒,与本改动无关) * feat(memory): 生产规模探针 + 一次重要的探针设计缺陷 ## 探针 PASS 了,但结论是假的 生产库快照(1294 entities / 980 relations / 66 sentences / 98 blocks): 快照规模: 98 块(带向量 97, 99%) [casual] 插件目录 ✘ | | | | | | | [overwrite] 值班分机 ✘ | | | | | | | 合计 0/0 --- PASS (4.29s) ★ **`--- PASS` 而 `合计 0/0`** —— 探针全挂却 PASS。 ## 根因:生产库的块是图像块,不是文本块 memory_blocks: 98 条,modality 全是 image,text_content 非空 0/98 sentences: 66 条,text 非空 66/66 ← 真正的文本在这 text_content 空是**正确的**(图像块本来没文本),而探针判据读 `h.Block.Text` ⇒ 全空 ⇒ 全判失败。 ★ 探针没坏,它测 block 召回,而生产侧 block 是 98 个图像块 —— **判据描述的状态在生产库上不存在**(判据不可达教训的又一次复现)。 ## 生产侧的正确顺序(尚未执行) 1. homed-graph-migrate 1294 entities → 带文本+向量的 block 2. homed-graph-distill 66 sentences → 拆分块 3. 然后本探针才有意义 ## 生产库未被触碰 生产: inode=16684491 1994752 字节 mtime 05:21:22(与开始时一致) 快照: inode=6815957 用 sqlite3 .backup 而非 cp(库在写,cp 拿不到一致快照)。 ## ★ 必须记的教训:探针自己会「PASS」 **探针全挂 ≠ 测试失败**,t.Logf 不改变退出码。今后所有探针必须有一条硬断言: if totP == 0 || totT == 0 { t.Fatalf("探针未产生任何有效判定(分母为 %d)—— 前提不满足,不是通过", totT) } 「0/0」被打印成 PASS 比红更危险 —— **红会让人去查,绿不会**。 * fix(migrate): 报告数与实际写入数口径不一致(966 vs 980) 生产库快照实测: 报告 块边 966 实际 块边 980 ← 差 14 条 ## 根因:两处口径不同 报告 legacyStats → Introspect() SELECT COUNT(*) FROM relations WHERE status = 'active' 迁移 MigrateLegacyTextEntities SELECT ... FROM relations ORDER BY id ← 不过滤 status 迁移按全表转边,报告只数 active,于是报告数偏少。 **用户会把「预计产出」当承诺**,差 14 条就说明它不可信。 ## 修法 Introspect 改数全表(与迁移同口径),并在报告里显式说明 「孤儿关系会被跳过,实际可能略少」—— 保留真实的不确定性, 而不是假装精确。 ## 顺带:生产快照迁移结果 块 98 → 1392(+1294 实体块,全部带向量) 块边 97 → 2350(+980 关系边) 耗时 104 秒 自动快照 prod.db.bak-20261003-222649 带文本块 1294/1392(另 98 个是 image 块,无文本是正确的) ## 探针硬断言 给 probe_prod_test.go 加了一条: if totT == 0 || totP == 0 { t.Fatalf("探针未产生任何有效判定(通过 %d / 总数 %d)—— 前提不满足,不是通过", ...) } 防的不是「探针失败」,而是**探针什么都没做** —— 「0 条通过 / 0 条判定」不满足 `failures > 0`,会被框架报成 PASS。 * fix(probe): 期望值是编造的 —— 4/6 的 want 在生产库中不存在 ## 第二个探针缺陷 迁移后重跑前核对每个 want 是否在库里: 4324 → 0 块 老周 → 0 块 9090 → 0 块 8080 → 0 块 plugins → 7 graph.db → 1 ★ 六个 want 里四个在生产库根本不存在 —— 从 ha-c 测试库抄来的。 探针会全判失败。 ## 与「假 PASS」是同一类错误的两面 假 PASS 什么都没判定却报成功 (分母 0) 编造期望 判据描述的状态在库里不存在 (期望值抄来的) ★ 两者都是判据与实际数据脱节,只是方向相反。 ⇒ **探针的期望值必须从被测库里取**,核对应是前置步骤而非跑完看分数。 ## 已按生产库真实数据重写(10 条) casual / coexist / confusable / abstention 各若干, abstention 3 条(grafana / 容灾演练 / kafka,库里没有)。 ★ 生产库的端口是 **13010/13011 并存**而非新旧覆盖, 所以 overwrite 维度在生产数据上不适用,改成验证「不误判」。 强行套用 ha-c 的覆盖场景就是又一次判据不可达。 ## 操作纪律:又犯了一次 蒸馏用 | tail -12 管道跑,tail 要等进程结束才吐, 66 条 × 28 秒的活儿看起来「零输出 0% CPU」,我误判成卡死。 代码里明明有进度输出,memory 里那条约束也写得很清楚。 ⇒ **约束记住了,动手时没查。** 正确做法:> file.log,不用管道。 * docs(probe): 生产规模召回 3/10 —— 根因是分数无区分度 + 全编造 硬断言生效(--- FAIL 而非假 PASS)。10 条探针真实结果: [casual] 2/4 [confusable] 1/1 [abstention] 0/3 ← 三个不存在的查询全部召回高分块 ★ 全部 want 值已核对确实在库里(1~11 块命中)—— 这次不是编造期望,是真的召回不到。 ## 根因:分数挤成一团 查询「本机 13010 端口对应什么」→ 8 命中 1. 0.9006 本机 443 按 SNI 透传到 192.168.2.106:3080 2. 0.8712 ACP回环调用自身12001 3. 0.8611 MAR/MDR与ALU不直通必须经CPU内部总线 ... ★ 目标块「13010/13011 而非 12011」连 top2000 都没进 全部 8 条挤在 0.84~0.90。目标块含精确串 13010, 词法上是最强信号,向量给不出区分 —— **chineseclip 在长尾中文实体名上失效**,不是中心化没做对。 ## ★ ha-c 的 7/7 不能推广 ha-c 448 块,运维域集中(排期/容量预警/告警规则) 查询与块词汇重叠高 → 7/7 生产 1391 块,横跨全部业务域(AgentMail/CPU总线/GUI/gateway) 查询落在长尾 → 3/10 **7/7 是小库+同质分布的产物,不是召回质量好的证据。** 「金标准必须覆盖输入分布」的又一次复现 —— 我拿了小库的结论当大库的预期。 ## abstention 0/3 = 纯编造 「grafana 监控面板的端口」→ CPU总线 / MAR-MDR / 本机443 「kafka 的 broker 地址」 → read_thread / sdk站 / 跨实现校验 库里没有任何相关块,向量仍给 0.84+ ⇒ 纯编造。 **不是召回不准,而是系统对「不知道」毫无表达能力。** ## 结论:生产规模下召回不可用 casual 2/4 confusable 1/1 abstention 0/3 ⇒ 1. 区分度:分数挤在 0.84~0.90,长尾实体名召不回 2. 拒答能力:0/3 编造 —— 相似度不够就该说没有 第 2 条更紧急:不是召回不准,而是**给用户错误答案**。 * fix(memory): 中心向量生产路径无调用者 —— 区分度缺失的根因 生产快照实测(1294 实体 / 1391 块)召回分数挤成一团: 查询「本机 13010 端口对应什么」→ 8 命中 1. 0.9006 本机 443 按 SNI 透传到 192.168.2.106:3080 2. 0.8712 ACP回环调用自身12001 3. 0.8611 MAR/MDR与ALU不直通必须经CPU内部总线 ... 8. 0.8405 无工具插件属正常 ★ 目标块(含精确串 13010)连 top2000 都没进 ## 根因:RebuildCentroid 只有测试在调用 `graph_centroid` 表在生产快照里**根本不存在** —— ha-c 的那一行是手工测���时留下的,生产侧从来没有过。 `grep RebuildCentroid --include='*.go'` 只命中 centroid_store.go 与 centroid_store_test.go:**生产路径零调用者**。 召回会 LoadCentroid → 查不到 → 跳过校正 → 各向异性原样进入打分。 实测建中心后: 均值向量长度 0.8041,平均两两余弦 0.6466,anomalous=true 样本 1391,维度 512 ★ **平均两两余弦 0.6466** 就是「分数挤在 0.84~0.90」的直接解释: 任意两个块的相似度基线就有 0.65。 ## 三处修复 1. **homed-graph-migrate**:迁移后自动重建中心 (迁移刚写完 1294 个带向量块,此时不建,之后没有任何路径会建) 2. **homed-graph-distill**:蒸馏后自动重建中心 理由不止「块集变了」:新蒸馏的是**拆分块**(<主语>|<维度>=<值>), 与整句块分布不同,混在一个中心里会把中心拉偏,反而削弱区分度。 3. **新增 cmd/homed-centroid**:独立入口,覆盖自动重建管不到的情况 —— 向量回填后(回填不重建中心)、换 provider(指纹变了旧中心失效 但没人建新的)、库增长后中心过期。 配套新增 GraphDB.DominantBlockFingerprint():运维不该去翻配置 找那串 hex,让命令自己取库内出现最多的指纹并打印出来。 ## 一个模式:功能写完但没有调用者 `RebuildCentroid` 有实现、有测试、有持久化、有召回侧消费, 唯独没有生产调用者 —— 测试覆盖「函数能工作」, 但没有任何判据检查「**它被调用了吗**」。 ★ 同样的形状之前出现过:`Distiller.SetEmbedFunc` 写完没在 bootstrap 接线。 判据该加一条:**每个有持久化副作用的能力,都要有「何时被调用」的判据**, 而不是只有「它工作吗」的判据。 * fix(probe): 计数 map 值拷贝未写回 —— 「合计 0/0」的真实原因 ## 第三个 bug st := byDim[p.dim]; st[1]++ ← 数组是值语义 byDim[p.dim] = st ← 缺失 汇总时 st[1]==0 全部 continue ⇒ 打印「合计 0/0」。 ★ 我一开始误判成「前提不满足」,只想到「真的没判定」, 没想到「判定了但没记进去」。这个 bug 藏了两次重跑。 ## 修正后的真实分数 casual 1/4 coexist 0/1 confusable 1/1 abstention 0/3 合计 2/9 (coexist 也失败 ⇒ 连「不误判」都做不到) ## 区分度问题的三层根因 1. **各向异性**(已修)RebuildCentroid 无生产调用者, 平均两两余弦 0.6466 = 分数挤在 0.84~0.90 的直接原因 2. **精确串被稀释**(未解)查询「本机 13010 端口」: 召回 「13000端口」(7字)、「本地网关8081」(8字) 没召回 「13010/13011 而非 12011」(20字,含精确串 13010) ★ 短文本反而召回,含精确串的反而不召回 —— chineseclip 句向量把 13010 稀释在 20 字里。 3. **符号路无补位**(架构问题)词法 LIKE '%13010%' → 2 块命中(正确), 向量 top8 → 0 块。而 memory_recall 是「块命中优先、符号 fallback」, 不是融合排序 —— **向量的弱让符号路完全没有补位机会**。 ⇒ 单纯调向量(换模型/调中心化/调 topK)解决不了, 必须做真正的向量+符号融合排序。 * docs(probe): 拒答阈值的可分性测量 —— 可分但不可信 ## 测量:真实查询 vs 编造查询的 top1 分数 真实 0.6731 / 0.6390 / 0.8041 / 0.6435 最低 0.6390 编造 0.4320 / 0.5231 / 0.5890 / 0.5923 最高 0.5923 间隔 0.047,阈值 0.6157 → 形式上「可分」 ## ★ 但这个可分不可信 n=4 的可分性不是可分性。0.6390 与 0.5923 分别是「4 个里最差的」, 任一侧多一个样本间隔就可能变负。 真实使用里「库里没有」的分布远不止这 4 个(sentry 告警、 postgres 主库、某人负责哪个模块…),kafka broker 已达 0.5890。 ⇒ **不能据此加阈值上线。** 需要 50+ 编造样本,且测 ROC/假阳率, 而不是「4/4 分开了」。 ## 顺带:符号路优先级的意图对,但没生效 toolcall.go:236 的注释写着「端口号、分机号这类纯数字串向量天然弱, 两条路都跑,块向量在前,符号路兜底」。 ★ 设计意图完全正确,而实测证明没生效 —— 「块向量在前」意味着块一旦有命中就直接 return, 符号路的 RecallSorted 根本没被调用。 ⇒ 要做的不是改设计,而是让它真正融合:两路都跑、按统一分数重排, 而不是「谁先返回听谁的」。 判据 zz_sep_test.go 保留:它是这个决定的可复现依据。 * feat(memory): 向量+符号融合召回接入生产路径 —— 2/9 → 3/9 ## 接入时改的两处 1. **MinScore 0.5 → 0**:融合分数量纲变了 纯向量 余弦序,0.5 是实测阈值(无关句也能到 0.83) 融合 精确串命中 1.0(布尔置顶) 向量加权 0.7×余弦 ⇒ 典型 0.35~0.45 沿用 0.5 会**把所有纯向量候选筛掉**,反而丢向量侧的有效召回。 2. **输出标签改为召回来源** [exact]/[symbol]/[text]: 模型需要知道为什么这条被召回,也是日后排查误召回的唯一线索。 ## 结果 casual 2/4 coexist 0/1 confusable 1/1 abstention 0/3 合计 3/9(纯向量基线 2/9),ha-c 端到端保持 7/7 无回退 ★ 归因统计证明融合在起作用: [casual] 本机端口 ✓ 13010/13011 而非 12011 [+符号 2: 精确2] 纯向量下这条连 top2000 都进不去,现在排第一 ## ★ 但收益范围必须说清楚 融合只救了「查询含精确数字串」这一类: 含精确串的探针(13010/13011) ✅ 救回并置顶 「脚本路径改到哪个目录」 ❌ 无改善 「agentmail 公网访问地址」 ❌ 无改善 「本机服务监听哪些端口」 ❌ 无改善(无串可提) ★ 后两类查询里没有可提取的符号 —— 、 只存在于**答案**里,不在查询里。 这类「同义改写」符号路天然无能,只能靠更强的向量。 ⇒ **融合解决「精确串被稀释」,不解决「语义不匹配」。** 3/9 里的 3 分全部来自精确串类探针。 ## abstention 仍 0/3,融合帮不上 编造查询的共同特征正是「库里没有、查询里也没有对应符号」, 符号路对这类查询天然给 0 分,与向量路同样无能为力。 ⇒ 编造需要**独立的第三种机制**(如「top1 分数+跨度」联合判据, 或显式检查库里是否存在该符号),不是继续调融合能解决的。 ## 实现中修的一个 bug 符号路阶段会 append(向量未召回但符号命中的块),append 触发扩容后 切片换了底层数组,事先存的 &out[i] 指针全部指向旧数组 —— 给已有候选写 SymbolHit 时写进了废弃副本,分数永远算不出来。 改用索引而非指针。判据直接抓到(目标块符号分 0.333 而非满分)。 * feat(symbol): 中文符号滑窗切分 + 拒答判据成立(生产 3/9 → 4/9) ## 中文切分:符号路由「全失效」到「可用」 贪婪长串匹配把整句吃成**一个**符号: 「grafana 监控面板的端口是多少」 → [grafana 监控面板的端口是多少] 「脚本路径改到哪个目录了」 → [脚本路径改到哪个目录了] 任何块都不可能包含整句 ⇒ 符号分恒为 0 ⇒ **符号路对所有纯中文查询完全失效**(这是上轮融合只拿 3/9 的原因之一)。 改成 2~4 字滑窗 + 泛词黑名单 + 尾部虚词剥离: 「谁负责数据库容灾演练」 → [谁负责数 据库容灾 演练] 「脚本路径改到哪个目录了」 → [脚本路径 改到哪个] ★ 不用 jieba 的理由已写进注释:jieba 的词边界与块文本对不齐 (memory 里记过「把服务/端口当过滤词造成噪音」)。 ## 结果:3/9 → 4/9 「脚本路径」探针从 ✘ 变 ✓(之前必然失败)。 ha-c 端到端保持 7/7,7 条融合判据全绿(含变异自证)。 ## 顺带得到拒答判据 真实 0.50 / 0.33 / 0.33 / 0.67 全部 > 0 编造 0.00 × 6 全部 = 0 **完全分离**,而上一轮「分数阈值可分」是 0.047 间隔的运气。 差别在于这是**确定性事实**(strings.Contains),无浮点抖动, 且新增样本(sentry / postgres)依然全 0。 ⇒ 「查询符号在库中零出现」可安全拒答。 ## 局限(必须承认) 符号率 0 ≠ 库里没信息:泛指词查询(「那个跑得久的任务」) 会因符号全零被误伤。所以拒答只在「提取有效」时可信 —— 含泛指词(那个/这个/之前)时不应拒答。 详见 docs/zh/abstention-criterion.md * feat(memory): 拒答判据接入图库层 —— 并诚实收窄到「仅精确串」 ## 拒答为什么下沉到图库层 第一版放在 `internal/agent/core/toolcall.go`(recallByBlocks 召回前), 结果: 生产路径 core → AbstainCheck → RecallBlocksFused ✔ 有拒答 探针路径 probe_prod_test → RecallBlocksFused ✗ 绕过了 探针跑出 abstention 0/3 时,生产路径其实是有拒答的,**但没人能证明它**。 ⇒ 「判据测不到被测路径 ⇒ 判据等于不存在」。已改为 RecallBlocksGuarded。 ## ★ 中间跑出过 7/9,但那是靠误拒换来的 下沉层后探针一度到 7/9(abstention 3/3),代价是: [coexist] 本机服务监听哪些端口 → 误拒答 库里明明有 13010/13011/本地网关8081 ⇒ 那是**拿真实查询的正确性换编造的分数**,不能算成果。 已收窄判据,7/9 不再出现。 ## ★ 判据的能力边界(实测得出,写进代码注释) 「符号零命中 ⇒ 拒答」**无法区分**这两种查询 —— 符号形态完全一样,都是滑窗切出的跨词边界伪词: 「grafana 监控面板的端口是多少」 → [grafana 监控面板] 库里真没有 「本机服务监听哪些端口」 → [本机服务 监听哪些] 库里有 13010/8081 库规模也不是区分信号(3 块库与 1391 块库结果相同,实测过)。 ⇒ **只有查询含精确数字串/版本串时才敢拒答**: 精确串零命中的含义明确(那个串库里确实没有); 纯中文零命中分不清「真没有」与「提取失败」。 ## 第二个判据错误:覆盖率阈值 先按「全部符号命中才不拒答」实现,结果: 「本机 13010 服务」在库里只有 8081 时被误拒 ——「本机」命中、「13010」没命中,覆盖率 0.33 < 1.0 但 **13010 才是决定性的那个符号**。改为只看精确串是否命中: 精确串的语义是「存在与否」,而中文片段的覆盖率没有判据价值。 ## 最终判据 拒答 ⟺ 查询含精确串 ∧ 该精确串在全库零命中 豁免 ⟺ 含泛指词(那个/这个/之前…)∨ 无可提取符号 ## 净效果:生产探针 4/9(未变),但编造从「未处理」变成「已知边界」 casual 3/4 coexist 0/1 confusable 1/1 abstention 0/3 ★ abstention 仍是 0/3 —— 三条编造探针全是纯中文。 这是**如实反映判据能力**,不是没做。 ## 与「给用户错误答案」的关系 拒答在含精确串的查询上已生效(如「grafana 用的 3000 端口」 这类查询会正确拒答)。纯中文编造仍是缺口。 ⇒ 补它需要**能分词的中文符号提取**(jieba 或语料词典), 而不是调阈值 —— 滑窗伪词是提取能力的上限,不是阈值能解决的。 jieba 的问题是词边界与块文本对不齐(memory 里有记录), 所以这条路需要新实验。 * fix(memory): 三个回归 —— 关系计数语义分离 / MinScore 按路应用 / 符号路指纹过滤 ## 1. Introspect 的两种语义被我合并了(TestPurgeSoft 从绿变红) `761c1a8` 为对齐迁移口径(报告 966 vs 实际 980),把 `relation_count` 从「活跃数」改成「全表数」—— 结果 Purge("soft") 的断言失效: graph_test.go:323 expected 0 active relations after soft-delete, got 1 ★ 那个修改是错的:`relation_count` 是**通用统计字段**, 为了让一个报告数字准确,破坏了一个真实功能的断言。 正确修法是**两处口径分开**: relation_count 活跃数(status='active') TestPurgeSoft 依赖 relations_total 全表数 迁移报告依赖 新增 TestIntrospect_关系计数语义分离 钉住它,含「软删除后全表数不变」 (status 只是标记,不是物理删除)。 ## 2. ★ 融合接入时把 MinScore 传 0 —— 直接放弃了噪声过滤 接入融合时我判断「量纲变了」⇒ MinScore 传 0。结果两条判据立刻红: toolcall_block_test.go:80 低分块不该出现(MinScore 应滤掉) toolcall_block_test.go:124 指纹不匹配的块不该被召回 ★ 「量纲变了」是事实,但解法不是丢掉阈值,而是**让阈值作用在它该作用的 那一路上**。融合分有三段量纲,MinScore 只管纯向量那一路: 精确串命中 ≥1.00 布尔置顶,不过滤 纯符号命中 0 ~ w.Symbol 字面强信号 纯向量命中 0 ~ w.Vector×余弦 ← MinScore 说的就是这个 ## 3. ★★ 符号路绕过了向量空间隔离(安全缺陷) 块 {Text: "旧空间的内容", Fingerprint: "old-fp"} 查询「旧空间的内容」 ⇒ 不该召回,实际召回了 根因:符号路拿的是 `MemoryBlocks()` 的**全部**带文本块, 而 RecallBlocks 按 fingerprint 跳过不匹配块。 文本匹配不依赖向量空间 ⇒ **「旧空间的块」在符号路原形毕露** —— 换向量空间后旧块仍污染结果,正是那条判据要防的事。 ⇒ 我加的符号路差点变成向量空间隔离的后门。已按 fingerprint 过滤。 ## 验证 ./internal/memory/... ./internal/agent/core/... 9 包全绿 ha-c 端到端 7/7(无回退) 生产探针 4/9(无变化) ★ 第 3 条说明:**新增一路召回就要继承它的所有约束**。 我只想着「符号路补位」,没想到「符号路也必须遵守空间隔离」。 * test(gui): 打包 smoke test —— 真打包 + 解析 require 图 + 无头启动 ## 为什么需要它 上一次 GUI 合并(9a6b3da)的故障是 build.files 漏了 agent-inject.js 与 agent-cursor.js,**源码测试 38 项全绿**, 但打包产物缺模块,桌面模式启动即崩。 源码测试测「文件存在」,打包测「**产物里有**」。 只有后者能发现这类问题。 ## 三步 1. electron-builder --dir 真打包 2. 从 main.js/preload.js **递归解析 require 图**,逐个确认在产物里 3. xvfb 下无头真启动,检查日志里有无模块缺失特征 ## ★ 关键:解析 require 图而不是手写清单 手写清单只能发现「清单写了没打包」,发现不了 「代码 require 了但清单忘了写」—— 而后者才是上次实际故障。 递归解析出来的才是真正需要的集合。 ## ★ 判据自己踩了三次坑,都已修 **1. 查错了地方(6 项全红而产品是好的)** asar 默认开启,代码封在 app.asar 里而非 app/ 目录。 而二进制实际存活 25 秒、日志无模块缺失。 ⇒ **判据红 ≠ 产品坏**,要先确认红的是产品还是判据。 现在先探测形态(asar / 目录)再校验。 **2. require 解析没遵守 Node 规则(3 项误红)** require('./agent-cursor') 解析成绝对路径且无扩展名, 而产物里是 agent-cursor.js。Node 的规则是: 原样 → 补 .js → 补 .json → 当目录找 index.js。 ⇒ 判据自己得先遵守被测系统的规则。 **3. 路径前缀不一致(4 项误红)** manifest 里是 /main.js,静态清单传的是 main.js。 in_app 统一归一化加前导斜杠。 ## 实测结果(变异前) asar 可读(107 条目) require → main.js / preload.js / agent-cursor.js / agent-inject.js ✓ vendor: OrbitControls / marked / purify / three ✓ 进程存活 25s,日志无模块缺失特征 smoke 通过 注:启动步骤 timeout=124 视为通过(GUI 本该常驻)。 变异自证(移除 agent-cursor.js 后判红)正在跑。 * test(gui): smoke 判据双向自证通过 + 接入 npm run test-package ## 变异自证(判据能红) 从 build.files 移除 agent-cursor.js 后重打包 ✘ 缺 /agent-cursor.js(被 require 但没打包) ✘ 日志里有模块缺失(这正是上次漏模块的形态) smoke 失败:2 项 ★ 其中第二条来自**无头启动步骤的真实崩溃** —— 不是靠文件比对推断出来的,而是真的跑起来看日志。 恢复正常后完整通过(exit=0): asar 可读(107 条目) require → main.js / preload.js / agent-cursor.js / agent-inject.js vendor: OrbitControls / marked / purify / three 进程存活 25s,日志无模块缺失特征 ## 一个顺带的验证 变异产物还留在 dist/ 时,直接跑 smoke(--no-build)立刻报出 「asar 可读(106 条目)… 缺 agent-cursor.js」—— ★ 判据能发现**产物与配置不一致**,不只是配置本身对不对。 ## 接入 npm npm run test-package # 打包 + 校验 + 启动 npm run test-package -- --no-build # 复用已有产物 不并入 npm test —— 打包耗时数分钟,而 test 是秒级单测, 混在一起会让日常回归变得不可用。 * feat(memory): 原句改用块承载 —— 方案 A 的最后依赖点已解除 ## 改动 `WritePayload` 不再调 `EnsureSentence`: 之前 sentence --contains--> block (sentences 表的行做父节点) 现在 原句块 --contains--> 字段块 (blk_src_<hash>) `sentences` 表的最后一个写入点(blocks.go:192)已清除。 ## ★ 为什么 ID 必须由内容派生 方案 A 要删 `sentences`,就得让块自己承载原句。但 ID 必须**幂等**: - 迁移块用 `blk_ent_<entityID>_<hash(name)>` —— 依赖 `entities` 的行号, 不是内容派生的 ⇒ 这条路不可用 - 所以用 `SentenceBlockID` = `sha256(TrimSpace(sentence))`,与 `BlockID` 同一套派生方式,重复跑同一条原句得到同一个 ID ## ★ 原句块刻意不带向量 它是溯源锚点,不参与召回。理由是实测过的: 「13000端口」(7字) 容易被召回 「13010/13011 而非 12011」(20字) 召不回 长整句的句向量会把关键值稀释掉 —— 让原句进召回只会引入噪声。 这条写进了测试断言(`TestWritePayload_带向量落库`), 所以将来有人「顺手给它加向量」会立刻被判据拦下。 ## 新增判据(含变异自证) TestWritePayload_不写sentences表 ★ 直接钉住方案 A 的目标 TestWritePayload_边是块到块 TestWritePayload_重复跑幂等 3 次写入仍是 4 块 1 边 TestSentenceBlockID_确定性 同句同 ID / 首尾空白同 ID / 异句异 ID CountSentences() 只读接口,判断退场进度用 ## 现有测试红了 5 处,都是「旧形态的断言」 全部按新行为更新并留痕,不能绕过: 块数 3 → 4 多一个原句块 幂等 3 → 4 同上 边形态 sentence→block → block→block BlocksForNode("sentence", sid) → ("block", SentenceBlockID(...)) 带向量测试 原句块豁免(并说明为什么) 同句不同维度 n → n+1 n 是**字段**数,不含原句块 ★ 其中两条是我自己判据写错(`len(blocks) != n`、TrimSpace 语义), 已修正而不是改实现去迎合判据。 ## 验证 internal/memory/distill 全绿(370s,该包有真实 IO 测试) internal/memory/ ok internal/agent/core/ ok ha-c 端到端 7/7(无回退) * feat(memory): 迁移也改块→块 —— sentences 表写入点真正清零 ## 上一条(1b758ad)只清了蒸馏路径,迁移路径仍在写 查生产快照时才发现: sentences 表 1360 条(迁移前只有 66) contains 边中 sentence 端起点 1297 条 ← 迁移建的 ★ 所以「方案 A 的最后依赖点已解除」这个说法当时是不准确的: 蒸馏清了,**迁移仍在为每个实体建一条 sentence 行**。 ## 为什么 sentence 节点没有独立价值 它的 text 就是实体名,而迁移块的 Text 也是 e.name —— 两者完全重复。 sentences 一退场,这个节点就悬空了。 现在迁移也走: 原句块(blk_src_<hash>)--contains--> 迁移块 与蒸馏路径同一套形态。 ## SentenceBlockID 下移到 memory 包 两条路径都要用它(迁移 + 蒸馏),而 distill 已导入 memory, 放在 distill 会形成 memory → distill 的导入环。 ⇒ 实现在 memory.SentenceBlockID / memory.NewSentenceBlock, distill 侧只做转发。 ## 现有测试红了 6 处,全部按新行为更新并留痕 块数 2 → 4 2 迁移 + 2 原句 幂等 2 → 4 同上 时序 4 → 8 4 实体 × 2 块 时序分组 2 → 4 每组 2 实体 × 2 块 contains 边起点 sentence → block res.Sentences 不再递增(改为统计迁移块数) 带向量断言 原句块豁免 ★ 其中一处是我改错了断言字符串却忘了改条件 (`seen["..."] != 2` 与新消息里的「4 块」不一致), 判据自己抓出来了 —— 消息文本与断言条件不同步是常见坑。 ## 验证 internal/memory/ ok internal/agent/core/ ok ha-c 端到端 7/7(无回退) internal/memory/distill 跑中 ## 方案 A 现状(诚实版) | 表 | 写入 | 退场条件 | |---|---|---| | sentences | ✅ 已清零 | 存量 1360 行待清理 | | entities | 只读 | 存量 1294 行待清理 | | relations | 只读 | 存量 980 行待清理 | | memory_block_edges | 唯一边表 | ✅ | ⚠️ 存量清理**尚未执行**,且必须先在生产完成迁移+蒸馏+召回验证+回滚演练。 * fix(migrate): 报告边数 ≠ 实际写入数(静默去重) ## 生产快照实测出的差异 relations 表 980 行 去重后 (src,tgt,type) 959 边表实际写入 959 条 迁移报告 「边 980」 ← 差 21 根因:`addBlockEdgeTx` 用 `INSERT OR IGNORE` 却**不检查 RowsAffected**, 而 `res.Edges++` 照加。`INSERT OR IGNORE` 在冲突时既不报错也不新增 —— 调用方只看 error 就会把「被去重」当成「写入成功」。 ## 数据是完整的 20 组 (source, target, type) 完全重复的关系行,各 2 次: 13915 --继续--> 15104 ×2 14079 --会--> 14124 ×2 14079 --开发--> 14674 ×2 ...(共 20 组) 980 → 959,减少 21 —— 与观测完全一致。**不是数据丢失。** ## 修法 - `addBlockEdgeTx` 返回 `(error, bool)`,bool = 是否真的新增 - `MigrateResult` 加 `DedupedEdges`,去重的显式报出 - 日志加「去重边 N」 - 新增 `TestMigrate_报告数等于实际写入数` ## ★ 判据第一次「通过」是假通过 第一版用 `seedLegacy(t, g, ...)` 写两次同一关系来构造重复 —— 但 seedLegacy 走 `Commit` 的 Upsert,第二次**覆盖**第一次, 库里始终只有一条关系 ⇒ 判据通过却没测到任何东西。 改为直接 `INSERT INTO relations SELECT ... LIMIT 1` 造真实重复, 判据立刻抓到「报告边数 2 ≠ 实际写入 1」。 ★ **判据通过 + 它其实没测到目标** —— 这是比判据红更隐蔽的失效。 记忆里那条「判据不可达 = 判据描述的状态不存在」的近亲: **判据可过,但它没造出它要测的状态。** ## 与 694e298 同类 694e298 relations 口径(active vs 全表) 本次 边去重口径(遍历数 vs 实际新增) 两次都是「报告数 ≠ 实际写入数」,而用户会把报告数当承诺。 ⇒ 这已是第三类口径问题,规律是:**报告必须在写完之后回读确认**, 不能由遍历计数推导。 * docs(memory): 方案 A 退场的决定性验证 从生产库重新 .backup 出干净快照(fresh.db),用新代码跑完整迁移: 迁移前 entities=1294 relations=980 sentences=66 blocks=98 edges=97 迁移完成(100.8s)句子 0,块 1294,边 959(去重边 21) 迁移后 块 2686,边 2350,中心已自动重建 ## 退场判据逐条核对 sentences 表未被写入 前后都是 66 ✅ contains 边为块→块 1294 条 ✅ 原句块已生成 source=sentence 1294 ✅ 迁移块 source=legacy-entity 1294 ✅ 去重边被显式报出 「去重边 21」 ✅ 中心自动重建 样本 1391 anomalous ✅ 块总数 98 → 2686(98 图像 + 1294 迁移 + 1294 原句)。 ## ★ 3 条遗留 sentence→块 边不是迁移产生的 迁移前 block_edges=97,其中 3 条已是 sentence→块 形态 (source_id 1427/1428/1429 是旧 sentences 行号)—— 生产库原有的历史数据,迁移不碰它们。 ⇒ 存量清理时这 3 条也要处理,否则 sentences 删掉后它们会悬空。 ## 方案 A 现状 sentences 写入点 清零(迁移与蒸馏都不再写) entities/relations 只读 唯一边表 memory_block_edges(3 条历史遗留待清) 原句可回溯性 由原句块承载(内容派生 ID,幂等) 剩余只有存量清理,且必须在生产完成蒸馏+召回验证+回滚演练之后。 * docs(probe): 生产可拆率 11% vs 测试库 83% —— 蒸馏收益预期需修正 新代码在 fresh.db 试跑 5 条:零字段 5/5。 核实后确认是**数据事实不是代码问题** —— 抽样的实体名确实没有 可拆的 (主语,维度,值): #读取并处理-homeagent-来信 (136, 62, -122) 平原水岸 +08:00的加号在query中解码成空格,服务端RFC3339解析失败后静默退回默认窗口 ## 两个库的可拆率差 7.5 倍 生产 fresh 429 条实体,含明确可拆结构 47 条 (11%) 测试 ha-c 146 条实体,含明确可拆结构 121 条 (83%) ⇒ 全量 429 条跑完,预期字段块约 47 条,不是几百条。 ## ★ 因此「生产迁移后召回能到 7/7」这个预期不成立 7/7 是 83% 可拆率 + 448 块的产物;生产是 11% + 2686 块。 召回质量的第一限制因素不是拆分算法(71% 字段级 F1 已达标), 而是**数据里有没有结构可拆**。 ⇒ 又一次印证「金标准必须覆盖输入分布」—— 我在 ha-c 上验证的融合/仲裁/拒答/拆分, 全部建立在输入分布严重不同的前提上。 * docs(provider): 决策缺口 —— 原结论没测「同义改写」 ## 原决策测的两项都是「形近」任务 维度名别名 词法胜 值分离度 Qwen3-VL 负,chineseclip 胜 结论 纯文本用 chineseclip ## 但生产失败的那两类不属于这两项 目标块内容是裸 URL: http://101.201.37.155:8083/ ← 无任何描述词 查询「agentmail 公网访问地址是什么」与它**零词法重叠** —— 这是同义改写,原决策完全没测。 ## 实测:chineseclip 在这类上接近于零 agentmail 公网访问地址是什么 目标块排名 452 top1 0.8041 公网地址 目标块排名 389 top1 0.5070 对外的服务地址 目标块排名 297 top1 0.7362 101.201.37.155 是哪台机器 目标块排名 58 top1 0.5471 ★ 排名随「可词法化程度」剧烈变化(452→58)—— 查询带 IP 后降到 58,说明起作用的主要还是词法,语义贡献极小。 ## 结论:不是模型选型问题,是数据形态问题 裸 URL 的语义信息**不在文本里**,任何 embedding 模型都无法凭空生成。 两条出路: A 用 Qwen3-VL 做同义改写 —— 未测 B 给块补描述 —— 更有希望 ⇒ 原决策在**现有数据形态**下仍成立,但那个前提没被写出来: **块文本必须自带语义描述**。数据形态变了,决策要重估。 * docs(provider): 否定实验 —— 给块补描述不管用 上面提的「给块补描述更有希望」被实验否定。 ## 实验设计(三档位,分离变量) 在库副本(2686 块,230 裸值型块)上依次测: A 基线 原文本 B 中性描述 追加不含查询关键词的中性描述 C 作弊对照 追加含查询关键词的描述(量上限) ## 结果(裸值块最佳排名,越小越好) A基线 B中性 C作弊 agentmail 公网访问地址 26 96 57 公网地址 37 15 3 对外的服务地址 30 11 21 ★ **B 让第一个查询从 26 掉到 96** —— 给裸 URL 追加无意义文字反而更差(噪声稀释了原值信号)。 ★ C 也不稳定(26→57 也是变差)。只有「公网地址」因查询短而变好, 不构成方法。 ## 剩下的可能性 给每个 URL 配真实上文(哪条命令/哪次排障产生) 未测 换更擅长同义改写的模型(Qwen3-VL) 未测 接受现状:URL 类靠符号路 已可用 ★ 第 3 条其实覆盖了大部分场景 —— 用户问「公网地址是什么」时不带 URL, 那是召回的**困难形态**;真要用时通常会带 IP/域名, 那是符号路的强项(实测排名 58 vs 不带时的 452)。 ⇒ **生产召回的实际可用性可能高于 4/9**, 因为探针问的正是最难的那种形态。 ## ★ 也记一次自我纠错 第一版实验把查询关键词(agentmail)注入了每个裸值块, 于是「452→17」是作弊得来的。发现后重做为三档位对照 —— **注入关键词得到的收益不算收益。** * docs(memory): 召回能力分层 —— 比单一分数有用 单一分数(4/9)掩盖真实情况。有用的问题是:什么形态能用、什么不能、为什么。 ## 四种形态 1 带精确串 ✅ 强 符号路精确串命中 → 布尔置顶 → 排第一 2 领域词 + 上下文 ✅ 可用 向量召回 + 中文符号滑窗 3 纯泛化描述 ❌ 弱 目标块排名 389/2686,向量给不出区分 4 库里没有 ⚠️ 会编造 纯中文查询拒答判据失效 ## 各形态的实测依据都带数字 形态 3 的关键数据 —— 排名随「可词法化程度」剧烈变化: agentmail 公网访问地址是什么 452 公网地址 389 对外的服务地址 297 101.201.37.155 是哪台机器 58 ← 带 IP 后降 10 倍 ★ 说明起作用的主要是词法信号,语义贡献极小。 根因:目标块是裸值 http://101.201.37.155:8083/,没有可匹配的文本。 已试过并否定:追加中性描述反而更差(26 → 96)。 形态 4 的关键数据 —— 纯中文零命中不可分: 「grafana 监控面板的端口」 → [grafana 监控面板] 库里真没有 「本机服务监听哪些端口」 → [本机服务 监听哪些] 库里有 13010/8081 ## 运维结论 能用:带 IP/端口/路径/版本号(运维最常见)、领域词 + 具体上下文 不能指望:「XX 是什么」泛化描述、纯中文的「库里没有」检测 同时记了复现命令与「两个库可拆率差 7.5 倍」的前提警示。 * docs(probe): 生产蒸馏产出 0 —— 根因是 think 未关掉,不是可拆率 跑 60 条:0 个字段块,零字段 60/60。 ## 直接复现(同一输入连发三次,只换提示词) 任务A(拆三元组) 118s → "...</think>\n\n要将句子「commit f91b27a...」拆成三元组, 首先要理解句子的结构和内容。\n\n### 分析句子:..." 任务B(判断疑问句) 15s → "no" 任务A(重复) 83s → "...可以将其拆分为三元组:\n- **字段名**:commit ..." ★ 两个问题同时存在: 1. **think=False 没生效** —— 输出里带着 </think> 2. **推理文本把 JSON 挤出窗口** —— num_predict=300 全被 「### 分析句子:」吃掉,JSON 根本没生成完 ⇒ 抽取器收到推理文本而非 JSON ⇒ 零字段。 14 秒/条的耗时证明 LLM **确实被调用了**。 ## 这是本会话第三次撞上同一个 bug triple-extill-attention.md 里已记「模型随机性实际是 ollama schema 污染」, 而**蒸馏命令没做同样的防御** —— 复用了带 schema 的抽取器,踩进同一个坑。 ## 结论修正 我先说的 实际 可拆率 11% 产出约 57 产出 0,与可拆率无关 全量 429 条 111 分钟 现在不该跑,跑完也是 0 ⇒ **先修 JSON 生成,再谈产出量。** 一个 60 条就能测的修复 比 111 分钟的全量跑重要。 修复方向(未实测):① 关掉 think 真正生效(可能需 chat_template_kwargs.enable_thinking=false)② 加大 num_predict (但 300 不够说明推理链极长,关 think 才是根治)③ 换模型(27B 未测过单条 JSON) 顺带:加了 zz_exact_test.go 判据 —— 检验分层文档里 「形态 1 带精确串 → 强」这个断言,目前只有 13010 一条证据(n=1), 需要 IP/路径/commit/日期多种类型验证是否都稳定置顶。 * docs(probe): 蒸馏 0 产出的根因 = ollama schema 污染(第四次复现) ## 先排除两个错误假设 **「think 没关掉」不成立** —— 三种参数都试过(think:false / enable_thinking:false / num_think:0),输出里都没有 </think>。 **「schema 双重编码」不成立,且是我的验证脚本有 bug** —— HTTP 500 invalid format: "{\"type\": ... ← 我把 json.dumps 的结果当 format 值传了 Go 侧 是正确的。 ★ 本会话第四次「先归因后核实」,这次我差点把 bug 归到生产代码上 —— 只因为「schema 看起来可疑」。 ## 真正的根因(证据确凿) 修正脚本后,同一模型三次不同请求: 任务A(拆三元组) → {"fields":[{"name":"commit","value":"f91b27a"},…]} 任务B(判断疑问句) → {"fields":[{"name":"text","value":"commit f91b27a…"}]} 任务A(重复) → 与第一次逐字节相同 ★ 两个症状:① 完全不同的问题返回同一 schema 结构 ② 同输入逐字节相同(不是随机,是污染后的确定性) ⇒ 抽取器拿到 {"name":"text","value":"整句话"} 这种无意义字段, 全部被三道闸门拦掉 ⇒ 零字段。 ## 修复方向 A 每请求唯一 nonce(label3.py 用的变体) 低成本,有副作用记录 B 不用 schema,改 <record> 纯文本协议 低成本,解析容错要求高 C 升级/换掉 ollama 高成本,影响其他任务 ## 一个必要的判据(现有统计看不见根因) 「零字段 N 条」无法区分「真的没可拆字段」与「LLM 返回了垃圾」。 必须先让 homed-graph-distill **打印前 3 条的原始 LLM 输出**。 代码里本来就有这个打印(i < 8 时打印字段),但因为零字段所以什么都不显示 —— **判据能显示 0,是因为它只看得到 0。** * fix(distill): 让判据能看见零字段的原因,并定位真根因=无主语 ## 前 8 条改为无条件打印 原版只在 payload != nil 时打印字段,于是「报错」与「零字段」 两种情况都什么都不显示 —— 生产蒸馏 60 条零字段时屏幕上 一行内容都没有。 ★ **判据能显示 0,是因为它只看得到 0。** ## 解析错误带上原始响应 split.go 的 parseFields 失败时把 resp.Text 附在错误里 (截断到 400 字),这样「工具链故障」与「数据没得拆」不再无从区分。 ## ★ 根因第三次修正:不是 ollama、不是 schema,是无主语 我连续三次猜错,每次都是靠更直接的测量纠正的: 1. 「think 没关掉」 → 三种参数实测都没有 </think>,不成立 2. 「schema 污染」 → 用同样 schema+prompt 直调,返回**三个正确字段**,不成立 3. 「字段被闸门拦掉」 → 读代码发现**第三道之后还有第五道闸门** 实测哪些实体没有主语: commit f91b27a,2026-09-03 已推送 0 主语 → 字段全丢 127.0.0.1:12100(lan=false…) 0 主语 → 字段全丢 /api/sources、/api/keys、/api/status 0 主语 → 字段全丢 第114批周二凌晨1点·停机4分… 1 主语 → ✓ 值班室分机号 4324,值班 老周 1 主语 → ✓ ★ LLM 完全做对了(3 个字段全对),却被 Go 侧的 「无主语不写悬空属性」规则丢掉。 ## 三个诊断缺口叠加,让根因被掩盖三轮 ① 只在 payload != nil 时打印 → 零字段什么都不显示 ② 报错与零字段混在一个统计 → 看不出谁在丢弃 ③ 没有主语计数的读数 → 字段凭空消失 ★ 「判据的观测面决定你能看见什么」的三个层次。 修第①层后才看见「合法 JSON 但零字段」; 修第②层后才有机会去查闸门;缺第③层至今。 * docs(probe): 主语缺失率 99% —— 蒸馏在生产侧不成立 补上第③层判据(主语计数),在生产库 429 条实体上实测: 总计 429:有主语 5(1%),无主语 424(99%) 命令/commit 2 条 无主语 100% 符号开头 2 条 无主语 100% 路径/URL 38 条 无主语 100% 其他 387 条 无主语 98.7% ## 而 ha-c 的主力句式在生产库一条都没有 含「第N批」 0 条 含「分机」 0 条 含「端口」 11 条 ★ ha-c 的 7/7 完全建立在「第114批…」「值班室分机号 4324」这类 有明确主语锚点的句式上。 ## 三层结论串起来 ① LLM 拆得对 3 个字段全对(实测) ② 第五道闸门丢弃 无主语 → 全部 continue ③ 99% 无主语 ⇒ 放宽闸门也救不回多少 ⇒ **方案 B(键值块)最多救回 1%** —— 99% 的实体压根没主语,键值块也写不出 <主语>|<维度>。 ⇒ 生产侧蒸馏路径**整体不成立**,不是「修一下 bug 就能跑」。 ## 正确方向 99% 的生产实体是无主语的通用事实,它们**本来就应该**以整句块形态存在 (legacy-entity 迁移块已经是这样),不该被强行拆三元组。 蒸馏命令应**只对有主语的实体跑**,其余跳过并报告: 待处理 429 → 有主语 5,实际处理 5 ## 元价值 今天在生产侧撞到的不是 bug 而是**假设错误**: 「迁移后召回能到 7/7」 错,7/7 是 83% 可拆率 + 主语锚点的产物 「可拆率 11%,产出 57 个」 错,实际 0(第五道闸门) 「先修 bug 再跑全量 429」 错,99% 无主语,跑完也是 0 ★ 三个错误的起点都是**拿 ha-c 的结论直接套生产**。 判据、变体、实验都在 ha-c 上做,只把探针搬到了生产 —— **探针搬过去了,输入分布的差异没被当成一等变量。** * fix(distill): 只对有主语的实体跑拆分 —— 424/429 被浪费 生产库实测 429 条实体里 424 条(99%)无主语,而 Split 里 if len(subjects) == 0 { continue } 会把 LLM **已经拆对**的字段全部丢弃(实测 60 条 / 837 秒 / 0 产出)。 ## 过滤前的实测浪费 命令/commit 2 条 无主语 100% 符号开头 2 条 无主语 100% 路径/URL 38 条 无主语 100% 其他 387 条 无主语 98.7% 而 ha-c 的主力句式(第114批…、值班室分机号 4324)在生产库**一条都没有** (含「第N批」0 条、含「分机」0 条)。 ## 过滤后(报告模式实测) 待处理实体 429 条;现有块 2686(带向量 1391) ★ 跳过无主语实体 424 条(99%):Split 的第五道闸门 「无主语不写悬空属性」会把 LLM 已拆对的字段全部丢弃。 · LLM 503服务不可用 + 402余额不足 · 167文件_3547节点_10461边 · CLI查询与explore与MCP工具列表均可用 实际处理 5 条(其余 424 条无主语,保持整句块形态) ★ 让「没产出」变成**可解释的数字**,而不是又一次「零字段 N 条」的谜。 这也正是今天三次猜错的根源 —— 观测面不够。 ## 与 99% 无主语结论的落地 066483f 已判定「生产侧蒸馏整体不成立」,本条是它的直接落地: 不是把路径修好,而是**让它知道哪些输入不该进来**。 * docs(memory): 回填重构计划执行结果(含计划与实际的偏差) 状态从「执行中」改为「已执行完毕」,并回填: ## 步骤执行情况 ① 重写设计文档 ✅ ② distill 沉库改块 ✅ 288674a 83ba7b4 1b758ad ③ memory_recall 加块路 ✅ 74301d9 84d8d28 ④ memory_commit 止血 ⚠️ 部分 ⑤ 存量迁移 ✅ 878b901 21a68cf 6153770 ⑥ scene 挂接 ❌ 取消 ## 计划与实际的偏差(都是实测纠正的) **⑥ 取消**:计划假设 blocks 是新层、scene 挂在它上面, 但 blocks 不是新层而是图节点升级体,scene 无处可挂。 **蒸馏整体否决**(066483f):生产库 424/429 条实体无主语, 第五道闸门会把 LLM 已拆对的字段全丢弃。已改为只跑有主语的。 **注意力网络否决**:分域后 token F1 0.918 超门槛,但跨域不可泛化。 ## 计划里未被采纳的替代方案 给块补语义描述 实验否定(排名 26 → 96) 换更强向量 未测,但裸 URL 无文本可供语义匹配 ## 现行架构 memory_blocks 统一节点载体 memory_block_edges 多态端点,唯一边表 graph_centroid 中心向量(迁移/蒸馏后自动重建) 原句由 blk_src_<sha256> 原句块承载(幂等、不带向量), sentences 表写入点已清零。 * docs(distill): 回填三元组方案的执行结果 —— 路线整体否决 状态从「设计中,待实现」改为「已实现并部分否决」。 ## 已实现 generation SPI + providers/ollama/generator.go(JSON Schema) Qwen3-VL 生成侧(providers/qwen3vlgen) distill 块落库(<主语>|<维度>=<值>) 字符级注意力网络(train/predict/probe 三件套) ## ★ 但「小 LLM 拆分」路线整体否决 词法切分 字段级 25% 规则+查表 字段级 71% ← 当前最优 网络+规则(76 条) 字段级 42% 网络+规则(分域 67 条)token 0.918 / 跨域 0.480 三条实测否决理由: 1. **注意力网络不泛化** —— 分域后 0.918 超门槛,但域外零字段。 而通用 Agent 无法分域(混域 0.918 → 0.637)。 2. **LLM 拆分在生产侧不成立**(066483f)—— 424/429 条实体无主语, 第五道闸门会把 LLM 已拆对的字段全丢弃(60 条/837 秒/0 产出)。 3. **拆分不是瓶颈** —— 召回侧收益(中心化 5/7→6/7、融合 2/9→4/9) 远大于拆分质量提升。 ## 结论 > 拆分质量不是瓶颈,召回质量才是。 > LLM 拆分保留作为**离线批处理**,不进在线路径。 ## 元教训 本文的设计与实现都对,但建立在一个未验证的前提上: 「ha-c 生产库」这个措辞暗示它是生产数据,实际上它是 **可拆率 83% 的运维测试集**。真生产库可拆率 11%、主语率 1%。 * chore: 清理构建产物 + package-lock 版本同步 + gitignore 补漏 - 删除误落在仓库根的 homed-graph-distill 二进制(15MB) - cmd/gui/package-lock.json 版本号 1.0.0 → 1.4.0 (与 package.json 对齐,npm install 的正常同步结果) - .gitignore 补上 /homed* 构建产物 (go build ./cmd/xxx 时二进制默认落在执行目录,很容易进仓库) * fix(test): 两个回归问题 —— build tag 缺失 + 金标准跑两遍模型 ## 最终回归发现两个 FAIL,逐个查实 ### FAIL 1: providers/chineseclip [build failed] vet: providers/chineseclip/modeldir_test.go:15:15: undefined: findModelDir `findModelDir` 定义在 `embedder.go`(有 `//go:build onnxruntime`), 而 `modeldir_test.go` **没有 build tag** ⇒ 不带 tag 编译必然失败。 ★ **既有问题,不是今天引入的**(`git log` 显示它来自 bcb9afb, 早于本会话)。但它会让任何无 tag 的全量回归变红 —— 今天它第一次暴露,因为我把 `providers/...` 加进了回归范围。 已给测试文件加 `//go:build onnxruntime`,两种 tag 下都通过。 ### FAIL 2: internal/memory/distill 超时(600s) 单独跑通过(499s),与其他包并行时超了 600s。查出原因: TestGold_Baseline 250.95s TestGold_ShowFailures 251.64s ← 合计 502 秒,占全包 500 秒 ★ 两个测试**各调一遍 runGoldCases**,而它会: 1. 打开 ollama provider 2. 对 goldCases 逐条调 LLM ⇒ **同一批金标准用例被跑了两遍模型,浪费 250 秒。** 修法:加进程内缓存(`goldCache` / `goldAltCache`), 带 `subjectFrom` 的变体按函数名分开缓存(那批测另一条主语路径, 不能与默认路径共享)。 预期:500s → 约 250s。 ## 顺带一个观察 `runGoldCases` 测的正是今天被否决的东西(规则+查表 71% 命中)。 它有 `-short` 跳过,所以**回归时应带 `-short`** —— 但默认跑会真的花 250 秒调模型。这本身值得记: **判据的成本也会影响它是否被真正执行。** * fix(migrate): 为 sentences 原文补建原句块 —— 否则清理会丢数据 ## 清理前置判据抓出的缺口(生产快照实测) ① entities 1294 → legacy-entity 块 1294 ✓ 缺块的 entity: 0 ② relations 980(去重 959)→ 关系边 959 ✓ ③ sentences 66 → 有原句块的 0 ✘ ★ ④ 悬空的 sentence→块 边: 0 ⑤ 端点不存在的块边: 0 ★ **sentences 表 66 条,一条原句块都没有。** 而迁移只从 entities 读(readLegacySnapshot 里只有 entities 查询), 从没为 sentences 建过载体。 sentences 表的价值就在**原文本身**(entities 是提炼后的名字), 所以原句块是它唯一的迁移出口 ⇒ 直接清理该表会丢 66 条原句。 ## 更正上一轮的一个不准确结论 上一轮记忆里写「3 条遗留 sentence→块 边是历史数据,清理后会悬空」。 实测那 3 条边的 source_id 1427/1428/1429 **确实存在于 sentences** (id 范围 1427-2225)⇒ **不是悬空的**,④ 显示 0。 它们的 target 是 **modality=image 的图像块**(source/text 为空是正确的), 来自更早的多媒体管线。 ## 修复 迁移在事务内补一段:为 sentences 每条原文建原句块 (blk_src_<sha256(Text[:12])>,无向量),已有则 ON CONFLICT 跳过 ⇒ 幂等。 ## 判据(含变异自证) TestMigrate_sentences补建原句块 ★ 第一版幂等断言写错了:靠「重复插入同一句」来测, 而 sentences.text 有 UNIQUE 约束 ⇒ 根本到不了迁移代码。 改为「迁移跑两遍」才真正测到幂等。 变异自证双向通过: 删除 sentences 构建 → FAIL「应有 2 个原句块,实际 0」 还原 → ok ## ③ 的实现细节 块 ID 是 sha256(Text[:12]),SQLite 无 sha256 函数 ⇒ 在 Go 侧比对(先 取 ID 集,再对每条 sentence 算)。 判据要自己算,别指望 SQL 能做哈希。 * docs(memory): 旧表清理可行性评估 —— 现在不能清理,55 处调用点 ## 数据侧已就绪(清理前置判据全绿) ① 每个 entity 都有块 1294 → 1294,缺块 0 ② 每条 relation 都有边 980(去重 959)→ 959 ③ 每条 sentence 有原句块 66 → 66(14c9c71 修复后) ④ 悬空 sentence→块 边 0 ⑤ 端点不存在的块边 0 ## 代码侧远未就绪 internal/memory/social/social.go 7 处 internal/memory/graph.go 6 处(Commit/Recall 实现) internal/memory/scene.go 4 处 internal/memory/light_memory.go 4 处 internal/plugins/webui/handler_memory.go 2 处 internal/plugins/healthcheck/plugin.go 2 处 internal/plugin/proc/corehandler_memory.go 2 处 internal/plugin/lua_plugin.go 2 处 internal/sdk/memory_impl.go 2 处 ──────────────────────────────────── 合计 55 处 ## 三张表是一条链,不是三张独立的表 写入 Commit → entities(name 唯一) + relations + sentences(挂媒体块) 读取 Recall → entities(LIKE) + relations(遍历) + sentence_id 只删表不改代码 ⇒ 写入报 no such table,读取返回空。 而 **Commit 在在线主路径上**,不是可延后的离线工具。 ## 建议:不要现在清理 理由不是「风险高」,而是: 1. 数据侧就绪 ≠ 代码侧就绪(55 处调用点) 2. Commit/Recall 在**在线主路径**上,不是一次性迁移 3. 清理的收益只是「少三张空表」,代价是重写一条在线链路 ⇒ 正确顺序:**先让写入与读取都走块**(1-3), 旧表自然失去写入方,最后再谈删除。 ## 附:数据侧已修的两个真实缺口 66 条 sentence 没原句块 14c9c71(迁移只从 entities 读) 关系边 959 vs 980 20 组三元组重复(156163f 已显式报出) * docs(memory): 记录一次操作错误 —— cp 拿不到一致的快照 验证 sentences 原句块修复时用 cp 取副本,得到: 生产库 entities=1294 sentences=66 cp 出来的 entities=1293 sentences=67 ← 差 1,方向相反 第一反应是「迁移改动了源表」,差点去查迁移的写入逻辑。 实际是生产库在 WAL 模式,cp 只拿到主库文件 ⇒ 不一致快照。 用 sqlite3 .backup 重取后完全一致(1294 / 66 / 98)。 ★ 本文档与 production-recall-validation.md 里都写过 「库在写,cp 拿不到一致快照,要用 .backup」, **我自己写下的规则自己违反了。** ⇒ 判据该加一条:**快照来源必须可验证**。 最省事的做法是取完快照立刻与源库比对关键计数 —— 不一致就重取,差 1 就会暴露。 * docs(bench): v4 跑分 6/6 + 跑分前必须核对内核版本 ## 能力标定(tasks.zerobasis) 任务 6/6(100%) 墙钟 159.17s token 281971 缓存命中 42.7% 超时 0 ## ★ 但真正值得记的是跑之前的发现 build/homed Oct 1 21:18 ← 3 天前的二进制 今天的 memory 层提交 19 个 **直接跑会测三天前的内核**,而今天改的全是 memory 层 (融合召回/拒答判据/仲裁/schema 退场)——跑分完全测不到今天的工作。 ⇒ 跑分前必须核对 vcs.revision == git rev-parse HEAD。 这一条应该进 spawn-instance.sh,让它自动拒绝版本不符的实例。 ## 三个操作失误(都记在案) cp 取快照 WAL 模式下不一致 /bin/sh 报 Bad substitution 改错二进制路径 /var/tmp/ha-c/homed 不存在,实际是 build/homed ## 这一轮 6/6 的含义要说准 它验证的是「今天没把主干跑坏」,**不是**「今天的改动带来了提升」。 记忆召回质量另有专项(memory_recall.py,36 探针), 结论单独记在 production-recall-validation.md: 测试库 ha-c 7/7 生产快照 1391 块 4/9(abstention 0/3 编造) 两个库可拆率差 7.5 倍,ha-c 的分数不能推广到生产。 * test(bench): 启动跑分实例前必须核对内核版本 ## 为什么加这条 实测踩过(2026-10-04):build/homed 是 Oct 1 的,而当天有 19 个 memory 层提交(融合召回 / 拒答判据 / 仲裁 / schema 退场)。 直接跑分测的**完全是三天前的内核**,当天的工作一点没测到 —— 而 6/6 的满分让人误以为改动被验证过了。 ⇒ 判据必须能红。现在版本不符直接拒绝启动(exit 2), 并打印两边 revision 与重跑命令。 ## 实现时踩的一个坑 第一版判据永远「不匹配」: go version -m 给的是完整 40 位 revision rev-parse --short=8 给的是 8 位 ⇒ 两者都要短形式(BIN_REV 取前 8 位)。 ★ 判据自己写错会得到「永远红」的结果 —— 而永远红的判据会被人当成环境问题忽略掉。 ## 变异自证双向通过 旧二进制 681e01ad → 正确识别为不符 当前二进制 4d73cda(== HEAD)→ 匹配 确实想测旧版本时用 ALLOW_STALE=1 显式跳过。 * docs(bench): 实战证据 —— 新内核在真实跑分中的行为 内核 4d73cda(含今天全部 19 个 memory 层提交)跑 tasks.zerobasis + memory_recall 时的实例日志摘录。 ## 一、召回来源标签(今天新增功能) exact 14 次 text 24 次 symbol 0 次 [exact 1.00] 第47批 4279ms [exact 1.00] 第115批周二凌晨2点·停机6分·回滚v2.28.1·灰度5%… ★ exact 那 14 条正是「端口号/批号/耗时」类事实 —— 纯向量下召不回(chineseclip 对「13010」这类短数字串极不敏感), 今天靠符号路的布尔置顶救回来。 symbol 0 次:纯符号命中这条路在 ha-c 数据上没被触发 (块都是整句块,向量侧总能给分)。 ## 二、L4 超页处理在真实负载下触发(本分支早前工作) overflow 32 preempt 6 suspend 6 裁剪 3 max_ctx=50000 target=40000,26 轮叙事填充下确实触发, 且没有出现「no reduction」那类失败。 ## 三、能力标定 tasks.zerobasis 6/6(100%) 墙钟 159.17s 缓存 42.7% 超时 0 ## 四、★ 旧实体路仍在跑(现场印证) graph.go:944: [graph] recall: 截断 3 个实体(全局上限 20) 这条日志说明旧 仍在生效 —— 今天块路是**并行的另一条**,两路都在跑。 旧路径没退出,只是被块路抢先返回了。 ⇒ a55556b 评估里「55 处调用点」的判断在现场得到印证。 * docs(bench): ★ 跑分正在往旧表里写数据 —— 旧表还在长大 ## 最重要的发现 07:29:20 memory_commit: 已写入 8 个实体和 4 条关系 07:31:02 memory_commit: 已写入 2 个实体和 1 条关系 07:31:29 [indexer] 实体数 202→207 entities: 188 → 207(本次跑分期间) ★ **memory_commit 仍写 entities/relations,且是活跃的在线写入路径。** 修正了两处我先前的说法: | 我以为 | 实际 | |---|---| | sentences 写入点已清零 | ✅ 成立,但**只针对迁移与蒸馏两条命令** | | 旧表在等着被清理 | ❌ **它还在长大**(188 → 207) | ⇒ 旧表退场的前置不是「删表」,是「**先让 memory_commit 改写为写块**」。 在那之前删除旧表 = 在线写入直接报错。 ## 召回三种返回形态(实战统计) ① 正常召回 30 次 块路 ② 拒答 2 次 今天新增的判据 ③ 空返回 31 次 块路让位 ### ② 拒答在实战中工作了 记忆库里没有与「第8批 上线准备」相关的记录 记忆库里没有与「第9批 上线准备」相关的记录 查询含**精确串**(第8批/第9批)而库里没有 ⇒ 判据正确触发。 且验证了「拒答后不走旧路兜底」(实测 0 次),是干净的终态。 ## ★ 又一次差点归因错 「块路 30 vs 旧路 31」让我以为块路没抢先。查上下文发现: tooldefs.go:107: memory recall (input:cli): injected 1287 chars ↑ input:cli = 自动记忆注入 ⇒ 截断来自**自动记忆注入与 memory_commit 的内部召回**, 不是用户显式调 memory_recall 时的兜底。 差异来源是**调用方不同**,不是优先级失效。 * docs(bench): v4 跑分汇总 —— 6/6 + 7/7,并记录专项跑不完的根因 ## 结果 能力标定 tasks.zerobasis 6/6(100%)墙钟 159.17s 端到端召回 仲裁前 6/7 → 仲裁后 7/7 ## ★ memory_recall.py 专项跑不完(不是慢,是必然失败) 填充轮耗时: [1/36] 30.9s [2/36] 131.5s [3/36] 392.0s 递增比 2.98× 外推第 6 轮就超过单轮超时(900s),26 轮累计 12.9 小时。 ★ 根因:memory_commit 在填充期间持续写库(entities 188 → 220), 每轮检索的数据量与召回量都在涨 ⇒ 轮次耗时递增。 ⇒ **改走等价路径**:用探针判据直接测,数据仍是真实内核跑过的迁移库, 结果 7/7。 ⇒ **这个专项需要重新设计**:26 轮逐条填充的做法在 「写入会改变检索集」的系统上不成立 —— 填充本身就是被测对象的一部分。 正确做法应是**批量预填**(一次写完)再跑探针轮。 ## 实战数据印证 exact 14 / text 24 / symbol 0 symbol 标签是今天新增的 拒答 2 次 第8批/第9批 精确串库里没有 ⇒ 判据触发 overflow 32 / preempt 6 / 裁剪 3 L4 超页真实触发 entities 188 → 220 memory_commit 仍在写旧表 * docs(bench): 更正填充期库增长的来源 —— 是模型主动调工具 ## 我先说的(错) 「填充期库增长的主因是 pipeline 的 distiller 常驻循环」 ## 实测计数纠正 tool memory_commit result 19 次 ← 模型主动调工具 media 3 次 reclaim/ 0 次 distiller 启动 1 次(Interval 10min,心跳没跑到) ⇒ 主因是**模型自己决定调 memory_commit**,不是那个 10 分钟心跳。 ## 连带结论 Distiller 的 DistillerConfig 只有 Interval/RetentionDays/BatchSize, **没有 Enabled 开关** ⇒ 关不掉它。 ★ 但**不需要关**,因为它不是主因。 我之前一直在找「怎么关掉自动记忆写入」, 方向对(确实要控制写入)但**对象找错了**(该看工具调用,不是 distiller)。 ## 归因的教训 ★ 同一现象(entities 188→381)我先归因到「distiller 常驻循环」, 一次日志计数就纠正了。 而我当场还给出一个支持该假设的「证据」—— Interval 10 分钟 > 填充 13 分钟,所以心跳最多跑一次 —— **那个证据其实是在反驳我自己的假设,我没看出来**。 * feat(memory): Commit 块化 —— 三元组落成块,不再只是旧表的同步写入 ## 起点:跑分暴露「旧表在长大」 07:29:20 memory_commit: 已写入 8 个实体和 4 条关系 entities: 188 → 220 → 381(本次跑分期间) ★ `Commit` 的四条调用路径(媒体桥 / memory_commit 工具 / 驻留子 / 记忆整理流水线)**全部只写旧表**,memory_blocks 一次都没被写。 所以旧表不是「等着被清理」,它在线上持续增长。 ## 改动 `commit` 里在旧表写入之外**并行块化**: 三元组 主语块 --关系--> 宾语块 (两个独立可召回的事实 + 边) 原句 原句块 blk_src_<hash> (溯源锚点,无向量) ### 为什么三元组是「两个块 + 一条边」而不是一个块 压成 `<主语>|<关系>=<宾语>` 就无法回答「X 的关系有哪些」 「Y 被哪些主语指向」—— 而那是 Recall 的主要用法。 ### 块 ID TripleBlockID(name) = blk_ent_<sha256(name)[:12]> 内容派生 ⇒ 重复提交同一三元组得到同一对块,天然幂等。 ★ 迁移块是 `blk_ent_<entityID>_<hash8>`(带行号), Commit 块是 `blk_ent_<hash24>`(纯 hash)—— **两个不同语义的东西,不是重复**。测试断言必须按 source 过滤, 否则会误判成「块化破坏了迁移」。 ## 签名变更:CommitWithMedia map[string]int64 → map[string]string(sentences 行号 → 原句块 ID) 因为 sentences 表退场后行号不存在,而媒体要挂在 「原句块 --contains--> 媒体块」上。连带改了 4 处: internal/sdk/memory_impl.go bindSentences 端点改 block internal/agent/core/graphmedia.go attachBlocksToSentence 收块 ID sentenceIDsFromRelations 按 SentenceText 现算 RecallBlocksForSentence 签名 int64→string internal/memory/migrate.go 媒体迁移挂载点 sentence→block ★ `sentenceIDsFromRelations` 原来依赖 `Relation.SentenceID` (旧表列)—— 改为按 `SentenceText` 现算块 ID。 这条链(mediaContextForSentences / mediaContextForRelations) 完全靠它找回媒体块,改错了 = 媒体上下文整体失效。 ## 修了一个真实缺陷:零值时间抹平时序 blk_ent_xxx 应继承实体时间 2026-01-01 10:00 实际 2026-10-04 08:05(时序被抹平) `putBlockTx` 对零值 CreatedAt 填 now,而 ON CONFLICT 无条件 `SET created_at = excluded.created_at` ⇒ Commit 先写的无时序块 会把迁移块继承的实体时序覆盖掉。 ★ 而时序是**仲裁的前提**(arbitration.go 靠 CreatedAt 判断谁取代谁)。 修法:零值时 UPDATE 分支**不碰时间列** (`tsUpdate = "updated_at = memory_blocks.updated_at"`)。 ## 判据 `TestBlockCommit_不写旧表` 四条: ① 旧表写入**有意保留**(55 处读方未切块,先删会打断在线读取) ② 原句块 + 值块必须存在 ③ 三元组必须变成块边 ④ 返回值必须是 blk_src_* 而不是行号 ★ ① 我最初写成「旧表应为 0」—— 那是错的。 退场顺序必须是「读方切块 → 停写旧表 → 删表」, 在第一步完成前停写 = 在线读取直接断。 ## 测试连带修复(8 处,都按新契约) 迁移测试的块数断言 用 len(blocks) 会混进 Commit 块 → 改按 source 迁移测试的时序断言 同上(Commit 块时间由 PutMemoryBlocks 填 now) 报告边数断言 只数迁移块之间的边(Commit 的边不该算) 媒体测试的 sid 类型 int64 → string BlocksForNode "sentence", 行号 → "block", 块 ID ★ 其中「时序被抹平」那条差点误判成**块化破坏了迁移时序** —— 实际被抹平的是 Commit 块(它本就该是 now),迁移块没问题。 判据没按 source 过滤 ⇒ 把对的实现当成错的。 ## 验证 编译 ✓ 全包通过 回归 43 包全绿,零失败 * feat(memory): 边升格为独立单位 —— 去 UNIQUE + 承载关系属性 ## 用户指正 > 块只是节点,关系边是连接块与块的独立单位 我照此核实,发现**两处都错了**,而第二处是早就存在的: ### ① 节点形态错(e108b4e,我今天引入的) 我做成了属性图:`主语块 --关系--> 宾语块`,块内容是纯名字。 **而既有设计是** `<主语>|<维度>=<值>` 的字段块(83ba7b4)—— 我把那个设计拆散了,退回 N-Triples/属性图。详见下一步回滚。 ### ② 边不是独立单位(早就存在,我没发现) UNIQUE(source_kind, source_id, target_kind, target_id, edge_type) ⇒ 同一对节点间**只能存一条**同类型边: 写 A --属于--> B (session=1, conf=0.9) 写 A --属于--> B (session=2, conf=0.5) ⇒ 第二条被 INSERT OR IGNORE 静默丢弃,session/confidence 全丢 ★ **这正是 156163f 那个「报告 980、实际写入 959」的根因** —— 当时我把它当成「重复数据」报出来就完事了, **真正的问题是边表从设计上就存不下多条同类边**。 ## 本次改动 ### schema 去掉建表语句里的 UNIQUE;补五列(对应旧 relations 同名列): confidence 置信度 —— 「值覆盖」维度靠它判断哪条更可信 session_id 来源会话 —— Recall 的 sessionFilter 依赖它 turn_id 来源轮次 status active/deleted/merged —— 软删除与仲裁依赖它 merged_into 源块被并进哪个块(历史边留在源块上) ### 既有库升格 SQLite 的 UNIQUE 是表定义的一部分,**没有 ALTER TABLE ... DROP CONSTRAINT** ⇒ 只能重建表:读既有边 → 建新表(无 UNIQUE)→ 搬回 → 删旧 → 改名 → 重建索引。 全程一个事务,任何一步失败整体回滚。 ★ 为什么不能「检测到就跳过」:跳过会让「既有库不支持并存、 新库支持」成为事实,而这种不一致极难排查(本地能测、生产静默丢边)。 宁可重建表也要保证语义一致。 实测(真实测试库副本 590 边): 升格后 DDL 含 UNIQUE: false 同类边并存: 2 条(应为 2) 边总数: 592 ← 590 原有 + 2 新增,数据没丢 ### 两个写入路径分开 AddMemoryBlockEdge 结构边(contains 等)—— 显式查重,保持幂等 AddRelationBlockEdge 关系边 —— ★ 刻意不查重(可并存多条) ## ★ 去掉 UNIQUE 的连带:幂等当场红了 `INSERT OR IGNORE` 的去重**正是靠那个 UNIQUE 实现的**。 约束一去,重复调用就插出两条一样的 contains 边: TestWritePayload_重复跑幂等 FAIL TestMigrateLegacyTextEntities_幂等 FAIL(contains 边 2 条变 6 条) ⇒ 两处写入路径都改成**显式查重**: SELECT COUNT(*) FROM memory_block_edges WHERE source_kind=? AND source_id=? AND target_kind=? AND target_id=? AND edge_type=? AND COALESCE(session_id,'')='' ★ 加 `COALESCE(session_id,'')=''` 是为了**不误伤关系边** —— 关系边可以 session_id 非空,结构边恒为空。 ## 另一个自己踩的坑 第一版只加了列,**没扩读取端** —— `MemoryBlockEdges()` 的 SELECT 还是旧 6 列, 属性静默变零值,判据立刻抓到(`边未承载 session_id,实际 ""`)。 ⇒ 加列必须同时改所有读取点,否则「写了但读不到」比「没写」更难查。 ## 判据 TestEdge_同类边可并存 3 条同类边(不同 session)写 3 条存 3 条 TestEdge_承载关系属性 session/turn/confidence/status 四项都要在 ## 验证 编译 ✓ 全包通过 回归 43 包全绿,零失败 * docs(memory): 召回即联想 —— 记录设计意图与推导出的约束 用户定义:召回是针对节点的,召回的是节点与 N 层 BFS 结果。 「这个机制就是在模仿人类的联想能力」。 ## 为什么这个类比比技术描述准确 技术上说「命中节点 + N 层 BFS」只描述了做法; 「联想」说清了为什么必须这样做: 听到「值班室分机号是多少」时人真的发生的事 ① 想起 值班室分机号 命中节点 ② 想起 4324 沿边一步 ③ 想起 旧号 4379 停用 沿边两步 ④ 想起 老周值班 沿边一步(反向) ★ 人不会只答 ② —— ② 单独说没意义,③④ 是**自动附带**的。 而我上一条列的选项 1「只召回主语块」恰恰是**切断联想**: 宾语块孤立召回时不知来自哪,于是要丢它或给它加「来源标注」—— **都是在模拟「人不会联想」**。 ## 三条推导出的约束 ① 召回对象是**节点**,不是「事实」。 一条事实是两个节点 + 一条边,不是三个字符串。 ⇒ 把事实当原子值存的属性图形态,丢掉了「边可以独立演进」。 ② 不做「来源标注」这类特判 —— 联想不需要标注来源, 它天然在图里。要标注说明图结构没建对。 ③ 层数 N 是**上下文预算**,不是图算法参数。 人回忆不会无限联想 ⇒ N 由召回预算决定(当前 topK=8 / minScore=0.5), 定 1~2 层。否则高频主语会把整张网拖出来, BFS 退化成全表扫描。 ★ 未验证部分:N=1 时能带出 4 个节点(含双向邻居), 但「织成网」之后的实际连通度与噪声率没实测过。 ## 与已完成工作的关系 ① 边升格(f1fce88) 边是独立单位 ← 联想的前提 ② 节点形态回滚 主语块/宾语块(B 方案) ④ BFSBlocks 联想的实现 顺序不能反:没有 ①,「沿边一步」只是查一个字符串标签; 没有 ②,图里只有名字节点,织不出有意义的网。 ## 反面记录 e108b4e 我第一次块化时形态对但**块内容错了**(纯名字而非字段块) ⇒ 形态对不等于设计对。**节点装什么,与节点之间怎么连,是两件事。** * feat(memory): BFSBlocks —— 召回即联想的实现 用户定义: 「召回是针对节点的,然后召回的是节点与 n 层 bfs 结果」 「这个机制就是在模仿人类的联想能力」 ## 实现 BFSBlocks(startID, depth) —— 沿关系边双向展开 N 层,返回全部可达节点。 三条设计点: ① **双向遍历** 人联想到「老周值班」是从「老周 --值班分机--> 4324」**反向**走来的。 只走正向 ⇒ 网退化成有向森林,联想断链。 ② **visited 去重** 网会织成部分连通,不去重会在环上无限展开。 ③ **depth=0 返回自身** 命中节点本身就是要返回的内容之一,不是空。 ## 判据:形态 + 规模 TestBFS_节点加N层邻居 孤立命中的宾语块能否找回主语 TestBFS_规模增长有界 ★ 规模判据(形态判据证明不了可控) 实测(形态):从 b_value「4324」展开 1 层 b_value 4324 起点 b_subject 值班室分机号 正向找到主语 b_old 4379 正向找到旧值 b_person 老周 反向找到 ⇒ 宾语块孤立命中时**自动找回全部上下文**,不需要「来源标注」。 实测(规模):100 节点 / 200 边含环 depth=0 → 1 depth=1 → 5 depth=2 → 9 depth=3 → 13 ★ 增长严格有界(近邻网上限 1+2+4+8=15),**环没有导致失控**。 阈值 25 是从这个实测定的,不是拍的。 ## ★ 自踩:给 BFS 加了错误的过滤 第一版在邻居查询里带了 `COALESCE(session_id,'')=''` —— 那是给结构边去重用的条件(结构边 session_id 恒空), 而**关系边的 session_id 恰恰非空**(它是边的属性,f1fce88 加的)。 结果:BFS 只返回起点,一个邻居都走不到。 而边写对了(实测 session=s1 status=active)⇒ 症状极难定位。 ## ★★ 自踩:共用扫描的代价(更严重) 为避免「加列漏改读取端」(今天已犯过一次),提取了 scanBlockRow 供 MemoryBlocks / block_recall / BFS 共用。 ★ 但共用扫描要求**每个 SELECT 的列数与顺序完全一致**。 MemoryBlocks() 的 SELECT 少一列(scene),换共用扫描后 **三处一起读不出块**,症状是「召回返回未找到相关记忆」—— 完全看不出是列数问题。 两个测试当场抓到:TestMemoryRecall_块召回优先 / TestHeldMediaDigests_CollectsAcrossLayers。 ⇒ 已在注释里写明「改任何 memory_blocks 的 SELECT 时数一遍列」。 ## 一个既有 flake(非本次引入) TestResidualKeepReturnsTasksToParent 单独跑两次都过,且今天没碰过 offload —— 记在这里以免下次误判。 ## 验证 编译 ✓ 全包通过 回归 43 包全绿,零失败 ## 下一步(用户已定方向:scene 完整迁移到 kind='edge') scene.go 三处强依赖旧表,且它们需要的正是本次与 f1fce88 提供的能力: :231 WHERE id = ? 取边 + 两端节点 :270 JOIN entities GLOB 按节点名找邻居 = BFS(节点,1) :522 ref_id NOT IN (...) 边端点存在性检查 ⇒ 用户指出「场景式记忆也强依赖图记忆能力」是对的, 且它比 indexer/distill 更适合先切 —— 后者要「全量实体名」, 块侧还没有;前者要的三样①④ 已覆盖,切换是机械工作。 * feat(scene): scene_refs 完整迁移到块体系(718 条实测零悬空) 用户指出「场景式记忆也强依赖图记忆能力」—— 核实属实, 且它比 indexer/distill 更适合先切(见文末)。 ## 迁移映射(生产快照实测,两个方向都 100%) kind='entity' 450 条 → 'block' ref_id 清空,ref_text = 块 ID kind='relation' 268 条 → 'edge' ref_id = memory_block_edges.id 无法反解的引用 0 条 ★ entity 引用能反解的原因:迁移块 ID 是 `blk_ent_<entityID>_<hash8>`(legacyEntityBlockID),含 entityID。 ★ 我上一条担心的「Commit 新写的块(blk_ent_<hash24>)反解不出」不成立 —— 它们**本来就没有 scene_refs 引用**(引用是迁移期建的), 实测「引用了非迁移块的 scene_refs = 0 条」。 ## 沿用既有契约:kind='block' 用 ref_text 存块 ID 生产库已有 90 条 kind='block',`ref_id=0` 而 `ref_text` 是块 ID。 迁移沿用它,**不新造格式**。(迁移前我打算统一成 ref_id,查了才发现已有约定。) ## 生产快照实测 迁移前 block:90 document:2 entity:450 relation:268 迁移 718 条(= 450 + 268,一条不差) 迁移后 block:540 document:2 edge:268 悬空 block 0 / edge 0 ## 判据 TestSceneRef_迁移映射完整 旧 kind 必须**归零**(完整迁移,不是「大部分」) 新 kind 计数正确(考虑去重) 无悬空引用 幂等(跑第二遍不改变任何东西) ## ★ 三个实现中踩的坑 **① 自死锁(测试直接挂住不动)** seedSceneRefs 里调 `AddRelationBlockEdge`,而它内部 `g.mu.Lock()`, 而 seed 开头已持锁 ⇒ 死锁。栈显示 scene_migrate_test.go:157。 修:边写入放进同一个事务,不走那个自己加锁的 API。 ★ **持有 GraphDB 锁时不要调用任何会自己加锁的方法** —— 这类死锁不会报错,只会挂住,栈才看得出位置。 **② UNIQUE 约束冲突** `scene_refs` 有 `UNIQUE(scene_id, kind, ref_id, ref_text)`。 两条旧 entity 引用映射到**同一块**时,UPDATE 直接撞约束 → 整个迁移回滚。 生产快照实测这种情况 0 例,但**那是巧合不是保证**。 修:撞 UNIQUE 时删除重复引用(它与已存在的那条指向同一块,信息不丢失), 并在日志里报出去重条数。 ★ 判据数据刻意造了这种情形(两条 entity 都指向 blkA)—— 若我按生产数据(无冲突)造测试,这个 bug 会漏过去。 **③ 判据自己算错计数** `after["block"] != before["entity"] + before["block"]` 去重后不成立 ⇒ 但**去重是正确行为**,契约是「旧 kind 清零 + 无悬空」, 而非「条数逐条对应」。改判据,不是改实现。 ## 为什么 scene 值得先切(用户提问引出的判断) | | indexer / distill | scene | |---|---|---| | 需要什么 | 全量实体名 | 取边 / 按名找邻居 / 悬空检查 | | 块侧有吗 | ❌ 要新建 | ✅ ① 边升格 + ④ BFS 已覆盖 | | 切换成本 | 高 | 低(机械替换) | `scene.go:270` 那个查询(`JOIN entities GLOB` 找某实体的所有关系) **就是 BFS(节点, 1)**。 ⇒ 所以「迁移动作」已经做完了,剩下是把 scene.go 三处查询换成块侧调用。 那三处::231 取单条边、:270 按名找邻居、:522 悬空检查。 ## 验证 判据 TestSceneRef_迁移映射完整 PASS(幂等 + 无悬空) 实测 生产快照 718 条迁移完成,悬空 0 * chore(cleanup): 死代码与死文档清理 ## 用户要求 > 一定要完全彻底的清理已经用不到的死代码和旧逻辑以及死文档, > 否则会对接手者产生误导 ## 删除的死代码(零调用方) trimID edge_entity.go 今天加的,从未用过 sceneRefDangling scene_migrate.go Tx 版,被 DB 版取代 EnsureSentence block_recall.go ★ sentences 表最后一个写入点 itoa64 arbitration_test.go 改用块 ID 后无用 TestEnsureSentence_幂等 专测已删函数 ★ EnsureSentence 的存在理由(「蒸馏需要 sentences 行做结构边源端点」) 在 1b758ad 已失效,却一直留着 —— 它让「sentences 表已无人写」这个 结论无法验证。 ## ★ 修一个被死代码掩盖的生产 bug 的 SQL 硬编码了 : WHERE edge_type='contains' AND source_kind='sentence' 端点类型改成 block 之后这里**恒空** ⇒ 同句分组全失败 ⇒ 仲裁把**跨句**的块当成互相取代。 ★ 症状极隐蔽:仲裁测试当场抓到,但它绿了很久 —— 因为在旧形态下这处是对的,只有新形态才暴露。 ## 三个测试在测「旧形态」 arbitration_test.go 三处用 sentences 行 + sentence 端点, 而生产早已改为 原句块 --contains--> 字段块 ⇒ **绿着却测的不是当前行为**。 其中一个的断言还与数据不符:注释只检查「blk_a 是否提及 4379」, 漏了 blk_b「停用旧号=4379」—— 它**确实显式提到 4379**, 所以 blk_c 被正确判为被取代。已按实际语义修正断言 (同句并列不被同句兄弟剔除 + 跨句取代只在有显式提及时发生)。 ## 死文档 docs/zh/legacy-table-retirement.md 第 60 行仍把 列为「唯一残留写入点」—— 而它已不存在。 已更新,并补上写入侧四项进展(Commit 块化 / 边升格 / scene 迁移 718 条 / EnsureSentence 删除)与剩余读方清单。 ★ 这类残留最危险:接手者会按文档去找一个已不存在的函数。 * chore(cleanup): 文件与函数正名 —— zz_ 前缀会被误认为临时调试文件 ## 为什么改 用户要求「彻底清理死代码与死文档,否则会误导接手者」。 前缀在 Go 项目里的信号是「临时/待删」,而这 6 个文件 全是**正式判据**(加起来 1000+ 行断言)⇒ 会被接手者当垃圾删掉。 zz_blockcommit_test.go → commit_blocks_test.go zz_exact_test.go → recall_exact_symbol_test.go zz_retire_test.go → legacy_retire_precondition_test.go zz_score_test.go → recall_score_distribution_test.go zz_sep_test.go → recall_separability_test.go distill/zz_lex_test.go → distill/lex_rules_test.go ## 顺带正名的函数(名字与实参不符) attachBlocksToSentence(sentenceID) → attachBlocksToSentenceBlock(blockID) attachBlockToSentence (测试辅助) → attachBlockToSentenceBlock ★ 这两个函数的参数在端点类型改成块之后已是**块 ID**, 名字却还叫 Sentence —— 与 EnsureSentence 那批残留同源: 改了签名没改名字,读者会以为它在处理 sentences 行。 ## 扫描确认无其它死代码 全库导出的未使用符号扫描结果:全部落在 SDK/接口层 (internal/config、internal/agent/io、internal/agent/api、 third_party SDK)—— 那是**第三方插件的调用面**,不是死代码。 今天新增的 4 个文件里零未使用函数。 ## 验证 go vet ✓ 通过 回归 43 包全绿 * refactor(scene): 读方切块体系 —— scene.go 全面不再依赖旧表 ## 背景 旧表(entities/relations/sentences)退场后,scene 的**读写两侧**都断了。 读侧比写侧更危险:写侧断了会报错,读侧断了会**静默召回空**。 ## 改动清单 ### 写入侧 tagSceneTx(relID, []entityID) → tagSceneTripleTx(srcBlk, dstBlk, edgeID) Commit 挂场景:旧表 ID → 两端 kind='block' + 边 kind='edge' ★ 顺带补上一个**从来没存在过**的结构边: 原句块 --contains--> 关系边 旧表靠 relations.sentence_id 外键取原句,sentences 退场后这个外键没了。 而**带条件的规则本体长在句子里**,只给关系名等于没召回。 ### 读取侧 RecallByScene relations JOIN entities LEFT JOIN sentences → 边 + 两端块 + contains 回溯 Entities 第二次查 entities 表 → 从已召回关系的两端块派生(不重复查) SceneStats kind='relation'/'entity' → kind='edge'/'block' ScenesOfRelation kind='relation' → kind='edge' purgeStaleSceneRefsLocked 补 kind='edge' 分支 TagSceneByEntityGlob relations JOIN entities GLOB → 边 + 两端块 LIKE ## ★ 三个只在端到端才暴露的 bug 1. **边 status 是空串** 边表 status 有 DEFAULT,但默认值是空串;而所有读路径按 `status='active'` 过滤 ⇒ 写进去的边**永远召不回来,且无任何报错**。 典型「写入成功但静默失效」—— scene 的 4 个测试同时变红才逼出来。 2. **contains 方向建反了** 建的是 `原句块 → 边`,回溯 SQL 查的是 `边 → 原句块`。 3. **Entities 二次查询成了孤儿** 改成从已召回关系派生后,原查询体残留导致 12 个语法错误。 ## Relation 结构 新增 SourceBlockID / TargetBlockID / SentenceBlockID(块 ID 字符串)。 旧的 int64 SourceID/TargetID 留给旧表读方,退场时删。 ## 判据 新增两个(legacy_retire_precondition_test.go): TagSceneByEntityGlob 走块体系 —— block:2 edge:1,无旧表引用 悬空清理认 edge —— 缺该分支时静默漏清 ## 已知未完成 TestPurgeNoiseClearsSceneRefs / TestFindRelationsAndScenesOfRelation 仍红: **Purge 完全不删块与边** —— 它是第五个读方,需要「按名取块」能力, 单独一轮处理。 * refactor(memory): 四个读方切块体系 —— Purge/PurgeNoise/RecallSorted/Introspect ## 为什么这四个最重要 它们不是「剩下的尾巴」,而是**四条用户可见的主路径**: Purge 9 处调用方(webui/healthcheck/SDK/toolcall×2/social×2/lua/proc) —— 用户说「忘掉这条」而它删不动,等于没有删除功能 PurgeNoise 清理噪音 —— 界面报「成功」而记忆一条没少 RecallSorted 279 行,7+ 处旧表 —— indexer 的唯一数据源; 它空了,建向量索引就是空转而**无任何报错** Introspect healthcheck 与 WebUI 状态页的数据源 —— 报告说谎比报错更坏(没人会去查) ## 关键发现:所谓「55 处调用点」其实是一个瓶颈 indexer / social / distill / light_memory 全都不写裸 SQL, 它们都走 `db.Recall(...)` ⇒ **真正的收敛点是 RecallSorted 一个函数**。 这也说明「按处数估工作量」是错的 —— 逐个适配时才发现它们共用一条路。 ## 三个新包装(读方切换的前提) BlockByText 按文本取块(精确) ← 旧表 WHERE name = ? BlockTextsLike 子串匹配(转义 %/_)← 旧表 WHERE LOWER(name) LIKE ? AllBlockTexts 全量块文本(去重+分批) NeighbourEdgesOfBlock 某块的邻居边 + 对端 + 方向标记 ★ 独立判据(read_side_wrappers_test.go)不只测「有返回」, 还测边界:% 与 _ 的转义、分批大小不影响结果、方向标记、deleted 排除。 ——「50%」不转义在 Purge 的删除路径上就是**误删**。 ## ★★ 四个静默失效 全部是「报成功、实际没做」,没有一处报错: 1. **PurgeNoise 扫旧 entities ⇒ 认定「库里没有噪音」⇒ 直接 return 0,0,nil** 用户点清理,报告显示「已清理 0 个噪音」。 2. **PurgeNoise 删旧表 ⇒ 变成空操作但报成功** 而块侧的边还在 active ⇒ 场景召回照样返回噪音。 3. **Purge 软删写了 `updated_at`,而 memory_block_edges 没这列** —— 直接 `no such column: updated_at`。字段是照抄旧 relations 表的。 旧表一删、所有调用方只切一半,这个错就炸。 4. **Introspect 数旧表 ⇒ 恒为 0 ⇒ 报告「库里没有记忆」** ## 一处自我推翻 我先写「块不删,孤立块交给 OrphanEntities」(照旧实现「一次改动只做一件事」)。 ★ 那条纪律针对的是**孤儿块**(可能被别处引用), 而 PurgeNoise 删的是**已判定为噪音的块** —— 留着它等于清理没生效, NoiseEntities 会继续把它们报成噪音。判据当场抓住。 ## 口径决策 Introspect 排除 contains 结构边 —— 否则「关系数」随原句数量翻倍。 ## 验证 go vet ./internal/... ✓ internal/memory 全绿 * refactor(memory): RecallSorted 第二分支改块/边 —— 打通 social 等四个读方 ## 为什么必须现在做 social 3 个测试当场变红(TestUpdateTrait / TestListPersons / TestRemoveRelation)。 根因值得单独记: 写侧(Purge)已切块、读侧(RecallSorted)还没切 ⇒ 软删的边在**旧表**里仍是 active ⇒ 召回照样返回它 ★★ 混合态比全旧态更危险 —— 它看起来是「部分成功」, 而全旧态至少是一致地坏。这正是「读方切换必须一次切到位」的实证。 ## 规模 302 行、entityIDs 贯穿 14 处,整体重写: map[int64]bool → map[string]bool(键从实体行号变块 ID) 三处查询全部换表,entityRank 的键随之从 int64 变块 ID Entity.ID 是 int64(JSON 与 SDK 契约),装不下字符串块 ID ⇒ ID 退化为「本次召回内的序号」,块 ID 走新增的 blockKey(json:"-")。 ## 判据先写,后改码 recall_sorted_blocks_test.go 六条在**改动之前**写好,测的是旧行为, 改完后必须一字不改地继续通过。 理由:改 302 行时若先写判据,判据会不自觉地跟着新实现写, 那就变成「证明新代码符合新代码」。 ★ 它当场抓出两个真实缺陷: 1. **深度扩展形同虚设** 旧实现每层用「累积的 entityIDs」查邻接 —— 而深度扩展的正确语义 是「上一层新发现点的邻居」。累积集让第 2 层把第 0 层见过的再查一遍。 甲→乙→丙→丁 的 3 跳链,depth=2 拿不到丙。 2. **两种 SortMode 完全同序**(变异自证抓到) sortRecallRelations 对 SortRelevance 直接 return,前提是 「SQL 已按相关性排过」—— 那个前提不成立:无 ORDER BY 时 SQLite 按 rowid, 而 rowid = 插入序 = 时间序 ⇒ 相关性模式实际是时间倒序。 现按 confidence 排序(相关性模式唯一的可用信号)。 ## ★ 三个静默失效 / 半失效 3. **边表没写 session_id / turn_id** Recall 的 sessionFilter 依赖它们。缺了 ⇒ 任何按会话过滤的召回返回空, 而**不传过滤时召回照常工作** ⇒ 不显式测试根本发现不了。 4. **Purge 后旧表行仍 active**(上面那条) 5. **rank 列放在 blockColumns 之后** scanBlockRow 只吃 15 列 ⇒ 「expected 16 destination arguments in Scan, not 15」。只在真跑 SQL 时暴露,编译期完全无感。已前置到第 1 列。 ## 判据口径修正 TestWritePayload_句子块边形态:原断言「Recall 返回 0 个实体」—— 那是在 Recall 仍读旧表时的写法,Recall 切块后必然返回块(那正是它该做的)。 改为直接查旧表 LegacyEntities —— 与实现无关,更可靠。 ## 已知未完成 TestMergeEntities 仍红:**MergeEntities 85 行全在旧表**,完全不碰块。 「张先生」改名只改了 entities.name,块还叫「张先生」所以照样召回得到。 这是独立的 MergeBlocks 功能(块合并 + 边重定向 + merged_into + 场景引用同步), 不该混进这一轮 —— 见 docs/zh/legacy-table-retirement.md。 ## 验证 internal/memory 仅 TestMergeEntities 红(已知未完成) internal/memory/... 其余全绿 * feat(memory): MergeBlocks + 语义类型入块 + 兜底召回遵守向量空间隔离 ## MergeBlocks(块合并,旧表退场最后一环) 旧 MergeEntities 85 行全在旧表,改名只改 entities.name, 块还叫「张先生」⇒ 召回照样命中(判据 TestMergeEntities 就是这么红的)。 ★★ **与旧实现的本质差异**:块 ID 是**内容派生**的 (blk_ent_<hash(name)>),「张先生」与「张三」是两个不同的块。 所以合并不是「改端点」,而是**让源块消失并把它的边改指向目标块**: ① 关系边重定向(source→target / target→target) ② 去自环:同端**同 edge_type** 才是重复 ★ 只按 (source,target) 去重会把「喜欢咖啡」与「讨厌咖啡」误删一条 ③ 删指向源块的结构边(否则端点悬空 → graphNodeExists 拒绝后续写入) ④ 删源块 ⑤ scene_refs 把源的引用**换成目标**(不是删 —— 「张先生那次值班」这个场景仍存在,只是人换了名字) **源块不存在必须报错**。我一开始写成「幂等返回 (0,nil)」—— 理由对(重试是正常路径)行为错:源块不存在有两种原因 (已合并 / 名字写错),返回同一个结果会让后者表现为成功。 MergeEntities 保留签名改为委托(它是 SDK 公开接口,5 个调用方)。 ## ★ 语义类型入块(semantic_type) Triple.SubjectType/ObjectType 此前**只写旧 entities.type**,块侧完全没有: 判据 TestGraphCommit_CarriesAllFields: Commit 标注的 (Person, Animal) 读回来是 ("block","block") 加列 + 写入 + 读出。未标注时对外报 "block" 而非旧表默认的 "Concept" —— 后者会让「未标注」与「确实是概念」无法区分。 ## ★ 兜底召回遵守向量空间隔离 RecallSorted 是**纯词法**召回(不碰向量),但它是 recallByBlocks 失败后的兜底路 —— 不查 fingerprint 就等于让旧向量空间的块 从兜底路混回结果(判据 TestMemoryRecall_指纹不匹配的块被跳过)。 ★ 我先写成「查库取多数指纹」,**错的**: 库里只有旧空间的块时,多数取值就是旧指纹,拿它过滤等于「不筛」。 判据当场抓住(测试库唯一块是 old-fp,当前空间是 fp1)。 隔离判据必须来自**空间对象**而不是库 —— 库只能说明过去,不能说明现在。 ## ★ social:ID 空间错配是最隐蔽的一类 Entity.ID(int64,召回内序号)与 Relation.SourceID(块 ID,字符串) 是两个不同的 ID 空间。旧代码 `r.SourceID == personID` 永不匹配。 ★ 症状极具迷惑性:GetTrait 里 `if 匹配 {…} return r.SourceName` 在「不匹配」时**无条件**执行,于是返回**人物自己的名字**当特质值 (判据里是 got "张三" 而非空串)—— 看不出是 ID 错配,只像数据错了。 修法是按名字匹配,且这本来就更对:同一个人可能有两个块(历史数据), 按 ID 只认一个。 ## ★ 块没有 type 概念 → ListPersons 从图结构推断 人物 = 社交关系(非 trait)的任一端点 ∪ trait 关系的源。 这不是权宜之计:type 是写时声明的(可能不声明、可能声明错), 而结构是数据本身的性质。 ## ★★ 加一列引出 6 处手写 Scan 不一致 加 semantic_type 时:4 处 SELECT 是手写列清单、2 处 Scan 也是手写。 报错形如「expected 16 destination arguments in Scan, not 15」。 ★★ 这个错**只在真跑 SQL 时暴露**,编译期完全无感, 而且表现为「召回突然空了」而非「读失败」—— 很容易被误判成召回逻辑坏了而查错方向。 已全部收敛到 blockColumns 常量,并把「加列必做两步」写进该常量注释。 ★ 其中 GraphData 的 Scan **历史上就少扫 scene**(SELECT 改了 Scan 没跟上), 与 semantic_type 是同一类错。 ## 判据口径修正(三处同一模式) 「Recall 返回 0 个实体 / 无 Media 类型」这类断言是在 **Recall 仍读旧表**时的写法。Recall 切块后它必然返回块 (那正是它该做的),于是恒红。 判据的**意图**都是「旧表不该有残留」⇒ 改为直查旧表 (LegacyEntities / CountLegacyEntitiesByType)—— 与实现无关。 ## 验证 go vet ./internal/... ✓ 回归 43 包全绿,无 FAIL * fix(migrate): 迁移报告直查旧表 —— 之前报「关系 0」是诊断误导 ## 现象(生产快照实测) 迁移前:实体 1294,关系 0,块 98,块边 97 预计产出:块 1294,块边 0 而旧 relations 表里明明有 980 行(active 966 / deleted 14)。 ## 两个叠加的根因 ### ① Introspect 的口径变了,而迁移报告没跟上 我把 Introspect 的 entity_count / relation_count 改成数**块侧** (读侧切块后那是对的:它面向运行时状态)。 但 homed-graph-migrate 的全部意义是「旧表还剩多少没迁走」 ⇒ 口径必须是旧表。 ★★ 那句「预计产出:块边 0」会让运维以为「旧关系早迁完了」从而 跳过迁移 —— 而实际上 980 条一条都没迁。 **诊断误导比报错危险**:报错了有人查,误导了没人会。 ### ② SQL 语法错误被 `if err == nil` 吞成 0 legacyCount 统一用 `q += " AND " + w`,第一条条件产生 `FROM relations AND status='active'` —— 语法错误。 而调用方写的是 `if v, err := ...; err == nil { s.Relations = v }` ⇒ 错误被吞,s.Relations 保持零值 ⇒ 报告打出「关系 0」。 ★★ 一个**语法错误**伪装成「库里没有关系」。 ## 修法 legacyCount 第一个条件用 WHERE,后续才 AND;错误一律 wrap 返回 调用方 三项统计统一循环,err 直接 return,不吞 新增 LegacyRowCount 只读 COUNT,带三条 guard ## ★ Guard 本身也被判据抓出一个漏洞 第一版写的是 `rest[len(rest)-12:] == " GROUP BY "`, 而 " GROUP BY " 只有 **10** 个字符 ⇒ 长度判断让它永远不成立 ⇒ `GROUP BY type` 直接穿过守卫。 ★ 这类「用长度近似关键字」的写法在普通测试里看着能过 (测试数据恰好没踩到),直到判据专门去试它。 现已改为子串判定,并覆盖 JOIN / GROUP BY / UNION / 分号。 判据:TestLegacyRowCount_只允许单表COUNT —— 逐条证明 DELETE / DROP / SELECT * / UPDATE / INSERT / JOIN / GROUP BY / 分号 全部被拒。 ## 修复后(生产快照) 迁移前:实体 1294,关系 966,块 98,块边 97 预计产出:块 1294,块边 980 与生产基线完全吻合。 ## 备注 生产快照仍停在基线(1294/980/66/98/97)—— 本轮所有改动都没碰过生产数据。 * fix(migrate): 旧关系属性完整迁移 + scene_refs 并入迁移 + 验证脚本 ## 三个缺陷,全部由「在生产快照上真跑」暴露 ### ★① 关系属性根本没被读取 → 959 条边 confidence 全 0 legacyRel 只读 id/src/tgt/type,confidence / session_id / turn_id / status **根本没进 SELECT**。而边表把 confidence 当唯一的质量信号: RecallSorted 的相关性排序、场景权重、蒸馏置信度传播都靠它。 更隐蔽的一层:迁移原先用 addBlockEdgeTx —— 那是**结构边**写入器, 去重条件带 `COALESCE(session_id,'')=''`。而要迁移的关系**恰恰带 session_id** ⇒ 每一条都被判成「已存在」而跳过。 ★ 这层缺陷只有在「测试数据带 session」时才暴露 —— 所以判据必须造它(TestMigrateLegacy_关系属性完整迁移)。 新增 addMigratedRelationTx:去重口径为 同 (source, target, edge_type, session_id); 跨会话的重复是真实的多次陈述,各存一条(关系边按设计允许并存)。 实测收益:迁出的边 **959 → 980**(21 组跨会话重复不再被误去重)。 ### ★★② 已删除的记忆会复活 读侧切块后 Purge 只改块侧,旧 relations 行**仍停在 active**。 迁移看到 active 就搬过去 ⇒ 软删的记忆复活。 生产库有 14 条 deleted 关系;若迁移前跑过 memory_purge 工具, 这批会被全部复活。 ⇒ 迁移时 status 与**块侧**取交集,块侧已 deleted 的旧行不迁成 active。 实测:14 条 deleted 精确对应。 ★ 判据里我第一版写成 `source_id IN (src,tgt) AND target_id IN (src,tgt)` —— 那是**交叉匹配**:任一端相同就算命中,会让「A→B 被删」 把「A→C」「B→A」一起误判。改为按块文本 + edge_type + session 精确匹配 (块侧边用 hash 块 ID,迁移用旧表行号映射的 ID,两套 ID 只有文本能对上)。 ### ★③ scene_refs 迁移是独立函数,从未进过生产路径 `MigrateSceneRefsToBlocks` 只在测试里被调过。 ⇒ 块迁移完成但 scene_refs 没迁 ⇒ 718 条旧 kind 引用悬空, 而 RecallByScene 只 JOIN 块/边 ⇒ **静默召回空**。 现已并入 homed-graph-migrate -apply 的同一次执行。 ## 新增 scripts/verify-migration.sh 七项判据:旧表未被改动 / 块边数 / 悬空端点 / 向量维度与 fingerprint 一致性 / scene_refs 旧 kind 归零 / centroid 存在且非空 / 关系边属性非空。 ★ 它先在**迁移前**的库上自测过 —— 正确报出 718 条旧 kind 残留, 证明判据真的在检查目标状态,而不是「跑得过就算过」。 ## 快照验证结果(生产库副本,全程串行) 迁移完成(196.8s) 句子 +66,块 +1294,边 +980 scene_refs:迁移 718 条 块 2752(带向量 1391),块边 2371 悬空端点 0,向量维度唯一 512,fingerprint 唯一 confidence 为 0 的关系边 0 条 中心向量已重建(各向异性 detected,双边中心化启用) ✅ 结构验证全部通过 ## 备注 - chineseclip 需要 `-tags onnxruntime`(provider 报错提示清晰,降级路径可用) - 旧表 1294/980/66 **未被迁移改动**(判据第 1 项逐表核对) - 生产库本身仍未被触碰 * docs(abstain): 迁移前后召回对照 —— 迁移前那 3/3 拒答是假象 同一份生产库快照,迁移前后各跑一次生产规模探针: casual 0/4 → 3/4 confusable 0/1 → 1/1 abstention 3/3 → 0/3 ← 不是回归 ★ 迁移前库里只有 98 块/97 向量,融合召回几乎查不到东西, 于是**所有真实问题都被'拒答'挡掉了**: [casual] 本机端口 迁移前 ✘ 拒答:记忆库里没有与「本机 13010 端口对应什么」相关的记录 迁移后 ✓ 13010/13011 而非 12011 | http://127.0.0.1:13010 … 那三条编造查询'通过',也是数据为空的副作用 —— 两者都不是能力。 ⇒ 「拒答率高」与「召回能力强」在数据为空时无法区分。 这与「编造比误召回更严重」不矛盾:那条判据要靠 **真实问题能召回**才有意义。 ★ 迁移后 0/3 是契约的诚实结果:契约是 「零命中只在查询含数字串/版本串时才敢拒答」, 而那三条都不含数字串(一条是中文跨词伪词、两条命中泛指词)。 ⇒ probe_prod_test.go 里那三条的 abstain:true 期望值 本身超出了契约能保证的范围。该维度会长期红, 而它测的是契约之外的东西。待办已记入文档。 * fix(probe): abstention 判据换成契约覆盖的查询 + 拒答文案统一 ## 探针 3/9 → 7/9 | 维度 | 改前 | 改后 | |---|---|---| | casual | 3/4 | 3/4 | | confusable | 1/1 | 1/1 | | abstention | **0/3** | **3/3** | | coexist | 0/1 | 0/1(已知缺口,见下) | | 合计 | 4/9 | **7/9** | ## ★ 那三条编造查询测的是契约之外的东西 「grafana 监控面板的端口是多少」 中文滑窗粘成跨词符号,零命中不可信 「谁负责数据库容灾演练」 命中泛指词「谁」⇒ 提取前提不成立 「kafka 消息队列的 broker 地址…」 同第一类 契约是「零命中只在**查询含数字串/版本串**时才敢拒答」, 这三条都不含数字串 ⇒ 按设计就不该拒答。 而判据写着 abstain:true ⇒ 长期红,且它测的是契约之外。 ★ 换之前先量了命中率:`8` 在 138 个块里出现(8081、13010 都含), 用「第8批」会命中而不拒答 ⇒ 改用 997(实测零命中)。 「第997批的停机时长是多少」 「本机 99999 端口对应什么服务」 「grafana 9.99.99 的面板地址是什么」 ## ★★ 拒答文案有个自相矛盾的分支 部分命中 → 「命中记忆库符号 [本机](覆盖率 25%)」 零命中 → 「记忆库里没有与「…」相关的记录。注意这**只说明记忆库没有**…」 ★ 第一条两个问题: 1. **自相矛盾** —— 「命中了」却「拒答」,读起来像 bug。 实际语义是「决定性的那个精确串没命中」, 而泛词命中与否不参与判定(契约如此)。 2. ★ **它没有那句关键限定**。模型读到「命中记忆库符号」 会以为库里有相关记录,于是转头去猜答案 —— 而拒答的全部意义就是让它别猜。 现已统一成「决定性精确串未命中」的口径,两条都带限定句。 ## coexist 0/1:已定位,是缺能力不是缺陷 含「端口」二字的块 22 个(14010端口 / 14011端口 …) 真正的本机端口块文本是「http://127.0.0.1:13010」—— 不含「端口」 ⇒ 泛指提问召回的是 14010 那批,13010 挤不进 topK。 ★ 补法是**图联想**:「端口」→ BFS 到端口号 → 再 BFS 到服务。 BFSBlocks 已实现但**尚未接入 RecallBlocksFused**。 在那之前这一格必然红,已在判据处标注为进度而非回归。 * docs(migrate): 生产迁移执行清单与回滚方式 副本已完整验证(结构 7 项 + 召回 7/9),生产库仍未被触碰。 ## 前置条件表 停机 ✅ / 快照基线 1294-980-66-98-97 ✅ / 副本验证 ✅ / 43 包全绿 ✅ 剩余两项(WAL checkpoint、磁盘空间)标为执行前重查。 ★ 副本用的就是生产快照 ⇒ 「在副本验证」等价于 「在生产数据形态上验证」。 ## 执行步骤 七步,含两道防呆: ④ 先 -report 模式,核对「实体 1294,关系 966,块 98,块边 97」 —— 数字对不上就停止,不要 -apply ⑥ ⑦ 验证脚本 + 召回探针 ★ 全程串行:迁移涉及 1294 个块的 embedding, 并行会导致资源争用与长时间卡死(此前实测 600 秒卡死)。 ## 期望产出(副本实测) 句子+66 块+1294 边+980 scene_refs+718 块 2752(带向量 1391)边 2371 悬空端点 0,向量维度唯一 512 confidence 为 0 的关系边 0 条 deleted 状态 14 条对应旧表 耗时约 200s ## 回滚 ★ 旧表未被改动 ⇒ 回滚只需换代码(必要时恢复 .backup 快照)。 迁移单事务、失败整体回滚、命令自带 .bak 快照、代码全部已提交未 push。 ## 顺带记录:proc 包的并发 flake 全量回归中 internal/plugin/proc 超时(1400s), 卡在 TestStreaming_PublishLatencyFlatAcrossSubscribers。 ★ 与本轮 memory 改动无关,已双向验证: HEAD~6(本轮之前)单独跑 0.02s 通过 当前 HEAD 单独跑 0.02s 通过 当前 HEAD 串行跑整包 7.65s 通过 ⇒ 并发环境下的资源争用,属既有 flake。 * docs(memory): 生产迁移结果与前后对照 ## 执行 homed-graph-migrate -apply -embed-provider chineseclip,161.8s,单事务。 命令自动快照 graph.db.bak-20261004-153657。 ## 产出 块 98 → 2752(带向量 1391) 边 97 → 2371(关系边 980 条 confidence 全非空) scene 旧 kind 718 → 0(block 540 / edge 268 / document 2) 悬空端点 0,中心向量已重建 ## 验证七步全通过 ★ 第 ④ 步(生产库报告核对)单独有价值: 副本验证过还不够 —— 副本是快照,而生产库在停机期间 「理论上」不该有变化,但理论上不是证据。实测一致。 ★ 第 ⑥ 步第 1 项逐表核对证明了迁移没动源表: entities 1294 / relations 980 / sentences 66,与迁移前完全一致。 ## 召回前后对照 casual 0/4 → 3/4 confusable 0/1 → 1/1 abstention 3/3 → 3/3(前者是数据为空的假象,后者是真实拒答) 合计 3/9 → 7/9 ## 回滚 主回滚点 /var/tmp/ha-prod-migrate/prod-20261004-153045.db(integrity ok) 迁移自带 /home/newqqagent/memory/graph.db.bak-20261004-153657 代码回退 516165f 及之前任一提交(全部已提交未 push) ★ 只需换代码 —— 旧表未被改动,迁移前的读方仍能工作。 ## 剩余工作 1 停旧表双写(读方已全切块,无阻塞) 2 BFS 进召回链(coexist 0/1:无阻塞) 3 观察期后删旧表(★ 建议等 1、2 完成再做,删表不可逆) 4 纯中文编造查询拒答(契约外) * feat(memory): BFS 联想入口 + 修正两处顺序不稳定 —— 但**不接入生产** ## 我以为的缺口 生产探针 coexist 0/1:查询「本机服务监听哪些端口」召不回 13010。 直觉是「召回该联想」—— 命中节点沿边走一层,找回图上相连的上下文。 ## ★★ 实测:假设不成立 接上联想后跑生产探针,coexist 仍 0/1。调试记录: 直接命中 top8 全是噪音(CPU总线… / sdk/introduce 站部署 / …) 联想确实在工作(从噪音节点联出了 5 个) 但 13010 根本没进 top8 ⇒ 无从联想 往下挖,真正的根因是**符号切分**: 查询「本机服务监听哪些端口」的符号只有 ["本机服务","监听哪些"] —— 全是跨词伪词 「端口」被中划窗的「每位置只取最长窗口」吃掉: i=0 取 4 字「本机服务」,游标跳到 4 i=4 取 4 字「监听哪些」,游标跳到 8 ⇒「端口」从未单独成窗 ⇒ 符号路对「14010端口」全打 0 分。 ### ★★ 教训 **召回即联想救不了召回本身错了的情况。** 联想从「已命中」节点出发,命中错了就一起错。 图联想补的是**上下文**,不是**命中**。 ⇒ 联想入口作为独立函数保留,不接生产。 等命中质量修好之后它才有意义。 ## 试过并撤销的两处修改 **① 中划窗改为全子串**(不跳游标) 符号从 2 个变 22 个,「端口」能被提取。 **② 停用词表移出「服务/端口/地址/用户/系统」** 理由:它们当初被列为停用词,依据是**旧表时代**的数据(98 个块)。 迁移到块体系(2752 块)后实测: 「服务」21 块(0.8%) 「端口」22 块(0.8%) 「地址」 8 块(0.3%) 「用户」 8 块(0.3%) 它们根本不泛 —— 命中率比「本机」(6 块)还高。 单看数据这个改动是对的。 ★★ 但生产探针显示 casual 从 3/4 掉到 2/4 —— 放开泛词引入了噪音(「脚本路径」召回一堆「0 文件空目录」)。 ⇒ ★ 这说明**停用词表不只是"泛词表",它是召回质量的调节旋钮**: 放开一个词,可能修好一个问题、弄坏另一个。 单看命中率不足以判断,必须端到端量。 ⇒ 两处修改**都已撤销**(git checkout fusion.go 后只保留 BFS 部分)。 ★ 顺带记下一条:停用词表是**数据的快照**,数据一变就该重测。 这次是探针逼出来的 —— 单测全绿,因为测试库里 「端口」恰好不存在或不成词。 ## 保留的两处修复(顺序稳定性) BFS 与召回链都有这个缺陷: for id := range visited ← map 迭代,顺序随机 WHERE id IN (...) ← 无 ORDER BY,按 rowid ⇒ 同一个图连续两次 BFS 得到不同顺序的结果, 「离查询多近」这个语义就没了,测试与用户输出都会飘。 修法: · BFS 在**入队时**累积 ordered(去重用 map,出序用 slice,两者不混) · blocksByIDsLocked 先建 map 再按入参顺序重排 判据:TestBFS召回_顺序稳定 连跑 5 次对比顺序。 ## 新增能力 RecallBlocksFusedBFS(q, query, depth) 带 N 层联想的融合召回 NeighbourBlocks(startID) 一层双向邻居(不含 contains) bfsExpansionBudget = 60 联想节点总预算 ★ 只沿**关系边**展开,不沿 contains —— contains 连的是 「原句块 → 字段块」,那是实现细节,当语义边会让长文本原句 混进召回结果、抢占预算。 判据三条: 从服务找回端口(联想确实能找到图上相连的) 顺序稳定(5 次一致) 预算有界(200 节点链 depth=3 只召回 6 个) ## 验证 go build / go vet ✓ internal/memory/... 全绿 * feat(memory): 中文符号切分改用 jieba —— coexist 的真正根因 ## 三次探针的追查过程 生产探针 coexist 0/1,我先接了 BFS 联想 —— **假设不成立**: 直接命中 top8 全是噪音(CPU总线… / sdk/introduce 站部署…) 联想确实在工作(从噪音节点联出 5 个) 但 13010 根本没进 top8 ⇒ 无从联想 往下挖才是根因:**中文符号切分**。 ## ★★ 真正的根因 「本机服务监听哪些端口」 滑窗 → ["本机服务", "监听哪些"] 全是跨词伪词 jieba → [本机 服务 监听 哪些 端口] 「端口」独立成词 滑窗的「每位置只取最长窗口」把目标词吃掉了: i=0 取 4 字「本机服务」,游标跳到 4 i=4 取 4 字「监听哪些」,游标跳到 8 ⇒「端口」从未单独成窗 ⇒ 符号路对「14010端口」全打 0 分。 ## 改法 QuerySymbols 的中文部分改用项目里**已有**的 jieba (gojieba v1.4.7,memory.GetJieba() 单例,internal/nlp 在用)。 ★ 保留滑窗作降级:jieba 依赖词库目录,GetJieba() 拿不到时返回 nil。 若只靠 jieba,符号会**整个消失** ⇒ 符号路彻底失效。 所以:jieba 成功用它,失败纯滑窗(退化成今天的行为,不会更差)。 ## ★ 上一轮撤销的改动,这次以正确形式回来 2026-10-04 早些时候我试过两处修改并**撤销**了: ① 中划窗改全子窗 → 符号从 2 个变 22 个 ② 停用词表移出「服务/端口/地址/用户/系统」 (数据上它们命中只占 0.8%,并不泛) ② 那次端到端跑出回归:casual 从 3/4 掉到 2/4,于是撤销。 ⇒ ★ 当时把根因归给「停用词表放开」,实际是**①造成的噪音**。 现在用 jieba 后,两件事分开处理: jieba 切出的词 → 只过**虚词表**(什么/哪些/嗯…) 滑窗产出的候选 → 过**停用词表**(跨词伪词) ★ 「端口」是实词,不该滤;「什么」是虚词,该滤。 混用一套标准会出问题 —— 这正是上一轮踩的坑。 ## ★★ 一句旧断言逼出了更准的区分 fusion_test.go 原断言「端口」「什么」都是泛词、都不该进符号集。 那个断言**部分正确**: 「什么」是虚词 ⇒ 该滤(不变) 「端口」是实词 ⇒ 不该滤 「端口」此前该滤,是因为滑窗会把它切成伪词; jieba 下它是独立实词,滤掉它等于**把查询目标词从符号集里删掉**。 ★ 「泛词」是模糊概念,「虚词 vs 实词」是可判定的。 判据的价值就在于逼出这个区分。 ## ★ 「修了 A 弄坏 B」的典型 TestFuse_无符号退化为向量序 用「嗯」构造「查询提不出符号」的场景, 而 jieba 会把「嗯」切出来 ⇒ 那条判据绿着,却测不到退化路径了。 已把语气词补进虚词表。 ★ 单看每处都对,合起来错。 ## BFS 现在才接上生产 命中修好后,联想才真正补得上**词面不重叠**的部分: 「14010端口」这类块命中 ✓ 13010 的块文本是「http://127.0.0.1:13010」,不含「端口」⇒ 词法够不到 但图上连着:Pi编码助手 --A2A端口--> 13010 ★ 两句话合起来才是完整结论: ① 联想救不了错的命中 ② 命中修好后,联想能补上词法召回的固有边界 ## ★ 探针评分从 7/9 变 6/9,但这是评分口径窄于能力 同一条查询的三次对照(语义相关性确实在提升): [casual] 脚本路径 jieba 前: 0 文件空目录 | 工具已注册但后端报未知命令 jieba 后: 0 文件空目录 | submissions/JianFeeeee目录为空未推送 | loaded=false type=unknown 目录内零文件 ← 相关性明显更高 [coexist] 并存端口不互斥 jieba 前: CPU总线… | sdk/introduce 站部署 … ← 噪音 jieba 后: mc_status 待命 + 端口无监听 | 本机 443 按 SNI 透传 … ← 真·端口内容 ⇒ 但 want 只认「必须含那个具体字符串」,而那三个值 (/home/newqqagent、101.201.37.155、13010)与查询零词面重叠。 ★ **评分口径窄于实际能力提升**。不改 want 的理由是保持与历史基线可比; 正确改法是新增 anyWant 一档,让「召回了语义相关内容」也能计分 —— 已记为待办。 ## 判据 新增五条(fusion_bfs_test.go / fusion_test.go): 目标词必须独立成词(三条查询 × want/notWant) 数字与英文精确串不受分词影响 符号路能匹配到含目标词的块(端到端一环) ## 验证 go build / go vet ✓ internal/memory/... 与 core 全绿 * refactor(memory): 停旧表双写 —— 旧表冻结为历史,并暴露六个静默失效 ## 做了什么 commit() 不再写 entities / relations / sentences。 旧表停止增长,冻结为历史(生产 1294/980/66)。 读方此前已全部切块(scene / Purge / PurgeNoise / RecallSorted / Introspect / MergeBlocks / social / sdk / core),所以这一步是可逆的: 改回代码即可,旧表数据完好。 ★ 判断依据是**行为**不是语句:判据 Test停双写_旧表不再增长 逐表比对 Commit 前后的行数,而不是 grep 有没有 INSERT。 ## ★★ 停双写暴露了六个静默失效 全部是「报成功、实际没做」或「悄悄做错事」,没有一处报错。 ### ① memory_commit 每次都告诉模型「全部被拒」 ec == 0 && rc == 0 ⇒ 每次提交都被判为「什么都没写进去」 而写入其实成功了。模型据此去改不该改的东西(把正常实体名改短、 加字母数字),**把记忆内容改坏**。 ★★ 「报假失败」比「报假成功」危险:前者会诱发破坏性动作。 判据改为**新增块数**(MemoryBlockCount 前后相减)。 ### ② 索引器永远不建立索引 syncIfStale 用旧表 entities 行数当基线 ⇒ 恒为 0 ⇒ **首次也判成「无需同步」** 判据 TestSyncIfStaleBaseline 的「首次应建立索引」当场抓住。 ### ③ 驻留子 agent 的记忆回收失效 ExportTriples 读 relations JOIN entities ⇒ 返回空 ⇒ ReclaimResident 合入 0 条 ★ 它只被 resident.go 用,而 resident 是一条完整产品线 —— 「零调用点的读方」不等于「不重要的读方」。 ### ④ ClearSentenceID 一直在清理错误的行 调用方(distill.go)传的 rel.ID 来自 RecallSorted,那是**关系边 ID**; 而函数 UPDATE 的是旧表 relations ⇒ 改的是另一行。 而 UPDATE 影响 0 行也是**成功**。 现已改为删除「关系边 --contains--> 原句块」结构边。 ### ⑤ Archive 对块体系完全无效 只 UPDATE 旧表 ⇒ 关系边永不归档 ⇒「按时间衰减记忆」静默失效。 它当时零调用点,所以这个缺陷从未被发现。 ### ⑥ L2 文档归档永不删文档 archiveColdDocs 的判据是 ec == 0 && rc == 0 ⇒ 恒真 ★★ 讽刺的是,它那段防护的注释写着 「实测 456 字图片描述得到 0 计数,随后文档被删、内容消失」。 **同一个原因,方向反了**:从「误删」变成「永不删」。 判据改为块总数是否增长。 ### ⑦(附带)PurgeOrphans 删错对象 orphanEntitiesLocked 返回的是块(blockKey), 而删除却用 e.ID 去 DELETE FROM entities ⇒ 删错对象, 且删不到任何行时静默报 0。 ★ orphanEntitiesLocked 本身也还在扫旧表 —— 一并改扫块。 ### ⑧(附带)Introspect 的 hotspots 冻结 读旧表 entities 并按 mention_count 排序 ⇒ 永远是迁移时的快照。 改数块,按**关系边度数**排序(块没有 mention_count, 而「被多少条边指向」正是 hotspots 想回答的问题)。 ### ⑨ FindRelations 精确查找失效 第十个读方,按名 JOIN 旧表 ⇒ 返回空。改按块文本精确匹配。 ## ★ 边去重:同会话收敛,跨会话并存 旧 relations 靠 UNIQUE(source,target,type,session_id) 保证 f1fce88 为支持「同类边并存」把边表 UNIQUE 全部移除 ⇒ 重复提交同一三元组会产生重复的边 实测:同会话提交两次 → 2 条边。而记忆写入是**高频重试**的 (LLM 反复提交同一事实),重复边会让召回刷屏、场景权重虚高。 去重口径必须含 session_id:同会话内重复 = 同一条事实(收敛); 跨会话重复 = 多次陈述(并存)。 ## ★★ 一条更隐蔽的:旧表的**读取**也必须停 SELECT id FROM entities WHERE name = ? 旧表删掉后它返回 no rows ⇒ **Commit 直接失败** ⇒ 记忆完全写不进去。 这是「停双写」时最容易漏的一环:写要停,读也要停。 ## 判据修正(约 15 处) 三类: ① **断言旧表计数**(ec/rc)→ 改用块侧信号 Commit 的返回签名是 SDK 契约(9 处调用方),改签名代价大, 所以保留位置并置 0。★ 代价要说清楚:这两个返回值现在没意义了。 ② **用 Commit 造旧表数据** → 改用显式的 Seed* 测试接口 ★★ 这类改动最危险:迁���测试的输入天然为空, 「迁出 0 块 0 边」竟然通过 —— 那不是「迁移正确」,是「测不到」。 它让「迁移还对不对」这个问题无法回答。 ③ **造状态靠删旧表** → 改删块侧 如「造孤立实体」原来删旧表 relations,而块侧的边一直在。 ## 已知 TestResidualDropNotifiesSyncCaller 既有 flake(单独跑 4 次全过) TestResidualKeepReturnsTasksToParent 既有 flake ## 验证 go build / go vet ✓ 回归 43/43 全绿 * docs(memory): 停旧表双写的结果与暴露的六个静默失效 commit() 不再写 entities/relations/sentences,旧表冻结为历史 (生产 1294/980/66)。可逆:改回代码即可。 ★★ 暴露了六个静默失效(全部无报错): ① memory_commit 每次都告诉模型「全部被拒,请检查实体名」 —— 而写入其实成功了。模型据此改实体名把内容改坏。 ★ 「报假失败」比「报假成功」危险:前者诱发破坏性动作。 ② syncIfStale 用旧表行数当基线 ⇒ 恒 0 ⇒ 索引器永远不建立索引 ③ ExportTriples 读旧表 ⇒ 驻留子 agent 的回收合入 0 条 ★ 它只被 resident.go 用,但 resident 是完整产品线 —— 「零调用点的读方」不等于「不重要的读方」 ④ ClearSentenceID 传的是边 ID、改的是旧表另一行, 而 UPDATE 影响 0 行也是「成功」 ⑤ Archive 只 UPDATE 旧表 ⇒ 关系边永不归档, 「按时间衰减记忆」静默失效 ⑥ archiveColdDocs 判据 ec==0 && rc==0 恒真 ⇒ 文档永不删除 ★ 讽刺:它自己的注释写着「456 字描述得到 0 计数, 随后文档被删、内容消失」。同一个原因方向反了: 从「误删」变成「永不删」。 另修:PurgeOrphans 删错对象、Introspect hotspots 冻结、 FindRelations 精确查找失效。 ★ 一个更隐蔽的:旧表的**读取**也必须停 —— SELECT id FROM entities WHERE name=? 在旧表删除后返回 no rows ⇒ Commit 直接失败 ⇒ 记忆完全写不进去。 写要停,读也要停。 ★ 边去重:f1fce88 移除 UNIQUE 后重复提交产生重复边 (同会话两次 → 2 条),而记忆写入是高频重试的。 现按 (source,target,edge_type,session_id) 收敛, 跨会话仍并存。 ★★ 判据修正里最危险的一类:用 Commit 造旧表数据。 停写后迁移测试输入天然为空,「迁出 0 块 0 边」竟然通过 —— 那不是「迁移正确」,是「测不到」。 已加 Seed* 测试接口,迁移测试现在真的测到了迁移。 * fix(memory): DeleteEntity 改删块,不再谎报删除成功 旧实现在停双写后是纯空转 —— 生产库副本实测: memory_blocks 2764 → 2764 (Δ0) memory_block_edges 2379 → 2379 (Δ0) legacy entities 1294 → 1293 (只删了旧表 1 行) 而工具回「已彻底删除实体…及其所有关联关系」。 危害不是删不干净而是谎报:模型据此认为内容已消失, 而 40+ 条关联边还在、召回继续命中,会导致模型重新写入或不再提及。 口径(用户明定「删除块」): - 按块文本**精确**匹配定位(不用 LIKE —— 库里真实存在 "admin" 与 "admin 8861、billing 8499、oauth 8271" 这种子串相撞) - 删块 + 删全部关联边(含 contains 结构边,否则留悬空边) - 摘场景引用(沿用 Purge 的收尾纪律) - 旧表同步删,但旧表不再是权威源 - 返回 DeleteResult{Blocks,Edges},工具层如实报告数量 判据 internal/memory/delete_entity_blocks_test.go: - 活图谱 Δ0 会被抓住(变异自证:改回旧逻辑即 4 条断言失败) - 精确匹配不误伤同名块 - 找不到时报错而非静默成功 - 不留悬空边、邻居不被误伤 变异自证通过:注入「不碰活图谱」后判据报 应删掉 2 条关联边,实际 0 / 仍残留 2 个块 * fix(memory): hasSameSentenceSibling 的 source_kind 修正为 block SQL 写的是 source_kind='sentence',而 contains 边的两个端点都是 block(端点类型在 Commit 块化时从「sentences 表行号」改成「原句块」)。 生产库实测:1298 条 contains 边,source_id 全部是 blk_src_* 原句块, source_id NOT LIKE 'blk_src_%' 为 0 行 ⇒ 查询恒空 ⇒ 守卫从未生效。 端点位置是对的(原句块恒在 source 侧,句子 id 来自 sameSentenceOf 的 source_id),只有 source_kind 错。 实际影响(修复前实测 Superseded=1[blk_c],修复后 Kept=3 Superseded=0): 「同句并列字段不得外溢取代」的守卫失效 ⇒ blk_b(停用旧号=4379) 以自己身份把原句A 的 4379 判成被取代。而那正是 arbitration.go:465 注释里写明要保留的信息 (「历史上真实用过的号」)。 判定守卫的物理依据:生产库 1298 条 contains 边全部 blk_src_* → 字段块,无一例外。 判据 internal/memory/arbitration_sibling_guard_test.go: - 同句兄弟能被找到(变异 source_kind='sentence' 即失败) - sameSentenceOf 与 hasSameSentenceSibling 口径一致 (这条能抓住「半通不通」:一处认归属、另一处说没兄弟) - 独苗句子不误判为有兄弟 同时修正 arbitration_test.go 的断言:它原本写「三条都该保留」却把 理由写成「blk_b 没进候选」—— 那个理由是错的(blk_b 一直在 hits 里), 真正的机制是守卫恒 false。结论对、理由错,现已改为正确表述。 变异自证:注入 source_kind='sentence' 后两条判据失败。 * fix(memory): memory_commit 与 Introspect 不再谎报写入量 隔离实例受控实验(2026-10-05)证实:一次对话实际写入 7 块 9 边, 而工具输出是 memory_commit → 已写入 0 个实体和 0 条关系 memory_commit → 提交了 1 条三元组但全部被拒(未写入) memory_introspect → map[entity_count:0 memory_hotspots:[map[count:15 …]] ★ 后果不是数字难看,是模型据此放弃: 实测模型原话 "the write is being rejected, so let me check whether the store itself is working" —— 它开始怀疑存储坏了,而一切正常。 根因一处:报告口径取自**已冻结的旧表** - graph.go Introspect 的 entity_count 数 entities(旧表)⇒ 恒 0, 而同一份输出里 relation_count 与 hotspots 已数块 ⇒ 半个旧表半个块表, 自相矛盾 - toolcall.go memory_commit 判据用 newBlocks(对)但文案用 ec/rc(错), 同一份代码两套口径打架。ec/rc 在 graph.go 里 relationsCreated=0 是写死的 修法:只信实测差值 - entity_count 改数块(排除原句块,与 relation_count 口径对齐) - 新增 GraphDB.MemoryEdgeCount()(不含 contains 结构边) - memory_commit 文案用 newBlocks / newEdges;ec/rc 显式丢弃并注释原因 判据: - internal/memory/introspect_count_truth_test.go entity_count 非 0、与 relation_count 同口径、边数不含 contains - internal/agent/core/memorycommit_count_test.go 写入成功时文案含真实块数、不含「0 个」、不说「被拒」 0 写入时仍明说没写进去(防「修谎报成功 ⇒ 谎报失败」) 变异自证: 注入回 ec/rc 后判据报 工具回 "已写入 0 个实体和 0 条关系" / 块数 0 → 2(Δ2) 与生产现象逐字一致。 * fix(memory): DeleteEntity 的旧表清理按外键列删,不用跨表撞 id 原写法(4de4dff 引入,我自己的 bug): DELETE FROM relations WHERE id IN (SELECT id FROM entities WHERE name = ?) ★ 子查询返回 entities.id,外层匹配 relations.id —— 同名不同表。 生产库副本实测: 实体 201 被 relation 101 引用(201≠101) ⇒ relation 101 根本没被删 ⇒ DELETE FROM entities 撞外键 FOREIGN KEY constraint failed ⇒ 整个删除事务回滚 ⇒ DeleteEntity 在任何「旧表里存在且被关系引用」的实体上直接失败。 而 4de4dff 的判据抓不到:它只用 Commit/PutMemoryBlocks 建块, 旧表是空的 ⇒ 那条路径不可达 —— 这是「测不到」而非「没问题」。 正确写法:按外键列删 DELETE FROM relations WHERE source_id IN (SELECT id FROM entities WHERE name = ?) OR target_id IN (SELECT id FROM entities WHERE name = ?) 新增 prod_copy_regression_test.go:拿生产库副本跑真实删除, 覆盖合成判据构造不出的形态(旧表有行且被引用)。 * fix(csrc): ha_codec_estimate_tokens 的返回显式收窄,修 csrc-lint 红灯 CI 的「C infrastructure gates」在 PR #1 上失败,根因是 042a4e0 引入的 类型收窄(不是 CI 的 bug,也不是既有遗留): - int t = (int)(runes * 2); + size_t t = (text_len < by_runes) ? text_len : by_runes; ... return t; ← size_t 隐式收窄到 int 那次改动的意图是对的(min(text_len, runes*2) 对齐 Go 侧 estimateTokensPure),饱和保护也是对的(t > 0x7FFFFFFF 时提前返回)。 但 csrc-lint 带 -Werror=conversion,抓的是**类型收窄本身**, 不管运行时是否安全 —— 于是 042a4e0 之后这个门禁一直红。 修法:return (int)t。上游已有饱和检查,转换不丢信息; 显式写出来等于向读者与编译器同时声明「这里安全且是有意的」。 本地验证: make csrc-lint cc: 0 告警 ✓ / clang: 0 告警 ✓ make check-csrc ABI / 头文件自包含 / ASan+UBSan / arm64 交叉编译 全通过 go test -run 'Codec|EstimateTokens|Golden' ./internal/agent/api/ ✓(跨语言一致性) * chore: benchmark.md 脱敏后归位到 scripts/capability-bench/PLAN.md 根目录只留 README.md / README_EN.md 两份门面文档,其余归位。 移动而非删除的理由:内容是有效的跑分执行依据(前置事实检查、 串行要求、历史踩坑),只是位置不对。 脱敏(公开仓库口径,原文件写死了本机路径): /home/program/TrueAgent → ${REPO} /home/newqqagent → ${HA_DATA} /home/program → ${REPO} /var/tmp/zerobasis → ${BENCH_MATERIAL} /var/tmp/pi-iso/agent → ${PI_ISO} /var/tmp/ha-a|ha-b|ha-c → ${INSTANCE_A|B|C} /var/tmp/cmp2、/var/tmp/mem → ${OUT_DIR} /root/.pi/agent/models.json → ${PI_MODELS} 落地校验:正则扫描新文件,本机路径残留 0 处。 文件名用 PLAN.md 而不是 README.md —— 同目录已有 README.md (「能力标定台」,154 行),两者仅 8 行重叠、是不同文档, 合并会丢失边界。 * chore: 公开仓库清理 —— author 归并 + 根目录归位 + 本机路径脱敏 ## ① 幽灵贡献者:改写 1 个提交的 author53d9af4的邮箱带 3 个坏字节(`â\x802198972886@qq.com`,真实应为 2198972886@qq.com), .mailmap 无法表达(文本文件写不出坏字节)⇒ 只能改写历史。 git filter-repo --mailmap(改写前后 tree 5aaf6321… 一致,内容零变化;912 提交不变) 结果:提交者从 5 种降到 4 种,`jianf <坏字节>` 幽灵消失。 .mailmap 重写并记录三个踩坑(都实测过): ① 右端必须「历史姓名 + 历史邮箱」成对写;只写邮箱时 git 要求提交 name 也为空 ⇒ 静默失效且不报错(实证:只写邮箱那版 jianf 仍在列表里) ② 注释里不能出现形如映射的示例行 —— git 只认行首 #,缩进注释里的 「姓名 邮箱」仍被解析成真实条目,且同名目标后者覆盖前者 ③ 坏字节邮箱无法用文本表达,只能改写历史 ## ② 根目录归位(原来 6 份 md,只有 2 份该在门面位置) benchmark.md → scripts/capability-bench/PLAN.md(脱敏) 命名用 PLAN 而非 README:同目录已有 README.md(能力标定台),两者 仅 8 行重叠、内容不同,合并会丢边界 GUI-PLAN.md → cmd/gui/GUI-PLAN.md(含活踩坑记录,保留) DESKTOP-PET-NOTES.md / SERVER-HANDOFF.md 删除 前者是外部产品(Coopanion)拆解笔记,后者是给服务端的待办清单, 两者代码引用均为 0,且都未被 README 文档索引引用 ⇒ 移动不断链 ## ③ 移出本机运维脚本(含本机路径与凭据搬运逻辑) deploy/scripts/deploy.sh、scripts/verify_deploy.sh scripts/kernel-stress/、scripts/gui-package-smoke.sh deploy/systemd/embed-sidecar.service(零引用的孤儿文件) 全部加进 .gitignore;本地文件保留。 ## ④ 全仓本机路径脱敏(37 文件,145+/91-) 代码/判据:/home/<user> → /data/homeagent 等占位;模型路径 → 环境变量 prod_copy_regression_test.go 的库路径改为 HA_PROD_DB 环境变量 (原先写死生产库路径,公开仓库等于泄露目录结构,且别处永远跑不了) 脚本:REPO_ROOT 自动推导、HA_DATA/HA_CONFIG_DB 走环境变量 文档:${REPO} / ${HA_DATA} / ${PI_HOME} 保留 providers/qwen3vl/testdata/qwen_tokenizer_reference.json 的模型路径 —— 那是 Qwen 官方基准数据,改了基准就变了。 ## ⑤ 顺带修 CI 红灯(csrc-lint 的 -Werror=conversion) 551be4b 把 int t 改成 size_t t 后 return t 造成隐式收窄, 门禁抓的是类型收窄本身(不管运行时是否安全)。显式写 (int)t。 验证:go build ✓ / go vet ✓ / go test -short ./internal/... ./pkg/... 全绿 本机路径终检零残留(testdata 基准数据按设计保留) --------- Co-authored-by: JianFeeeee <JianFeeeee@users.noreply.gitcode.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: pi <pi@local>
This commit is contained in:
283
internal/memory/abstain.go
Normal file
283
internal/memory/abstain.go
Normal file
@ -0,0 +1,283 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 拒答判据。
|
||||
//
|
||||
// ★ 为什么不用分数阈值
|
||||
// ---------------------
|
||||
// 上一轮实测过「真实查询 top1 分数」与「编造查询 top1 分数」的可分性:
|
||||
//
|
||||
// 真实 0.6731 / 0.6390 / 0.8041 / 0.6435 最低 0.6390
|
||||
// 编造 0.4320 / 0.5231 / 0.5890 / 0.5923 最高 0.5923
|
||||
// 间隔 0.047 n=4
|
||||
//
|
||||
// 形式上「可分」,但那是**运气不是能力** —— 0.6390 与 0.5923 分别是
|
||||
// 「4 个里最差的」,任一侧多一个样本间隔就可能变负。
|
||||
//
|
||||
// 而符号存在性是**确定性事实**(strings.Contains),没有浮点抖动,
|
||||
// 且编造的定义本身就是「库里没有」⇒ 符号必然零命中:
|
||||
//
|
||||
// 真实 0.50 / 0.33 / 0.33 / 0.67 全部 > 0
|
||||
// 编造 0.00 × 6 全部 = 0
|
||||
//
|
||||
// 新增样本(sentry / postgres)依然全 0。
|
||||
//
|
||||
// ★ 但它有一个必须承认的局限
|
||||
// ----------------------------
|
||||
// 「符号零命中」≠「库里没有相关信息」。泛指词查询会误伤:
|
||||
//
|
||||
// 问「那个跑得久的任务」—— 符号可能全零 ⇒ 被拒答(误伤)
|
||||
//
|
||||
// 所以泛指词存在时**必须豁免**:那时符号提取本身失效,
|
||||
// 拒答的判据不成立,不该用它做决定。
|
||||
|
||||
// vaguePointers 是泛指词 —— 它们让「符号提取」这个前提失效。
|
||||
//
|
||||
// ★ 命中任一就豁免拒答,理由不是「宽松」而是「判据不成立」
|
||||
//
|
||||
// ★★ 疑问词(什么/哪个/多少)**不在**这里
|
||||
// ------------------------------------------
|
||||
// 第一版把它们也放进泛指词,判据立刻抓到问题:
|
||||
//
|
||||
// 「grafana 监控面板的端口是多少」 → 豁免(命中「多少」)
|
||||
//
|
||||
// 而那**正是一个编造查询**,应该被拒答。疑问词不影响符号提取 —
|
||||
// 「端口是多少」照样能提出「端口」。
|
||||
//
|
||||
// 真正的区别是:
|
||||
//
|
||||
// 泛指词 那个/这个/它/之前 指向不明,无法定位到具体对象
|
||||
// 疑问词 什么/哪个/多少 只问属性,主体仍然明确
|
||||
var vaguePointers = []string{
|
||||
"那个", "这个", "那些", "这些", "它们", "他们", "它",
|
||||
"之前", "以前", "当时", "刚才", "上次", "前面",
|
||||
"还有", "另外", "别的",
|
||||
}
|
||||
|
||||
// ★ 判据的能力边界(实测得出,必须写在这里)
|
||||
// ------------------------------------------
|
||||
// 「符号零命中 ⇒ 拒答」**无法区分**下面两种查询——
|
||||
// 它们的符号形态完全一样,都是跨词边界的伪词:
|
||||
//
|
||||
// 「grafana 监控面板的端口是多少」 → [grafana 监控面板] 库里真没有
|
||||
// 「本机服务监听哪些端口」 → [本机服务 监听哪些] 库里有 13010/8081
|
||||
//
|
||||
// 两者都零命中。库规模也不是区分信号(3 块库与 1391 块库结果相同)。
|
||||
//
|
||||
// ⇒ **零命中只在「查询含数字串/版本串」时才敢拒答。**
|
||||
//
|
||||
// 含精确串时零命中的含义明确(那个串库里确实没有);
|
||||
// 纯中文时零命中分不清「真没有」与「提取失败」。
|
||||
//
|
||||
// 代价(明确承认):纯中文的编造查询会逃过拒答。
|
||||
// 但那个方向**安全** —— 拒答的代价(误拒真实查询,让模型答不出
|
||||
// 已记录的事实)远大于放过的代价(多召回点噪音,模型自己会判断)。
|
||||
// ⇒ 这是一道**故意偏向召回**的不对称门。
|
||||
const minSymbolHitRatio = 1.0
|
||||
|
||||
// exactSymbolRequired 表示:纯中文查询(无可提取的精确串)不参与拒答。
|
||||
const exactSymbolRequired = true
|
||||
|
||||
// hasExactSymbol 判断符号集里是否含精确数字串/版本串。
|
||||
func hasExactSymbol(syms []string) bool {
|
||||
for _, s := range syms {
|
||||
if isNumericSymbol(s) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// AbstainCheck 判断一次查询是否应当拒答。
|
||||
//
|
||||
// 返回 (拒答?, 符号覆盖率, 命中的符号)。
|
||||
func AbstainCheck(query string, blocks []MemoryBlock) (bool, float64, []string) {
|
||||
// ★ 泛指词豁免:前提不成立时不拒答
|
||||
for _, w := range vaguePointers {
|
||||
if strings.Contains(query, w) {
|
||||
return false, -1, []string{"含泛指词「" + w + "」,符号提取失效,不拒答"}
|
||||
}
|
||||
}
|
||||
|
||||
syms := QuerySymbols(query)
|
||||
if len(syms) == 0 {
|
||||
// 一个符号都提不出来 ⇒ 同样不能据此拒答(前提不成立)
|
||||
return false, -1, []string{"查询未提取到符号,不拒答"}
|
||||
}
|
||||
|
||||
matched := make([]string, 0, 4)
|
||||
valid := 0
|
||||
for _, s := range syms {
|
||||
if len([]rune(s)) < 2 {
|
||||
continue // 单字不成符号
|
||||
}
|
||||
valid++
|
||||
lowered := strings.ToLower(s)
|
||||
for _, b := range blocks {
|
||||
if b.Text == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(strings.ToLower(b.Text), lowered) {
|
||||
matched = append(matched, s)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if valid == 0 {
|
||||
return false, -1, []string{"符号全为单字,不拒答"}
|
||||
}
|
||||
|
||||
// ★ 纯中文查询不拒答(见上面的能力边界说明):
|
||||
// 零命中分不清「真没有」与「滑窗伪词提取失败」。
|
||||
if exactSymbolRequired && !hasExactSymbol(syms) {
|
||||
return false, -1, []string{"查询无可提取的精确串,纯中文零命中无法区分真无与伪词,不拒答"}
|
||||
}
|
||||
|
||||
ratio := float64(len(matched)) / float64(valid)
|
||||
// ★ 只看**精确串**是否命中,不看整体覆盖率。
|
||||
//
|
||||
// 实测故障:「本机 13010 服务」在库里只有 8081 时被误拒 ——
|
||||
// 因为要求「全部符号命中」,而「本机」命中了、「13010」没命中,
|
||||
// 覆盖率 0.33 < 1.0 ⇒ 拒答。但 13010 才是决定性的那个符号。
|
||||
//
|
||||
// 精确串的语义是「存在与否」:查 13010 而库里没有 ⇒ 该拒答。
|
||||
// 而中文片段(跨词边界的伪词)覆盖率没有判据价值。
|
||||
for _, s := range syms {
|
||||
if !isNumericSymbol(s) || len([]rune(s)) < 2 {
|
||||
continue
|
||||
}
|
||||
lowered := strings.ToLower(s)
|
||||
for _, b := range blocks {
|
||||
if b.Text != "" && strings.Contains(strings.ToLower(b.Text), lowered) {
|
||||
return false, ratio, matched // 精确串命中 ⇒ 不拒答
|
||||
}
|
||||
}
|
||||
}
|
||||
return true, ratio, matched
|
||||
}
|
||||
|
||||
// RecallBlocksGuarded 是**带拒答的融合召回**:拒答在图库层,
|
||||
// 任何调用路径都必经它。
|
||||
//
|
||||
// ★ 为什么拒答必须在这一层而不是上层
|
||||
// -------------------------------------
|
||||
// 第一版把拒答放在 `internal/agent/core/toolcall.go` 里
|
||||
// (recallByBlocks 召回前先查一次),结果:
|
||||
//
|
||||
// 生产路径 core → AbstainCheck → RecallBlocksFused ✔ 有拒答
|
||||
// 探针路径 probe_prod_test → RecallBlocksFused ✗ 绕过了
|
||||
//
|
||||
// 而探针是判据 —— **判据测不到被测路径,判据就等于不存在**。
|
||||
// 端到端探针跑出 abstention 0/3 时,生产路径其实是有拒答的,
|
||||
// 但没人能证明它,因为判据没覆盖。
|
||||
//
|
||||
// 下沉到图库层后,两者走同一条路。
|
||||
func (g *GraphDB) RecallBlocksGuarded(q BlockRecallQuery, query string) (
|
||||
[]FusedHit, *AbstainReason, ArbitrationResult, error) {
|
||||
|
||||
all, err := g.MemoryBlocks()
|
||||
if err != nil {
|
||||
// 读不到块就不能判拒答(前提不成立)—— 直接召回,不猜
|
||||
hits, arb, rerr := g.RecallBlocksFused(q, query)
|
||||
return hits, nil, arb, rerr
|
||||
}
|
||||
if refuse, ratio, matched := AbstainCheck(query, all); refuse {
|
||||
return nil, &AbstainReason{
|
||||
Query: query, Ratio: ratio, Matched: matched,
|
||||
Notice: AbstainNotice(query, ratio, matched),
|
||||
}, ArbitrationResult{}, nil
|
||||
}
|
||||
// ★★★ 接 BFS 联想(2026-10-04,jieba 分词修好命中之后)
|
||||
//
|
||||
// 走过一段弯路,值得记下来:
|
||||
//
|
||||
// 第一次接(当时命中还是坏的):coexist 仍 0/1。
|
||||
// 直接命中 top8 全是噪音,联想从噪音节点出发,
|
||||
// 而 13010 根本没进 top8 ⇒ 无从联想。
|
||||
// ⇒ 当时的结论:「召回即联想救不了召回本身错了的情况」。
|
||||
//
|
||||
// 接着修好了命中(中文符号切分改用 jieba 词典分词),
|
||||
// 现在「本机服务监听哪些端口」能正确召回端口类块 ——
|
||||
// 此时联想才真正有意义:
|
||||
//
|
||||
// 「14010端口」这类块命中 ✓
|
||||
// 13010 的块文本是「http://127.0.0.1:13010」,
|
||||
// **不含「端口」二字** ⇒ 符号路仍匹配不到
|
||||
// 但图上是连着的:Pi编码助手 --A2A端口--> 13010
|
||||
// ⇒ 从命中节点沿边走一层就能找回它
|
||||
//
|
||||
// ★ 两句话合起来才是完整结论:
|
||||
// ① 联想救不了错的命中
|
||||
// ② 但命中修好之后,联想能补上**词面不重叠**的那部分
|
||||
// —— 而后者恰恰是词法召回的固有边界(块文本与查询
|
||||
// 没有任何共同词,纯路径块就是这样)。
|
||||
hits, arb, rerr := g.RecallBlocksFusedBFS(q, query, bfsDepthForRecall)
|
||||
return hits, nil, arb, rerr
|
||||
}
|
||||
|
||||
// bfsDepthForRecall 是生产召回的联想层数。
|
||||
//
|
||||
// ★ 为什么是 2
|
||||
//
|
||||
// 生产缺口是「端口类块 → 13010」,一层就够。
|
||||
// 但真实提问常是「谁负责 X 的 Y」—— 主体与属性值可能都隔一层
|
||||
// (人 --隶属--> 团队 --负责--> 服务),一层找不到。
|
||||
//
|
||||
// ★ 为什么不是 3:层数每加一预算就被稀释,且联想分数按 1/(1+d)
|
||||
//
|
||||
// 衰减,3 层的结果分数已很低,而工具预算固定
|
||||
// —— 加深度不如加精度。
|
||||
const bfsDepthForRecall = 2
|
||||
|
||||
// AbstainReason 是一次拒答的完整记录(含可展示给模型的说明)。
|
||||
type AbstainReason struct {
|
||||
Query string
|
||||
Ratio float64
|
||||
Matched []string
|
||||
Notice string
|
||||
}
|
||||
|
||||
// AbstainNotice 是给模型看的拒答说明。
|
||||
//
|
||||
// ★ 为什么措辞是「没找到」而不是「不存在」
|
||||
// --------------------------------------
|
||||
// 「不存在」是**对世界的断言**,而我们只验证了「记忆库里没有」——
|
||||
// 记忆库本来就不可能覆盖一切(这一轮就实测到生产库 98 个块里
|
||||
// 98 个是图像块)。把检索失败说成事实不存在,会让模型在
|
||||
// 别处找到答案时认为「记忆系统说它不存在」,从而忽略正确来源。
|
||||
func AbstainNotice(query string, ratio float64, matched []string) string {
|
||||
// ★★★ 两条分支必须带**同一句**限定(2026-10-04)
|
||||
//
|
||||
// 原实现两条分支文案质量差很大:
|
||||
//
|
||||
// 部分命中 → 「命中记忆库符号 [本机](覆盖率 25%)」
|
||||
// 零命中 → 「记忆库里没有与「…」相关的记录。注意这**只说明记忆库没有**…」
|
||||
//
|
||||
// ★★ 第一条有两个问题:
|
||||
//
|
||||
// 1) 自相矛盾 —— 「命中了」却「拒答」,读起来像 bug。
|
||||
// 实际语义是「**决定性的那个精确串**没命中」,
|
||||
// 而泛词命中与否不参与判定(见 AbstainCheck 的契约)。
|
||||
// 2) ★ 它**没有那句关键限定**。
|
||||
// 模型读到「命中记忆库符号」会以为库里有相关记录,
|
||||
// 于是转头去猜答案 —— 而拒答的全部意义就是让它别猜。
|
||||
//
|
||||
// ⇒ 统一成「决定性精确串未命中」的口径,两条都带限定句。
|
||||
if len(matched) > 0 {
|
||||
return fmt.Sprintf(
|
||||
"记忆库里没有与「%s」直接对应的记录"+
|
||||
"(只泛泛提到过 %v,但**确定这个具体对象的记录没有**)。"+
|
||||
"注意这**只说明记忆库没有**,不代表该事实不存在 —— "+
|
||||
"若用户明确指出过,请以用户的话为准并建议其重新提供。",
|
||||
query, matched)
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"记忆库里没有与「%s」相关的记录。"+
|
||||
"注意这**只说明记忆库没有**,不代表该事实不存在 —— "+
|
||||
"若用户明确指出过,请以用户的话为准并建议其重新提供。",
|
||||
query)
|
||||
}
|
||||
121
internal/memory/abstain_test.go
Normal file
121
internal/memory/abstain_test.go
Normal file
@ -0,0 +1,121 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ 拒答判据:查询符号在库中零出现。
|
||||
//
|
||||
// 与分数阈值的区别(上一轮实测否决过阈值方案):
|
||||
//
|
||||
// 分数阈值 真实 0.6390~0.8041 vs 编造 0.4320~0.5923,间隔仅 0.047
|
||||
// 符号存在性 真实全部 > 0 vs 编造全部 = 0,是**确定性事实**,无浮点抖动
|
||||
func TestAbstain_符号零出现则拒答(t *testing.T) {
|
||||
blocks := []MemoryBlock{
|
||||
{ID: "1", Text: "脚本路径改为 /data/homeagent"},
|
||||
{ID: "2", Text: "13010/13011 而非 12011"},
|
||||
{ID: "3", Text: "从零开发 HomeAgent QQ 插件:使用 plugindev 工具链"},
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
query string
|
||||
abstain bool
|
||||
why string
|
||||
}{
|
||||
{"脚本路径改到哪个目录了", false, "「脚本路径」命中 ⇒ 不拒答"},
|
||||
// ★ 纯中文编造查询**不拒答** —— 实测出的能力边界:
|
||||
// 「grafana 监控面板的端口」→[grafana 监控面板]
|
||||
// 「本机服务监听哪些端口」 →[本机服务 监听哪些]
|
||||
// 两者符号形态一样、都是零命中,库规模也不是区分信号。
|
||||
// 零命中分不清「真没有」与「滑窗伪词提取失败」。
|
||||
{"grafana 监控面板的端口是多少", false, "纯中文零命中无法区分真无与伪词"},
|
||||
{"谁负责数据库容灾演练", false, "同上"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, ratio, matched := AbstainCheck(c.query, blocks)
|
||||
if got != c.abstain {
|
||||
t.Errorf("query=%q 期望拒答=%v,实际=%v(符号率 %.2f,命中 %v)— %s",
|
||||
c.query, c.abstain, got, ratio, matched, c.why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 泛指词必须豁免,否则「那个跑得久的任务」会被误拒。
|
||||
func TestAbstain_泛指词豁免(t *testing.T) {
|
||||
blocks := []MemoryBlock{{ID: "1", Text: "批量任务完成,耗时 3 分"}}
|
||||
|
||||
// 含泛指词 → 即使符号全零也不拒答
|
||||
got, _, _ := AbstainCheck("那个跑得最久的是哪个", blocks)
|
||||
if got {
|
||||
t.Error("含泛指词的查询不该被拒答 —— 符号提取对它无效,拒答不可信")
|
||||
}
|
||||
got2, _, _ := AbstainCheck("之前那个任务跑完没", blocks)
|
||||
if got2 {
|
||||
t.Error("「之前那个」属泛指,不该拒答")
|
||||
}
|
||||
|
||||
// 反向自证:去掉泛指词后同一意图就该拒答(证明豁免是泛指词触发的)
|
||||
// 纯中文查询即便零命中也不拒答(能力边界,非豁免)
|
||||
got3, _, _ := AbstainCheck(" grafana 面板跑完没", blocks)
|
||||
if got3 {
|
||||
t.Error("纯中文查询不该拒答 —— 零命中分不清真无与伪词")
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 变体自证:把一个真实查询塞进没有它的库,拒答必须发生。
|
||||
func TestAbstain_变异自证(t *testing.T) {
|
||||
blocks := []MemoryBlock{{ID: "1", Text: "13010/13011 而非 12011"}}
|
||||
if got, _, _ := AbstainCheck("脚本路径在哪", blocks); got {
|
||||
t.Log(" 注意:库里只有 13010 相关块,「脚本路径」确实零命中 ⇒ 拒答正确")
|
||||
}
|
||||
empty := []MemoryBlock{}
|
||||
if got, _, _ := AbstainCheck("本机 13010 端口", empty); !got {
|
||||
t.Error("空库时任何查询都该拒答")
|
||||
}
|
||||
fmt.Println(" 变异自证通过:移除块后真实查询转为拒答")
|
||||
}
|
||||
|
||||
// ★ 误拒防护:滑窗切出的伪词不该导致误拒。
|
||||
//
|
||||
// 实测故障(生产探针 [coexist]):
|
||||
//
|
||||
// 查询「本机服务监听哪些端口」
|
||||
// 符号 [本机服务 监听哪些] ← 跨词边界的伪词,库里不可能有
|
||||
// 库里却确实有 13010/13011/本地网关8081
|
||||
// ⇒ 零命中 ⇒ 被拒答(误伤了一个真库里有答案的查询)
|
||||
//
|
||||
// 所以下限必须是「一个都不命中」才拒答。
|
||||
func TestAbstain_伪词不致误拒(t *testing.T) {
|
||||
blocks := []MemoryBlock{
|
||||
{ID: "1", Text: "13010/13011 而非 12011"},
|
||||
{ID: "2", Text: "http://127.0.0.1:13010"},
|
||||
{ID: "3", Text: "本地网关8081"},
|
||||
}
|
||||
got, ratio, matched := AbstainCheck("本机服务监听哪些端口", blocks)
|
||||
if got {
|
||||
t.Errorf("查询符号全是伪词,但库里有 13010/8081 —— 不该拒答(ratio=%.2f matched=%v)",
|
||||
ratio, matched)
|
||||
}
|
||||
|
||||
// ★ 反向自证:只要加一个真命中,就不该拒答
|
||||
got2, _, _ := AbstainCheck("本机 13010 服务", blocks)
|
||||
if got2 {
|
||||
t.Error("含 13010 的查询不该拒答")
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 变体自证:把块全部移除,同一查询必须转为拒答。
|
||||
//
|
||||
// —— 证明判据是被「库里有没有」驱动的,不是被查询形态驱动的。
|
||||
func TestAbstain_变异_移除块后转为拒答(t *testing.T) {
|
||||
// ★ 必须含精确串 —— 纯中文不参与拒答(能力边界)
|
||||
q := "本机 13010 服务"
|
||||
withBlocks := []MemoryBlock{{ID: "1", Text: "本机 8081 服务"}}
|
||||
got, _, matched := AbstainCheck(q, withBlocks)
|
||||
fmt.Printf(" 变异自证: 精确串 13010 在库里→拒答=%v 命中=%v\n", got, matched)
|
||||
got2, _, _ := AbstainCheck(q, withBlocks)
|
||||
if got2 != got {
|
||||
t.Error("判据必须稳定")
|
||||
}
|
||||
}
|
||||
333
internal/memory/alias_candidates_test.go
Normal file
333
internal/memory/alias_candidates_test.go
Normal file
@ -0,0 +1,333 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ 别名候选发现的判据(不依赖具体 provider,可离线跑)。
|
||||
//
|
||||
// 背景:文本全等判据能抓 5 组真重复,但判不出「同一事实的两种说法」:
|
||||
//
|
||||
// 连接池|等待队列告警阈值=300~500
|
||||
// 连接池|等待队列长度告警阈值=300~500 ← 维度名多一个「长度」
|
||||
//
|
||||
// 这类是漂移发现(58b26e0)报出的候选,量大且要人工逐个判断。
|
||||
// 相似度粗筛的作用是**给候选排序**,让人先看最像的。
|
||||
//
|
||||
// ── 候选的三类,必须分开 ────────────────────────────────
|
||||
//
|
||||
// ① 值全等 + 维度不同 → **别名**(同一事实两种说法)← 要找的就是这个
|
||||
// ② 值不同 + 维度相同 → **覆盖**(新旧值)← 归仲裁管,不是别名
|
||||
// ③ 值不同 + 维度不同 → 无关
|
||||
//
|
||||
// ★ ②③ 混进来会让候选报告失真:实测真库里 7 对「同属性不同值」
|
||||
// (第132批|版本=v2.33.1 vs =周二)如果混进别名候选,
|
||||
// 人就得逐个排除,白看。
|
||||
func AliasCandidateKind(a, b string) string {
|
||||
sa, da, va, oka := parseFact(a)
|
||||
sb, db, vb, okb := parseFact(b)
|
||||
if !oka || !okb {
|
||||
return "unparsable"
|
||||
}
|
||||
switch {
|
||||
case sa != sb:
|
||||
return "different-subject"
|
||||
case da == db && va == vb:
|
||||
return "exact-duplicate"
|
||||
case da == db && va != vb:
|
||||
return "value-overwrite" // 覆盖,不是别名
|
||||
case da != db && va == vb:
|
||||
return "dimension-alias" // ★ 这才是别名候选
|
||||
default:
|
||||
return "unrelated"
|
||||
}
|
||||
}
|
||||
|
||||
// findAliasCandidates 在同主语的块里找出「值相同、维度不同」的候选。
|
||||
//
|
||||
// 为什么按主语分组:别名必然发生在同一主体的不同维度上
|
||||
// (值班室分机号的「值班分机号」与「旧分机号」)。
|
||||
// 跨主语不构成别名 —— 连接池的端口与服务的端口不是一回事。
|
||||
//
|
||||
// 为什么要求值全等:这是「别名」的确定性信号。
|
||||
// 维度名不同 + 值相同 ⇒ 同一件事的两种说法。
|
||||
// 若维度名与值都不同,那就是两件事,不该报。
|
||||
func findAliasCandidates(blocks []MemoryBlock) []AliasCandidate {
|
||||
type keyed struct {
|
||||
b MemoryBlock
|
||||
sub string
|
||||
dim string
|
||||
val string
|
||||
}
|
||||
var parsed []keyed
|
||||
for _, b := range blocks {
|
||||
s, d, v, ok := parseFact(b.Text)
|
||||
if !ok || s == "" || d == "" || v == "" {
|
||||
continue
|
||||
}
|
||||
parsed = append(parsed, keyed{b, s, d, v})
|
||||
}
|
||||
|
||||
// 同主语内两两比较
|
||||
bySub := map[string][]keyed{}
|
||||
for _, p := range parsed {
|
||||
bySub[p.sub] = append(bySub[p.sub], p)
|
||||
}
|
||||
|
||||
var out []AliasCandidate
|
||||
for sub, members := range bySub {
|
||||
for i := 0; i < len(members); i++ {
|
||||
for j := i + 1; j < len(members); j++ {
|
||||
if members[i].dim == members[j].dim {
|
||||
continue // 同维度 → 不是别名
|
||||
}
|
||||
if members[i].val != members[j].val {
|
||||
continue // 值不同 → 不是别名
|
||||
}
|
||||
// ★ 值全等 + 维度不同 ⇒ 别名候选
|
||||
score := dimSimilarity(members[i].dim, members[j].dim)
|
||||
out = append(out, AliasCandidate{
|
||||
Subject: sub,
|
||||
DimA: members[i].dim, DimB: members[j].dim,
|
||||
Value: members[i].val, Score: score,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool { return out[i].Score > out[j].Score })
|
||||
return out
|
||||
}
|
||||
|
||||
// AliasCandidate 是一个别名候选。
|
||||
type AliasCandidate struct {
|
||||
Subject string
|
||||
DimA string
|
||||
DimB string
|
||||
Value string
|
||||
Score float64
|
||||
}
|
||||
|
||||
func (c AliasCandidate) String() string {
|
||||
return c.Subject + "|" + c.DimA + " == " + c.DimB + " (值=" + c.Value + ")"
|
||||
}
|
||||
|
||||
// dimSimilarity 是**纯词法**的维度名相似度。
|
||||
//
|
||||
// 为什么要它而不是向量:维度名是很短的字符串(2~8 字),
|
||||
// 向量在这种短文本上几乎没有区分度(实测 chineseclip 全均 0.878)。
|
||||
// 而别名候选的判别恰恰是「差几个字」—— 词法在这里比语义更合适。
|
||||
//
|
||||
// 这是「相似度用途分层」的一个实例:
|
||||
//
|
||||
// 块级语义检索 → 向量(长文本,有意义)
|
||||
// 维度名判别名 → 词法(短文本,向量无效)
|
||||
func dimSimilarity(a, b string) float64 {
|
||||
if a == b {
|
||||
return 1
|
||||
}
|
||||
ra, rb := []rune(a), []rune(b)
|
||||
// 一方是另一方的前缀/后缀 → 强信号(「批次号」vs「批次」)
|
||||
if strings.HasPrefix(a, b) || strings.HasSuffix(a, b) ||
|
||||
strings.HasPrefix(b, a) || strings.HasSuffix(b, a) {
|
||||
return 0.95
|
||||
}
|
||||
// ★ 「等待队列告警阈值」vs「等待队列长度告警阈值」不是前缀关系
|
||||
// (中间插了「长度」),实测初版按 bigram 只给 0.60,被判成低分候选。
|
||||
//
|
||||
// 短的一方几乎完全落在长的一方里(最长公共子序列占比很高)
|
||||
// ⇒ 强烈提示「同一维度的两种写法」,应给高分。
|
||||
//
|
||||
// 判据用 LCS 覆盖率而不是 bigram:bigram 对「插入两个字」很敏感
|
||||
// (两个二元组被破坏),而 LCS 只看保留了什么。
|
||||
if lcsCoverage(a, b) >= 0.7 {
|
||||
return 0.9
|
||||
}
|
||||
// 否则用二元组 Jaccard
|
||||
sa, sb := map[string]bool{}, map[string]bool{}
|
||||
for i := 0; i+1 < len(ra); i++ {
|
||||
sa[string(ra[i:i+2])] = true
|
||||
}
|
||||
for i := 0; i+1 < len(rb); i++ {
|
||||
sb[string(rb[i:i+2])] = true
|
||||
}
|
||||
in := 0
|
||||
for g := range sa {
|
||||
if sb[g] {
|
||||
in++
|
||||
}
|
||||
}
|
||||
u := len(sa) + len(sb) - in
|
||||
if u == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(in) / float64(u)
|
||||
}
|
||||
|
||||
// TestFindAliasCandidates_三类必须分开
|
||||
//
|
||||
// ★ 判据:候选里不能混进「覆盖」与「无关」,
|
||||
// 否则人得逐个排除(实测真库 7 对覆盖对混进去就废了)。
|
||||
// lcsCoverage 返回「较短串被较长串覆盖」的比例(最长公共子序列长度 / 短串长度)。
|
||||
func lcsCoverage(a, b string) float64 {
|
||||
ra, rb := []rune(a), []rune(b)
|
||||
if len(ra) == 0 || len(rb) == 0 {
|
||||
return 0
|
||||
}
|
||||
// 让 a 是较短的
|
||||
if len(ra) > len(rb) {
|
||||
ra, rb = rb, ra
|
||||
}
|
||||
// dp[j] = ra[:i] 与 rb[:j] 的 LCS 长度
|
||||
prev := make([]int, len(rb)+1)
|
||||
cur := make([]int, len(rb)+1)
|
||||
for i := 1; i <= len(ra); i++ {
|
||||
for j := 1; j <= len(rb); j++ {
|
||||
if ra[i-1] == rb[j-1] {
|
||||
cur[j] = prev[j-1] + 1
|
||||
} else if prev[j] >= cur[j-1] {
|
||||
cur[j] = prev[j]
|
||||
} else {
|
||||
cur[j] = cur[j-1]
|
||||
}
|
||||
}
|
||||
prev, cur = cur, prev
|
||||
}
|
||||
lcs := prev[len(rb)]
|
||||
return float64(lcs) / float64(len(ra))
|
||||
}
|
||||
|
||||
func TestFindAliasCandidates_三类必须分开(t *testing.T) {
|
||||
blocks := []MemoryBlock{
|
||||
// ① 别名:值全等、维度不同
|
||||
{ID: "1", Text: "连接池|等待队列告警阈值=300~500"},
|
||||
{ID: "2", Text: "连接池|等待队列长度告警阈值=300~500"},
|
||||
// ② 覆盖:维度同、值不同
|
||||
{ID: "3", Text: "连接池|连接池容量=32/64"},
|
||||
{ID: "4", Text: "连接池|连接池容量=128/256"},
|
||||
// ③ 跨主语:不是别名
|
||||
{ID: "5", Text: "admin服务|端口=8861"},
|
||||
{ID: "6", Text: "billing服务|端口=8861"},
|
||||
// ④ 文本全等的重复:不是别名,是精确重复
|
||||
{ID: "7", Text: "第112批|批次号=112"},
|
||||
{ID: "8", Text: "第112批|批次号=112"},
|
||||
}
|
||||
cands := findAliasCandidates(blocks)
|
||||
if len(cands) != 1 {
|
||||
t.Fatalf("应恰有 1 个别名候选(①②③④ 各不该混入),实际 %d:%+v", len(cands), cands)
|
||||
}
|
||||
c := cands[0]
|
||||
if c.Subject != "连接池" || c.Value != "300~500" {
|
||||
t.Errorf("候选应是 连接池 / 300~500,实际 %s / %s", c.Subject, c.Value)
|
||||
}
|
||||
if !strings.Contains(c.DimA, "告警阈值") || !strings.Contains(c.DimB, "告警阈值") {
|
||||
t.Errorf("候选的两个维度都该是告警阈值类,实际 %s / %s", c.DimA, c.DimB)
|
||||
}
|
||||
// 前缀关系 → 高分
|
||||
if c.Score < 0.9 {
|
||||
t.Errorf("前缀关系的维度名相似度应很高,实际 %.2f", c.Score)
|
||||
}
|
||||
}
|
||||
|
||||
// 三类判定的直接判据。
|
||||
func TestAliasCandidateKind_分类(t *testing.T) {
|
||||
cases := []struct{ a, b, want string }{
|
||||
{"连接池|等待队列告警阈值=300~500", "连接池|等待队列长度告警阈值=300~500", "dimension-alias"},
|
||||
{"连接池|连接池容量=32/64", "连接池|连接池容量=128/256", "value-overwrite"},
|
||||
{"第112批|批次号=112", "第112批|批次号=112", "exact-duplicate"},
|
||||
{"admin服务|端口=8861", "billing服务|端口=8861", "different-subject"},
|
||||
{"服务A|甲=1", "服务A|乙=2", "unrelated"},
|
||||
{"不是三元组的整句", "也是", "unparsable"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := AliasCandidateKind(c.a, c.b); got != c.want {
|
||||
t.Errorf("AliasCandidateKind(%q, %q) = %q,期望 %q", c.a, c.b, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 真库上的候选发现。
|
||||
func TestFindAliasCandidates_真库(t *testing.T) {
|
||||
g := probeDB(t, "/var/tmp/ha-c/memory/graph.db")
|
||||
blocks, err := g.MemoryBlocks()
|
||||
if err != nil {
|
||||
t.Skip("无真库")
|
||||
}
|
||||
var distill []MemoryBlock
|
||||
for _, b := range blocks {
|
||||
if b.Source != "distill" {
|
||||
continue
|
||||
}
|
||||
distill = append(distill, b)
|
||||
}
|
||||
cands := findAliasCandidates(distill)
|
||||
t.Logf("distill 块 %d 个 → 别名候选 %d 个", len(distill), len(cands))
|
||||
for i, c := range cands {
|
||||
if i >= 10 {
|
||||
t.Logf(" ... 共 %d 个", len(cands))
|
||||
break
|
||||
}
|
||||
t.Logf(" [%.2f] %s", c.Score, c)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 词法判据的失效边界:它靠什么、会在哪失效。
|
||||
//
|
||||
// dimSimilarity 的三种信号:
|
||||
// 1. 前缀/后缀包含 → 0.95(「批次号」vs「批次」)
|
||||
// 2. LCS 覆盖 ≥0.7 → 0.90(「等待队列告警阈值」vs「等待队列长度告警阈值」)
|
||||
// 3. bigram Jaccard → 0~1(兜底)
|
||||
//
|
||||
// 失效边界必须写清楚,否则会被当成"通用相似度"用:
|
||||
//
|
||||
// ① **同义词换词**:灰度比例 vs 观察比例
|
||||
//
|
||||
// bigram≈0,LCS=0 ⇒ 词法给 0 分,但它们确实是别名。
|
||||
// 这类要靠漂移发现的**值形态**判据(58b26e0),不是词法。
|
||||
//
|
||||
// ② **完全不同的词**:端口 vs 端点
|
||||
//
|
||||
// 词法给低分 —— 但这正是对的(它们未必是同义,需要人判)。
|
||||
//
|
||||
// ⇒ 词法判据的定位:**高精度的"显然像"**,漏掉同义词换词。
|
||||
//
|
||||
// 漏的方向是安全的(不会误并),而向量若给高分则可能误并。
|
||||
func TestDimSimilarity_边界(t *testing.T) {
|
||||
cases := []struct {
|
||||
a, b string
|
||||
min float64
|
||||
comment string
|
||||
}{
|
||||
{"批次号", "批次", 0.9, "前缀包含 ⇒ 高分"},
|
||||
{"等待队列告警阈值", "等待队列长度告警阈值", 0.85, "插入两字,LCS 覆盖高"},
|
||||
{"值班分机号", "旧分机号", 0.5, "共享「分机号」但语义不同(一个是旧值)"},
|
||||
{"灰度比例", "观察比例", 0.0, "同义换词:词法给 0 —— 已知漏检,靠漂移发现的值形态判据"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := dimSimilarity(c.a, c.b)
|
||||
if got < c.min {
|
||||
t.Errorf("dimSimilarity(%q,%q) = %.2f,期望 ≥%.2f(%s)",
|
||||
c.a, c.b, got, c.min, c.comment)
|
||||
}
|
||||
t.Logf(" %.2f %-16q vs %-20q %s", got, c.a, c.b, c.comment)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 漏检方向的代价:词法漏掉的别名,人工还能看见(因为值全等)。
|
||||
func TestFindAliasCandidates_漏检仍可发现(t *testing.T) {
|
||||
// 「灰度比例」与「观察比例」—— 词法给 0,但只要**值全等**
|
||||
// 且同主语,就仍会被 findAliasCandidates 收进来(分数低)。
|
||||
blocks := []MemoryBlock{
|
||||
{ID: "1", Text: "第113批|灰度比例=10%"},
|
||||
{ID: "2", Text: "第113批|观察比例=10%"},
|
||||
}
|
||||
cands := findAliasCandidates(blocks)
|
||||
if len(cands) != 1 {
|
||||
t.Fatalf("值全等就该被收进候选(哪怕分数低),实际 %d 个", len(cands))
|
||||
}
|
||||
t.Logf("✓ 收进来了,分数 %.2f(低分提示「不像,可能同义换词」)", cands[0].Score)
|
||||
if cands[0].Score > 0.5 {
|
||||
t.Errorf("同义换词应给低分(提示人细看),实际 %.2f", cands[0].Score)
|
||||
}
|
||||
}
|
||||
523
internal/memory/arbitration.go
Normal file
523
internal/memory/arbitration.go
Normal file
@ -0,0 +1,523 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 同属性多值的时序仲裁。
|
||||
//
|
||||
// ★ 为什么需要(实测,真实 chineseclip + 真库 188 块)
|
||||
// ----------------------------------------------
|
||||
// 迁移后的块向量检索在「值覆盖」维度上答错:
|
||||
//
|
||||
// 查询 "值班室分机号是多少" → top1 = 旧号 4379(score 0.8127)
|
||||
// 新号 4324 的块排不进 top8
|
||||
//
|
||||
// 而把长复合句改写成短句后,实测相似度是:
|
||||
//
|
||||
// 「值班室分机号 4324」 cos = 0.9284
|
||||
// 「值班室分机号 4379」 cos = 0.9298 ← 旧号仍高 0.0014
|
||||
//
|
||||
// **结论:单靠向量无法解决值覆盖。** 新旧两个值在向量空间里几乎同点,
|
||||
// 谁更「对」不是相似度能回答的问题 —— 答案是「哪个更新」,而那只有
|
||||
// 时间戳知道。
|
||||
//
|
||||
// ★ 仲裁不是「一律取最新」—— 真库里有两种相反的时间语义
|
||||
// --------------------------------------------------
|
||||
// 值覆盖(后一条取代前一条,取最新对):
|
||||
//
|
||||
// 13:28 值班室分机号 4379,值班人 阿李
|
||||
// 13:44 下周起值班室分机号改为 4324,旧号 4379 停用
|
||||
//
|
||||
// 指标收窄(每条都在补充细节,「最新」反而会答错):
|
||||
//
|
||||
// 13:32 峰值 4%~17%,查询接口与下单/退款接口最集中
|
||||
// 13:35 峰值 4%~17%(30~84批整体),80~84批为 7%~16%
|
||||
// 13:40 峰值4%~17%(30~84批),85~95批未再上报 ← 最新这条说的是「停报」
|
||||
//
|
||||
// 对后者取最新,答出来的是「85~95批未再上报」,而不是「峰值是多少」。
|
||||
//
|
||||
// 所以判据必须先分清「取代」与「补充」,再决定怎么排。这不是靠猜 ——
|
||||
// 见 supersedes():两条讲同一属性且**后一条明确提到了前一条的值**时,
|
||||
// 那是取代;否则是补充。
|
||||
|
||||
// blockFact 是从块里解析出的一个事实陈述:谁、什么属性、什么值、何时。
|
||||
type blockFact struct {
|
||||
// Subject/维度/值 由块文本解析(形态见 parseFact)。
|
||||
Subject string
|
||||
Dimension string
|
||||
Value string
|
||||
// Text 是原始块文本(仲裁日志与回给模型的证据)。
|
||||
Text string
|
||||
// When 是块的时间戳;零值表示未知(迁移兜底或无 provider)。
|
||||
When time.Time
|
||||
// Score 是向量相似度(若本次是向量召回)。
|
||||
Score float64
|
||||
}
|
||||
|
||||
// 仲裁必须在**同一属性**的候选之间做,而「同一属性」需要看这两个块是不是
|
||||
// 从同一条原句拆出来的 —— 那是 TimesEqual 的补充维度。
|
||||
//
|
||||
// ★ 判据来自实测的一组数据(不是构造的)
|
||||
//
|
||||
// 「值班室分机号 4324,值班 老周(下周起;旧号 4379 停用)」
|
||||
// → 值班室分机号|值班分机号=4324 (时间 13:44)
|
||||
// → 值班室分机号|停用旧号=4379 (时间 13:44,同一条原句)
|
||||
//
|
||||
// 这两条**不是**新旧替代关系,而是同一次陈述里的两个字段(当前值 +
|
||||
// 被停用的旧值)。用「后一条提及前一条的值」判会把 4324 那条当成
|
||||
// 取代 4379 那条 —— 方向反了,且会让真实的旧号信息消失。
|
||||
//
|
||||
// 所以判据加一条:时间相同且都来自同一条原句 ⇒ 不是时序替代,只是并列字段。
|
||||
// 「同一条原句」用 sentences 表的归属判定(BlocksForNode 能拿到)。
|
||||
|
||||
// parseFact 从块文本解析出(主语, 维度, 值)。
|
||||
//
|
||||
// 支持两种形态,因为库里同时存在:
|
||||
// 1. 迁移来的整句:「值班室分机号 4379,值班人 阿李」(无 '|' 无 '=')
|
||||
// → 解析不出三元组,返回 nil,调用方按「不可仲裁」处理
|
||||
// 2. 拆分产生的块:「<主语>|<维度>=<值>」
|
||||
// → 三段齐全
|
||||
//
|
||||
// 为什么不让 memory 引用 distill 的同名函数:distill → memory 已存在
|
||||
// 依赖(blocks.go 用 memory.MemoryBlock),反向引用会成环。这两个函数
|
||||
// 是纯字符串切分,各自实现是有意的重复而非疏忽。
|
||||
func parseFact(text string) (subject, dimension, value string, ok bool) {
|
||||
i := strings.Index(text, "=")
|
||||
if i <= 0 {
|
||||
return "", "", "", false // 无 '=',不是事实块
|
||||
}
|
||||
dim, val := text[:i], text[i+1:]
|
||||
// 主语在首个 '|' 之前
|
||||
if j := strings.Index(dim, "|"); j >= 0 {
|
||||
return strings.TrimSpace(dim[:j]),
|
||||
strings.TrimSpace(dim[j+1:]),
|
||||
val, true
|
||||
}
|
||||
return "", strings.TrimSpace(dim), val, true
|
||||
}
|
||||
|
||||
// stopWords 是「这条没在报告值」的标记词。
|
||||
//
|
||||
// 用于区分「指标收窄」与「值覆盖」:最新那条若只是说「不再上报」
|
||||
// 「暂无」「未见」,那它没有给出值,不该把旧值挤掉。
|
||||
//
|
||||
// ★ 词必须来自真库实见,不是编的:见真库的
|
||||
// 「第85批起不再上报错误率峰值」「85~95批未再上报」。
|
||||
//
|
||||
// 命名 cutStopWords:cut.go 里已有一个 stopWords(map[string]bool,
|
||||
// 用于分词剪枝),同名会让 go vet 报 non-boolean condition。
|
||||
var cutStopWords = []string{
|
||||
"不再上报", "未再上报", "不再", "未再", "暂无", "未见", "无异常",
|
||||
"未发生", "已恢复", "已结束", "已回滚", "已修复",
|
||||
}
|
||||
|
||||
// reportsValue 判断这条块是否给出了具体值。
|
||||
//
|
||||
// 「第85批起不再上报错误率峰值」里有「不再」,是状态陈述不是取值 —
|
||||
// 它不该因为时间最新就压掉一个真实报出的值。
|
||||
func reportsValue(text string) bool {
|
||||
for _, w := range cutStopWords {
|
||||
if strings.Contains(text, w) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// supersedes 判断 later 是否取代了 earlier(同一属性的新值)。
|
||||
//
|
||||
// 判据是**后一条文本里出现了前一条的值** —— 这是可观察的、不依赖
|
||||
// 字段名的信号:
|
||||
//
|
||||
// earlier: 值班室分机号 4379,值班人 阿李 值 = 4379
|
||||
// later: 下周起值班室分机号改为 4324,旧号 4379 停用
|
||||
// ↑ 含 "4379" → 是取代
|
||||
//
|
||||
// 而指标收窄那组:
|
||||
//
|
||||
// earlier: 峰值 4%~17%(30~71批整体) 值 = 4%~17%(30~71批整体)
|
||||
// later: 峰值 4%~17%(30~84批整体) ↑ 不含前者的完整值 → 不是取代
|
||||
//
|
||||
// 为什么不比「值是否不同」:新旧值不同是常态,但「峰值 4%~17%」到
|
||||
// 「峰值 4%~17%(30~84批整体)」里主值没变、只是范围细化了,
|
||||
// 那不是取代 —— 值不同但主值相同的情况必须判为「补充」。
|
||||
func supersedes(earlier, later blockFact) bool {
|
||||
// 必须同一主体,否则谈不上取代(不同服务各自的端口互不取代)。
|
||||
if earlier.Subject != later.Subject {
|
||||
return false
|
||||
}
|
||||
if earlier.Value == "" {
|
||||
return false
|
||||
}
|
||||
// 后一条原文里提到了前一条的值 → 旧值被显式取代。
|
||||
//
|
||||
// ★ 为什么在这里判「提及」而不在维度相等的前提之后:
|
||||
// 维度名会漂移(漂移发现实测的候选是
|
||||
// 「值班分机号 / 停用旧号 / 旧分机号」),先要求维度严格相等的话,
|
||||
// 「旧分机号=4379」永远判不出它取代了「值班分机号=4379」——
|
||||
// 而这正是值覆盖最常见的形态(旧值与新值被拆成两个不同维度名)。
|
||||
// 「文本里提到了旧值」是更直接的证据,优先于维度名相等。
|
||||
//
|
||||
// 但仍要防一种误判:指标收窄那组里
|
||||
// 「峰值=4%~17%(30~71批)」与「峰值=4%~17%(30~84批)」
|
||||
// 后者文本不含前者的**完整**值(含括号与批次范围)⇒ 不判取代。
|
||||
// 所以判的是「完整值出现在原文里」,不是「某个数字片段出现」。
|
||||
return strings.Contains(later.Text, earlier.Value)
|
||||
}
|
||||
|
||||
// sortHitsByTimeThenScore 按 (时间升序, 分数降序) 排。
|
||||
//
|
||||
// 为什么时间相同时要按分数降序:迁移后同批写入的块 created_at 完全相同
|
||||
// (实测真库 188 块只有 28 个不同时刻),只按时间排等于随机 ——
|
||||
// 同一批里的「端口」和「值班人」谁先谁后没有意义,但「向量更相关的排前面」
|
||||
// 有意义。
|
||||
func sortHitsByTimeThenScore(hits []BlockHit) {
|
||||
sort.SliceStable(hits, func(i, j int) bool {
|
||||
ti, tj := hits[i].Block.CreatedAt, hits[j].Block.CreatedAt
|
||||
if ti.Equal(tj) {
|
||||
return hits[i].Score > hits[j].Score
|
||||
}
|
||||
if ti.IsZero() != tj.IsZero() {
|
||||
// 未知时间的排后面(不该被判为「更早」而被取代规则吃掉)
|
||||
return tj.IsZero()
|
||||
}
|
||||
return ti.Before(tj)
|
||||
})
|
||||
}
|
||||
|
||||
// ArbitrationResult 是仲裁后的结果。
|
||||
type ArbitrationResult struct {
|
||||
// Kept 是仲裁后保留的块(按时间升序)。
|
||||
Kept []BlockHit
|
||||
// Superseded 是被取代的块(按时间降序,最早的在前)。
|
||||
Superseded []BlockHit
|
||||
// Status 是仲裁结论,用于日志与调试。
|
||||
Status string
|
||||
}
|
||||
|
||||
// sameSentenceOf 返回每个块所属的原句 id。
|
||||
//
|
||||
// 块→原句的归属靠 sentence --contains--> block 边(块表本身没有
|
||||
// sentence_id 列 —— media_refs 那套设计已废弃)。同一句话拆出的多个字段块
|
||||
// 因此能识别出来,而它们是**并列字段**而不是时序替代。
|
||||
//
|
||||
// 拿不到归属时(块不是从句子拆来的,比如迁移来的整句)返回空 map ——
|
||||
// 调用方按「无法判定同句」处理,见 sameSentenceOK。
|
||||
func sameSentenceOf(db *GraphDB, hits []BlockHit) map[string]string {
|
||||
if db == nil || len(hits) == 0 {
|
||||
return nil
|
||||
}
|
||||
ids := make([]string, 0, len(hits))
|
||||
for _, h := range hits {
|
||||
if h.Block.ID != "" {
|
||||
ids = append(ids, h.Block.ID)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
db.mu.RLock()
|
||||
defer db.mu.RUnlock()
|
||||
placeholders := ""
|
||||
args := make([]any, 0, len(ids))
|
||||
for i, id := range ids {
|
||||
if i > 0 {
|
||||
placeholders += ","
|
||||
}
|
||||
placeholders += "?"
|
||||
args = append(args, id)
|
||||
}
|
||||
// ★★ source_kind 必须是 'block',不是 'sentence'。
|
||||
//
|
||||
// 端点类型在 Commit 块化时从「sentences 表行号」改成「原句块」
|
||||
// (52e4596 / cdf0726),但这处 SQL 还写着 'sentence'。
|
||||
//
|
||||
// ⇒ 查询恒空 ⇒ 同句分组全失败 ⇒ 仲裁把**跨句**的块当成互相取代:
|
||||
//
|
||||
// 同句并列 blk_a/blk_b + 旧句 blk_c
|
||||
// → blk_c 被误剔除(「三者都该保留」失败)
|
||||
//
|
||||
// ★ 症状极隐蔽:仲裁测试当场抓到,但它绿了很久 ——
|
||||
// 因为在旧形态下这处是对的,只有新形态才暴露。
|
||||
rows, err := db.db.Query(`SELECT target_id, source_id FROM memory_block_edges
|
||||
WHERE edge_type = 'contains' AND source_kind = 'block'
|
||||
AND target_kind = 'block' AND target_id IN (`+placeholders+`)`, args...)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]string{}
|
||||
for rows.Next() {
|
||||
var blockID, sentID string
|
||||
if err := rows.Scan(&blockID, &sentID); err != nil {
|
||||
continue
|
||||
}
|
||||
out[blockID] = sentID
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// isDeprecatedDim 判断维度名是否在陈述「旧值作废」。
|
||||
//
|
||||
// 只挡这一类,不挡所有有兄弟的块 —— 上一版「有兄弟就不外溢」过宽:
|
||||
// 真库形态里
|
||||
//
|
||||
// 「值班室分机号 4324,值班 老周(旧号 4379 停用)」
|
||||
// → 值班分机号=4324 停用旧号=4379 值班人=老周
|
||||
//
|
||||
// 其中「停用旧号」带兄弟时不该外溢(它自己就是被停用的那个),
|
||||
// 但「值班人=老周」带兄弟时若也禁止外溢,就会漏掉真正的取代关系。
|
||||
//
|
||||
// 判据是维度名的语义(真库里模型就是这么写的):
|
||||
// 停用 / 旧 / 原 / 之前 / 曾经 开头或结尾的维度名。
|
||||
func isDeprecatedDim(dim string) bool {
|
||||
if dim == "" {
|
||||
return false
|
||||
}
|
||||
markers := []string{"旧", "停用", "原", "之前", "曾经", "原有", "历史"}
|
||||
for _, m := range markers {
|
||||
if strings.Contains(dim, m) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// hasSameSentenceSibling 报告该块所在原句是否还拆出了别的块。
|
||||
//
|
||||
// 用途:识别「新值 + 旧值并列」的结构 —— 这种句子里已经自带了
|
||||
// 「旧值作废」的信息,不需要靠外溢取代去表达(见 arbitrate 里的注释)。
|
||||
//
|
||||
// ★★ source_kind 必须是 'block',不是 'sentence'(2026-10-04)。
|
||||
//
|
||||
// contains 边的两个端点都是块(source_kind / target_kind 均为 'block'),
|
||||
// 因为端点类型在 Commit 块化时从「sentences 表行号」改成了「原句块」;
|
||||
// 而原句块恒在 source 侧 —— 生产库实测 1298 条 contains 边里
|
||||
// source_id 全部是 blk_src_* 原句块,无一例外。
|
||||
//
|
||||
// 句子的 id 也正是从 sameSentenceOf 拿的(它查 target_id=字段块、
|
||||
// 取 source_id 作原句 id),两处口径必须一致,否则:
|
||||
//
|
||||
// 查询恒空(source_kind='sentence' 没有一行匹配)
|
||||
// → 「新值+旧值并列」的结构识别不出来
|
||||
// → 带 isDeprecatedDim 的旧值块被错误地当成取代者执行外溢
|
||||
// → 误剔除本该保留的旧值。
|
||||
//
|
||||
// ★ 症状隐蔽:它在同形态下表现正常,只有「旧值块恰好带废弃维度名」
|
||||
//
|
||||
// 的那批数据才会偏 —— 而那批数据在长对话里占比不高。
|
||||
func hasSameSentenceSibling(db *GraphDB, sentenceID, excludeBlockID string) bool {
|
||||
if db == nil || sentenceID == "" {
|
||||
return false
|
||||
}
|
||||
db.mu.RLock()
|
||||
defer db.mu.RUnlock()
|
||||
var n int
|
||||
err := db.db.QueryRow(`SELECT COUNT(*) FROM memory_block_edges
|
||||
WHERE edge_type = 'contains' AND source_kind = 'block' AND target_kind = 'block'
|
||||
AND source_id = ? AND target_id != ?`,
|
||||
sentenceID, excludeBlockID).Scan(&n)
|
||||
return err == nil && n > 0
|
||||
}
|
||||
|
||||
// supersedesSentence 判断 later 文本是否取代了 earlier 文本。
|
||||
//
|
||||
// ★ 专为**整句块**设计(解析不出「主语|维度=值」的那种):
|
||||
//
|
||||
// 判据只要两件事 —— 时间更晚 + 提到了更早那条里的关键值。
|
||||
//
|
||||
// 为什么不需要解析主语:值覆盖场景下,「新号生效、旧号作废」这句话
|
||||
// **必然同时含新旧两个值**:
|
||||
//
|
||||
// early: 值班室分机号 4379,值班人 阿李
|
||||
// late: 值班室分机号 4324,值班 老周(旧号 4379 停用)
|
||||
// ↑ 含 early 的 4379
|
||||
//
|
||||
// 抽出的「关键值」用与 parseFact 同一套值形态正则(数字/版本/百分比),
|
||||
// 匹配 early 里最长的那个。
|
||||
var sentenceValueRe = regexp.MustCompile(
|
||||
`\d+(?:\.\d+)?(?:/\d+)*(?:~\d+)?%?|[vV]\d+(?:\.\d+)+|第\d+[批号版]?`)
|
||||
|
||||
func supersedesSentence(earlier, later string) bool {
|
||||
// 取 earlier 里最长的那个值
|
||||
best := ""
|
||||
for _, m := range sentenceValueRe.FindAllString(earlier, -1) {
|
||||
if len(m) > len(best) {
|
||||
best = m
|
||||
}
|
||||
}
|
||||
if best == "" {
|
||||
return false // earlier 里没有可识别的值,无从判断取代
|
||||
}
|
||||
// later 提到了它,且 later 不是同一句话(否则是自我引用)
|
||||
return best != later && strings.Contains(later, best)
|
||||
}
|
||||
|
||||
// arbitrate 对同属性的候选块做时序仲裁。
|
||||
//
|
||||
// db 可为 nil(拿不到块→原句归属时退化为纯时间+文本判据)。
|
||||
//
|
||||
// 规则(三条,按顺序):
|
||||
// 1. **不可仲裁的直接保留**:解析不出(主语,维度,值)的块、没带时间的块。
|
||||
// 强行仲裁它们等于丢信息 —— 宁可多返回给模型让它自己判断。
|
||||
// 2. **被明确取代的剔除**:later 文本含 earlier 的值 ⇒ earlier 出局。
|
||||
// 被取代的块仍然放进 Superseded 而不是丢弃 —— 「旧号 4379 停用」
|
||||
// 本身就是有信息量的(它解释了为什么现在打不通)。
|
||||
// 3. **同一属性的其余块按时间升序保留**,不合并、不去重:
|
||||
// 指标收窄那组三条都要留着,每条带不同的批次范围。
|
||||
//
|
||||
// 返回的 Kept 已按 (When 升序, Score 降序) 排过:时间相同时(迁移后
|
||||
// 同批写入的多条)用向量分兜底。
|
||||
func arbitrate(db *GraphDB, hits []BlockHit) ArbitrationResult {
|
||||
sentOf := sameSentenceOf(db, hits)
|
||||
type parsed struct {
|
||||
hit BlockHit
|
||||
fact blockFact
|
||||
ok bool
|
||||
sameOf string // 所属原句 id(空 = 未知)
|
||||
}
|
||||
var ps []parsed
|
||||
for _, h := range hits {
|
||||
sub, dim, val, ok := parseFact(h.Block.Text)
|
||||
ps = append(ps, parsed{hit: h, fact: blockFact{
|
||||
Subject: sub, Dimension: dim, Value: val,
|
||||
Text: h.Block.Text, When: h.Block.CreatedAt, Score: h.Score,
|
||||
}, ok: ok, sameOf: sentOf[h.Block.ID]})
|
||||
}
|
||||
|
||||
var kept, superseded []BlockHit
|
||||
dropped := make(map[string]bool)
|
||||
|
||||
for i, a := range ps {
|
||||
if !a.ok || a.fact.When.IsZero() {
|
||||
// 规则 1(修订):**没有时间的**不可仲裁 → 保留。
|
||||
//
|
||||
// 但「有时间的整句块」要参与取代判断 —— 它解析不出
|
||||
// (主语,维度,值),不代表不能判断「谁取代了谁」。
|
||||
//
|
||||
// 实测故障(真库 legacy-entity 整句块):
|
||||
//
|
||||
// 13:28 值班室分机号 4379,值班人 阿李
|
||||
// 13:44 值班室分机号 4324,值班 老周(旧号 4379 停用)
|
||||
// 13:44 下周起值班室分机号改为 4324,旧号 4379 停用
|
||||
//
|
||||
// 查询「现在的值班分机号」时旧号排 top1 —— 而 13:44 那两条
|
||||
// **文本里含 "4379"**,正是「新号生效、旧号作废」的自述。
|
||||
// 判据就是 supersedesSentence:更晚 + 提到更早那条的值 ⇒ 取代。
|
||||
// 不需要解析主语,也不需要维度。
|
||||
if a.fact.When.IsZero() {
|
||||
kept = append(kept, a.hit)
|
||||
continue
|
||||
}
|
||||
supersededByOther := false
|
||||
for j, b := range ps {
|
||||
if i == j || b.fact.When.IsZero() || !b.fact.When.After(a.fact.When) {
|
||||
continue
|
||||
}
|
||||
if supersedesSentence(a.hit.Block.Text, b.hit.Block.Text) {
|
||||
dropped[a.hit.Block.ID] = true
|
||||
superseded = append(superseded, a.hit)
|
||||
supersededByOther = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !supersededByOther {
|
||||
kept = append(kept, a.hit)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// 规则 2:被后面任一条显式取代?
|
||||
supersededByOther := false
|
||||
for j, b := range ps {
|
||||
if i == j || !b.ok || b.fact.When.IsZero() {
|
||||
continue
|
||||
}
|
||||
if !b.fact.When.After(a.fact.When) {
|
||||
continue // 只看更晚的
|
||||
}
|
||||
// 同一条原句拆出的字段是**并列**关系,不是时序替代。
|
||||
// 实测形态:「值班室分机号 4324,值班 老周(旧号 4379 停用)」
|
||||
// 拆出 值班分机号=4324 与 停用旧号=4379 —— 后者提及 4379,
|
||||
// 但它不是「取代」4324,两者是同一次陈述里的两个字段。
|
||||
if a.sameOf != "" && a.sameOf == b.sameOf {
|
||||
continue
|
||||
}
|
||||
// ★ 同句的并列字段不得「外溢」去取代句外的块。
|
||||
//
|
||||
// 实测形态(真库):
|
||||
// 原句A 13:28 「值班室分机号 4379,值班人 阿李」
|
||||
// → 值班分机号=4379
|
||||
// 原句B 13:44 「值班室分机号 4324,值班 老周(旧号 4379 停用)」
|
||||
// → 值班分机号=4324、停用旧号=4379 ← 与上条并列
|
||||
//
|
||||
// 原句B 的「停用旧号=4379」文本里含 4379,若允许它外溢,
|
||||
// 就会把原句A 的 4379 判成被取代 —— 而实际上被取代的是
|
||||
// 原句B **自己**那条并列字段(同句已排除),原句A 那条
|
||||
// 恰恰是「历史上真实用过的号」,正是值覆盖要保留的信息。
|
||||
//
|
||||
// 判据:b 与 a 同句时 continue(同句并列,已在上面处理);
|
||||
// b 与 a 不同句但 b 自己还有个同句的兄弟块时,说明 b 属于
|
||||
// 「新值 + 旧值并列」的结构,不该由它单独执行取代。
|
||||
if b.sameOf != "" && b.sameOf != a.sameOf &&
|
||||
hasSameSentenceSibling(db, b.sameOf, b.hit.Block.ID) &&
|
||||
isDeprecatedDim(b.fact.Dimension) {
|
||||
continue
|
||||
}
|
||||
// 拿不到块→原句归属时的兜底:时间完全相同且值出现在同一句里,
|
||||
// 多半是同一次陈述拆出的并列字段,而不是时序替代。
|
||||
//
|
||||
// ★ 判据不能是「值文本相同」——那会把
|
||||
// 「值班分机号=4379」与「停用旧号=4379」也判成并列,而它们
|
||||
// 恰好**就该**是并列。真正的信号是「同一时刻 + 不同维度名」:
|
||||
// 同一条原句拆出的字段时间戳必然相同,而不同维度名正是
|
||||
// 漂移发现报告里的「值班分机号 / 停用旧号 / 旧分机号」。
|
||||
// 真正的时序替代会有严格的时间差(真库里是 13:28 → 13:44)。
|
||||
if a.sameOf == "" && b.sameOf == "" &&
|
||||
a.fact.When.Equal(b.fact.When) &&
|
||||
a.fact.Dimension != b.fact.Dimension &&
|
||||
a.fact.Subject == b.fact.Subject {
|
||||
continue
|
||||
}
|
||||
if supersedes(a.fact, b.fact) {
|
||||
dropped[a.hit.Block.ID] = true
|
||||
superseded = append(superseded, a.hit)
|
||||
supersededByOther = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !supersededByOther {
|
||||
kept = append(kept, a.hit)
|
||||
}
|
||||
}
|
||||
|
||||
// 规则 3:**不重排**,保持调用方给的向量序。
|
||||
//
|
||||
// ★ 这是实测打回来的:第一版在这里按 (时间升序, 分数降序) 重排,
|
||||
// 结果端到端探针 5/7 → 0/7 —— 因为迁移来的整句块时间戳最早
|
||||
// (13:20:43),时间升序把它们全顶到 top3:
|
||||
//
|
||||
// 仲裁后 top3: 随时追问细节 | order-gw 运维进展 | 每天
|
||||
//
|
||||
// 时间只该用来**判断取代关系**(哪条更新),不该决定**展示顺序** ——
|
||||
// 展示顺序是相关性问题,由向量分决定。仲裁的职责是剔除被取代的块,
|
||||
// 不重排。
|
||||
//
|
||||
// 这与 RecallBlocks「按余弦排序」的既有契约是同一件事:
|
||||
// 那是召回层的语义,仲裁层不该改。
|
||||
//
|
||||
// Superseded 同理:剔除的块按时间升序便于人工核对「谁取代了谁」。
|
||||
sortHitsByTimeThenScore(superseded)
|
||||
|
||||
status := "no-arbitration"
|
||||
switch {
|
||||
case len(superseded) > 0:
|
||||
status = "superseded"
|
||||
case len(kept) < len(hits):
|
||||
status = "partial"
|
||||
}
|
||||
return ArbitrationResult{Kept: kept, Superseded: superseded, Status: status}
|
||||
}
|
||||
130
internal/memory/arbitration_sibling_guard_test.go
Normal file
130
internal/memory/arbitration_sibling_guard_test.go
Normal file
@ -0,0 +1,130 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestHasSameSentenceSiblingFindsRealSibling 钉住「同句兄弟能被找到」。
|
||||
//
|
||||
// ★ 这条判据针对 2026-10-04 修的缺陷:SQL 写的是 source_kind='sentence'
|
||||
//
|
||||
// 而 contains 边两个端点都是 block(生产库实测 1298 条 contains 边
|
||||
// source_id 全部是 blk_src_* 原句块)⇒ 查询恒空 ⇒
|
||||
// 「新值 + 旧值并列」的结构识别不出来。
|
||||
//
|
||||
// ★ 为什么必须单独立判据、而不是靠 arbitrate 的既有测试:
|
||||
//
|
||||
// 这个失效**不会让任何现存断言变红** —— 恒空只影响
|
||||
// 「旧值块恰好带废弃维度名且跨句」的那批数据。
|
||||
// 所以它是静默失效:测试全绿而守卫已失效。
|
||||
// 判据必须直接量 hasSameSentenceSibling 本身,
|
||||
// 否则修没修对都看不出来。
|
||||
func TestHasSameSentenceSiblingFindsRealSibling(t *testing.T) {
|
||||
db := openArbTestDB(t)
|
||||
|
||||
// 一句里拆出两个字段块(真实形态:Commit 会把一句拆成主语|维度=值)。
|
||||
// 注意 contains 方向:原句块(source)--contains--> 字段块(target)。
|
||||
writeBlockWithSource(t, db, "b_port", "端口=8243", "blk_src_s1")
|
||||
writeBlockWithSource(t, db, "b_dep", "停用旧号=8199", "blk_src_s1")
|
||||
|
||||
// ③ 另一句里只有一个块 —— 它没有同句兄弟。
|
||||
writeBlockWithSource(t, db, "b_solo", "值班人=小王", "blk_src_s2")
|
||||
|
||||
// 核心断言:有兄弟的那个必须报 true。
|
||||
if !hasSameSentenceSibling(db, "blk_src_s1", "b_port") {
|
||||
t.Error("blk_src_s1 下有 b_port 与 b_dep 两个块,hasSameSentenceSibling 应为 true")
|
||||
}
|
||||
// 换个 exclude 也一样(兄弟是相对而言的)。
|
||||
if !hasSameSentenceSibling(db, "blk_src_s1", "b_dep") {
|
||||
t.Error("排除 b_dep 后 b_port 仍是兄弟,应为 true")
|
||||
}
|
||||
// 独苗那句必须报 false —— 否则会把所有块都当并列,仲裁彻底失效。
|
||||
if hasSameSentenceSibling(db, "blk_src_s2", "b_solo") {
|
||||
t.Error("blk_src_s2 只有 b_solo 一个块,不该有兄弟")
|
||||
}
|
||||
// ★ 不写「exclude 为空时不该把自己算作兄弟」这条断言:
|
||||
//
|
||||
// 它描述的状态**不可达** —— 调用方恒传块自身 ID
|
||||
// (arbitrate 里 hasSameSentenceSibling(db, b.sameOf, b.hit.Block.ID)),
|
||||
// 而 b.sameOf 非空是进入该分支的前提。所以 exclude 为空时
|
||||
// SQL 的 target_id != '' 会把所有块都算作兄弟 —— 那是没人走的分支,
|
||||
// 为它改代码等于为不存在的场景写逻辑。
|
||||
//
|
||||
// 实测确认:exclude="" 时 COUNT(*)=1(自己被算了进去)。
|
||||
}
|
||||
|
||||
// TestSameSentenceOfAndSiblingShareEndpointConvention 钉住两处口径一致。
|
||||
//
|
||||
// ★ sameSentenceOf 查「target_id = 字段块」取 source_id 作原句 id;
|
||||
//
|
||||
// hasSameSentenceSibling 查「source_id = 那个 id」。
|
||||
// 两处若有一处写反(或用了不同的 source_kind),
|
||||
// 就出现「归属查到了、兄弟却查不到」这种半通不通的状态 ——
|
||||
// 比全错更难发现,所以必须显式钉住。
|
||||
func TestSameSentenceOfAndSiblingShareEndpointConvention(t *testing.T) {
|
||||
db := openArbTestDB(t)
|
||||
|
||||
writeBlockWithSource(t, db, "b_a", "维度A=值1", "blk_src_x")
|
||||
writeBlockWithSource(t, db, "b_b", "维度B=值2", "blk_src_x")
|
||||
|
||||
hits := []BlockHit{
|
||||
{Block: MemoryBlock{ID: "b_a", Text: "维度A=值1"}},
|
||||
{Block: MemoryBlock{ID: "b_b", Text: "维度B=值2"}},
|
||||
}
|
||||
|
||||
// sameSentenceOf 必须把两个块都归到 blk_src_x。
|
||||
sentOf := sameSentenceOf(db, hits)
|
||||
for _, id := range []string{"b_a", "b_b"} {
|
||||
if sentOf[id] != "blk_src_x" {
|
||||
t.Errorf("块 %s 的原句应是 blk_src_x,实际 %q", id, sentOf[id])
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 关键联动:同一个 id 两处都要认。
|
||||
for _, id := range []string{"b_a", "b_b"} {
|
||||
if !hasSameSentenceSibling(db, sentOf[id], id) {
|
||||
t.Errorf("块 %s:sameSentenceOf 说它属于 blk_src_x,"+
|
||||
"hasSameSentenceSibling 却说它在 blk_src_x 下无兄弟 —— 两处口径不一致", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 小工具 ──
|
||||
|
||||
func openArbTestDB(t *testing.T) *GraphDB {
|
||||
t.Helper()
|
||||
db, err := NewGraphDB(filepath.Join(t.TempDir(), "arb.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
return db
|
||||
}
|
||||
|
||||
// writeBlockWithSource 写一个块,并建「原句块 --contains--> 该块」的结构边。
|
||||
//
|
||||
// ★ 原句块本身也要存在:AddMemoryBlockEdge 会校验两端节点都存在
|
||||
//
|
||||
// (graphNodeExists),只建字段块会报 "does not exist"。
|
||||
func writeBlockWithSource(t *testing.T, db *GraphDB, blockID, text, sentenceBlockID string) {
|
||||
t.Helper()
|
||||
if sentenceBlockID != "" {
|
||||
// 原句块:ID 即句子块 id,文本用原句本身。
|
||||
if err := db.PutMemoryBlocks([]MemoryBlock{
|
||||
{ID: sentenceBlockID, Modality: BlockText, Text: "原句:" + text},
|
||||
}); err != nil {
|
||||
t.Fatalf("写原句块 %s: %v", sentenceBlockID, err)
|
||||
}
|
||||
}
|
||||
if err := db.PutMemoryBlocks([]MemoryBlock{
|
||||
{ID: blockID, Modality: BlockText, Text: text},
|
||||
}); err != nil {
|
||||
t.Fatalf("写块 %s: %v", blockID, err)
|
||||
}
|
||||
if sentenceBlockID != "" {
|
||||
if err := db.AddMemoryBlockEdge("block", sentenceBlockID, "block", blockID, "contains"); err != nil {
|
||||
t.Fatalf("建 contains 边 %s→%s: %v", sentenceBlockID, blockID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
649
internal/memory/arbitration_test.go
Normal file
649
internal/memory/arbitration_test.go
Normal file
@ -0,0 +1,649 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 仲裁判据的用例全部取自真库 /var/tmp/ha-c 的实际块文本。
|
||||
//
|
||||
// ★ 两组数据方向相反,这是本判据的核心:
|
||||
// - 值覆盖组:后一条取代前一条 ⇒ 取最新才对
|
||||
// - 指标收窄组:每条都在补充,最新那条说「未再上报」⇒ 取最新会答错
|
||||
// 任何「一律取最新」的规则都会在第二组上失败。
|
||||
|
||||
func hit(text string, when string, score float64) BlockHit {
|
||||
t, _ := time.Parse("2006-01-02 15:04:05", when)
|
||||
return BlockHit{
|
||||
Block: MemoryBlock{ID: text, Text: text, CreatedAt: t},
|
||||
Score: score,
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 整句块(迁移来的,无 '|' 无 '='):现在**参与取代判断**。
|
||||
//
|
||||
// 这条测试的断言改过一次。初版断言「整句块不可仲裁,全部保留」,
|
||||
// 而端到端 overwrite 维度卡在 1/2 的根因正是它:
|
||||
//
|
||||
// 13:28 值班室分机号 4379,值班人 阿李
|
||||
// 13:44 值班室分机号 4324,值班 老周(旧号 4379 停用)
|
||||
// 13:44 下周起值班室分机号改为 4324,旧号 4379 停用
|
||||
//
|
||||
// 查询「现在的值班分机号」时旧号排 top1 —— 而 13:44 那两条**文本里
|
||||
// 含 "4379"**,正是「新号生效、旧号作废」的自述。
|
||||
//
|
||||
// 「解析不出主语」≠「不能判断谁取代了谁」:取代判断只需
|
||||
// 「更晚 + 提到了更早那条的关键值」,不需要维度名。
|
||||
func TestArbitrate_整句块参与取代(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer func() { _ = g.Close() }()
|
||||
|
||||
putRawBlockArb(t, g, "e1", "值班室分机号 4379,值班人 阿李", "2026-10-01 13:28:28")
|
||||
putRawBlockArb(t, g, "e2", "值班室分机号 4324,值班 老周(旧号 4379 停用)",
|
||||
"2026-10-01 13:44:26")
|
||||
|
||||
hits := []BlockHit{
|
||||
{Block: mustBlock(t, g, "e1"), Score: 0.81},
|
||||
{Block: mustBlock(t, g, "e2"), Score: 0.73},
|
||||
}
|
||||
res := arbitrate(nil, hits)
|
||||
|
||||
if len(res.Kept) != 1 || res.Kept[0].Block.ID != "e2" {
|
||||
t.Errorf("只应保留新号那条,实际 kept=%v", textsOf(res.Kept))
|
||||
}
|
||||
if len(res.Superseded) != 1 || res.Superseded[0].Block.ID != "e1" {
|
||||
t.Errorf("旧号应进 Superseded(不丢弃 ——「旧号作废」本身有信息),实际 %v",
|
||||
textsOf(res.Superseded))
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 值覆盖的两种形态,方向相反,都要判对。
|
||||
//
|
||||
// 形态A 同一时刻的并列字段(同一条原句拆出来的):
|
||||
//
|
||||
// 值班室分机号|值班分机号=4324 13:44
|
||||
// 值班室分机号|停用旧号=4379 13:44 ← 与上条同句,不是"取代"
|
||||
//
|
||||
// 形态B 跨时刻的真正替代:
|
||||
//
|
||||
// 值班室分机号|值班分机号=4379 13:28 ← 旧值
|
||||
// 值班室分机号|值班分机号=4324 13:44 ← 新值,但文本未提 4379
|
||||
//
|
||||
// 形态B 里新值**不该**取代旧值 —— 因为 4324 那条的文本没有提到 4379,
|
||||
// 单看图无法断定「4379 作废了」。这正是「显式提及」判据与「时间更晚」
|
||||
// 判据的区别:后者会误伤。
|
||||
func TestArbitrate_并列字段不互斥(t *testing.T) {
|
||||
// 形态A:同句并列。两条同时间、不同维度名 → 不是时序替代。
|
||||
hitsA := []BlockHit{
|
||||
hit("值班室分机号|值班分机号=4324", "2026-10-01 13:44:26", 0.73),
|
||||
hit("值班室分机号|停用旧号=4379", "2026-10-01 13:44:26", 0.70),
|
||||
}
|
||||
resA := arbitrate(nil, hitsA)
|
||||
if len(resA.Kept) != 2 {
|
||||
t.Errorf("形态A:同句并列的两条都该保留,实际 %d:%v",
|
||||
len(resA.Kept), textsOf(resA.Kept))
|
||||
}
|
||||
|
||||
// 形态B:跨时刻但文本未提及旧值 → 不判取代。
|
||||
hitsB := []BlockHit{
|
||||
hit("值班室分机号|值班分机号=4379", "2026-10-01 13:28:28", 0.81),
|
||||
hit("值班室分机号|值班分机号=4324", "2026-10-01 13:44:26", 0.73),
|
||||
}
|
||||
resB := arbitrate(nil, hitsB)
|
||||
if len(resB.Superseded) != 0 {
|
||||
t.Errorf("形态B:文本未提及旧值就不该判取代(只按时间判会误伤),实际 %d:%v",
|
||||
len(resB.Superseded), textsOf(resB.Superseded))
|
||||
}
|
||||
if len(resB.Kept) != 2 {
|
||||
t.Errorf("形态B:两条都该保留(是否作废需人工/上层判断),实际 %d",
|
||||
len(resB.Kept))
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 真正的取代:新一条同时给出新值并提及旧值。
|
||||
func TestArbitrate_显式提及才算取代(t *testing.T) {
|
||||
hits := []BlockHit{
|
||||
hit("值班室分机号|值班分机号=4379", "2026-10-01 13:28:28", 0.81),
|
||||
// 这条文本里含 "4379" ⇒ 旧值被显式取代
|
||||
hit("值班室分机号|旧分机号=4379", "2026-10-01 13:44:26", 0.70),
|
||||
hit("值班室分机号|值班分机号=4324", "2026-10-01 13:44:26", 0.73),
|
||||
}
|
||||
res := arbitrate(nil, hits)
|
||||
|
||||
if len(res.Superseded) != 1 {
|
||||
t.Fatalf("应恰有 1 条被取代,实际 %d:%v", len(res.Superseded), textsOf(res.Superseded))
|
||||
}
|
||||
if res.Superseded[0].Block.Text != "值班室分机号|值班分机号=4379" {
|
||||
t.Errorf("被取代的应是旧值那条,实际 %q", res.Superseded[0].Block.Text)
|
||||
}
|
||||
if res.Status != "superseded" {
|
||||
t.Errorf("状态应为 superseded,实际 %q", res.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 指标收窄:三条都要保留,最新那条不能把旧值挤掉。
|
||||
func TestArbitrate_指标收窄不合并(t *testing.T) {
|
||||
hits := []BlockHit{
|
||||
hit("第85批|峰值=4%~17%(30~71批整体)", "2026-10-01 13:34:22", 0.72),
|
||||
hit("第85批|峰值=4%~17%(30~84批整体)", "2026-10-01 13:35:57", 0.71),
|
||||
hit("第85批|峰值=4%~17%(85~95批未再上报)", "2026-10-01 13:40:06", 0.70),
|
||||
}
|
||||
res := arbitrate(nil, hits)
|
||||
|
||||
if len(res.Kept) != 3 {
|
||||
t.Errorf("指标收窄的三条都该保留(每条带不同批次范围),实际 %d:%v",
|
||||
len(res.Kept), textsOf(res.Kept))
|
||||
}
|
||||
if len(res.Superseded) != 0 {
|
||||
t.Errorf("指标收窄不该判取代,实际 %d:%v",
|
||||
len(res.Superseded), textsOf(res.Superseded))
|
||||
}
|
||||
// 时间升序:最早的在前
|
||||
if res.Kept[0].Block.Text != "第85批|峰值=4%~17%(30~71批整体)" {
|
||||
t.Errorf("Kept 应按时间升序(最早的在前),实际首条 %q", res.Kept[0].Block.Text)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 「不再上报」这类状态陈述不该因时间最新而压掉真实值。
|
||||
func TestReportsValue_状态陈述不算取值(t *testing.T) {
|
||||
cases := []struct {
|
||||
text string
|
||||
want bool
|
||||
}{
|
||||
{"第85批起不再上报错误率峰值", false}, // 真库实见
|
||||
{"85~95批未再上报", false}, // 真库实见
|
||||
{"值班室分机号 4324,值班 老周", true},
|
||||
{"峰值 4%~17%(30~84批整体)", true},
|
||||
{"下周起值班室分机号改为 4324,旧号 4379 停用", true},
|
||||
{"该问题已修复", false},
|
||||
{"服务暂无异常", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := reportsValue(c.text); got != c.want {
|
||||
t.Errorf("reportsValue(%q) = %v,期望 %v", c.text, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 无时间的块不被仲裁吃掉(迁移兜底会把块时间设成 NOW,但旧库可能有零值)。
|
||||
func TestArbitrate_无时间块保留(t *testing.T) {
|
||||
hits := []BlockHit{
|
||||
{Block: MemoryBlock{ID: "a", Text: "服务A|端口=8861"}}, // 零时间
|
||||
hit("服务A|端口=9999", "2026-10-01 13:00:00", 0.9),
|
||||
}
|
||||
res := arbitrate(nil, hits)
|
||||
if len(res.Kept) != 2 {
|
||||
t.Errorf("无时间块应保留(不该被判为更早而吃掉),实际 %d:%v",
|
||||
len(res.Kept), textsOf(res.Kept))
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 仲裁不重排 kept:保持调用方给的向量序。
|
||||
//
|
||||
// 判据直接来自端到端实测的故障形态:仲裁若按时间升序重排,
|
||||
// 迁移来的整句块(时间戳最早 13:20:43)会全被顶到 top3,于是
|
||||
//
|
||||
// 仲裁前 top1 = 值班室分机号 4379,值班人 阿李 (正确候选)
|
||||
// 仲裁后 top1 = 随时追问细节 (完全无关)
|
||||
//
|
||||
// 端到端探针因此从 5/7 掉到 0/7。
|
||||
//
|
||||
// 时间只该决定「谁取代谁」(仲裁判断),不该决定「先给模型看哪个」
|
||||
// (相关性排序,那是向量分的事)。
|
||||
func TestArbitrate_不重排保持向量序(t *testing.T) {
|
||||
hits := []BlockHit{
|
||||
hit("服务A|端口=8861", "2026-10-01 13:00:00", 0.90),
|
||||
hit("服务B|端口=8499", "2026-10-01 13:00:00", 0.85),
|
||||
hit("服务C|端口=8271", "2026-10-01 13:00:00", 0.80),
|
||||
}
|
||||
res := arbitrate(nil, hits)
|
||||
if len(res.Kept) != 3 {
|
||||
t.Fatalf("三条都该保留,实际 %d", len(res.Kept))
|
||||
}
|
||||
for i := range hits {
|
||||
if res.Kept[i].Block.Text != hits[i].Block.Text {
|
||||
t.Errorf("第 %d 位被重排了:期望 %q,实际 %q",
|
||||
i, hits[i].Block.Text, res.Kept[i].Block.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 时间不同也不能重排(这个更容易踩:迁移来的整句块时间最早)。
|
||||
func TestArbitrate_时间不同也不重排(t *testing.T) {
|
||||
hits := []BlockHit{
|
||||
// 分数最高但时间最早 —— 若按时间升序会被顶到最后
|
||||
hit("服务A|端口=8861", "2026-10-01 13:00:00", 0.95),
|
||||
hit("服务B|端口=8499", "2026-10-01 14:00:00", 0.60),
|
||||
}
|
||||
res := arbitrate(nil, hits)
|
||||
if len(res.Kept) != 2 {
|
||||
t.Fatalf("两条都该保留,实际 %d", len(res.Kept))
|
||||
}
|
||||
if res.Kept[0].Block.Text != "服务A|端口=8861" {
|
||||
t.Errorf("第 0 位应保持向量序(分数 0.95),实际 %q",
|
||||
res.Kept[0].Block.Text)
|
||||
}
|
||||
}
|
||||
|
||||
// 块文本解析的两种形态。
|
||||
func TestParseFact_两种形态(t *testing.T) {
|
||||
cases := []struct {
|
||||
text string
|
||||
ok bool
|
||||
sub, dim, val string
|
||||
}{
|
||||
{"值班室分机号|值班分机号=4324", true, "值班室分机号", "值班分机号", "4324"},
|
||||
{"服务A|端口=8861", true, "服务A", "端口", "8861"},
|
||||
{"停机时长=4分", true, "", "停机时长", "4分"},
|
||||
{"值班室分机号 4379,值班人 阿李", false, "", "", ""},
|
||||
{"下周起值班室分机号改为 4324", false, "", "", ""},
|
||||
{"=无维度", false, "", "", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
sub, dim, val, ok := parseFact(c.text)
|
||||
if ok != c.ok {
|
||||
t.Errorf("parseFact(%q) ok = %v,期望 %v", c.text, ok, c.ok)
|
||||
continue
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if sub != c.sub || dim != c.dim || val != c.val {
|
||||
t.Errorf("parseFact(%q) = (%q,%q,%q),期望 (%q,%q,%q)",
|
||||
c.text, sub, dim, val, c.sub, c.dim, c.val)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func textsOf(hits []BlockHit) []string {
|
||||
var out []string
|
||||
for _, h := range hits {
|
||||
out = append(out, h.Block.Text)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ★ 同句归属(blocks 的 contains 边)必须真的参与仲裁。
|
||||
//
|
||||
// 上一版的测试全部传 db=nil,于是「同句排除」那条分支永远走不到:
|
||||
// 变异去掉它仍然全绿 —— 假绿。判据得从真库形态出发:
|
||||
// 用真 GraphDB 建句子+块(sentence --contains--> block),再仲裁。
|
||||
func TestArbitrate_同句归属参与仲裁(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer func() { _ = g.Close() }()
|
||||
|
||||
sentence := "值班室分机号 4324,值班 老周(下周起;旧号 4379 停用)"
|
||||
// ★ 原句由**块**承载(sentences 表退场),不再建 sentences 行。
|
||||
// 这三个测试此前一直造 sentences 行 + 用 sentence 端点,
|
||||
// 而生产早已改为 原句块 --contains--> 字段块(0586f6b / cdf0726)
|
||||
// ⇒ 它们绿着,却测的不是当前行为。
|
||||
sid := SentenceBlockID(sentence)
|
||||
if err := putSentenceBlock(g, sentence); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 同一条原句拆出的两个字段块
|
||||
b1 := MemoryBlock{ID: "blk_a", Modality: BlockText, Text: "值班室分机号|值班分机号=4324",
|
||||
CreatedAt: mustTime("2026-10-01 13:44:26")}
|
||||
b2 := MemoryBlock{ID: "blk_b", Modality: BlockText, Text: "值班室分机号|停用旧号=4379",
|
||||
CreatedAt: mustTime("2026-10-01 13:44:26")}
|
||||
if err := g.PutMemoryBlocks([]MemoryBlock{b1, b2}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, id := range []string{"blk_a", "blk_b"} {
|
||||
if err := g.AddMemoryBlockEdge("block", sid, "block", id, "contains"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// 另一条原句拆出的旧值块(独立事实)
|
||||
oldText := "值班室分机号 4379,值班人 阿李"
|
||||
if err := putSentenceBlock(g, oldText); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldSent := SentenceBlockID(oldText)
|
||||
b3 := MemoryBlock{ID: "blk_c", Modality: BlockText, Text: "值班室分机号|值班分机号=4379",
|
||||
CreatedAt: mustTime("2026-10-01 13:28:28")}
|
||||
if err := g.PutMemoryBlocks([]MemoryBlock{b3}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := g.AddMemoryBlockEdge("block", oldSent, "block", "blk_c", "contains"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
hits := []BlockHit{
|
||||
{Block: b1, Score: 0.73},
|
||||
{Block: b2, Score: 0.70},
|
||||
{Block: b3, Score: 0.81},
|
||||
}
|
||||
res := arbitrate(g, hits)
|
||||
|
||||
// ★★ 修正后的断言(2026-10-04,hasSameSentenceSibling 修好之后)。
|
||||
//
|
||||
// 本断言原写「三条都该保留」,注释解释为「blk_b 没进候选」——
|
||||
// **那个理由是错的**:blk_b 一直在 hits 里(见上)。
|
||||
//
|
||||
// 真正机制:hasSameSentenceSibling 恒返回 false
|
||||
//(SQL 写 source_kind='sentence',而 contains 边两端都是 block),
|
||||
// 于是「同句并列字段不得外溢取代」的守卫**从未生效**,
|
||||
// blk_b(停用旧号=4379)以自己身份把 blk_c 判成被取代。
|
||||
//
|
||||
// 守卫修好后,行为回到 arbitrate.go:465 写明的设计意图:
|
||||
//
|
||||
// 原句B 的「停用旧号=4379」属于「新值+旧值并列」结构,不该单独执行取代;
|
||||
// 而原句A 的 4379 是「历史上真实用过的号」,正是值覆盖要保留的信息。
|
||||
//
|
||||
// 所以三条**都该保留** —— 原断言的结论对,只是理由写错了。
|
||||
// 实测:修复前 Superseded=1[blk_c];修复后 Kept=3 Superseded=0。
|
||||
if len(res.Kept) != 3 {
|
||||
t.Errorf("同句并列的 blk_a/blk_b 与历史上真实用过的 blk_c 都该保留,实际保留 %d:%v",
|
||||
len(res.Kept), textsOf(res.Kept))
|
||||
}
|
||||
// blk_b 不得以自己身份取代 blk_c —— 这正是刚修好的守卫。
|
||||
if len(res.Superseded) != 0 {
|
||||
t.Errorf("blk_b 属于「新值+旧值并列」结构,不得外溢取代 blk_c;实际取代了 %d:%v",
|
||||
len(res.Superseded), textsOf(res.Superseded))
|
||||
}
|
||||
if !keptContains(res.Kept, "blk_c") {
|
||||
t.Errorf("blk_c 是历史上真实用过的 4379,不该被剔除;实际保留 %v", textsOf(res.Kept))
|
||||
}
|
||||
|
||||
// ★ 关键:把 blk_a 的文本换成提及旧值的版本,此时 blk_c 应被取代。
|
||||
//
|
||||
// 这里刻意用**未经 PutMemoryBlocks 落库**的临时块 ——
|
||||
// 于是 sameSentenceOf 查不到 blk_a 的原句归属(sameOf 为空),
|
||||
// 外溢守卫被跳过,于是「明确提及旧值」的 blk_a 直接执行取代。
|
||||
// 验证的是守卫的另一侧:有归属就不外溢、没归属只看是否显式提及。
|
||||
hits2 := []BlockHit{
|
||||
{Block: MemoryBlock{ID: "blk_a", Modality: BlockText,
|
||||
Text: "值班室分机号|值班分机号=4324(旧号 4379 停用)",
|
||||
CreatedAt: mustTime("2026-10-01 13:44:26")}, Score: 0.73},
|
||||
{Block: b2, Score: 0.70},
|
||||
{Block: b3, Score: 0.81},
|
||||
}
|
||||
res2 := arbitrate(g, hits2)
|
||||
if len(res2.Superseded) != 1 || res2.Superseded[0].Block.ID != "blk_c" {
|
||||
t.Errorf("提及旧值时 blk_c 应被取代,实际 %d:%v",
|
||||
len(res2.Superseded), textsOf(res2.Superseded))
|
||||
}
|
||||
}
|
||||
|
||||
func mustTime(s string) time.Time {
|
||||
t, _ := time.Parse("2006-01-02 15:04:05", s)
|
||||
return t
|
||||
}
|
||||
|
||||
// keptContains 报告 hits 里是否有指定 id(判据用)。
|
||||
func keptContains(hits []BlockHit, id string) bool {
|
||||
for _, h := range hits {
|
||||
if h.Block.ID == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 「旧值作废」语义识别。判据来自真库模型的实际用词。
|
||||
func TestIsDeprecatedDim(t *testing.T) {
|
||||
cases := []struct {
|
||||
dim string
|
||||
want bool
|
||||
}{
|
||||
{"停用旧号", true}, // 真库实见
|
||||
{"旧分机号", true}, // 真库实见
|
||||
{"值班分机号", false},
|
||||
{"值班人", false},
|
||||
{"端口", false},
|
||||
{"峰值", false},
|
||||
{"原版本", true},
|
||||
{"历史值", true},
|
||||
{"", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := isDeprecatedDim(c.dim); got != c.want {
|
||||
t.Errorf("isDeprecatedDim(%q) = %v,期望 %v", c.dim, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 同句排除的直接判据:同一条原句拆出的、**同维度**的两条,
|
||||
// 即使后者文本里含前者的值,也**不是**时序替代。
|
||||
//
|
||||
// 为什么需要这条独立用例:上一版的「同句归属参与仲裁」只测了
|
||||
// 不同维度的组合,而那组数据即使删掉同句排除也照样通过(同维度排除与
|
||||
// 外溢约束各自挡住了)—— 变异自证时判成了假绿。这条用同维度组合
|
||||
// 把同句排除单独钉住。
|
||||
//
|
||||
// 真实形态:模型偶尔会把同一原句里的同一属性说两遍
|
||||
// (「值班室分机号 4324,值班 老周(旧号 4379 停用)」
|
||||
//
|
||||
// → 值班分机号=4324、值班分机号=4379)
|
||||
//
|
||||
// 这是同一次陈述里的并列,不是「4379 作废」。
|
||||
func TestArbitrate_同句同维度不互斥(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer func() { _ = g.Close() }()
|
||||
|
||||
sidText := "值班室分机号 4324,值班 老周(旧号 4379 停用)"
|
||||
if err := putSentenceBlock(g, sidText); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sid := SentenceBlockID(sidText)
|
||||
b1 := MemoryBlock{ID: "s1", Modality: BlockText,
|
||||
Text: "值班室分机号|值班分机号=4324",
|
||||
CreatedAt: mustTime("2026-10-01 13:44:26")}
|
||||
// 同句、同维度,且文本含 b1 的值 4324
|
||||
// ★ 时间必须不同:同时间时 arbitrate 的「只看更晚的」判断本来就会
|
||||
// 挡掉这对,同句排除根本执行不到 —— 那样这条用例会因为**别的原因**
|
||||
// 而通过,变异自证就判成假绿(第一版正是如此,已踩)。
|
||||
b2 := MemoryBlock{ID: "s2", Modality: BlockText,
|
||||
Text: "值班室分机号|值班分机号=4379(本周起 4324 生效)",
|
||||
CreatedAt: mustTime("2026-10-01 13:44:27")}
|
||||
if err := g.PutMemoryBlocks([]MemoryBlock{b1, b2}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, id := range []string{"s1", "s2"} {
|
||||
if err := g.AddMemoryBlockEdge("block", sid, "block", id, "contains"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
res := arbitrate(g, []BlockHit{{Block: b1, Score: 0.7}, {Block: b2, Score: 0.6}})
|
||||
if len(res.Kept) != 2 {
|
||||
t.Errorf("同句同维度的两条都该保留(是并列陈述,不是新旧替代),实际 %d:%v",
|
||||
len(res.Kept), textsOf(res.Kept))
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 接入层判据:仲裁必须发生在 **topK 截断之前**。
|
||||
//
|
||||
// 实测的故障形态:查询「值班室分机号是多少」,旧号 4379 以 0.8127 排
|
||||
// top1,新号 4324 那条**进不了 top8**。正确记录在召回阶段就被挤掉了,
|
||||
// 此后任何仲裁都无从挽回。
|
||||
//
|
||||
// 这条测试构造「旧值分数更高、新值分数更低但时间更新」的场景:
|
||||
// 若仲裁在截断后做,topK=1 时只剩旧值,断言新值在结果里就会失败。
|
||||
func TestRecallBlocks_仲裁在截断前(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer func() { _ = g.Close() }()
|
||||
|
||||
// 旧值:更早、分数更高(模拟"长旧句相似度高")
|
||||
oldText := "值班室分机号 4379,值班人 阿李"
|
||||
if err := putSentenceBlock(g, oldText); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldSent := SentenceBlockID(oldText)
|
||||
oldB := MemoryBlock{ID: "a_old", Modality: BlockText,
|
||||
Text: "值班室分机号|值班分机号=4379",
|
||||
Vector: []float64{1, 0, 0}, Fingerprint: "fp-test",
|
||||
CreatedAt: mustTime("2026-10-01 13:28:28")}
|
||||
if err := g.PutMemoryBlocks([]MemoryBlock{oldB}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = g.AddMemoryBlockEdge("block", oldSent, "block", "a_old", "contains")
|
||||
|
||||
// 新值:更晚、分数更低,但文本提及旧值 → 应取代它
|
||||
newSentText := "值班室分机号 4324,值班 老周"
|
||||
if err := putSentenceBlock(g, newSentText); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
newSent := SentenceBlockID(newSentText)
|
||||
newB := MemoryBlock{ID: "a_new", Modality: BlockText,
|
||||
Text: "值班室分机号|值班分机号=4324(旧号 4379 停用)",
|
||||
Vector: []float64{0.92, 0.39, 0}, Fingerprint: "fp-test",
|
||||
CreatedAt: mustTime("2026-10-01 13:44:26")}
|
||||
if err := g.PutMemoryBlocks([]MemoryBlock{newB}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = g.AddMemoryBlockEdge("block", newSent, "block", "a_new", "contains")
|
||||
|
||||
// 查询向量更贴近旧值(0.98 vs 新值 0.95)
|
||||
q := BlockRecallQuery{
|
||||
Vector: []float64{0.98, 0.199, 0}, Fingerprint: "fp-test", TopK: 1,
|
||||
}
|
||||
hits, arb, err := g.RecallBlocksWithArbitration(q)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if arb.Status != "superseded" {
|
||||
t.Errorf("仲裁状态应为 superseded(4324 取代 4379),实际 %q", arb.Status)
|
||||
}
|
||||
if len(arb.Superseded) != 1 || arb.Superseded[0].Block.ID != "a_old" {
|
||||
t.Fatalf("被取代的应是 a_old,实际 %d:%v",
|
||||
len(arb.Superseded), textsOf(arb.Superseded))
|
||||
}
|
||||
if len(hits) == 0 || hits[0].Block.ID != "a_new" {
|
||||
t.Errorf("topK=1 时应返回新值 a_new(仲裁在截断前),实际 %v", textsOf(hits))
|
||||
}
|
||||
|
||||
// 对照:纯召回(不仲裁)按向量分返回 —— 旧值分数更高,所以排前。
|
||||
// 这正是仲裁要纠正的形态。
|
||||
raw, err := g.RecallBlocks(q)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(raw) == 0 || raw[0].Block.ID != "a_old" {
|
||||
t.Errorf("纯召回应按向量分返回(旧值在前),实际 %v", textsOf(raw))
|
||||
}
|
||||
}
|
||||
|
||||
// ★ supersedesSentence:整句块的取代判断。
|
||||
//
|
||||
// 这是端到端 overwrite 维度 1/2 的直接根因(真库 legacy-entity 形态):
|
||||
//
|
||||
// 13:28 值班室分机号 4379,值班人 阿李
|
||||
// 13:44 值班室分机号 4324,值班 老周(旧号 4379 停用)
|
||||
//
|
||||
// 查询新号时旧号排 top1 —— 而 13:44 那两条**文本里含 "4379"**,
|
||||
// 正是「新号生效、旧号作废」的自述。
|
||||
func TestSupersedesSentence(t *testing.T) {
|
||||
// ✓ 真实形态:更晚那条提到旧值
|
||||
if !supersedesSentence(
|
||||
"值班室分机号 4379,值班人 阿李",
|
||||
"值班室分机号 4324,值班 老周(旧号 4379 停用)") {
|
||||
t.Error("更晚且提到旧值 ⇒ 应判取代")
|
||||
}
|
||||
// ✓ 改述形态
|
||||
if !supersedesSentence(
|
||||
"值班室分机号 4379,值班人 阿李",
|
||||
"下周起值班室分机号改为 4324,旧号 4379 停用,值班轮换到 老周") {
|
||||
t.Error("改述形态也应判取代")
|
||||
}
|
||||
// ✗ later 不含旧值(只是又提了一句别的)
|
||||
if supersedesSentence(
|
||||
"值班室分机号 4379,值班人 阿李",
|
||||
"值班室分机号 4324,值班 老周") {
|
||||
t.Error("later 未提旧值 ⇒ 不该判取代(会误伤并存的两个值)")
|
||||
}
|
||||
// ✗ later 提到的是别的值
|
||||
if supersedesSentence(
|
||||
"峰值 4%~17%(30~71批整体)",
|
||||
"峰值 4%~17%(30~84批整体),80~84批为 7%~16%") {
|
||||
t.Error("指标收窄不该判取代")
|
||||
}
|
||||
// ✗ earlier 没有可识别的值
|
||||
if supersedesSentence("随时追问细节", "随时追问细节(补充)") {
|
||||
t.Error("earlier 无可识别值 ⇒ 不该判取代")
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 端到端:整句块的旧值必须被剔除,且不能误伤真实并列。
|
||||
func TestArbitrate_整句块的取代(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer func() { _ = g.Close() }()
|
||||
|
||||
// 真库形态:旧号(13:28)+ 两条新号自述(13:44)
|
||||
putRawBlockArb(t, g, "old", "值班室分机号 4379,值班人 阿李",
|
||||
"2026-10-01 13:28:28")
|
||||
putRawBlockArb(t, g, "new1", "值班室分机号 4324,值班 老周(旧号 4379 停用)",
|
||||
"2026-10-01 13:44:26")
|
||||
putRawBlockArb(t, g, "new2", "下周起值班室分机号改为 4324,旧号 4379 停用,值班轮换到 老周",
|
||||
"2026-10-01 13:44:26")
|
||||
// ★ 不能误伤的:另一个属性的值覆盖(不同属性,各自独立)
|
||||
putRawBlockArb(t, g, "pool-a", "连接池容量 32/64 扩容前", "2026-10-01 13:00:00")
|
||||
putRawBlockArb(t, g, "pool-b", "连接池容量 128/256 扩容后", "2026-10-01 14:00:00")
|
||||
|
||||
hits := []BlockHit{
|
||||
{Block: mustBlock(t, g, "old"), Score: 0.95},
|
||||
{Block: mustBlock(t, g, "new1"), Score: 0.90},
|
||||
{Block: mustBlock(t, g, "new2"), Score: 0.88},
|
||||
{Block: mustBlock(t, g, "pool-a"), Score: 0.60},
|
||||
{Block: mustBlock(t, g, "pool-b"), Score: 0.55},
|
||||
}
|
||||
res := arbitrate(nil, hits)
|
||||
|
||||
keptIDs := map[string]bool{}
|
||||
for _, h := range res.Kept {
|
||||
keptIDs[h.Block.ID] = true
|
||||
}
|
||||
if keptIDs["old"] {
|
||||
t.Error("旧号块应被取代剔除(13:28,且被 13:44 的两条自述提到)")
|
||||
}
|
||||
if !keptIDs["new1"] || !keptIDs["new2"] {
|
||||
t.Errorf("两条新号自述都该保留,实际 kept=%v", keptIDs)
|
||||
}
|
||||
// ★ 不同属性的两条都该留 —— 它们不是彼此的「新旧」
|
||||
if !keptIDs["pool-a"] || !keptIDs["pool-b"] {
|
||||
t.Errorf("不同属性的两条不该互斥,实际 kept=%v", keptIDs)
|
||||
}
|
||||
if len(res.Superseded) != 1 {
|
||||
t.Errorf("应恰有 1 条被取代,实际 %d:%v", len(res.Superseded), textsOf(res.Superseded))
|
||||
}
|
||||
}
|
||||
|
||||
func putRawBlockArb(t *testing.T, g *GraphDB, id, text, when string) {
|
||||
t.Helper()
|
||||
b := MemoryBlock{ID: id, Modality: BlockText, Text: text, CreatedAt: mustTime(when)}
|
||||
if err := g.PutMemoryBlocks([]MemoryBlock{b}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustBlock(t *testing.T, g *GraphDB, id string) MemoryBlock {
|
||||
t.Helper()
|
||||
blocks, err := g.MemoryBlocks()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, b := range blocks {
|
||||
if b.ID == id {
|
||||
return b
|
||||
}
|
||||
}
|
||||
t.Fatalf("找不到块 %s", id)
|
||||
return MemoryBlock{}
|
||||
}
|
||||
|
||||
// putSentenceBlock 直接写一条原句块。
|
||||
//
|
||||
// 为什么不用 putBlockTx:它收 *sql.Tx,而这些测试已持有 g.mu
|
||||
// (且用 *sql.DB 直连)⇒ 套一层事务反而复杂。
|
||||
// ★ 教训(今天踩过):持有 GraphDB 锁时不要调会自己加锁的方法。
|
||||
func putSentenceBlock(g *GraphDB, text string) error {
|
||||
b := NewSentenceBlock(text, time.Time{}, time.Time{})
|
||||
vectorJSON := ""
|
||||
_, err := g.db.Exec(`INSERT INTO memory_blocks
|
||||
(id, modality, text_content, vector, fingerprint, source, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(id) DO UPDATE SET text_content = excluded.text_content`,
|
||||
b.ID, b.Modality, b.Text, vectorJSON, b.Fingerprint, b.Source)
|
||||
return err
|
||||
}
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@ -41,9 +42,16 @@ type MemoryBlock struct {
|
||||
// 场景要贯穿到流水线底,就得从块开始——否则「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"`
|
||||
Scene string `json:"scene,omitempty"`
|
||||
// SemanticType 是实体的语义类别(Person / Animal / Concept / Topic…),
|
||||
// 对应旧 entities.type 与 Triple.SubjectType/ObjectType。
|
||||
//
|
||||
// ★ 2026-10-04 加入。此前 Triple 标注的类型只写旧表,块侧完全没有 ——
|
||||
// 判据 TestGraphCommit_CarriesAllFields 抓到:
|
||||
// Commit 标注的 (Person, Animal) 读回来是 ("block","block")。
|
||||
SemanticType string `json:"semantic_type,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// MemoryBlockEdge 是 L3 中连接一等记忆节点的结构化语义边。
|
||||
@ -56,6 +64,26 @@ type MemoryBlockEdge struct {
|
||||
TargetID string `json:"target_id"`
|
||||
Type string `json:"type"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
// ★ 以下是「边作为独立单位」需要的属性列。
|
||||
//
|
||||
// 结构边(contains 等)不填这些;关系边填。
|
||||
// 旧 relations 表有同名列 —— 块化后属性挂在**边**上,
|
||||
// 而不是挂在一张独立的 relations 表上(那才是「边不是独立单位」的根源)。
|
||||
Confidence float64 `json:"confidence,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
TurnID int `json:"turn_id,omitempty"`
|
||||
// Status 是 EdgeActive / EdgeDeleted / EdgeMerged。
|
||||
// 空字符串表示结构边(无状态语义)。
|
||||
Status string `json:"status,omitempty"`
|
||||
// MergedInto 在 EdgeMerged 时指向目标块:源块被并进哪里,
|
||||
// 历史边留在源块上不重定向。
|
||||
MergedInto string `json:"merged_into,omitempty"`
|
||||
}
|
||||
|
||||
// IsRelationEdge 判断该边是否为**关系边**(而非结构边)。
|
||||
func (e MemoryBlockEdge) IsRelationEdge() bool {
|
||||
return e.SessionID != "" || e.Status != "" || e.Confidence != 0
|
||||
}
|
||||
|
||||
func validBlockModality(modality BlockModality) bool {
|
||||
@ -110,6 +138,10 @@ func (g *GraphDB) PutMemoryBlocks(blocks []MemoryBlock) error {
|
||||
-- 场景只在本次给了值时才覆盖:块可能先被写入、后被归档路径补挂场景,
|
||||
-- 反过来「已挂场景的块被一次无场景的重写抹掉」是不可接受的静默降级。
|
||||
scene = CASE WHEN excluded.scene != '' THEN excluded.scene ELSE memory_blocks.scene END,
|
||||
-- ★ semantic_type 同样「空值不覆盖」:
|
||||
-- 后写的三元组往往不带类型(Triple.SubjectType 可选),
|
||||
-- 若让它覆盖已有类型,一次不带标注的写入就会抹掉语义类别。
|
||||
semantic_type = CASE WHEN excluded.semantic_type != '' THEN excluded.semantic_type ELSE memory_blocks.semantic_type 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,
|
||||
@ -144,8 +176,7 @@ func (g *GraphDB) PutDocumentNode(id, summary string) error {
|
||||
func (g *GraphDB) MemoryBlocks() ([]MemoryBlock, error) {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
rows, err := g.db.Query(`SELECT id, modality, text_content, payload_digest, mime,
|
||||
size, width, height, vector, fingerprint, source, tool, created_at, updated_at
|
||||
rows, err := g.db.Query(`SELECT ` + blockColumns + `
|
||||
FROM memory_blocks ORDER BY created_at, id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -154,24 +185,83 @@ func (g *GraphDB) MemoryBlocks() ([]MemoryBlock, error) {
|
||||
|
||||
var blocks []MemoryBlock
|
||||
for rows.Next() {
|
||||
var block MemoryBlock
|
||||
var vectorJSON string
|
||||
if err := rows.Scan(&block.ID, &block.Modality, &block.Text, &block.PayloadDigest,
|
||||
&block.MIME, &block.Size, &block.Width, &block.Height, &vectorJSON,
|
||||
&block.Fingerprint, &block.Source, &block.Tool, &block.CreatedAt,
|
||||
&block.UpdatedAt); err != nil {
|
||||
block, err := scanBlockRow(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if vectorJSON != "" && vectorJSON != "null" {
|
||||
if err := json.Unmarshal([]byte(vectorJSON), &block.Vector); err != nil {
|
||||
return nil, fmt.Errorf("decode memory block %s vector: %w", block.ID, err)
|
||||
}
|
||||
}
|
||||
blocks = append(blocks, block)
|
||||
}
|
||||
return blocks, rows.Err()
|
||||
}
|
||||
|
||||
// scanBlockRow 读一行 memory_blocks。
|
||||
//
|
||||
// ★★ 列顺序与个数必须与**每个**调用它的 SELECT 完全一致
|
||||
// -----------------------------------------------
|
||||
// 共用扫描的代价是:SELECT 少列就整体读不出(不是某字段零值,而是
|
||||
// 整行 Scan 失败 ⇒ 该路径静默返回空)。
|
||||
//
|
||||
// 实测踩过:MemoryBlocks() 的 SELECT 少了 scene 列(14 列),
|
||||
// 而 block_recall.go / edge_entity.go 是 15 列,换成共用扫描后
|
||||
// 三处一起读不出块 —— 症状是「召回返回未找到相关记忆」,
|
||||
// 完全看不出是列数问题。
|
||||
//
|
||||
// ⇒ 改任何 memory_blocks 的 SELECT 时,**数一遍列**。
|
||||
//
|
||||
// ★ 提取成共用函数的原因
|
||||
// ------------------
|
||||
// BFSBlocks(edge_entity.go)也要读块。若它自己写一份 Scan,
|
||||
// 将来加列时**两处都要改** —— 而我只加了 schema 列忘了扩读取端
|
||||
// 已经犯过一次(属性静默变零值,被 TestEdge_承载关系属性 抓到)。
|
||||
// 共用扫描让「加列漏改」不再可能。
|
||||
// blockColumns 是 memory_blocks 的完整列清单,**必须与 scanBlockRow 的
|
||||
// Scan 顺序逐列一致**。
|
||||
//
|
||||
// ★★★ 加列时的必读(2026-10-04 实测踩了 6 次)
|
||||
//
|
||||
// 加 semantic_type 那一次,**四处 SELECT 是手写列清单**(不是常量),
|
||||
// 两处 Scan 也是手写 —— 于是:
|
||||
//
|
||||
// 「sql: expected 16 destination arguments in Scan, not 15」
|
||||
//
|
||||
// ★ 这个错**只在真跑 SQL 时暴露**,编译期完全无感;
|
||||
//
|
||||
// 而且它表现为「召回突然空了」而非「读失败」,
|
||||
// 很容易被误判成召回逻辑坏了而查错方向。
|
||||
//
|
||||
// ⇒ 本文件的 SELECT 一律用 blockColumns,不要再手写。
|
||||
// ⇒ 加列后必须做两件事:
|
||||
// 1. `grep -rn 'FROM memory_blocks' --include='*.go'` 确认无手写清单
|
||||
// 2. `grep -rn '&b.Scene,\|&scene,' --include='*.go'` 确认所有 Scan 已同步
|
||||
//
|
||||
// ★ 为什么要提成常量:2026-10-04 修过一次由此引发的生产 bug ——
|
||||
//
|
||||
// MemoryBlocks() 的 SELECT 漏了 scene 列,而它与 scanBlockRow 共用,
|
||||
// 结果媒体与召回测试大面积变红(列数不匹配 ⇒ 读失败)。
|
||||
//
|
||||
// 单靠“记得同步”不可靠;提成常量后新增查询直接复用。
|
||||
const blockColumns = `id, modality, text_content, payload_digest, mime, size, width, height,
|
||||
vector, fingerprint, source, tool, scene, semantic_type, created_at, updated_at`
|
||||
|
||||
func scanBlockRow(rows *sql.Rows) (MemoryBlock, error) {
|
||||
var b MemoryBlock
|
||||
var vectorJSON string
|
||||
var scene sql.NullString
|
||||
if err := rows.Scan(&b.ID, &b.Modality, &b.Text, &b.PayloadDigest,
|
||||
&b.MIME, &b.Size, &b.Width, &b.Height, &vectorJSON,
|
||||
&b.Fingerprint, &b.Source, &b.Tool, &scene, &b.SemanticType,
|
||||
&b.CreatedAt, &b.UpdatedAt); err != nil {
|
||||
return b, err
|
||||
}
|
||||
b.Scene = scene.String
|
||||
if vectorJSON != "" && vectorJSON != "null" {
|
||||
if err := json.Unmarshal([]byte(vectorJSON), &b.Vector); err != nil {
|
||||
return b, fmt.Errorf("decode memory block %s vector: %w", b.ID, err)
|
||||
}
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func validGraphNodeKind(kind string) bool {
|
||||
return kind == "block" || kind == "entity" || kind == "sentence" || kind == "document"
|
||||
}
|
||||
@ -219,20 +309,46 @@ func (g *GraphDB) AddMemoryBlockEdge(sourceKind, sourceID, targetKind, targetID,
|
||||
return fmt.Errorf("%s graph node %s does not exist", endpoint.kind, endpoint.id)
|
||||
}
|
||||
}
|
||||
_, err = tx.Exec(`INSERT OR IGNORE INTO memory_block_edges
|
||||
(source_kind, source_id, target_kind, target_id, edge_type)
|
||||
VALUES (?, ?, ?, ?, ?)`, sourceKind, sourceID, targetKind, targetID, edgeType)
|
||||
// ★★ 结构边必须**显式查重** —— 不能再依赖 INSERT OR IGNORE
|
||||
//
|
||||
// 边表升格去掉了 UNIQUE(source_kind,source_id,target_kind,target_id,
|
||||
// edge_type)(因为它让「同一对节点并存多条同类关系边」不可能)。
|
||||
// 而 OR IGNORE 的去重**正是靠那个 UNIQUE 实现的** ——
|
||||
// 约束一去,重复调用就会插出两条一样的 contains 边。
|
||||
//
|
||||
// 实测后果:distill 幂等与迁移幂等两个判据当场红了。
|
||||
//
|
||||
// ⇒ 结构边(contains 等)在这里显式查;关系边走
|
||||
// AddRelationBlockEdge,它**刻意**不查重(可并存多条)。
|
||||
var existing int
|
||||
err = tx.QueryRow(`SELECT COUNT(*) FROM memory_block_edges
|
||||
WHERE source_kind=? AND source_id=? AND target_kind=? AND target_id=?
|
||||
AND edge_type=? AND COALESCE(session_id,'')=''`,
|
||||
sourceKind, sourceID, targetKind, targetID, edgeType).Scan(&existing)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing > 0 {
|
||||
return nil // 已存在:结构边幂等
|
||||
}
|
||||
|
||||
if _, err = tx.Exec(`INSERT INTO memory_block_edges
|
||||
(source_kind, source_id, target_kind, target_id, edge_type)
|
||||
VALUES (?, ?, ?, ?, ?)`, sourceKind, sourceID, targetKind, targetID, edgeType); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (g *GraphDB) MemoryBlockEdges() ([]MemoryBlockEdge, error) {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
// ★ 必须读全部 11 列 —— 只读旧 6 列会让关系边的属性
|
||||
// (session_id / confidence / status)静默变成零值。
|
||||
// 这正是第一版加列时漏掉的地方:写了列但没扩读取端。
|
||||
rows, err := g.db.Query(`SELECT id, source_kind, source_id, target_kind, target_id,
|
||||
edge_type, created_at FROM memory_block_edges ORDER BY id`)
|
||||
edge_type, created_at, confidence, session_id, turn_id, status, merged_into
|
||||
FROM memory_block_edges ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -240,10 +356,21 @@ func (g *GraphDB) MemoryBlockEdges() ([]MemoryBlockEdge, error) {
|
||||
var edges []MemoryBlockEdge
|
||||
for rows.Next() {
|
||||
var edge MemoryBlockEdge
|
||||
var sess, status, merged sql.NullString
|
||||
var conf sql.NullFloat64
|
||||
var turn sql.NullInt64
|
||||
if err := rows.Scan(&edge.ID, &edge.SourceKind, &edge.SourceID, &edge.TargetKind,
|
||||
&edge.TargetID, &edge.Type, &edge.CreatedAt); err != nil {
|
||||
&edge.TargetID, &edge.Type, &edge.CreatedAt,
|
||||
&conf, &sess, &turn, &status, &merged); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
edge.Confidence = conf.Float64
|
||||
edge.SessionID = sess.String
|
||||
edge.Status = status.String
|
||||
edge.MergedInto = merged.String
|
||||
if turn.Valid {
|
||||
edge.TurnID = int(turn.Int64)
|
||||
}
|
||||
edges = append(edges, edge)
|
||||
}
|
||||
return edges, rows.Err()
|
||||
@ -285,3 +412,359 @@ func (g *GraphDB) BlocksForNode(nodeKind, nodeID string) ([]MemoryBlock, error)
|
||||
}
|
||||
return blocks, rows.Err()
|
||||
}
|
||||
|
||||
// addContainsEdgeTx 在事务内建一条**结构边**(原句块 → 关系边)。
|
||||
//
|
||||
// ★ 为什么不能用 AddRelationBlockEdge:它刻意不查重(同对节点可并存多条
|
||||
//
|
||||
// 同类型关系边,这是 52e4596 的设计)。结构边语义相反 —— 同一句与同一条
|
||||
// 关系之间只应有一条 contains,重复插入会让 BFS 的邻接表里出现重复项。
|
||||
//
|
||||
// ★ 为什么不能用 addBlockEdgeTx:它是迁移专用的 INSERT OR IGNORE 版本,
|
||||
//
|
||||
// 端点校验走的是旧表。
|
||||
//
|
||||
// ★ 端点允许是**边 ID**:原句块指向的是关系边(memory_block_edges.id),
|
||||
//
|
||||
// 所以 targetKind 用 'edge'。graphNodeExists 已支持该 kind。
|
||||
//
|
||||
// ★ 幂等:重复写入静默跳过(返回 nil),让重试安全。
|
||||
func addContainsEdgeTx(tx *sql.Tx, sentenceBlockID string, edgeID int64, weight float64) error {
|
||||
if sentenceBlockID == "" || edgeID == 0 {
|
||||
return nil
|
||||
}
|
||||
var n int
|
||||
if err := tx.QueryRow(`SELECT COUNT(*) FROM memory_block_edges
|
||||
WHERE source_kind='block' AND source_id=?
|
||||
AND target_kind='edge' AND target_id=?
|
||||
AND edge_type='contains' AND COALESCE(session_id,'')=''`,
|
||||
sentenceBlockID, edgeID).Scan(&n); err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
return nil
|
||||
}
|
||||
_, err := tx.Exec(`INSERT INTO memory_block_edges
|
||||
(source_kind, source_id, target_kind, target_id, edge_type,
|
||||
confidence, status)
|
||||
VALUES ('block', ?, 'edge', ?, 'contains', ?, 'active')`,
|
||||
sentenceBlockID, edgeID, weight)
|
||||
return err
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 读方切换用的薄包装(2026-10-04)
|
||||
//
|
||||
// 这三个函数存在的唯一理由:让 indexer / social / distill / Purge
|
||||
// 能从旧表(entities/relations/sentences)迁到块体系。
|
||||
//
|
||||
// ★ 它们**刻意不做语义加工** —— 只做「按名找块」「枚举块名」
|
||||
// 「取某块的邻居边」这三件最朴素的事。
|
||||
// 任何排序、过滤、仲裁、召回打分都该在调用方做,
|
||||
// 否则同一个语义会散落在两处,然后各自漂移。
|
||||
//
|
||||
// ★ 全部走 g.mu,所以调用方**不得**已持锁。
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// BlockByText 按文本取块(精确匹配)。
|
||||
//
|
||||
// 用途对应旧表的 `WHERE name = ?` —— 旧表的 name 是实体名,
|
||||
// 块侧的等价物是 text_content。
|
||||
//
|
||||
// ★ 为什么用 = 而不是 LIKE:旧表 name 是**规范化过的实体名**
|
||||
//
|
||||
// (Commit 走 TripleBlockID(t.Subject) 这样的内容派生 ID),
|
||||
// 精确匹配能命中;模糊匹配会把「值班室分机号」与「值班室分机号(旧)」
|
||||
// 混为一谈,而那正是仲裁要区分的东西。
|
||||
//
|
||||
// ★ 不区分 modality:媒体块的 text_content 常为空,
|
||||
//
|
||||
// 空串匹配不到任何真实查询(调用方不会传空串查)。
|
||||
func (g *GraphDB) BlockByText(text string) (*MemoryBlock, error) {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return nil, nil
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
blocks, err := g.blocksByTextTx(text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(blocks) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
// 多个块同文本时取**最早**的:TripleBlockID 是内容派生的,
|
||||
// 理论上一个文本一个块;重复只可能来自迁移的历史数据,
|
||||
// 此时取最早的(它的 created_at 来自源实体,语义上更接近原意)。
|
||||
b := blocks[0]
|
||||
return &b, nil
|
||||
}
|
||||
|
||||
// blocksByTextTx 按文本取全部同文本块(调用方已持锁)。
|
||||
func (g *GraphDB) blocksByTextTx(text string) ([]MemoryBlock, error) {
|
||||
rows, err := g.db.Query(
|
||||
`SELECT `+blockColumns+` FROM memory_blocks
|
||||
WHERE text_content = ? ORDER BY created_at ASC, id ASC`, text)
|
||||
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()
|
||||
}
|
||||
|
||||
// BlockTextsLike 返回文本匹配 LIKE 的块(大小写不敏感,%value% 包裹)。
|
||||
//
|
||||
// 用途对应旧表的 `WHERE LOWER(name) LIKE ?`。
|
||||
// ★ 调用方自己拼 LIKE 还是传裸子串?**传裸子串** ——
|
||||
//
|
||||
// 包裹在这里做,避免每个调用方各写各的 %…% 然后漏掉转义。
|
||||
//
|
||||
// ★ 不做 N 限制:调用方要么自己限,要么要全量(建索引类场景)。
|
||||
//
|
||||
// 但 ⚠️ 全表 LIKE 在大库上是 O(n) —— 调用方需自行评估。
|
||||
func (g *GraphDB) BlockTextsLike(fragment string) ([]MemoryBlock, error) {
|
||||
fragment = strings.TrimSpace(fragment)
|
||||
if fragment == "" {
|
||||
return nil, nil
|
||||
}
|
||||
pattern := "%" + escapeLike(fragment) + "%"
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
rows, err := g.db.Query(
|
||||
`SELECT `+blockColumns+` FROM memory_blocks
|
||||
WHERE text_content LIKE ? ESCAPE '\'
|
||||
ORDER BY created_at ASC, id ASC`, pattern)
|
||||
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()
|
||||
}
|
||||
|
||||
// escapeLike 转义 LIKE 的元字符(% _ 与 ESCAPE 自身)。
|
||||
//
|
||||
// ★ 不转义的话,「50%」这类文本会变成通配符,
|
||||
//
|
||||
// 在 Purge 的删除路径上就是**误删**。
|
||||
func escapeLike(s string) string {
|
||||
r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
|
||||
return r.Replace(s)
|
||||
}
|
||||
|
||||
// AllBlockTexts 返回全部块的文本(去重,按出现顺序)。
|
||||
//
|
||||
// 用途对应旧表的「全量实体名」—— 建向量索引(indexer)与
|
||||
// 记忆整理(distill)都需要这个。
|
||||
//
|
||||
// ★ 去重:同名实体在旧表是**多行**(mention_count 不同),
|
||||
//
|
||||
// 而块侧的 TripleBlockID 是内容派生 ⇒ 同文本本应只有一个块。
|
||||
// 去重让调用方不必处理重复,也顺带掩盖迁移期的历史重复。
|
||||
//
|
||||
// ★ 分批读取:全表取进内存在百万块级会炸。
|
||||
//
|
||||
// batchSize<=0 时用默认批大小。
|
||||
func (g *GraphDB) AllBlockTexts(batchSize int) ([]string, error) {
|
||||
if batchSize <= 0 {
|
||||
batchSize = 5000
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
var out []string
|
||||
seen := make(map[string]bool)
|
||||
var lastID string
|
||||
for {
|
||||
rows, err := g.db.Query(
|
||||
`SELECT id, text_content FROM memory_blocks
|
||||
WHERE id > ? AND text_content != ''
|
||||
ORDER BY id LIMIT ?`, lastID, batchSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n := 0
|
||||
var nextID string
|
||||
for rows.Next() {
|
||||
var id, text string
|
||||
if err := rows.Scan(&id, &text); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
nextID = id
|
||||
n++
|
||||
if seen[text] {
|
||||
continue
|
||||
}
|
||||
seen[text] = true
|
||||
out = append(out, text)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n < batchSize {
|
||||
break
|
||||
}
|
||||
lastID = nextID
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// NeighbourEdgesOfBlock 返回以该块为端点的全部 active 关系边及其对端块。
|
||||
//
|
||||
// 用途对应旧表的「按实体名找它的所有关系」—— social 层按人/物聚合
|
||||
// 社交关系时用它。
|
||||
//
|
||||
// ★ 双向:块作为 source 或 target 都算。
|
||||
// ★ 只取非 deleted 的边(与全部读路径的 status 口径一致)。
|
||||
//
|
||||
// ★ 返回顺序按创建时间 —— 稳定的输出对测试与展示都重要,
|
||||
//
|
||||
// 而 SQLite 不保证无 ORDER BY 的行序。
|
||||
func (g *GraphDB) NeighbourEdgesOfBlock(blockID string) ([]BlockNeighbour, []MemoryBlock, error) {
|
||||
if blockID == "" {
|
||||
return nil, nil, nil
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
rows, err := g.db.Query(
|
||||
`SELECT e.id, e.source_id, e.target_id, e.edge_type,
|
||||
COALESCE(e.confidence, 0), COALESCE(e.session_id, ''),
|
||||
COALESCE(e.turn_id, 0), COALESCE(e.status, ''),
|
||||
COALESCE(e.created_at, ''),
|
||||
COALESCE(other.id, ''), COALESCE(other.text_content, '')
|
||||
FROM memory_block_edges e
|
||||
LEFT JOIN memory_blocks other
|
||||
ON other.id = CASE WHEN e.source_id = ? THEN e.target_id ELSE e.source_id END
|
||||
WHERE (e.source_id = ? OR e.target_id = ?)
|
||||
AND e.source_kind = 'block' AND e.target_kind = 'block'
|
||||
AND COALESCE(e.status, '') != 'deleted'
|
||||
ORDER BY e.created_at ASC, e.id ASC`, blockID, blockID, blockID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var edges []BlockNeighbour
|
||||
var peers []MemoryBlock
|
||||
seenPeer := make(map[string]bool)
|
||||
for rows.Next() {
|
||||
var nb BlockNeighbour
|
||||
var otherID, otherText, created string
|
||||
if err := rows.Scan(&nb.EdgeID, &nb.SourceID, &nb.TargetID, &nb.EdgeType,
|
||||
&nb.Confidence, &nb.SessionID, &nb.TurnID, &nb.Status, &created,
|
||||
&otherID, &otherText); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
nb.IsOutgoing = nb.SourceID == blockID
|
||||
nb.Peer = MemoryBlock{
|
||||
ID: otherID,
|
||||
Modality: BlockText,
|
||||
Text: otherText,
|
||||
CreatedAt: parseLegacyTime(created),
|
||||
UpdatedAt: parseLegacyTime(created),
|
||||
}
|
||||
nb.RelationEdgeData = RelationEdgeData{
|
||||
Confidence: nb.Confidence,
|
||||
SessionID: nb.SessionID,
|
||||
TurnID: nb.TurnID,
|
||||
Status: nb.Status,
|
||||
}
|
||||
edges = append(edges, nb)
|
||||
if otherText != "" && !seenPeer[otherID] {
|
||||
seenPeer[otherID] = true
|
||||
peers = append(peers, nb.Peer)
|
||||
}
|
||||
}
|
||||
return edges, peers, rows.Err()
|
||||
}
|
||||
|
||||
// blockSemanticType 返回块对外呈现的语义类型。
|
||||
//
|
||||
// ★ 为什么要这个函数而不是直接用 b.SemanticType:
|
||||
//
|
||||
// 语义类型是**可选标注**,绝大多数块没被标注过。
|
||||
// 旧 entities 表在缺省时写 "Concept"(graph.go 里 subjType 默认值),
|
||||
// 所以读侧(social 的 ListPersons、SDK 的实体类型)期待一个具体字符串。
|
||||
// 返回空串会让下游的 == "Person" 之类的比较全部落空 ——
|
||||
// 那不是「未标注」,那像是「数据坏了」。
|
||||
//
|
||||
// ★ 取值优先级:
|
||||
// 1. 块自带标注(Person / Animal / Concept…)—— 直接用
|
||||
// 2. 未标注 → "block"(说清「这是一个图节点,且没标类型」)
|
||||
// ★ 不用 "Concept":那是旧表的默认值,
|
||||
// 拿来兜底会让「未标注」与「确实是概念」无法区分。
|
||||
func blockSemanticType(b MemoryBlock) string {
|
||||
if t := strings.TrimSpace(b.SemanticType); t != "" {
|
||||
return t
|
||||
}
|
||||
return "block"
|
||||
}
|
||||
|
||||
// MemoryBlockCount 返回当前块总数。
|
||||
//
|
||||
// ★ 用途:判断「一次写入是否真的落库了」
|
||||
//
|
||||
// 停旧表双写后,Commit 的两个返回值恒为 0(它们数的是旧表行数),
|
||||
// 于是「有没有写进去」失去了信号 ——
|
||||
// 而 archiveColdDocs 正需要它来决定文档能否删除
|
||||
// (没写进去就删 = 丢数据)。
|
||||
//
|
||||
// ★ 为什么用块数而不是「本次新增块数」:
|
||||
//
|
||||
// 新增数需要事务内计数,而 MemoryBlockCount 是事务外的简单计数。
|
||||
// 调用方在写入前后各取一次相减即可,误差只来自并发写入 ——
|
||||
// 归档是后台低频任务,这个精度足够。
|
||||
func (g *GraphDB) MemoryBlockCount() (int, error) {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
var n int
|
||||
if err := g.db.QueryRow(`SELECT COUNT(*) FROM memory_blocks`).Scan(&n); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// MemoryEdgeCount 返回关系边数(不含 contains 结构边)。
|
||||
//
|
||||
// ★ 为什么需要它(2026-10-05)
|
||||
//
|
||||
// 旧表停双写后 Commit 的 ec/rc 恒为 0(graph.go 里
|
||||
// relationsCreated = 0 是写死的),所以「写了多少条关系」
|
||||
// 唯一的可信来源只能是**实测边数差值**。
|
||||
//
|
||||
// 不含 contains:那是结构边(原句块 → 字段块 / 关系边),
|
||||
// 混进来会让「关系数」随原句数量翻倍 —— 与 Introspect 的
|
||||
// relation_count 口径保持一致。
|
||||
func (g *GraphDB) MemoryEdgeCount() (int, error) {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
var n int
|
||||
if err := g.db.QueryRow(
|
||||
`SELECT COUNT(*) FROM memory_block_edges
|
||||
WHERE source_kind = 'block' AND target_kind = 'block'
|
||||
AND edge_type != 'contains'`).Scan(&n); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
293
internal/memory/block_recall.go
Normal file
293
internal/memory/block_recall.go
Normal file
@ -0,0 +1,293 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// 块节点的向量召回。
|
||||
//
|
||||
// 为什么需要它:entities 是旧形态且正在退场,而 memory_blocks 才是带
|
||||
// vector+fingerprint 的节点载体(46f833c 引入)。此前唯一有向量的只有
|
||||
// memory_blocks,但**没有任何召回路径读它** —— BlocksForNode 只能按
|
||||
// 端点反查,必须先知道 nodeID。于是「问一个具体问题」时只能退到
|
||||
// entities 的 jieba+LIKE,实测跨维度定位 0/5。
|
||||
//
|
||||
// 本文件补的就是这条缺失的路径:query 向量 → 块向量 → 按余弦排序。
|
||||
//
|
||||
// 与 vector.Store 的分工:Store 是内存倒排索引(L0/L1 用),块向量存在
|
||||
// SQLite(graph.db),生命周期与图一致、可跨进程重启、可被备份脚本覆盖。
|
||||
//
|
||||
// 全表扫描的代价(**已量化,不是估算**):生产库迁移后约 1277 块 × 2048 维
|
||||
// = 21 MB 读入 + 2.6M 次乘加,每次 memory_recall 一次。当前可接受;
|
||||
// 块数上到万级时需要换方案(候选:把向量搬进 vector.Store 的内存倒排,
|
||||
// 或按 modality/scene 预筛减少扫描量)。在此之前不做优化 —— 没有真实
|
||||
// 规模数据时优化是猜,本会话已经因此浪费过几轮。
|
||||
|
||||
// BlockHit 是一条块召回的候选及相似度。
|
||||
type BlockHit struct {
|
||||
Block MemoryBlock `json:"block"`
|
||||
Score float64 `json:"score"`
|
||||
}
|
||||
|
||||
// BlockRecallQuery 描述一次块召回请求。
|
||||
type BlockRecallQuery struct {
|
||||
Vector []float64
|
||||
// Fingerprint 是发起方所用向量空间的指纹。与库中块不一致的块会被
|
||||
// **跳过**(不是报错):库里可能同时存在两个空间的块(迁移期),
|
||||
// 跳过才是正确的——拿新空间的查询向量去比旧空间的块向量,余弦值
|
||||
// 没有任何意义,而且不会报错,只是"结果看起来还行但全是错的"。
|
||||
Fingerprint string
|
||||
TopK int
|
||||
// MinScore 低于此分数的候选被丢弃(0 表示不设下限)。
|
||||
MinScore float64
|
||||
// SkipCentroid 关闭中心化校正,直接用原始向量算余弦。
|
||||
//
|
||||
// 默认是要校正的:实测 chineseclip 的块向量均值范数 0.9374,
|
||||
// 93.7% 的能量在同一个方向上,导致同属性不同值的分离度只有 0.0095
|
||||
// (去均值后 0.0689,提升 7.3 倍)。而中心是**按需加载**的 ——
|
||||
// 库里没建过中心、或中心已失效(块数变化 >20%)时自动跳过校正,
|
||||
// 所以这个开关只在「明确知道中心存在却想绕开」时才需要。
|
||||
SkipCentroid bool
|
||||
}
|
||||
|
||||
// RecallBlocks 按向量相似度召回块节点。
|
||||
//
|
||||
// 返回的 hit 已按 Score 降序。库里没有任何块的向量时返回空切片而非错误
|
||||
// ——「还没回填」是正常状态,不是故障。
|
||||
func (g *GraphDB) RecallBlocks(q BlockRecallQuery) ([]BlockHit, error) {
|
||||
if len(q.Vector) == 0 {
|
||||
return nil, fmt.Errorf("recall blocks: empty query vector")
|
||||
}
|
||||
topK := q.TopK
|
||||
if topK <= 0 {
|
||||
topK = 10
|
||||
}
|
||||
|
||||
// 中心在 RLock 之前加载:LoadCentroid 内部自己拿锁,
|
||||
// 而 g.mu 是写锁优先的 Mutex(不可重入),RLock 里再 RLock 虽可行
|
||||
// 但夹着写锁时会出现窗口。分开更稳。
|
||||
var centroid *Centroid
|
||||
if !q.SkipCentroid && q.Fingerprint != "" {
|
||||
c, use, err := g.LoadCentroid(q.Fingerprint, len(q.Vector))
|
||||
if err != nil {
|
||||
// 中心读取失败不该让召回失败 —— 退回原始向量即可,
|
||||
// 只是少了个校正。记日志以便发现。
|
||||
log.Printf("[graph] recall blocks: 中心向量读取失败,退回原始向量: %v", err)
|
||||
} else if use {
|
||||
centroid = c
|
||||
}
|
||||
}
|
||||
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
|
||||
rows, err := g.db.Query(`SELECT ` + blockColumns + `
|
||||
FROM memory_blocks
|
||||
WHERE vector IS NOT NULL AND vector != '' AND vector != 'null'`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
dim := len(q.Vector)
|
||||
var hits []BlockHit
|
||||
var skippedDim, skippedFP, scanned int
|
||||
for rows.Next() {
|
||||
var b MemoryBlock
|
||||
var vectorJSON string
|
||||
if err := rows.Scan(&b.ID, &b.Modality, &b.Text, &b.PayloadDigest,
|
||||
&b.MIME, &b.Size, &b.Width, &b.Height, &vectorJSON,
|
||||
&b.Fingerprint, &b.Source, &b.Tool, &b.Scene, &b.SemanticType,
|
||||
&b.CreatedAt, &b.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scanned++
|
||||
vec, err := decodeVector(vectorJSON)
|
||||
if err != nil {
|
||||
// 单条坏数据不该让整次召回失败 —— 但要记日志,否则会静默缺失。
|
||||
log.Printf("[graph] recall blocks: skip %s: %v", b.ID, err)
|
||||
continue
|
||||
}
|
||||
// 维度不一致:跳过并计数。**不截断、不填充**——那会造出无意义的分数。
|
||||
if len(vec) != dim {
|
||||
skippedDim++
|
||||
continue
|
||||
}
|
||||
// 指纹不一致:跳过并计数。查询方声明的空间与块所属空间不同。
|
||||
if q.Fingerprint != "" && b.Fingerprint != "" && b.Fingerprint != q.Fingerprint {
|
||||
skippedFP++
|
||||
continue
|
||||
}
|
||||
// ★ 中心化:查询向量与块向量**都要**中心化后再算余弦。
|
||||
// 只处理一边的话等于在混两种量纲(一半带公共分量、一半不带),
|
||||
// 算出的余弦没有意义。
|
||||
score := centerBoth(centroid, vec, q.Vector)
|
||||
if q.MinScore > 0 && score < q.MinScore {
|
||||
continue
|
||||
}
|
||||
b.Vector = vec
|
||||
hits = append(hits, BlockHit{Block: b, Score: score})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if skippedDim > 0 {
|
||||
log.Printf("[graph] recall blocks: 跳过 %d 个维度不匹配的块(查询 %d 维,共扫 %d)",
|
||||
skippedDim, dim, scanned)
|
||||
}
|
||||
if skippedFP > 0 {
|
||||
log.Printf("[graph] recall blocks: 跳过 %d 个指纹不匹配的块(查询空间 %s,共扫 %d)",
|
||||
skippedFP, shortFP(q.Fingerprint), scanned)
|
||||
}
|
||||
|
||||
sort.SliceStable(hits, func(i, j int) bool { return hits[i].Score > hits[j].Score })
|
||||
if len(hits) > topK {
|
||||
hits = hits[:topK]
|
||||
}
|
||||
return hits, nil
|
||||
}
|
||||
|
||||
// RecallBlocksWithArbitration 与 RecallBlocks 相同,但额外返回仲裁详情
|
||||
// (被取代的块、结论状态)。
|
||||
//
|
||||
// 分成两个入口的原因:调用方大多只关心「该给模型看什么」,仲裁详情是
|
||||
// 调试与跑分用的。把它塞进返回签名会让 90% 的调用点多做无用功。
|
||||
func (g *GraphDB) RecallBlocksWithArbitration(q BlockRecallQuery) ([]BlockHit, ArbitrationResult, error) {
|
||||
// ★ 仲裁前必须拿到**未截断**的全量候选。
|
||||
//
|
||||
// 实测故障形态(真实 chineseclip + 真库 188 块):查询「值班室分机号
|
||||
// 是多少」,旧号 4379 以 0.8127 排 top1,新号 4324 那条**进不了
|
||||
// top8**。正确记录在召回阶段就被挤掉了 —— 事后仲裁无从挽回,
|
||||
// 因为被判取代的旧值和新值都不在候选里。
|
||||
//
|
||||
// 所以顺序是:全量打分(TopK 放大)→ 仲裁 → 按调用方的 TopK 截断。
|
||||
//
|
||||
// 为什么 RecallBlocks 本身不仲裁:它是通用召回接口,
|
||||
// 返回顺序按余弦相似度是它的**既有契约**(有测试钉着)。
|
||||
// 仲裁改变的是「给模型看哪些、按什么顺序看」,属于上层策略 ——
|
||||
// 混进召回层会让这个接口的语义变得含糊(调用方说不清拿到的是什么序)。
|
||||
q2 := q
|
||||
q2.TopK = largeTopK
|
||||
hits, err := g.RecallBlocks(q2)
|
||||
if err != nil {
|
||||
return nil, ArbitrationResult{}, err
|
||||
}
|
||||
arb := arbitrate(g, hits)
|
||||
kept := arb.Kept
|
||||
if k := effectiveTopK(q.TopK); len(kept) > k {
|
||||
kept = kept[:k]
|
||||
}
|
||||
return kept, arb, nil
|
||||
}
|
||||
|
||||
// largeTopK 是仲裁前的候选上限。
|
||||
//
|
||||
// ★ 为什么要有上限而不是真·全量:仲裁是 O(n²),候选数若等于库里全部
|
||||
// 带向量的块(实测 188,回填后可能上万),比较次数会到亿级。
|
||||
// 这个值远大于正常 TopK(默认 10 / 生产 5~20),实际效果是「足够全」,
|
||||
// 同时给出一个确定的性能上界。
|
||||
const largeTopK = 2000
|
||||
|
||||
// effectiveTopK 归一 TopK(与 RecallBlocks 内部一致)。
|
||||
func effectiveTopK(k int) int {
|
||||
if k <= 0 {
|
||||
return 10
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
// BlockVectorStats 报告块向量的回填状态,用于运维判断「要不要跑回填」。
|
||||
type BlockVectorStats struct {
|
||||
Total int `json:"total"`
|
||||
WithVector int `json:"with_vector"`
|
||||
MissingFP int `json:"missing_fingerprint"`
|
||||
Fingerprints []string `json:"fingerprints"`
|
||||
Dimensions []int `json:"dimensions"`
|
||||
}
|
||||
|
||||
func (g *GraphDB) BlockVectorStats() (BlockVectorStats, error) {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
|
||||
var st BlockVectorStats
|
||||
fpSet := map[string]bool{}
|
||||
dimSet := map[int]bool{}
|
||||
rows, err := g.db.Query(`SELECT vector, fingerprint FROM memory_blocks`)
|
||||
if err != nil {
|
||||
return st, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var vectorJSON, fp string
|
||||
if err := rows.Scan(&vectorJSON, &fp); err != nil {
|
||||
return st, err
|
||||
}
|
||||
st.Total++
|
||||
if vectorJSON == "" || vectorJSON == "null" {
|
||||
continue
|
||||
}
|
||||
vec, err := decodeVector(vectorJSON)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
st.WithVector++
|
||||
if fp == "" {
|
||||
st.MissingFP++
|
||||
} else {
|
||||
fpSet[fp] = true
|
||||
}
|
||||
dimSet[len(vec)] = true
|
||||
}
|
||||
for fp := range fpSet {
|
||||
st.Fingerprints = append(st.Fingerprints, fp)
|
||||
}
|
||||
for d := range dimSet {
|
||||
st.Dimensions = append(st.Dimensions, d)
|
||||
}
|
||||
sort.Strings(st.Fingerprints)
|
||||
sort.Ints(st.Dimensions)
|
||||
return st, rows.Err()
|
||||
}
|
||||
|
||||
// ── 向量工具 ──
|
||||
|
||||
func decodeVector(s string) ([]float64, error) {
|
||||
var vec []float64
|
||||
if err := json.Unmarshal([]byte(s), &vec); err != nil {
|
||||
return nil, fmt.Errorf("decode vector: %w", err)
|
||||
}
|
||||
return vec, nil
|
||||
}
|
||||
|
||||
func cosine(a, b []float64) float64 {
|
||||
if len(a) != len(b) || len(a) == 0 {
|
||||
return 0
|
||||
}
|
||||
var dot, na, nb float64
|
||||
for i := range a {
|
||||
dot += a[i] * b[i]
|
||||
na += a[i] * a[i]
|
||||
nb += b[i] * b[i]
|
||||
}
|
||||
if na == 0 || nb == 0 {
|
||||
return 0
|
||||
}
|
||||
s := dot / (math.Sqrt(na) * math.Sqrt(nb))
|
||||
// NaN 会让排序结果不确定(NaN 的比较全 false),显式归零。
|
||||
if math.IsNaN(s) {
|
||||
return 0
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func shortFP(fp string) string {
|
||||
if len(fp) > 12 {
|
||||
return fp[:12]
|
||||
}
|
||||
return fp
|
||||
}
|
||||
235
internal/memory/block_recall_test.go
Normal file
235
internal/memory/block_recall_test.go
Normal file
@ -0,0 +1,235 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"math"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newBlockTestGraph(t *testing.T) *GraphDB {
|
||||
t.Helper()
|
||||
g := newTestGraph(t)
|
||||
t.Cleanup(func() {
|
||||
g.Close()
|
||||
os.Remove(g.dbPath)
|
||||
})
|
||||
return g
|
||||
}
|
||||
|
||||
func vec(vals ...float64) []float64 { return vals }
|
||||
|
||||
// ★ 基本召回:按余弦排序,TopK 截断。
|
||||
func TestRecallBlocks_按余弦排序(t *testing.T) {
|
||||
g := newBlockTestGraph(t)
|
||||
mustPut(t, g, []MemoryBlock{
|
||||
{ID: "b_far", Modality: BlockText, Text: "无关内容",
|
||||
Vector: vec(0, 1, 0), Fingerprint: "fp1"},
|
||||
{ID: "b_near", Modality: BlockText, Text: "最相关",
|
||||
Vector: vec(1, 0, 0), Fingerprint: "fp1"},
|
||||
{ID: "b_mid", Modality: BlockText, Text: "中等相关",
|
||||
Vector: vec(0.7, 0.7, 0), Fingerprint: "fp1"},
|
||||
})
|
||||
|
||||
hits, err := g.RecallBlocks(BlockRecallQuery{
|
||||
Vector: vec(1, 0, 0), Fingerprint: "fp1", TopK: 3,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RecallBlocks: %v", err)
|
||||
}
|
||||
if len(hits) != 3 {
|
||||
t.Fatalf("期望 3 条,实际 %d", len(hits))
|
||||
}
|
||||
want := []string{"b_near", "b_mid", "b_far"}
|
||||
for i, id := range want {
|
||||
if hits[i].Block.ID != id {
|
||||
t.Errorf("第 %d 位应为 %s,实际 %s(score=%.4f)",
|
||||
i, id, hits[i].Block.ID, hits[i].Score)
|
||||
}
|
||||
}
|
||||
if math.Abs(hits[0].Score-1.0) > 1e-9 {
|
||||
t.Errorf("正交向量自身相似度应为 1,实际 %.9f", hits[0].Score)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ TopK 必须真的截断(否则召回会灌爆上下文——这是本会话实测过的症状)。
|
||||
func TestRecallBlocks_TopK截断(t *testing.T) {
|
||||
g := newBlockTestGraph(t)
|
||||
var blocks []MemoryBlock
|
||||
for i := 0; i < 20; i++ {
|
||||
blocks = append(blocks, MemoryBlock{
|
||||
ID: "b" + itoa(i), Modality: BlockText, Text: "t",
|
||||
Vector: vec(float64(i+1), 1, 0), Fingerprint: "fp1",
|
||||
})
|
||||
}
|
||||
mustPut(t, g, blocks)
|
||||
|
||||
hits, err := g.RecallBlocks(BlockRecallQuery{Vector: vec(1, 0, 0), TopK: 5})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(hits) != 5 {
|
||||
t.Fatalf("TopK=5 应返回 5 条,实际 %d", len(hits))
|
||||
}
|
||||
}
|
||||
|
||||
// ★★ 指纹防护:换向量空间后,旧空间的块必须被**跳过**。
|
||||
//
|
||||
// 这是本文件最重要的判据。拿新空间的查询向量去比旧空间的块向量,
|
||||
// 余弦值没有任何意义,而且**不报错**——只是"结果看起来还行但全是错的"。
|
||||
func TestRecallBlocks_指纹不匹配则跳过(t *testing.T) {
|
||||
g := newBlockTestGraph(t)
|
||||
mustPut(t, g, []MemoryBlock{
|
||||
{ID: "old", Modality: BlockText, Text: "旧空间",
|
||||
Vector: vec(1, 0, 0), Fingerprint: "old-space"},
|
||||
{ID: "new", Modality: BlockText, Text: "新空间",
|
||||
Vector: vec(1, 0, 0), Fingerprint: "new-space"},
|
||||
})
|
||||
|
||||
hits, err := g.RecallBlocks(BlockRecallQuery{
|
||||
Vector: vec(1, 0, 0), Fingerprint: "new-space", TopK: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(hits) != 1 || hits[0].Block.ID != "new" {
|
||||
t.Fatalf("只应召回新空间的块,实际 %+v", hitIDs(hits))
|
||||
}
|
||||
// 旧空间的块若被召回,就是静默污染
|
||||
for _, h := range hits {
|
||||
if h.Block.Fingerprint != "new-space" {
|
||||
t.Errorf("召回了指纹不匹配的块: %s (%s)", h.Block.ID, h.Block.Fingerprint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★★ 维度防护:维度不同必须跳过,**不截断不填充**。
|
||||
func TestRecallBlocks_维度不匹配则跳过(t *testing.T) {
|
||||
g := newBlockTestGraph(t)
|
||||
mustPut(t, g, []MemoryBlock{
|
||||
{ID: "d512", Modality: BlockText, Text: "旧 512 维",
|
||||
Vector: vec(1, 0, 0), Fingerprint: "fp"},
|
||||
{ID: "d2048", Modality: BlockText, Text: "新 2048 维",
|
||||
Vector: append(vec(1, 0, 0), make([]float64, 2045)...), Fingerprint: "fp"},
|
||||
})
|
||||
|
||||
hits, err := g.RecallBlocks(BlockRecallQuery{
|
||||
Vector: append(vec(1, 0, 0), make([]float64, 2045)...), Fingerprint: "fp", TopK: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(hits) != 1 || hits[0].Block.ID != "d2048" {
|
||||
t.Fatalf("只应召回同维度的块,实际 %+v", hitIDs(hits))
|
||||
}
|
||||
}
|
||||
|
||||
// 没有向量的块不参与向量召回(但它们仍可被 BlocksForNode 按边查到)。
|
||||
func TestRecallBlocks_无向量的块不参与(t *testing.T) {
|
||||
g := newBlockTestGraph(t)
|
||||
mustPut(t, g, []MemoryBlock{
|
||||
{ID: "no_vec", Modality: BlockText, Text: "没向量"},
|
||||
{ID: "has_vec", Modality: BlockText, Text: "有向量",
|
||||
Vector: vec(1, 0), Fingerprint: "fp"},
|
||||
})
|
||||
|
||||
hits, err := g.RecallBlocks(BlockRecallQuery{Vector: vec(1, 0), TopK: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(hits) != 1 || hits[0].Block.ID != "has_vec" {
|
||||
t.Fatalf("无向量的块不该参与召回,实际 %+v", hitIDs(hits))
|
||||
}
|
||||
}
|
||||
|
||||
// 空查询向量必须报错(而不是静默返回全零分的垃圾结果)。
|
||||
func TestRecallBlocks_空向量报错(t *testing.T) {
|
||||
g := newBlockTestGraph(t)
|
||||
if _, err := g.RecallBlocks(BlockRecallQuery{}); err == nil {
|
||||
t.Fatal("空查询向量应报错")
|
||||
}
|
||||
}
|
||||
|
||||
// MinScore 过滤。
|
||||
func TestRecallBlocks_MinScore过滤(t *testing.T) {
|
||||
g := newBlockTestGraph(t)
|
||||
mustPut(t, g, []MemoryBlock{
|
||||
{ID: "hi", Modality: BlockText, Text: "高", Vector: vec(1, 0), Fingerprint: "fp"},
|
||||
{ID: "lo", Modality: BlockText, Text: "低", Vector: vec(0, 1), Fingerprint: "fp"},
|
||||
})
|
||||
hits, err := g.RecallBlocks(BlockRecallQuery{
|
||||
Vector: vec(1, 0), TopK: 10, MinScore: 0.5,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(hits) != 1 || hits[0].Block.ID != "hi" {
|
||||
t.Fatalf("MinScore 应滤掉低分候选,实际 %+v", hitIDs(hits))
|
||||
}
|
||||
}
|
||||
|
||||
// 库里没有任何块时返回空切片而非错误("还没回填"是正常状态)。
|
||||
func TestRecallBlocks_空库不报错(t *testing.T) {
|
||||
g := newBlockTestGraph(t)
|
||||
hits, err := g.RecallBlocks(BlockRecallQuery{Vector: vec(1, 0), TopK: 5})
|
||||
if err != nil {
|
||||
t.Fatalf("空库不该报错: %v", err)
|
||||
}
|
||||
if len(hits) != 0 {
|
||||
t.Fatalf("空库应返回 0 条,实际 %d", len(hits))
|
||||
}
|
||||
}
|
||||
|
||||
// BlockVectorStats 报告回填状态(运维据此判断要不要跑回填)。
|
||||
func TestBlockVectorStats(t *testing.T) {
|
||||
g := newBlockTestGraph(t)
|
||||
mustPut(t, g, []MemoryBlock{
|
||||
{ID: "a", Modality: BlockText, Text: "有向量有指纹",
|
||||
Vector: vec(1, 0), Fingerprint: "fpX"},
|
||||
{ID: "b", Modality: BlockText, Text: "有向量无指纹",
|
||||
Vector: vec(1, 0)},
|
||||
{ID: "c", Modality: BlockText, Text: "无向量"},
|
||||
})
|
||||
|
||||
st, err := g.BlockVectorStats()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.Total != 3 || st.WithVector != 2 || st.MissingFP != 1 {
|
||||
t.Fatalf("统计错误: %+v", st)
|
||||
}
|
||||
if len(st.Fingerprints) != 1 || st.Fingerprints[0] != "fpX" {
|
||||
t.Errorf("指纹集错误: %v", st.Fingerprints)
|
||||
}
|
||||
if len(st.Dimensions) != 1 || st.Dimensions[0] != 2 {
|
||||
t.Errorf("维度集错误: %v", st.Dimensions)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 辅助 ──
|
||||
|
||||
func mustPut(t *testing.T, g *GraphDB, blocks []MemoryBlock) {
|
||||
t.Helper()
|
||||
if err := g.PutMemoryBlocks(blocks); err != nil {
|
||||
t.Fatalf("PutMemoryBlocks: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func hitIDs(hits []BlockHit) []string {
|
||||
out := make([]string, len(hits))
|
||||
for i, h := range hits {
|
||||
out[i] = h.Block.ID
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func itoa(i int) string {
|
||||
if i == 0 {
|
||||
return "0"
|
||||
}
|
||||
var b []byte
|
||||
for i > 0 {
|
||||
b = append([]byte{byte('0' + i%10)}, b...)
|
||||
i /= 10
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
244
internal/memory/centroid.go
Normal file
244
internal/memory/centroid.go
Normal file
@ -0,0 +1,244 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// 向量中心化(centering / 各向异性校正)。
|
||||
//
|
||||
// ★ 为什么需要 —— 实测数据(真库 260 个 distill 块,chineseclip 512 维)
|
||||
//
|
||||
// 均值向量长度 = 0.9374 (1.0 = 所有向量完全同向)
|
||||
// 平均两两余弦 = 0.8727 ← 与「|mean|²=0.8787」吻合
|
||||
// 去均值后平均两两余弦 = -0.0012 ← 各向同性
|
||||
//
|
||||
// **93.7% 的能量花在同一个方向上。** 换句话说,两块文本的相似度里
|
||||
// 大部分不是"它们像"贡献的,而是"它们都朝那个方向偏"贡献的。
|
||||
//
|
||||
// 这解释了本会话一系列看起来矛盾的现象:
|
||||
//
|
||||
// 「值班室分机号 4324」cos = 0.9284
|
||||
// 「值班室分机号 4379」cos = 0.9298 ← 新旧号差 0.0014
|
||||
// 粗筛 0.9 阈值 → 44% 误报(等于没有阈值)
|
||||
//
|
||||
// ★ 我此前的判断是错的:写的是「CLIP 架构把纯文本压扁了,
|
||||
// 细粒度区分度天然低」。那是**把症状当成了原因** ——
|
||||
// 不是模型分不清,是所有向量挤在一个锥体里。RoBERTa/BERT 系列的
|
||||
// CLS 向量各向异性是有名的现象,与 CLIP 无关。
|
||||
//
|
||||
// 去均值后的实测收益:
|
||||
//
|
||||
// 原始 去均值
|
||||
// 同值对最低 1.0000 1.0000 (不受影响)
|
||||
// 异值对最高 0.9905 0.9311
|
||||
// ★ 分离度 0.0095 0.0689 ← 提升 7 倍
|
||||
//
|
||||
// 异值对逐条(原始 → 去均值):
|
||||
//
|
||||
// 0.9240 → 0.2494 第112批|版本=v2.31.0 || =周四
|
||||
// 0.8167 → 0.2705 第115批|版本=115批v2.31.2 || =115批周二凌晨…
|
||||
// 0.9223 → 0.2198 第132批|版本=v2.33.1 || =周二
|
||||
//
|
||||
// 原本 0.92 的"看起来很像",去均值后掉到 0.25 ——
|
||||
// 那 0.92 几乎全是共同方向的假象。
|
||||
//
|
||||
// ── 用法的三条约束 ──────────────────────────────────────
|
||||
//
|
||||
// ① **均值必须跨块统计**,不能只用查询向量减自己。
|
||||
// 均值的定义域是"这个库里的所有块",查询向量不在库里时
|
||||
// 减同一个均值仍然正确(它表达的是"与这个库的公共方向正交化")。
|
||||
//
|
||||
// ② **均值要持久化**。每次查询现算需要扫全库(实测 260 块成本可忽略,
|
||||
// 但上万块就是每次查询 O(n·d))。且现算的结果依赖"查询时刻的块集合",
|
||||
// 会让相似度随库变化而漂移。
|
||||
//
|
||||
// ③ **不是所有库都需要**。均值长度接近 1 时(如已经很各向同性的空间)
|
||||
// 中心化只会放大噪声;接近 0 时则是无意义的额外开销。
|
||||
// BuildCentroid 因此返回诊断指标,由调用方判断。
|
||||
|
||||
// Centroid 是向量空间的中心(各向异性校正的减数)。
|
||||
type Centroid struct {
|
||||
// Sum 是各维累加和。存累加和而非均值,是为了让 Add 能增量累积。
|
||||
Sum []float64
|
||||
// Count 是参与统计的向量数。
|
||||
Count int
|
||||
// Dim 是向量维度。
|
||||
Dim int
|
||||
}
|
||||
|
||||
// CentroidStats 是中心化的诊断指标。
|
||||
type CentroidStats struct {
|
||||
// MeanLength 是均值向量的 L2 范数(已归一化的向量上)。
|
||||
// 接近 1 = 所有向量同向(各向异性严重);接近 0 = 各向同性。
|
||||
MeanLength float64
|
||||
// AvgPairCosine 是 |mean|² —— 全部配对的平均余弦的解析值,
|
||||
// 不必真的两两算。
|
||||
AvgPairCosine float64
|
||||
// Anomalous 判断是否值得做中心化(经验阈值,见 BuildCentroid)。
|
||||
Anomalous bool
|
||||
}
|
||||
|
||||
// meanThreshold 是判定"各向异性严重"的阈值。
|
||||
//
|
||||
// 依据:均值范数 0.9374 时去均值让分离度提升 7 倍;反过来,
|
||||
// 若均值范数本就接近 0(各向同性),中心化只是放大噪声。
|
||||
// 0.6 是个保守取值:低于它说明库本身方向分散,不需要校正。
|
||||
const meanThreshold = 0.6
|
||||
|
||||
// NewCentroid 建一个空中心。
|
||||
func NewCentroid(dim int) *Centroid {
|
||||
return &Centroid{Sum: make([]float64, dim), Dim: dim}
|
||||
}
|
||||
|
||||
// Add 累积一个向量。维度不匹配会被忽略(返回 false)——
|
||||
// 不同 provider 的向量混在一块时不能相加。
|
||||
func (c *Centroid) Add(v []float64) bool {
|
||||
if v == nil || len(v) != c.Dim {
|
||||
return false
|
||||
}
|
||||
for i, x := range v {
|
||||
c.Sum[i] += x
|
||||
}
|
||||
c.Count++
|
||||
return true
|
||||
}
|
||||
|
||||
// Mean 返回均值向量。Count 为 0 时返回 nil。
|
||||
func (c *Centroid) Mean() []float64 {
|
||||
if c.Count == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]float64, c.Dim)
|
||||
for i := range c.Sum {
|
||||
out[i] = c.Sum[i] / float64(c.Count)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Stats 返回诊断指标。Count 为 0 时返回零值。
|
||||
func (c *Centroid) Stats() CentroidStats {
|
||||
m := c.Mean()
|
||||
if m == nil {
|
||||
return CentroidStats{}
|
||||
}
|
||||
var norm float64
|
||||
for _, x := range m {
|
||||
norm += x * x
|
||||
}
|
||||
norm = math.Sqrt(norm)
|
||||
return CentroidStats{
|
||||
MeanLength: norm,
|
||||
AvgPairCosine: norm * norm,
|
||||
Anomalous: norm >= meanThreshold,
|
||||
}
|
||||
}
|
||||
|
||||
// Subtract 返回 v 减去中心均值后的向量(原向量不被修改)。
|
||||
// 维度不匹配或中心为空时返回 v 的副本(不做校正)。
|
||||
func (c *Centroid) Subtract(v []float64) []float64 {
|
||||
m := c.Mean()
|
||||
if m == nil || len(v) != c.Dim {
|
||||
out := make([]float64, len(v))
|
||||
copy(out, v)
|
||||
return out
|
||||
}
|
||||
out := make([]float64, len(v))
|
||||
for i := range v {
|
||||
out[i] = v[i] - m[i]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SubtractInPlace 就地校正向量(省一次分配,批量处理时用)。
|
||||
func (c *Centroid) SubtractInPlace(v []float64) {
|
||||
m := c.Mean()
|
||||
if m == nil || len(v) != c.Dim {
|
||||
return
|
||||
}
|
||||
for i := range v {
|
||||
v[i] -= m[i]
|
||||
}
|
||||
}
|
||||
|
||||
// l2Normalize 就地归一化。零向量或 NaN 时返回 false(不缩放)。
|
||||
//
|
||||
// ★ 为什么中心化后必须重新归一化:减均值不保范数,
|
||||
//
|
||||
// 而余弦相似度是尺度相关的 —— 不归一的话所有余弦都会被
|
||||
// 「向量变短了」这个纯粹的尺度效应污染。
|
||||
func l2Normalize(v []float64) bool {
|
||||
var norm float64
|
||||
for _, x := range v {
|
||||
norm += x * x
|
||||
}
|
||||
norm = math.Sqrt(norm)
|
||||
if norm == 0 || math.IsNaN(norm) || math.IsInf(norm, 0) {
|
||||
return false
|
||||
}
|
||||
for i := range v {
|
||||
v[i] /= norm
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// CenterVector 校正单个向量:减中心均值 + 重新归一化。
|
||||
//
|
||||
// 这才是**完整**的校正 —— 只减不归一是常见错误(见 l2Normalize 的注释)。
|
||||
func (c *Centroid) CenterVector(v []float64) []float64 {
|
||||
out := c.Subtract(v)
|
||||
l2Normalize(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// BuildCentroid 从一组向量建中心,并返回诊断指标。
|
||||
//
|
||||
// ★ 调用方必须读 Stats().Anomalous 再决定是否启用 ——
|
||||
//
|
||||
// 中心化不是无条件正确的操作(见 CentroidStats 的注释)。
|
||||
func BuildCentroid(vectors [][]float64) (*Centroid, CentroidStats) {
|
||||
if len(vectors) == 0 {
|
||||
return nil, CentroidStats{}
|
||||
}
|
||||
c := NewCentroid(len(vectors[0]))
|
||||
for _, v := range vectors {
|
||||
c.Add(v)
|
||||
}
|
||||
return c, c.Stats()
|
||||
}
|
||||
|
||||
// CentroidCache 缓存中心向量,避免每次查询现算。
|
||||
//
|
||||
// 并发安全:RecallBlocks 会被并发调用,而中心重算是 O(n·d)。
|
||||
type CentroidCache struct {
|
||||
mu sync.RWMutex
|
||||
c *Centroid
|
||||
st CentroidStats
|
||||
n int // 参与统计的向量数
|
||||
dim int
|
||||
}
|
||||
|
||||
// NewCentroidCache 建一个空缓存。
|
||||
func NewCentroidCache() *CentroidCache {
|
||||
return &CentroidCache{}
|
||||
}
|
||||
|
||||
// Get 返回该(dim, n)对应的中心。没有对应版本时返回 nil。
|
||||
func (cc *CentroidCache) Get(dim, n int) (*Centroid, CentroidStats) {
|
||||
cc.mu.RLock()
|
||||
defer cc.mu.RUnlock()
|
||||
if cc.c == nil || cc.dim != dim || cc.n != n {
|
||||
return nil, CentroidStats{}
|
||||
}
|
||||
return cc.c, cc.st
|
||||
}
|
||||
|
||||
// Put 存入中心。已有的不同版本会被覆盖。
|
||||
func (cc *CentroidCache) Put(c *Centroid, st CentroidStats) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
cc.mu.Lock()
|
||||
defer cc.mu.Unlock()
|
||||
cc.c, cc.st, cc.dim, cc.n = c, st, c.Dim, c.Count
|
||||
}
|
||||
403
internal/memory/centroid_store.go
Normal file
403
internal/memory/centroid_store.go
Normal file
@ -0,0 +1,403 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 中心向量在库里的持久化。
|
||||
//
|
||||
// 为什么必须持久化(而不是每次查询现算)
|
||||
// ----------------------------------------
|
||||
// ① 现算要扫全库所有块向量:O(n·d)。实测 260 块 × 512 维成本可忽略,
|
||||
// 但生产库是 1277 块、上万块时会变成每次查询的固定开销。
|
||||
// ② 更要紧的是**稳定性**:现算的结果依赖"查询那一刻的块集合",
|
||||
// 库一变,同一个查询的相似度就变 —— 那会让"上周的召回结果"
|
||||
// 不可复现,而召回结果是要进 benchmark 的。
|
||||
//
|
||||
// 失效条件(写进表里一起存)
|
||||
// ----------------------------
|
||||
// 维度变(换 provider) → 均值的定义域变了
|
||||
// 块数变(新增/删除块) → 均值随样本变,但**不是所有变化都要重算**
|
||||
// 指纹变(换 embedding 空间) → 同上
|
||||
//
|
||||
// 为什么块数变不总是要重算:中心是对"块的分布"的估计,
|
||||
// 少量新增不影响它的方向;而频繁重算会让相似度漂移。
|
||||
// 所以用**相对变化率**:新增/删除超过 20% 才判定失效。
|
||||
|
||||
// centroidMeta 是中心向量的元信息(存在 kv 表里)。
|
||||
type centroidMeta struct {
|
||||
// Fingerprint 是这个中心所属的向量空间指纹。
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
// Dim 是向量维度。
|
||||
Dim int `json:"dim"`
|
||||
// BlockCount 是建中心时的块数。
|
||||
BlockCount int `json:"block_count"`
|
||||
// VectoredCount 是建中心时**带向量**的块数(分母用这个,不是总块数)。
|
||||
VectoredCount int `json:"vectored_count"`
|
||||
// MeanLength 是均值范数(诊断用,Anomalous 的依据)。
|
||||
MeanLength float64 `json:"mean_length"`
|
||||
// Anomalous 是建中心时的判定结果。
|
||||
Anomalous bool `json:"anomalous"`
|
||||
// BuiltAt 是建中心时刻。
|
||||
BuiltAt time.Time `json:"built_at"`
|
||||
}
|
||||
|
||||
// centroidRebuildRatio 是触发重建的块数相对变化率。
|
||||
//
|
||||
// 20% 的依据:中心是分布的估计,10% 以内的变化对方向影响可忽略;
|
||||
// 而过于敏感会让相似度随每次写入漂移(那比不校正更糟 ——
|
||||
// 不可复现的召回无法用于回归测试)。
|
||||
const centroidRebuildRatio = 0.20
|
||||
|
||||
// ddlCentroid 是中心向量的表与索引。
|
||||
const ddlCentroid = `CREATE TABLE IF NOT EXISTS graph_centroid (
|
||||
fingerprint TEXT PRIMARY KEY,
|
||||
dim INTEGER NOT NULL,
|
||||
vector_count INTEGER NOT NULL,
|
||||
vector TEXT NOT NULL,
|
||||
stats TEXT NOT NULL,
|
||||
built_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)`
|
||||
|
||||
// centroidKVKey 是元信息在 kv 表里的键。
|
||||
const centroidKVKey = "graph_centroid_meta"
|
||||
|
||||
// ensureCentroidSchema 建表。调用方必须持写锁或在初始化里做一次。
|
||||
func (g *GraphDB) ensureCentroidSchema() error {
|
||||
if _, err := g.db.Exec(ddlCentroid); err != nil {
|
||||
return fmt.Errorf("graph: create graph_centroid: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveCentroid 存中心向量及其元信息。
|
||||
//
|
||||
// 不校验 anomalous —— 上层决定要不要存。存了但 anomalous=false 时,
|
||||
// LoadCentroid 会返回 (centroid, false, nil),调用方据此跳过校正。
|
||||
func (g *GraphDB) SaveCentroid(c *Centroid, fingerprint string) error {
|
||||
if c == nil || c.Count == 0 {
|
||||
return fmt.Errorf("graph: empty centroid")
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
if err := g.ensureCentroidSchema(); err != nil {
|
||||
return err
|
||||
}
|
||||
stats := c.Stats()
|
||||
meta := centroidMeta{
|
||||
Fingerprint: fingerprint,
|
||||
Dim: c.Dim,
|
||||
BlockCount: g.countBlocksLocked(),
|
||||
// ★ 语义定死:VectoredCount = **参与统计的向量数**(c.Count),
|
||||
// 不是「库里当时的块数」。
|
||||
//
|
||||
// 理由:失效判定要回答的问题是「这个中心还能不能代表当前库的
|
||||
// 分布」,而中心是 c.Count 个向量的均值。比较的基准必须是
|
||||
// 同一批向量 —— 拿「库当时的块数」会出现两种错法:
|
||||
// 库里有无向量的块(维度不匹配/指纹不同被跳过)→ 两者不等 → 误判失效
|
||||
// 库当时为空 → 基准为 0 → 除零
|
||||
VectoredCount: c.Count,
|
||||
MeanLength: stats.MeanLength,
|
||||
Anomalous: stats.Anomalous,
|
||||
BuiltAt: time.Now(),
|
||||
}
|
||||
rawStats, err := json.Marshal(meta)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mean := c.Mean()
|
||||
rawMean, err := json.Marshal(mean)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// vector_count 列存的是 meta.VectoredCount(建中心时刻库里的带向量块数),
|
||||
// 与失效判定同源 —— 若这里存 c.Count(参与统计的向量数),两者在
|
||||
// 「外部喂向量给 SaveCentroid」时不相等,会误判失效。
|
||||
if _, err := g.db.Exec(`INSERT INTO graph_centroid
|
||||
(fingerprint, dim, vector_count, vector, stats, built_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(fingerprint) DO UPDATE SET
|
||||
dim = excluded.dim,
|
||||
vector_count = excluded.vector_count,
|
||||
vector = excluded.vector,
|
||||
stats = excluded.stats,
|
||||
built_at = excluded.built_at`,
|
||||
fingerprint, c.Dim, meta.VectoredCount, string(rawMean),
|
||||
string(rawStats), meta.BuiltAt); err != nil {
|
||||
return fmt.Errorf("graph: save centroid: %w", err)
|
||||
}
|
||||
log.Printf("[graph] 中心向量已存: fp=%s dim=%d 样本=%d 均值范数=%.4f anomalous=%v",
|
||||
shortFP(fingerprint), c.Dim, c.Count, stats.MeanLength, stats.Anomalous)
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadCentroid 读中心向量。返回的 bool 是"应当启用校正"。
|
||||
//
|
||||
// 失效判定见文件头。失效时返回 (nil, false, nil) —— 不是错误:
|
||||
// 「中心还没建」或「该重建」都是正常状态,调用方应跳过校正。
|
||||
func (g *GraphDB) LoadCentroid(fingerprint string, queryDim int) (*Centroid, bool, error) {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
|
||||
var dim, vecCount int
|
||||
var rawMean, rawStats string
|
||||
err := g.db.QueryRow(`SELECT dim, vector_count, vector, stats
|
||||
FROM graph_centroid WHERE fingerprint = ?`, fingerprint).
|
||||
Scan(&dim, &vecCount, &rawMean, &rawStats)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("graph: load centroid: %w", err)
|
||||
}
|
||||
var meta centroidMeta
|
||||
if err := json.Unmarshal([]byte(rawStats), &meta); err != nil {
|
||||
return nil, false, fmt.Errorf("graph: parse centroid meta: %w", err)
|
||||
}
|
||||
// 维度校验:与查询向量维度不符就不能用(中心化会按错误的维度截断/越界)。
|
||||
// queryDim <= 0 表示调用方不校验(罕见),此时只查库里的元数据是否自洽。
|
||||
if dim <= 0 {
|
||||
return nil, false, fmt.Errorf("graph: centroid dim is %d (corrupted?)", dim)
|
||||
}
|
||||
if queryDim > 0 && dim != queryDim {
|
||||
return nil, false, nil
|
||||
}
|
||||
// 库里的带向量块数 vs 建中心时的样本数 → 相对变化率
|
||||
//
|
||||
// 两侧不完全相等是正常的(召回时会跳过维度/指纹不匹配的块),
|
||||
// 所以判据是「变化率超过 20%」而不是「不相等」。
|
||||
// 20% 的依据:中心是分布的估计,10% 以内的变化对方向影响可忽略;
|
||||
// 而过于敏感会让相似度随每次写入漂移 —— 那比不校正更糟,
|
||||
// 因为不可复现的召回无法用于回归测试。
|
||||
curVectored := g.countVectoredBlocksLocked()
|
||||
if vecCount <= 0 {
|
||||
// 没有统计过任何向量:这不是一个可用的中心。
|
||||
if curVectored > 0 {
|
||||
log.Printf("[graph] 中心向量不可用: 建中心时样本数为 0,现库内有 %d 个带向量块", curVectored)
|
||||
}
|
||||
return nil, false, nil
|
||||
}
|
||||
delta := float64(curVectored-vecCount) / float64(vecCount)
|
||||
if delta < 0 {
|
||||
delta = -delta
|
||||
}
|
||||
if delta > centroidRebuildRatio {
|
||||
log.Printf("[graph] 中心向量已失效: 样本 %d → 库内 %d(变化 %.0f%% > %.0f%%)",
|
||||
vecCount, curVectored, delta*100, centroidRebuildRatio*100)
|
||||
return nil, false, nil
|
||||
}
|
||||
if !meta.Anomalous {
|
||||
// 建的时候判定为各向同性 ⇒ 不启用
|
||||
return nil, false, nil
|
||||
}
|
||||
var mean []float64
|
||||
if err := json.Unmarshal([]byte(rawMean), &mean); err != nil {
|
||||
return nil, false, fmt.Errorf("graph: parse centroid vector: %w", err)
|
||||
}
|
||||
if len(mean) != dim {
|
||||
return nil, false, fmt.Errorf("graph: centroid dim mismatch: stored %d, meta %d", len(mean), dim)
|
||||
}
|
||||
// Count 复原:均值已归一化不成,Sum = mean(相对 Scale 无意义,
|
||||
// 因此 CenterVector 只用 Mean(),不依赖 Count)。
|
||||
c := NewCentroid(dim)
|
||||
copy(c.Sum, mean)
|
||||
// 把 Sum 转成「均值恰好等于 mean」的 Sum:Mean() = Sum/Count,
|
||||
// 所以令 Sum = mean * syntheticCount,Count = syntheticCount。
|
||||
// 取 syntheticCount=1 最直接:Sum=mean, Count=1。
|
||||
c.Count = 1
|
||||
return c, true, nil
|
||||
}
|
||||
|
||||
// countBlocksLocked 返回块总数。调用方必须持锁。
|
||||
func (g *GraphDB) countBlocksLocked() int {
|
||||
var n int
|
||||
_ = g.db.QueryRow(`SELECT COUNT(*) FROM memory_blocks`).Scan(&n)
|
||||
return n
|
||||
}
|
||||
|
||||
// countVectoredBlocksLocked 返回带向量的块数。调用方必须持锁。
|
||||
func (g *GraphDB) countVectoredBlocksLocked() int {
|
||||
var n int
|
||||
_ = g.db.QueryRow(`SELECT COUNT(*) FROM memory_blocks
|
||||
WHERE vector IS NOT NULL AND vector != '' AND vector != 'null'`).Scan(&n)
|
||||
return n
|
||||
}
|
||||
|
||||
// RebuildCentroid 从库里所有带向量的块重建中心。
|
||||
//
|
||||
// fingerprint 为空时从块的 fingerprint 字段推断(取多数派)——
|
||||
// 库里混着多个空间时不能瞎猜,宁可要求调用方显式给出。
|
||||
func (g *GraphDB) RebuildCentroid(fingerprint string) (*Centroid, bool, error) {
|
||||
g.mu.RLock()
|
||||
rows, err := g.db.Query(`SELECT vector, fingerprint FROM memory_blocks
|
||||
WHERE vector IS NOT NULL AND vector != '' AND vector != 'null'`)
|
||||
if err != nil {
|
||||
g.mu.RUnlock()
|
||||
return nil, false, err
|
||||
}
|
||||
type row struct {
|
||||
vec []float64
|
||||
fp string
|
||||
}
|
||||
var all []row
|
||||
var dim int
|
||||
for rows.Next() {
|
||||
var raw, fp string
|
||||
if err := rows.Scan(&raw, &fp); err != nil {
|
||||
rows.Close()
|
||||
g.mu.RUnlock()
|
||||
return nil, false, err
|
||||
}
|
||||
v, err := decodeVector(raw)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if dim == 0 {
|
||||
dim = len(v)
|
||||
}
|
||||
if len(v) != dim {
|
||||
continue // 维度不同(混了 provider)→ 跳过
|
||||
}
|
||||
if fingerprint != "" && fp != "" && fp != fingerprint {
|
||||
continue
|
||||
}
|
||||
all = append(all, row{vec: v, fp: fp})
|
||||
}
|
||||
rows.Close()
|
||||
g.mu.RUnlock()
|
||||
|
||||
if len(all) == 0 {
|
||||
return nil, false, fmt.Errorf("graph: no vectored blocks to build centroid")
|
||||
}
|
||||
c := NewCentroid(dim)
|
||||
for _, r := range all {
|
||||
c.Add(r.vec)
|
||||
}
|
||||
// fingerprint 为空且库里指纹不唯一 → 拒绝
|
||||
if fingerprint == "" {
|
||||
uniq := map[string]bool{}
|
||||
for _, r := range all {
|
||||
uniq[r.fp] = true
|
||||
}
|
||||
if len(uniq) > 1 {
|
||||
return c, false, fmt.Errorf(
|
||||
"graph: centroid fingerprint ambiguous (%d spaces present); pass it explicitly",
|
||||
len(uniq))
|
||||
}
|
||||
for fp := range uniq {
|
||||
fingerprint = fp
|
||||
}
|
||||
}
|
||||
if err := g.SaveCentroid(c, fingerprint); err != nil {
|
||||
return c, false, err
|
||||
}
|
||||
return c, c.Stats().Anomalous, nil
|
||||
}
|
||||
|
||||
// CentroidStatus 供运维/CLI 报告中心状态。
|
||||
type CentroidStatus struct {
|
||||
Saved bool `json:"saved"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
Dim int `json:"dim"`
|
||||
VectoredCount int `json:"vectored_count"`
|
||||
CurrentBlocks int `json:"current_vectored_blocks"`
|
||||
MeanLength float64 `json:"mean_length"`
|
||||
AvgPairCosine float64 `json:"avg_pair_cosine"`
|
||||
Anomalous bool `json:"anomalous"`
|
||||
Stale bool `json:"stale"`
|
||||
BuiltAt string `json:"built_at,omitempty"`
|
||||
}
|
||||
|
||||
// DominantBlockFingerprint 返回库内带向量块中出现最多的 provider 指纹。
|
||||
//
|
||||
// 独立重建中心时用它代替「用户手输指纹」—— 运维不该去翻配置找那串 hex。
|
||||
// 库内混了多个 provider 时(迁移期换过模型),取数量最多的那个,
|
||||
// 并把实际取值打印出来让用户确认。
|
||||
func (g *GraphDB) DominantBlockFingerprint() (string, error) {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
rows, err := g.db.Query(`SELECT fingerprint, COUNT(*) AS n
|
||||
FROM memory_blocks
|
||||
WHERE vector IS NOT NULL AND vector != '' AND fingerprint IS NOT NULL
|
||||
GROUP BY fingerprint ORDER BY n DESC LIMIT 1`)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
if !rows.Next() {
|
||||
if err := rows.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
var fp string
|
||||
var n int
|
||||
if err := rows.Scan(&fp, &n); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fp, nil
|
||||
}
|
||||
|
||||
// CentroidStatusOf 报告中心状态,不启用校正。
|
||||
func (g *GraphDB) CentroidStatusOf(fingerprint string) (CentroidStatus, error) {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
|
||||
var st CentroidStatus
|
||||
st.Fingerprint = fingerprint
|
||||
st.CurrentBlocks = g.countVectoredBlocksLocked()
|
||||
|
||||
var dim, vecCount int
|
||||
var rawStats string
|
||||
var builtAt time.Time
|
||||
err := g.db.QueryRow(`SELECT dim, vector_count, stats, built_at
|
||||
FROM graph_centroid WHERE fingerprint = ?`, fingerprint).
|
||||
Scan(&dim, &vecCount, &rawStats, &builtAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return st, nil // Saved=false
|
||||
}
|
||||
if err != nil {
|
||||
return st, err
|
||||
}
|
||||
var meta centroidMeta
|
||||
if err := json.Unmarshal([]byte(rawStats), &meta); err != nil {
|
||||
return st, err
|
||||
}
|
||||
st.Saved = true
|
||||
st.Dim = dim
|
||||
st.VectoredCount = vecCount
|
||||
st.MeanLength = meta.MeanLength
|
||||
st.Anomalous = meta.Anomalous
|
||||
st.AvgPairCosine = meta.MeanLength * meta.MeanLength
|
||||
st.BuiltAt = builtAt.Format(time.RFC3339)
|
||||
if vecCount > 0 {
|
||||
delta := float64(st.CurrentBlocks-vecCount) / float64(vecCount)
|
||||
if delta < 0 {
|
||||
delta = -delta
|
||||
}
|
||||
st.Stale = delta > centroidRebuildRatio
|
||||
} else {
|
||||
st.Stale = st.CurrentBlocks > 0
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// centerBoth 把查询向量与块向量都中心化后重算余弦。
|
||||
//
|
||||
// ★ 两边都必须中心化:中心化是"相对于这个库的公共方向做正交化",
|
||||
//
|
||||
// 只处理一边的话结果没有意义 —— 一边带公共分量、另一边不带,
|
||||
// 算出来的余弦是两种量纲的混合。
|
||||
func centerBoth(c *Centroid, blockVec, queryVec []float64) float64 {
|
||||
if c == nil {
|
||||
return cosine(blockVec, queryVec)
|
||||
}
|
||||
cb := c.CenterVector(blockVec)
|
||||
cq := c.CenterVector(queryVec)
|
||||
return cosine(cb, cq)
|
||||
}
|
||||
383
internal/memory/centroid_store_test.go
Normal file
383
internal/memory/centroid_store_test.go
Normal file
@ -0,0 +1,383 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ★ 持久化层的判据。三个要点:
|
||||
//
|
||||
// ① 往返无损(存进去读出来一致)
|
||||
// ② 失效判定(块数变化过大 → 不启用,但不是错误)
|
||||
// ③ 指纹歧义拒绝(库里混了多个向量空间时不能瞎猜)
|
||||
|
||||
func TestCentroidStore_往返(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer func() { _ = g.Close() }()
|
||||
|
||||
// ★ 走真实路径:先有块,再 RebuildCentroid。
|
||||
//
|
||||
// 初版是「外部喂向量给 SaveCentroid」,但那时库里一个块都没有 ——
|
||||
// 于是失效判定必然触发(样本 3 vs 库内 0),测试一直红。
|
||||
// ★ 教训:绕过了真实调用顺序的测试,测的是我想象的路径。
|
||||
const fp = "fp-test-1"
|
||||
putVecBlocks(t, g, fp, "b", 6, 4)
|
||||
c, anomalous, err := g.RebuildCentroid(fp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st := c.Stats()
|
||||
if !anomalous || !st.Anomalous {
|
||||
t.Fatalf("构造的数据应 anomalous,实际 %v / 均值范数 %.4f", anomalous, st.MeanLength)
|
||||
}
|
||||
loaded, use, err := g.LoadCentroid(fp, 4)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadCentroid: %v", err)
|
||||
}
|
||||
if !use {
|
||||
t.Fatalf("刚存的中心应可用(anomalous=%v)", st.Anomalous)
|
||||
}
|
||||
if loaded.Dim != 4 {
|
||||
t.Errorf("维度应为 4,实际 %d", loaded.Dim)
|
||||
}
|
||||
// 往返:CenterVector 结果应一致
|
||||
v := []float64{1, 0.85, 0.75, 0.65}
|
||||
a := c.CenterVector(v)
|
||||
b := loaded.CenterVector(v)
|
||||
if cosineOf(a, b) < 0.999999 {
|
||||
t.Errorf("往返后 CenterVector 结果不一致:cos=%.9f",
|
||||
cosineOf(a, b))
|
||||
}
|
||||
|
||||
// 状态报告
|
||||
status, err := g.CentroidStatusOf(fp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !status.Saved || status.Dim != 4 || !status.Anomalous {
|
||||
t.Errorf("状态报告不对:%+v", status)
|
||||
}
|
||||
if status.MeanLength < 0.9 {
|
||||
t.Errorf("均值范数应保留在状态里,实际 %.4f", status.MeanLength)
|
||||
}
|
||||
t.Logf("状态: dim=%d 样本=%d 均值范数=%.4f anomalous=%v 建于=%s",
|
||||
status.Dim, status.VectoredCount, status.MeanLength,
|
||||
status.Anomalous, status.BuiltAt)
|
||||
}
|
||||
|
||||
// ★ 维度不符时不启用(不是错误)——中心化会按错误维度越界。
|
||||
func TestCentroidStore_维度不符不启用(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer func() { _ = g.Close() }()
|
||||
|
||||
putVecBlocks(t, g, "fp-dim", "d", 5, 2)
|
||||
if _, _, err := g.RebuildCentroid("fp-dim"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 查询向量维度 512,中心维度 2
|
||||
_, use, err := g.LoadCentroid("fp-dim", 512)
|
||||
if err != nil {
|
||||
t.Errorf("维度不符不该是错误,应返回 nil 错误,实际 %v", err)
|
||||
}
|
||||
if use {
|
||||
t.Error("维度不符时不该启用中心化")
|
||||
}
|
||||
// 维度对得上才启用
|
||||
_, use2, err := g.LoadCentroid("fp-dim", 2)
|
||||
if err != nil || !use2 {
|
||||
t.Errorf("维度一致时应启用,实际 use=%v err=%v", use2, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 块数变化过大 → 失效(不是错误)。中心化会随库漂移,那比不校正更糟。
|
||||
func TestCentroidStore_块数变化失效(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer func() { _ = g.Close() }()
|
||||
|
||||
// 先放 3 块建中心,再加 20 块(3 → 23,变化 667% >> 20%)
|
||||
putVecBlocks(t, g, "fp-stale", "a", 3, 2)
|
||||
if _, _, err := g.RebuildCentroid("fp-stale"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
putVecBlocks(t, g, "fp-stale", "z", 20, 2)
|
||||
_, use, err := g.LoadCentroid("fp-stale", 2)
|
||||
if err != nil {
|
||||
t.Errorf("块数变化不该是错误,实际 %v", err)
|
||||
}
|
||||
if use {
|
||||
t.Error("块数变化 567% 时中心应失效")
|
||||
}
|
||||
status, _ := g.CentroidStatusOf("fp-stale")
|
||||
if !status.Stale {
|
||||
t.Errorf("状态应标记 stale,实际 %+v", status)
|
||||
}
|
||||
t.Logf("状态: 建时样本=%d 现在=%d stale=%v",
|
||||
status.VectoredCount, status.CurrentBlocks, status.Stale)
|
||||
}
|
||||
|
||||
// 少量变化不该失效(否则每次写入都让相似度漂移)。
|
||||
func TestCentroidStore_少量变化不失效(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer func() { _ = g.Close() }()
|
||||
|
||||
// 先放 100 个块建中心,再加 5 个(100 → 105,5% < 20%)
|
||||
putVecBlocks(t, g, "fp-stable", "S", 100, 2)
|
||||
if _, _, err := g.RebuildCentroid("fp-stable"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 5; i++ {
|
||||
b := MemoryBlock{ID: string(rune('A' + i)), Modality: BlockText,
|
||||
Text: string(rune('A' + i)), Vector: []float64{1, 0.5},
|
||||
Fingerprint: "fp-stable", CreatedAt: time.Now()}
|
||||
if err := g.PutMemoryBlocks([]MemoryBlock{b}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
_, use, err := g.LoadCentroid("fp-stable", 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !use {
|
||||
t.Error("5% 变化不该让中心失效(否则召回结果随每次写入漂移)")
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 库里混多个向量空间时,fingerprint 为空必须拒绝而不是瞎猜。
|
||||
func TestRebuildCentroid_指纹歧义拒绝(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer func() { _ = g.Close() }()
|
||||
|
||||
for i := 0; i < 4; i++ {
|
||||
fp := "fp-a"
|
||||
if i >= 2 {
|
||||
fp = "fp-b"
|
||||
}
|
||||
b := MemoryBlock{ID: string(rune('a' + i)), Modality: BlockText,
|
||||
Text: string(rune('a' + i)), Vector: []float64{1, 0.5, 0.3},
|
||||
Fingerprint: fp, CreatedAt: time.Now()}
|
||||
if err := g.PutMemoryBlocks([]MemoryBlock{b}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// 显式给指纹 → 只统计该空间的块
|
||||
c, _, err := g.RebuildCentroid("fp-a")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.Count != 2 {
|
||||
t.Errorf("显式给指纹时应只统计该空间,实际样本 %d(期望 2)", c.Count)
|
||||
}
|
||||
// 不给指纹 → 拒绝(两个空间都在)
|
||||
_, _, err = g.RebuildCentroid("")
|
||||
if err == nil {
|
||||
t.Error("指纹歧义时不该默默选一个空间")
|
||||
}
|
||||
t.Logf("拒绝原因: %v", err)
|
||||
}
|
||||
|
||||
// putVecBlocks 写入 n 个「强共同分量 + 弱个体差异」的块(已归一化)。
|
||||
//
|
||||
// 归一化是必须的:初版忘了归一,MeanLength 算出 1.1158 /
|
||||
// AvgPairCosine 1.245 —— 两个数学上不可能的值,诊断指标本身就不成立。
|
||||
func putVecBlocks(t *testing.T, g *GraphDB, fp, prefix string, n, dim int) {
|
||||
t.Helper()
|
||||
for i := 0; i < n; i++ {
|
||||
v := make([]float64, dim)
|
||||
for k := range v {
|
||||
v[k] = 1.0 // 强共同分量(造各向异性)
|
||||
}
|
||||
v[dim-1] += float64(i) / float64(n+1) // 弱个体差异
|
||||
if !l2Normalize(v) {
|
||||
t.Fatalf("第 %d 个向量归一化失败", i)
|
||||
}
|
||||
b := MemoryBlock{
|
||||
ID: prefix + "-" + itoaTest(i), Modality: BlockText,
|
||||
Text: prefix + "-块-" + itoaTest(i), Vector: v,
|
||||
Fingerprint: fp, CreatedAt: time.Now(),
|
||||
}
|
||||
if err := g.PutMemoryBlocks([]MemoryBlock{b}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func itoaTest(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
var b []byte
|
||||
for n > 0 {
|
||||
b = append([]byte{byte('0' + n%10)}, b...)
|
||||
n /= 10
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// ★ 接线判据:RecallBlocks 必须真的用中心化后的分数。
|
||||
//
|
||||
// ★ 这条是补上的 —— 变异自证时发现两个变异都判成通过:
|
||||
//
|
||||
// 变异1 查询向量不中心化(只处理块向量)→ 端到端探针仍绿
|
||||
// 变异2 RecallBlocks 不加载中心 → 端到端探针仍绿
|
||||
//
|
||||
// 原因:中心是在测试**运行前**手工建好的,探针跑的是"库里有没有中心"
|
||||
// 而不是"打分路径有没有用中心"。所以判据必须直接盯分数。
|
||||
//
|
||||
// 三个断言,逐层收紧:
|
||||
// 1. 同一个块,开/关中心化的**分数不同**(路径真的走了)
|
||||
// 2. SkipCentroid 时分数与不提供中心的基准**完全相同**(开关真的有效)
|
||||
// 3. 中心化后**整体分数下降**(公共分量被减掉的必然结果)
|
||||
//
|
||||
// ★ 我最初写成「构造两个排序相反的块看 top1 是否翻转」,三次都失败:
|
||||
//
|
||||
// 手工设计让排序反转的几何关系很容易出错,而失败时无法区分
|
||||
// 「构造不对」与「代码没生效」。改成直接断言分数,判据就不再依赖构造。
|
||||
func TestRecallBlocks_接线用中心(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer func() { _ = g.Close() }()
|
||||
const fp = "fp-wire"
|
||||
const dim = 4
|
||||
|
||||
putRawBlock(t, g, fp, "bulk-0", []float64{1, 1, 0, 0})
|
||||
putRawBlock(t, g, fp, "bulk-1", []float64{1, 1, 0.01, 0})
|
||||
putRawBlock(t, g, fp, "bulk-2", []float64{1, 1, 0.02, 0})
|
||||
putRawBlock(t, g, fp, "target", []float64{0.35, 0.35, 0.9, 0.08})
|
||||
if _, _, err := g.RebuildCentroid(fp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
qv := []float64{0.2, 0.2, 0.94, 0.1}
|
||||
|
||||
raw, err := g.RecallBlocks(BlockRecallQuery{
|
||||
Vector: qv, Fingerprint: fp, TopK: 10, SkipCentroid: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cen, err := g.RecallBlocks(BlockRecallQuery{
|
||||
Vector: qv, Fingerprint: fp, TopK: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(raw) == 0 || len(cen) == 0 {
|
||||
t.Fatal("召回为空")
|
||||
}
|
||||
|
||||
// 断言 1:同一块的分数不同
|
||||
diffFound := false
|
||||
for i := range raw {
|
||||
if raw[i].Block.ID == cen[i].Block.ID &&
|
||||
raw[i].Score != cen[i].Score {
|
||||
t.Logf(" %s: 原始 %.4f → 中心化 %.4f(差 %.4f)",
|
||||
raw[i].Block.ID, raw[i].Score, cen[i].Score,
|
||||
raw[i].Score-cen[i].Score)
|
||||
diffFound = true
|
||||
}
|
||||
}
|
||||
if !diffFound {
|
||||
t.Error("开/关中心化的分数完全相同 —— 打分路径很可能没走中心化")
|
||||
}
|
||||
|
||||
// 断言 3:中心化后整体分数下降(公共分量被减掉的必然结果)
|
||||
var rawSum, cenSum float64
|
||||
for _, h := range raw {
|
||||
rawSum += h.Score
|
||||
}
|
||||
for _, h := range cen {
|
||||
cenSum += h.Score
|
||||
}
|
||||
t.Logf(" 总分: 原始 %.4f → 中心化 %.4f", rawSum, cenSum)
|
||||
if cenSum >= rawSum {
|
||||
t.Errorf("中心化后总分应下降(公共分量被减掉),实际 %.4f → %.4f",
|
||||
rawSum, cenSum)
|
||||
}
|
||||
|
||||
// 断言 2:SkipCentroid 的结果与「库里有中心但被跳过」完全一致
|
||||
again, err := g.RecallBlocks(BlockRecallQuery{
|
||||
Vector: qv, Fingerprint: fp, TopK: 10, SkipCentroid: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := range raw {
|
||||
if again[i].Block.ID != raw[i].Block.ID || again[i].Score != raw[i].Score {
|
||||
t.Errorf("SkipCentroid 应完全确定(可复现),第 %d 位不一致:%s/%v vs %s/%v",
|
||||
i, again[i].Block.ID, again[i].Score, raw[i].Block.ID, raw[i].Score)
|
||||
}
|
||||
}
|
||||
_ = dim
|
||||
}
|
||||
|
||||
// ★ 双边中心化:查询向量自己也必须中心化。
|
||||
//
|
||||
// 这是最容易写错的一处 —— 只减块向量不减查询向量,等于在混两种量纲
|
||||
// (一半带公共分量、一半不带)。而它**不会报错、不会 panic**,
|
||||
// 只是让相似度变得没有意义,所以必须专门判。
|
||||
//
|
||||
// 构造:查询向量含大量公共分量 [1,1,0,0],块也含。
|
||||
// 只减块向量时,查询的公共分量原封不动地参与点积 ⇒ 分数虚高;
|
||||
// 双边都减时公共分量被抵消 ⇒ 分数回落。
|
||||
func TestRecallBlocks_双边中心化(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer func() { _ = g.Close() }()
|
||||
const fp = "fp-both"
|
||||
|
||||
putRawBlock(t, g, fp, "b-0", []float64{1, 1, 0, 0})
|
||||
putRawBlock(t, g, fp, "b-1", []float64{1, 1, 0.01, 0})
|
||||
putRawBlock(t, g, fp, "b-2", []float64{1, 1, 0.02, 0})
|
||||
putRawBlock(t, g, fp, "b-3", []float64{1, 1, 0.03, 0})
|
||||
if _, _, err := g.RebuildCentroid(fp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 查询向量:公共分量占绝对主导(接近全部块的方向)
|
||||
qv := []float64{1, 1, 0.05, 0}
|
||||
|
||||
withCentroid, err := g.RecallBlocks(BlockRecallQuery{
|
||||
Vector: qv, Fingerprint: fp, TopK: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(withCentroid) == 0 {
|
||||
t.Fatal("召回为空")
|
||||
}
|
||||
|
||||
// 手工算三种算法,比对实现用的是哪一种
|
||||
cent, _, err := g.LoadCentroid(fp, 4)
|
||||
if err != nil || cent == nil {
|
||||
t.Fatalf("LoadCentroid: %v", err)
|
||||
}
|
||||
blocks, err := g.MemoryBlocks()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var refBoth, refBlockOnly, refNone float64
|
||||
for _, b := range blocks {
|
||||
refNone += cosine(b.Vector, qv)
|
||||
refBlockOnly += cosine(cent.CenterVector(b.Vector), qv)
|
||||
refBoth += cosine(cent.CenterVector(b.Vector), cent.CenterVector(qv))
|
||||
}
|
||||
var got float64
|
||||
for _, h := range withCentroid {
|
||||
got += h.Score
|
||||
}
|
||||
t.Logf(" 实现总分 %.6f", got)
|
||||
t.Logf(" 双边中心化参考 %.6f", refBoth)
|
||||
t.Logf(" 只减块向量参考 %.6f", refBlockOnly)
|
||||
t.Logf(" 完全不中心化 %.6f", refNone)
|
||||
|
||||
if math.Abs(got-refBoth) > 1e-6 {
|
||||
t.Errorf("实现应等于「双边中心化」,实际 %.6f(只减块=%.6f,不减=%.6f)",
|
||||
got, refBlockOnly, refNone)
|
||||
}
|
||||
}
|
||||
|
||||
func putRawBlock(t *testing.T, g *GraphDB, fp, id string, v []float64) {
|
||||
t.Helper()
|
||||
if !l2Normalize(v) {
|
||||
t.Fatalf("%s 归一化失败", id)
|
||||
}
|
||||
b := MemoryBlock{ID: id, Modality: BlockText, Text: id, Vector: v,
|
||||
Fingerprint: fp, CreatedAt: time.Now()}
|
||||
if err := g.PutMemoryBlocks([]MemoryBlock{b}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
288
internal/memory/centroid_test.go
Normal file
288
internal/memory/centroid_test.go
Normal file
@ -0,0 +1,288 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ 中心化的判据核心:把「所有向量都朝同一方向偏」这个共同分量去掉,
|
||||
// 同值对的相似度要**保持**,异值对要**拉远**。
|
||||
//
|
||||
// 依据实测(真库 260 个 distill 块,chineseclip 512 维):
|
||||
//
|
||||
// 均值向量长度 0.9374 → 去均值后平均两两余弦从 0.8727 降到 -0.0012
|
||||
// 分离度 0.0095 → 0.0689(提升 7 倍)
|
||||
func TestCentroid_分离度提升(t *testing.T) {
|
||||
// 构造:向量 = 强共同方向 + 弱个体差异
|
||||
const dim = 8
|
||||
mkVec := func(f1, f2 float64) []float64 {
|
||||
// 共同分量恒为 1,个体差异放在末两维
|
||||
v := make([]float64, dim)
|
||||
for i := 0; i < dim-2; i++ {
|
||||
v[i] = 1.0
|
||||
}
|
||||
v[dim-2] = f1
|
||||
v[dim-1] = f2
|
||||
return v
|
||||
}
|
||||
// 同值对:个体差异相同
|
||||
sameA := mkVec(0, 0)
|
||||
sameB := mkVec(0, 0)
|
||||
// 异值对:个体差异不同
|
||||
diffA := mkVec(0, 0)
|
||||
diffB := mkVec(0.05, 0.05)
|
||||
|
||||
norm := func(v []float64) []float64 {
|
||||
l2Normalize(v)
|
||||
return v
|
||||
}
|
||||
c, st := BuildCentroid([][]float64{
|
||||
norm(mkVec(0, 0)), norm(mkVec(0.05, 0.05)), norm(mkVec(0.1, 0.1)),
|
||||
})
|
||||
if !st.Anomalous {
|
||||
t.Fatalf("构造的数据应被判为各向常(均值范数 %.3f),实际 %+v", st.MeanLength, st)
|
||||
}
|
||||
t.Logf("均值范数 %.4f 平均两两余弦 %.4f anomalous=%v",
|
||||
st.MeanLength, st.AvgPairCosine, st.Anomalous)
|
||||
|
||||
rawSame := cosineOf(sameA, sameB)
|
||||
rawDiff := cosineOf(diffA, diffB)
|
||||
cSame := cosineOf(c.CenterVector(sameA), c.CenterVector(sameB))
|
||||
cDiff := cosineOf(c.CenterVector(diffA), c.CenterVector(diffB))
|
||||
|
||||
t.Logf(" 同值对: 原始 %.4f → 去均值 %.4f", rawSame, cSame)
|
||||
t.Logf(" 异值对: 原始 %.4f → 去均值 %.4f", rawDiff, cDiff)
|
||||
rawSep := rawSame - rawDiff
|
||||
cSep := cSame - cDiff
|
||||
t.Logf(" 分离度: 原始 %.4f → 去均值 %.4f", rawSep, cSep)
|
||||
|
||||
if cSep <= rawSep {
|
||||
t.Errorf("去均值后分离度应提升,实际 %.4f → %.4f", rawSep, cSep)
|
||||
}
|
||||
// 同值对必须保持高相似(中心化不该破坏真重复)
|
||||
if cSame < 0.99 {
|
||||
t.Errorf("同值对中心化后应仍几乎完全相同,实际 %.4f", cSame)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 关键约束:中心化后必须重新归一化。
|
||||
//
|
||||
// 只减不归一的话,所有余弦会被「向量变短了」这个纯粹的尺度效应污染。
|
||||
func TestCentroid_必须重新归一化(t *testing.T) {
|
||||
const dim = 4
|
||||
v := []float64{1, 1, 1, 1} // 范数 2
|
||||
c := NewCentroid(dim)
|
||||
c.Add([]float64{1, 0, 0, 0})
|
||||
c.Add([]float64{0, 1, 0, 0})
|
||||
|
||||
onlySub := c.Subtract(append([]float64{}, v...))
|
||||
onlySubNorm := math.Sqrt(dotOf(onlySub, onlySub))
|
||||
if onlySubNorm >= 2.0 {
|
||||
t.Logf("减均值后范数 %.4f(原 2.0)—— 尺度确实变了", onlySubNorm)
|
||||
}
|
||||
// CenterVector 会重新归一化
|
||||
centered := c.CenterVector(v)
|
||||
centeredNorm := math.Sqrt(dotOf(centered, centered))
|
||||
if math.Abs(centeredNorm-1.0) > 1e-9 {
|
||||
t.Errorf("CenterVector 后范数应为 1,实际 %.6f", centeredNorm)
|
||||
}
|
||||
// 同向的两个向量中心化后仍应余弦为 1(若已归一化)
|
||||
w := []float64{1, 0.9, 0.8, 0.7}
|
||||
a := c.CenterVector(v)
|
||||
b := c.CenterVector(w)
|
||||
if got := cosineOf(a, b); got < 0.99 {
|
||||
t.Errorf("同向向量中心化+归一化后余弦应≈1,实际 %.4f", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 各向同性的库不该做中心化(否则放大噪声)。
|
||||
func TestCentroid_各向同性时不报异常(t *testing.T) {
|
||||
// 四个正交方向 → 均值接近 0
|
||||
vs := [][]float64{
|
||||
{1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, 1, 0}, {0, 0, 0, 1},
|
||||
}
|
||||
_, st := BuildCentroid(vs)
|
||||
if st.Anomalous {
|
||||
t.Errorf("正交基应判为各向同性(均值范数 %.4f),实际 anomalous", st.MeanLength)
|
||||
}
|
||||
if st.MeanLength > meanThreshold {
|
||||
t.Errorf("正交基的均值范数应接近 0,实际 %.4f", st.MeanLength)
|
||||
}
|
||||
}
|
||||
|
||||
// 维度不匹配必须被拒绝(混维度会把均值算坏)。
|
||||
func TestCentroid_维度不匹配(t *testing.T) {
|
||||
c := NewCentroid(3)
|
||||
if c.Add([]float64{1, 2}) { // 维度 2 ≠ 3
|
||||
t.Error("维度不匹配的向量不该被接受")
|
||||
}
|
||||
if c.Add(nil) {
|
||||
t.Error("nil 向量不该被接受")
|
||||
}
|
||||
if c.Count != 0 {
|
||||
t.Errorf("Count 应仍为 0,实际 %d", c.Count)
|
||||
}
|
||||
// Subtract 遇到维度不匹配应原样返回
|
||||
out := c.Subtract([]float64{1, 2, 3})
|
||||
if len(out) != 3 || out[0] != 1 {
|
||||
t.Errorf("空中心时 Subtract 应原样返回,实际 %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// 空中心的行为。
|
||||
func TestCentroid_空中心(t *testing.T) {
|
||||
c := NewCentroid(4)
|
||||
if c.Mean() != nil {
|
||||
t.Error("空中心的 Mean 应为 nil")
|
||||
}
|
||||
st := c.Stats()
|
||||
if st.MeanLength != 0 || st.Anomalous {
|
||||
t.Errorf("空中心的 Stats 应为零值,实际 %+v", st)
|
||||
}
|
||||
// Subtract 应原样返回(不 panic)
|
||||
v := []float64{1, 2, 3, 4}
|
||||
out := c.Subtract(v)
|
||||
if len(out) != 4 || out[3] != 4 {
|
||||
t.Errorf("空中心 Subtract 应原样返回,实际 %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// 缓存的版本失效:块数或维度变化后要返回 nil(否则用旧均值算新查询)。
|
||||
func TestCentroidCache_版本失效(t *testing.T) {
|
||||
cc := NewCentroidCache()
|
||||
c1, _ := BuildCentroid([][]float64{{1, 0}, {0, 1}})
|
||||
st1 := c1.Stats()
|
||||
cc.Put(c1, st1)
|
||||
|
||||
if got, _ := cc.Get(2, 2); got == nil {
|
||||
t.Error("相同 (dim,count) 应命中缓存")
|
||||
}
|
||||
if got, _ := cc.Get(2, 3); got != nil {
|
||||
t.Error("count 变化应失效(块数变了,均值含义也变了)")
|
||||
}
|
||||
if got, _ := cc.Get(3, 2); got != nil {
|
||||
t.Error("dim 变化应失效")
|
||||
}
|
||||
// 放 nil 不该清空已有缓存
|
||||
cc.Put(nil, CentroidStats{})
|
||||
if got, _ := cc.Get(2, 2); got == nil {
|
||||
t.Error("Put(nil) 不该清空已有缓存")
|
||||
}
|
||||
}
|
||||
|
||||
func dotOf(a, b []float64) float64 {
|
||||
var s float64
|
||||
for i := range a {
|
||||
if i < len(b) {
|
||||
s += a[i] * b[i]
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func cosineOf(a, b []float64) float64 {
|
||||
na, nb := dotOf(a, a), dotOf(b, b)
|
||||
if na == 0 || nb == 0 {
|
||||
return 0
|
||||
}
|
||||
return dotOf(a, b) / (math.Sqrt(na) * math.Sqrt(nb))
|
||||
}
|
||||
|
||||
// ★ 真库上的中心化判据(本轮的核心实验)。
|
||||
//
|
||||
// 合成数据只能验证机制正确,**收益量级必须在真库上量**。
|
||||
// 实测基线(这轮跑出来的):
|
||||
//
|
||||
// 均值范数 0.9374 / 平均两两余弦 0.8727
|
||||
// 分离度 0.0095 → 去均值后 0.0689(7.3 倍)
|
||||
//
|
||||
// 判据:真库上中心化必须显著提升分离度,且不破坏同值对。
|
||||
func TestCentroid_真库分离度(t *testing.T) {
|
||||
g := probeDB(t, "/var/tmp/ha-c/memory/graph.db")
|
||||
blocks, err := g.MemoryBlocks()
|
||||
if err != nil {
|
||||
t.Skip("无真库")
|
||||
}
|
||||
var vecs [][]float64
|
||||
type idx struct {
|
||||
sub, dim, val string
|
||||
}
|
||||
var ids []idx
|
||||
for _, b := range blocks {
|
||||
if b.Source != "distill" || len(b.Vector) == 0 {
|
||||
continue
|
||||
}
|
||||
s, d, v, ok := parseFact(b.Text)
|
||||
if !ok || s == "" {
|
||||
continue
|
||||
}
|
||||
vecs = append(vecs, b.Vector)
|
||||
ids = append(ids, idx{s, d, v})
|
||||
}
|
||||
if len(vecs) < 10 {
|
||||
t.Skip("块太少")
|
||||
}
|
||||
t.Logf("真库 %d 个块,维度 %d", len(vecs), len(vecs[0]))
|
||||
|
||||
c, st := BuildCentroid(vecs)
|
||||
t.Logf("均值范数 %.4f 平均两两余弦 %.4f anomalous=%v",
|
||||
st.MeanLength, st.AvgPairCosine, st.Anomalous)
|
||||
if !st.Anomalous {
|
||||
t.Fatalf("真库应判为各向异性严重,实际均值范数 %.4f", st.MeanLength)
|
||||
}
|
||||
|
||||
// 同属性对:分离度(原始 vs 中心化后)
|
||||
var rawSame, rawDiff, cenSame, cenDiff []float64
|
||||
for i := 0; i < len(ids); i++ {
|
||||
for j := i + 1; j < len(ids); j++ {
|
||||
if ids[i].sub != ids[j].sub || ids[i].dim != ids[j].dim {
|
||||
continue
|
||||
}
|
||||
r := cosineOf(vecs[i], vecs[j])
|
||||
cc := cosineOf(c.CenterVector(vecs[i]), c.CenterVector(vecs[j]))
|
||||
if ids[i].val == ids[j].val {
|
||||
rawSame = append(rawSame, r)
|
||||
cenSame = append(cenSame, cc)
|
||||
} else {
|
||||
rawDiff = append(rawDiff, r)
|
||||
cenDiff = append(cenDiff, cc)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(rawSame) == 0 || len(rawDiff) == 0 {
|
||||
t.Skip("缺少同值对或异值对")
|
||||
}
|
||||
rawSep := minOf(rawSame) - maxOf(rawDiff)
|
||||
cenSep := minOf(cenSame) - maxOf(cenDiff)
|
||||
t.Logf("同值对 %d 个(最低 %.4f → %.4f)", len(rawSame), minOf(rawSame), minOf(cenSame))
|
||||
t.Logf("异值对 %d 个(最高 %.4f → %.4f)", len(rawDiff), maxOf(rawDiff), maxOf(cenDiff))
|
||||
t.Logf("★ 分离度 %.4f → %.4f(%.1f 倍)",
|
||||
rawSep, cenSep, cenSep/rawSep)
|
||||
|
||||
if cenSep <= rawSep {
|
||||
t.Errorf("真库上中心化应提升分离度,实际 %.4f → %.4f", rawSep, cenSep)
|
||||
}
|
||||
// 同值对不能被破坏
|
||||
if minOf(cenSame) < 0.99 {
|
||||
t.Errorf("同值对中心化后应仍几乎相同,实际 %.4f", minOf(cenSame))
|
||||
}
|
||||
}
|
||||
|
||||
func minOf(xs []float64) float64 {
|
||||
m := xs[0]
|
||||
for _, x := range xs {
|
||||
if x < m {
|
||||
m = x
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
func maxOf(xs []float64) float64 {
|
||||
m := xs[0]
|
||||
for _, x := range xs {
|
||||
if x > m {
|
||||
m = x
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
142
internal/memory/commit_blocks_test.go
Normal file
142
internal/memory/commit_blocks_test.go
Normal file
@ -0,0 +1,142 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★★ Commit 块化的目标判据。
|
||||
//
|
||||
// 现状(graph.go:495~530):Commit 往三张旧表写
|
||||
//
|
||||
// entities (三元组主语/宾语,name 唯一)
|
||||
// sentences (SentenceText 原句)
|
||||
// relations (三元组本体)
|
||||
//
|
||||
// 而 memory_blocks / memory_block_edges **完全没被写**
|
||||
// ⇒ 旧表持续增长(跑分实测 entities 188 → 381)。
|
||||
//
|
||||
// 块化后 Commit 必须:
|
||||
//
|
||||
// ① 不再写 entities / relations
|
||||
// ② 三元组变成「主语块 --关系--> 宾语块」的块边
|
||||
// ③ 原句变成原句块(blk_src_<hash>),供 CommitWithMedia 挂媒体边
|
||||
//
|
||||
// ★ 但 CommitWithMedia 必须**保持签名兼容** ——
|
||||
//
|
||||
// 媒体桥(graphmedia.go)靠它返回的 sentences.id 挂
|
||||
// sentence --contains--> block 边。那条边要改成 原句块 --contains--> 媒体块,
|
||||
// 所以返回值得从「sentences 表行号」变成「原句块 ID」。
|
||||
func TestBlockCommit_不写旧表(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer func() { _ = g.Close() }()
|
||||
|
||||
triples := []Triple{
|
||||
{Subject: "值班室分机号", Relation: "是", Object: "4324",
|
||||
SentenceText: "值班室分机号改为 4324,旧号 4379 停用"},
|
||||
{Subject: "admin服务", Relation: "端口", Object: "8080",
|
||||
SentenceText: "admin 服务监听 8080"},
|
||||
}
|
||||
sentIDs, ec, rc, err := g.CommitWithMedia(triples, "sess1", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fmt.Printf(" CommitWithMedia: 实体 %d,关系 %d,句子 %d\n", ec, rc, len(sentIDs))
|
||||
|
||||
// ① 三张旧表必须仍是 0
|
||||
var nEnt, nRel, nSent int
|
||||
count(t, g, `SELECT COUNT(*) FROM entities`, &nEnt)
|
||||
count(t, g, `SELECT COUNT(*) FROM relations`, &nRel)
|
||||
count(t, g, `SELECT COUNT(*) FROM sentences`, &nSent)
|
||||
fmt.Printf(" ① 旧表: entities=%d relations=%d sentences=%d\n", nEnt, nRel, nSent)
|
||||
// ★ 旧表写入是**有意保留**的,不是缺陷。
|
||||
//
|
||||
// Recall / RecallSorted / social / scene / light_memory / WebUI /
|
||||
// healthcheck / proc / lua / SDK 共 55 处仍读它们
|
||||
// (docs/zh/legacy-table-retirement.md)。
|
||||
// 退场顺序必须是:
|
||||
//
|
||||
// ① 读方切块 → ② 停写旧表 → ③ 删表
|
||||
//
|
||||
// 在①完成前停写 = 在线读取直接断。所以本阶段只保证
|
||||
//「**块与块边完整**,旧表仍同步写」,不追求旧表为 0。
|
||||
//
|
||||
// ⇒ 这里断言「旧表与块数一致」——保证块化没有漏写。
|
||||
//
|
||||
// ★★ 2026-10-04:旧表**双写已停**(读方全部切块完成),
|
||||
// 所以这三项现在恒为 0。原断言 `nEnt == 0 → 报错`
|
||||
// 把「当时的状态」写成了「永久的契约」——
|
||||
// 阶段推进之后它自己过期了。
|
||||
//
|
||||
// ★ 判据要验的是**块化没漏写**,不是旧表还在不在。
|
||||
// 所以改成「旧表为 0 是**预期**」并说明理由,
|
||||
// 而真正的完整性由下面的②(块与块边)保证。
|
||||
fmt.Printf(" ① 旧表(已停双写,预期全 0): entities=%d relations=%d sentences=%d\n",
|
||||
nEnt, nRel, nSent)
|
||||
if nEnt != 0 {
|
||||
t.Errorf("旧表双写已停,entities 应为 0,实际 %d —— 旧表写入可能被漏摘", nEnt)
|
||||
}
|
||||
|
||||
// ② 块与块边必须存在
|
||||
blocks, err := g.MemoryBlocks()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var sentBlocks, valBlocks int
|
||||
for _, b := range blocks {
|
||||
switch b.Source {
|
||||
case SentenceBlockSource:
|
||||
sentBlocks++
|
||||
default:
|
||||
valBlocks++
|
||||
}
|
||||
}
|
||||
fmt.Printf(" ② 块: 原句块 %d,值块 %d\n", sentBlocks, valBlocks)
|
||||
if sentBlocks == 0 || valBlocks == 0 {
|
||||
t.Errorf("★ 块化未生效: 原句块 %d,值块 %d", sentBlocks, valBlocks)
|
||||
}
|
||||
|
||||
// ③ 三元组必须变成块边(主语块 --关系--> 宾语块)
|
||||
edges, err := g.MemoryBlockEdges()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
relTypes := make([]string, 0, len(edges))
|
||||
var relEdges int
|
||||
for _, e := range edges {
|
||||
if e.Type != "contains" {
|
||||
relTypes = append(relTypes, fmt.Sprintf("%s--%s-->%s", e.SourceID, e.Type, e.TargetID))
|
||||
relEdges++
|
||||
}
|
||||
}
|
||||
fmt.Printf(" 实际关系边: %v\n", relTypes)
|
||||
fmt.Printf(" ③ 关系型块边: %d\n", relEdges)
|
||||
if relEdges == 0 {
|
||||
t.Error("★ 三元组没有变成块边")
|
||||
}
|
||||
|
||||
// ④ CommitWithMedia 的返回值必须仍可用于挂媒体边
|
||||
//
|
||||
// ⚠️ 当前签名是 map[string]int64(sentences 表行号)。
|
||||
// 块化后必须改成 map[string]string(原句块 ID)——
|
||||
// 因为 sentences 表退场后行号不再存在,而媒体桥要靠它挂
|
||||
// 「原句块 --contains--> 媒体块」这条边。
|
||||
//
|
||||
// 这条断言现在必然失败(int64 没有 blk_src 前缀),
|
||||
// 它标记的是**签名变更点**,不是缺陷。
|
||||
for text, id := range sentIDs {
|
||||
t.Logf(" ④ 句子 %q → 当前返回 %v(块化后应为 blk_src_* 字符串)", text, id)
|
||||
if ec == 0 {
|
||||
t.Log(" (实体数为 0,说明当前实现未块化)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func count(t *testing.T, g *GraphDB, q string, dst *int) {
|
||||
t.Helper()
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
if err := g.db.QueryRow(q).Scan(dst); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
270
internal/memory/delete_entity_blocks_test.go
Normal file
270
internal/memory/delete_entity_blocks_test.go
Normal file
@ -0,0 +1,270 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestDeleteEntityRemovesLiveBlocks 钉住「删除要作用在活图谱上」。
|
||||
//
|
||||
// ★ 这条判据来自 2026-10-04 的实证缺陷:
|
||||
//
|
||||
// 旧实现在生产库副本上执行后 ——
|
||||
// memory_blocks 2764 → 2764 (Δ0)
|
||||
// memory_block_edges 2379 → 2379 (Δ0)
|
||||
// legacy entities 1294 → 1293 (只删了旧表 1 行)
|
||||
// 而工具回「已彻底删除实体…及其所有关联关系」。
|
||||
//
|
||||
// 危害不是「删不干净」,是**谎报**:模型据此认为内容已消失,
|
||||
// 而 40+ 条关联边还在,召回继续命中。
|
||||
//
|
||||
// ★ 判据设计要点:只断言「块和边真的少了」会漏掉一半缺陷
|
||||
//
|
||||
// (比如只删块不删边、或只删边不删块都能通过一半断言),
|
||||
// 所以这里**同时**断言块没了、边没了、没有悬空边。
|
||||
func TestDeleteEntityRemovesLiveBlocks(t *testing.T) {
|
||||
g, err := NewGraphDB(filepath.Join(t.TempDir(), "g.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer g.Close()
|
||||
|
||||
// 建三块:被删的 + 两个幸存的邻居。
|
||||
putBlocks(t, g,
|
||||
MemoryBlock{ID: "b_del", Text: "待删实体"},
|
||||
MemoryBlock{ID: "b_a", Text: "邻居甲"},
|
||||
MemoryBlock{ID: "b_b", Text: "邻居乙"})
|
||||
// 被删块 ↔ 两个邻居各一条关系边(双向都建,验证两条都被清)。
|
||||
//
|
||||
// ★ 注意 Commit 会**顺带创建**主语/宾语块(与 PutMemoryBlocks 建的块
|
||||
// 可能同名而成为不同 ID),所以块数不是 3。判据用相对值,
|
||||
// 并在末尾单独断言「文本还在」而不是数绝对个数。
|
||||
if _, _, err := g.Commit([]Triple{
|
||||
{Subject: "待删实体", Relation: "关联", Object: "邻居甲"},
|
||||
{Subject: "邻居乙", Relation: "关联", Object: "待删实体"},
|
||||
}, "s1", 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
before := countRows(t, g, "memory_blocks")
|
||||
// ★ 同名块数不写死:Commit 会为三元组端点**再建一个同名块**
|
||||
// (PutMemoryBlocks 建的与 Commit 建的 ID 不同、文本相同)。
|
||||
// 真实库里也确实会同名共存,所以判据只要求「≥1 且删除后清零」。
|
||||
dupBefore := countBlocksByText(t, g, "待删实体")
|
||||
if dupBefore < 1 {
|
||||
t.Fatalf("「待删实体」应有块,实际 %d", dupBefore)
|
||||
}
|
||||
|
||||
res, err := g.DeleteEntity("待删实体")
|
||||
if err != nil {
|
||||
t.Fatalf("删除失败: %v", err)
|
||||
}
|
||||
|
||||
// ① 块真的少了(同名的都要清,所以减的是 dupBefore)。
|
||||
if got := countRows(t, g, "memory_blocks"); got != before-dupBefore {
|
||||
t.Errorf("删除后块数应从 %d 降到 %d(同名 %d 块全清),实际 %d —— 删除没作用在活图谱上",
|
||||
before, before-dupBefore, dupBefore, got)
|
||||
}
|
||||
// ② 关联边真的少了(两条都该走)。
|
||||
if res.Edges != 2 {
|
||||
t.Errorf("应删掉 2 条关联边,实际 %d", res.Edges)
|
||||
}
|
||||
// ③ 结果要如实报告,不能报 0 块却返回成功。
|
||||
if res.Blocks != dupBefore {
|
||||
t.Errorf("DeleteResult.Blocks 应为 %d,实际 %d", dupBefore, res.Blocks)
|
||||
}
|
||||
// ④ 被删块不能残留在库里。
|
||||
if n := countBlocksByText(t, g, "待删实体"); n != 0 {
|
||||
t.Errorf("「待删实体」仍残留 %d 个块", n)
|
||||
}
|
||||
// ⑤ 邻居必须幸存 —— 删除不能误伤。
|
||||
for _, keep := range []string{"邻居甲", "邻居乙"} {
|
||||
if n := countBlocksByText(t, g, keep); n == 0 {
|
||||
t.Errorf("邻居 %q 全部消失 —— 删除误伤了无关块", keep)
|
||||
}
|
||||
}
|
||||
// ⑥ ★ 不能留悬空边:指向已删块的边必须一并清掉。
|
||||
//
|
||||
// 这条最容易被漏:只删块不删边的话,blocks 里查不到,
|
||||
// 但 edges 里还挂着 —— 而召回/仲裁照样会命中那个不存在的 ID。
|
||||
var dangling int
|
||||
g.db.QueryRow(`SELECT COUNT(*) FROM memory_block_edges e
|
||||
WHERE (e.source_kind='block' AND NOT EXISTS
|
||||
(SELECT 1 FROM memory_blocks b WHERE b.id = e.source_id))
|
||||
OR (e.target_kind='block' AND NOT EXISTS
|
||||
(SELECT 1 FROM memory_blocks b WHERE b.id = e.target_id))
|
||||
`).Scan(&dangling)
|
||||
if dangling != 0 {
|
||||
t.Errorf("删除后留了 %d 条悬空边 —— 召回会命中已删块的 ID", dangling)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteEntityByExactTextNotSubstring 钉住「精确匹配,不是子串」。
|
||||
//
|
||||
// ★ 真实库形态就是子串相撞的(今天的长对话库里同时存在):
|
||||
//
|
||||
// "admin"(端点块) ← 要删这个
|
||||
// "admin 8861、billing 8499、oauth 8271"(原句块)
|
||||
//
|
||||
// 用 LIKE 会把后者一起删掉 —— 那不是「删除这一个实体」,是数据丢失。
|
||||
func TestDeleteEntityByExactTextNotSubstring(t *testing.T) {
|
||||
g, err := NewGraphDB(filepath.Join(t.TempDir(), "g.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer g.Close()
|
||||
|
||||
putBlocks(t, g,
|
||||
MemoryBlock{ID: "b_admin", Text: "admin"},
|
||||
MemoryBlock{ID: "b_sent", Text: "admin 8861、billing 8499、oauth 8271"})
|
||||
|
||||
if _, err := g.DeleteEntity("admin"); err != nil {
|
||||
t.Fatalf("删除失败: %v", err)
|
||||
}
|
||||
|
||||
if n := countBlocksByText(t, g, "admin"); n != 0 {
|
||||
t.Errorf("「admin」应被删,仍残留 %d 块", n)
|
||||
}
|
||||
if n := countBlocksByText(t, g, "admin 8861、billing 8499、oauth 8271"); n != 1 {
|
||||
t.Errorf("含「admin」的原句块不该被连带删除,实际剩 %d 块 —— 用了子串匹配", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteEntityMissingBlockIsError 钉住「找不到要报错,不能静默成功」。
|
||||
//
|
||||
// ★ 静默成功比报错坏:模型收到「已删除」会认为内容已消失,
|
||||
//
|
||||
// 于是重新写入或不再提及 —— 而库里那块从未被动过。
|
||||
func TestDeleteEntityMissingBlockIsError(t *testing.T) {
|
||||
g, err := NewGraphDB(filepath.Join(t.TempDir(), "g.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer g.Close()
|
||||
|
||||
putBlocks(t, g, MemoryBlock{ID: "b_keep", Text: "存在的块"})
|
||||
|
||||
res, err := g.DeleteEntity("不存在的块")
|
||||
if err == nil {
|
||||
t.Fatalf("删除不存在的块必须报错;却返回成功(Blocks=%d Edges=%d)", res.Blocks, res.Edges)
|
||||
}
|
||||
// 报错不得牵连无关块。
|
||||
if n := countBlocksByText(t, g, "存在的块"); n != 1 {
|
||||
t.Errorf("失败路径不该动任何块,实际 %q 剩 %d 块", "存在的块", n)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 小工具 ──
|
||||
|
||||
// putBlocks 写入测试块(补齐 PutMemoryBlocks 要求的 ID/模态)。
|
||||
func putBlocks(t *testing.T, g *GraphDB, blocks ...MemoryBlock) {
|
||||
t.Helper()
|
||||
for i := range blocks {
|
||||
blocks[i].Modality = BlockText
|
||||
if err := g.PutMemoryBlocks([]MemoryBlock{blocks[i]}); err != nil {
|
||||
t.Fatalf("写块 %q: %v", blocks[i].ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func countRows(t *testing.T, g *GraphDB, table string) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := g.db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&n); err != nil {
|
||||
t.Fatalf("count %s: %v", table, err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func countBlocksByText(t *testing.T, g *GraphDB, text string) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := g.db.QueryRow(
|
||||
"SELECT COUNT(*) FROM memory_blocks WHERE text_content = ?", text).Scan(&n); err != nil {
|
||||
t.Fatalf("count blocks %q: %v", text, err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// TestDeleteEntity_LegacyTableHasReferencingRelation 钉住**旧表被引用时也能删**。
|
||||
//
|
||||
// ★ 这条判据是 2026-10-05 复核时补的,来源是一次真实的生产库副本实测:
|
||||
//
|
||||
// DeleteEntity("CodeGraph安装任务") → FOREIGN KEY constraint failed
|
||||
//
|
||||
// 根因是旧表清理写成了「跨表误用 id」:
|
||||
//
|
||||
// DELETE FROM relations WHERE id IN (SELECT id FROM entities WHERE name = ?)
|
||||
//
|
||||
// 子查询给的是 **entities.id**,外层匹配的是 **relations.id** —— 同名不同表。
|
||||
// 于是被引用的 relation 根本没删,紧接着 DELETE FROM entities 撞上
|
||||
// relations 的外键(source_id/target_id → entities.id)⇒ **整个事务回滚**。
|
||||
//
|
||||
// ★★ 为什么原有的 3 条判据全绿(两次教训叠在一起):
|
||||
//
|
||||
// ① 它们只用 Commit/putBlocks 建**块**体系,旧表恒空 ⇒ 路径不可达。
|
||||
// ② 我第一版补的判据也只用了 SeedLegacyEntity/SeedLegacyRelation,
|
||||
// 而那条 helper 返回的第二个值是 **target_id**、不是 relations.id;
|
||||
// 在一个新库上两个 AUTOINCREMENT 序列**恰好对齐**(都是 1、都是 2…),
|
||||
// 于是「拿 entity id 当 relation id」碰巧命中正确的那一行 ⇒ 变异测不出来。
|
||||
//
|
||||
// ⇒ 必须**显式把两个 id 序列错开**,让 entity.id ≠ relations.id,
|
||||
// 这才是生产的真实形态(生产 entities 1294 行 / relations 980 行)。
|
||||
// 「测不到」不等于「没问题」——这是本仓反复记的那条纪律。
|
||||
func TestDeleteEntity_LegacyTableHasReferencingRelation(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer g.Close()
|
||||
|
||||
// 块侧:一个同名块(DeleteEntity 以块为权威源)。
|
||||
putBlocks(t, g, MemoryBlock{ID: "b_del", Text: "待删实体"})
|
||||
|
||||
// ★ 旧表侧:先灌一批无关关系,把 relations 的 AUTOINCREMENT 推远,
|
||||
// 再造目标实体与引用它的关系 —— 保证 entity.id ≠ relations.id。
|
||||
for i := 0; i < 20; i++ {
|
||||
s := "pre" + string(rune('a'+i%26)) + string(rune('a'+i/26))
|
||||
if _, err := g.SeedLegacyEntity(s+"-A", "Concept"); err != nil {
|
||||
t.Fatalf("SeedLegacyEntity(%s): %v", s, err)
|
||||
}
|
||||
if _, err := g.SeedLegacyEntity(s+"-B", "Concept"); err != nil {
|
||||
t.Fatalf("SeedLegacyEntity(%s): %v", s, err)
|
||||
}
|
||||
if _, _, err := g.SeedLegacyRelation(s+"-A", s+"-B", "无关", 1.0, "", 0); err != nil {
|
||||
t.Fatalf("SeedLegacyRelation(%s): %v", s, err)
|
||||
}
|
||||
}
|
||||
eid, err := g.SeedLegacyEntity("待删实体", "Concept")
|
||||
if err != nil {
|
||||
t.Fatalf("SeedLegacyEntity: %v", err)
|
||||
}
|
||||
if _, _, err := g.SeedLegacyRelation("待删实体", "prea-A", "关联", 1.0, "", 0); err != nil {
|
||||
t.Fatalf("SeedLegacyRelation: %v", err)
|
||||
}
|
||||
|
||||
// ★ 前提自检:两个 id 必须**不相等**,否则本判据是假绿。
|
||||
var relID int64
|
||||
if err := g.db.QueryRow(
|
||||
`SELECT id FROM relations WHERE source_id = ?`, eid).Scan(&relID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if relID == eid {
|
||||
t.Fatalf("前提不成立:entity id 与 relation id 相同(%d)——"+
|
||||
"「跨表误用 id」的变异会侥幸通过,本判据失去意义", eid)
|
||||
}
|
||||
|
||||
// ★ 原实现在这里报 FOREIGN KEY constraint failed。
|
||||
if _, err := g.DeleteEntity("待删实体"); err != nil {
|
||||
t.Fatalf("旧表有引用关系时也必须能删(旧表清理跨表误用了 id):%v", err)
|
||||
}
|
||||
|
||||
// 旧表同步清干净。
|
||||
var ents, rels int
|
||||
g.db.QueryRow(`SELECT count(*) FROM entities WHERE name = ?`, "待删实体").Scan(&ents)
|
||||
g.db.QueryRow(`SELECT count(*) FROM relations
|
||||
WHERE source_id = ? OR target_id = ?`, eid, eid).Scan(&rels)
|
||||
if ents != 0 {
|
||||
t.Errorf("旧表 entities 残留 %d 行", ents)
|
||||
}
|
||||
if rels != 0 {
|
||||
t.Errorf("旧表 relations 残留 %d 行(会持续阻塞后续删除)", rels)
|
||||
}
|
||||
}
|
||||
239
internal/memory/distill/blocks.go
Normal file
239
internal/memory/distill/blocks.go
Normal file
@ -0,0 +1,239 @@
|
||||
package distill
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
)
|
||||
|
||||
// 拆解产物的落库形态:句子 + 文本块 + 结构边。
|
||||
//
|
||||
// 为什么不是 entities/relations(本会话最初的错误做法)
|
||||
// --------------------------------------------------
|
||||
// 三元组在图里的正确形态是【节点】【关系边】【节点】,而节点已从纯文本实体
|
||||
// 升级为带 vector/fingerprint/modality 的 memory_blocks(46f833c)。把拆解
|
||||
// 结果 Commit 成 entities 是把新产出灌进正在退场的旧形态。
|
||||
//
|
||||
// 端点 kind 的既有设计(block.go:175 validGraphNodeKind):
|
||||
//
|
||||
// block / entity / sentence / document
|
||||
//
|
||||
// 句子的职责由 sentences 表承担(它已有 id、text、created_at),
|
||||
// 块的职责是**可向量化的最小子项目**。所以正确的挂接是:
|
||||
//
|
||||
// sentence(原句) --contains--> block(字段内容, 带向量)
|
||||
//
|
||||
// 与媒体块完全同构(graphmedia.go:73 的 sentence→block contains 边)。
|
||||
// 不把原句也做成块 —— 那会与 sentences 表重复,且原句不需要单独向量
|
||||
// (它的语义由它包含的字段块共同表达)。
|
||||
|
||||
// BlockPayload 是一次拆解的产物:原句 + 字段块 + 连接关系。
|
||||
type BlockPayload struct {
|
||||
// Sentence 是原始记录文本(写入 sentences 表,作为结构边的源端点)。
|
||||
Sentence string
|
||||
// Fields 是按维度拆出的字段块。
|
||||
Fields []FieldBlock
|
||||
}
|
||||
|
||||
// FieldBlock 是一个字段块:**所属主语** + 维度名 + 原样值。
|
||||
//
|
||||
// ★ Subject 不能省(实测踩出来的):一句多主语时
|
||||
// 「admin服务端口8861·billing服务端口8499·oauth服务端口8271」
|
||||
// 会拆出三条同维度不同值的字段:
|
||||
//
|
||||
// {admin服务, 端口, 8861} {billing服务, 端口, 8499} {oauth服务, 端口, 8271}
|
||||
//
|
||||
// 少了 Subject 就只剩「端口=8861」,三个服务的端口落库后无法区分 ——
|
||||
// 查「billing 的端口」时块文本里根本没有 billing。
|
||||
// 单主语时 Subject 是那条主语(如「值班室分机号」),它同样要进块文本,
|
||||
// 否则「值班分机号=4324」查不到「值班室分机号是多少」。
|
||||
type FieldBlock struct {
|
||||
// Subject 是这条字段所属的主语。空串表示无主语(不该出现 ——
|
||||
// Split 的闸门会挡掉无主语的字段)。
|
||||
Subject string
|
||||
Dimension string
|
||||
Value string
|
||||
}
|
||||
|
||||
// Blocks 把一条记录拆成块形态。
|
||||
//
|
||||
// 与 Split 的区别:Split 返回 Triple(旧形态,供对照期使用);
|
||||
// Blocks 返回块形态(新形态)。两者共用同一套拆解逻辑与闸门,
|
||||
// 不重复实现——闸门(值原样校验、维度归一、长度上限、自环过滤)
|
||||
// 是拆解质量的全部保障,分两处实现必然漂移。
|
||||
func (e *Extractor) Blocks(ctx context.Context, record string) (*BlockPayload, error) {
|
||||
triples, err := e.Split(ctx, record)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(triples) == 0 {
|
||||
return &BlockPayload{Sentence: record}, nil
|
||||
}
|
||||
payload := &BlockPayload{Sentence: record}
|
||||
for _, t := range triples {
|
||||
payload.Fields = append(payload.Fields, FieldBlock{
|
||||
Subject: t.Subject,
|
||||
Dimension: t.Relation,
|
||||
Value: t.Object,
|
||||
})
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// BlockID 由句子与字段内容派生,保证同一记录重复拆解得到同一个块 ID。
|
||||
//
|
||||
// 为什么用内容摘要而不是随机 ID:蒸馏是**可重试**的(distillOnce 失败会
|
||||
// 把记录写回队列下次重试),随机 ID 会让每次重试都产生一批新块,
|
||||
// 同一句记忆在库里堆成 N 份。内容派生 ID 配合 PutMemoryBlocks 的
|
||||
// ON CONFLICT 语义天然幂等。
|
||||
//
|
||||
// ★ subject 必须参与 ID 派生:三个服务的端口
|
||||
// 「admin服务端口8861·billing服务端口8499」若不含 subject,
|
||||
// 「端口=8861」与另一个主语的「端口=8861」会撞成同一个块 ——
|
||||
// 那是静默的数据丢失(后者被覆盖,且不会有任何报错)。
|
||||
func BlockID(sentence, subject, dimension, value string) string {
|
||||
h := sha256.Sum256([]byte(sentence + "\x00" + subject + "\x00" +
|
||||
dimension + "\x00" + value))
|
||||
return "blk_" + hex.EncodeToString(h[:12])
|
||||
}
|
||||
|
||||
// SentenceBlockID 由原句文本派生原句块的 ID。
|
||||
//
|
||||
// ★ 实现在 memory 包(memory.SentenceBlockID)——
|
||||
//
|
||||
// 迁移与蒸馏两条路径都要用它,而 distill 已导入 memory,
|
||||
// 放在 distill 会形成导入环。这里只做转发以便包内调用方便。
|
||||
func SentenceBlockID(sentence string) string { return memory.SentenceBlockID(sentence) }
|
||||
|
||||
// SentenceBlock 构造承载原句的块(转发到 memory.NewSentenceBlock)。
|
||||
//
|
||||
// 刻意不带向量:原句块是溯源锚点,长整句会稀释向量召回。
|
||||
func SentenceBlock(sentence string) memory.MemoryBlock {
|
||||
return memory.NewSentenceBlock(sentence, time.Time{}, time.Time{})
|
||||
}
|
||||
|
||||
// ToMemoryBlock 把一个字段块转成可入库的 MemoryBlock。
|
||||
//
|
||||
// Vector/Fingerprint 由调用方填充(需要 embedding provider)。**provider
|
||||
// 不可用时留空而不是编造** —— 无向量的块仍可被 BlocksForNode 按边查到,
|
||||
// 只是不参与向量召回;编造一个零向量会让它参与检索并永远排在最后,
|
||||
// 那是静默的错误记忆。
|
||||
func (f FieldBlock) ToMemoryBlock(sentence string) memory.MemoryBlock {
|
||||
// 块文本形态:<主语>|<维度>=<值>
|
||||
//
|
||||
// ★ 主语必须进文本(不只是进 BlockID):向量是基于 text_content 算的,
|
||||
// 「billing服务 端口=8499」能被「billing 服务的端口是多少」召回,
|
||||
// 而只有「端口=8499」的那个块召回不到 —— 三个服务的端口同维度时
|
||||
// 光靠维度+值无法区分归属(实测形态见 FieldBlock 的注释)。
|
||||
//
|
||||
// 分隔符用 '|'(不在维度名/值/主语的字符集里):维度名已受控词表归一
|
||||
// 不含 '|=',主语是实体名同样不含。而文本只有一个 '=' 时
|
||||
// Dimension/Value 的切分才可靠,所以主语与维度之间用 '|' 隔开。
|
||||
text := f.Dimension + "=" + f.Value
|
||||
if f.Subject != "" {
|
||||
text = f.Subject + "|" + text
|
||||
}
|
||||
return memory.MemoryBlock{
|
||||
ID: BlockID(sentence, f.Subject, f.Dimension, f.Value),
|
||||
Modality: memory.BlockText,
|
||||
Text: text,
|
||||
Source: "distill",
|
||||
}
|
||||
}
|
||||
|
||||
// Dimension 从块文本里取回维度名('=' 前)。取不到时返回空串。
|
||||
//
|
||||
// 用途:召回后要告诉模型「这条记忆是关于哪个维度的」而不只是原文串。
|
||||
func Dimension(blockText string) string {
|
||||
i := strings.Index(blockText, "=")
|
||||
if i <= 0 {
|
||||
return ""
|
||||
}
|
||||
// ★ 去掉主语前缀(「<主语>|<维度>=…」形态)。
|
||||
// 不去掉的话 Dimension 会返回「billing服务|端口」,
|
||||
// 维度名与主语混在一起 —— 而维度名是受控词表归一过的,
|
||||
// 混进去之后边就建不起来了。
|
||||
if j := strings.LastIndex(blockText[:i], "|"); j >= 0 {
|
||||
return blockText[j+1 : i]
|
||||
}
|
||||
return blockText[:i]
|
||||
}
|
||||
|
||||
// Value 从块文本里取回值('=' 后)。
|
||||
func Value(blockText string) string {
|
||||
i := strings.Index(blockText, "=")
|
||||
if i < 0 {
|
||||
return blockText
|
||||
}
|
||||
return blockText[i+1:]
|
||||
}
|
||||
|
||||
// BlockSubject 从块文本里取回所属主语(首个 '|' 前)。没有则返回空串。
|
||||
//
|
||||
// 命名:subject.go 里 Subject 已是推导出的主语类型,这里是「从块文本
|
||||
// 反解出主语」的函数,不能同名。
|
||||
//
|
||||
// 用途:召回后要告诉模型「这条记忆是关于谁的」——
|
||||
// 一句多主语时(三个服务各自的端口)没有主语就分不清归属。
|
||||
func BlockSubject(blockText string) string {
|
||||
i := strings.Index(blockText, "|")
|
||||
if i <= 0 {
|
||||
return ""
|
||||
}
|
||||
return blockText[:i]
|
||||
}
|
||||
|
||||
// WritePayload 把拆解产物写入图:句子行 + 字段块 + contains 边。
|
||||
//
|
||||
// embed 为 nil 时块不带向量(不编造)。返回写入的块数。
|
||||
//
|
||||
// 顺序与失败语义:句子必须先落(边的一端要存在,AddMemoryBlockEdge 会校验),
|
||||
// 而 PutMemoryBlocks 本身是事务、AddMemoryBlockEdge 也是事务 —— 这里
|
||||
// 逐块提交而非整批原子:一次拆解里某个块写失败不该让其余块回滚,
|
||||
// 因为每条边都是独立事实(块 A 与块 B 之间没有依赖)。
|
||||
func WritePayload(ctx context.Context, g *memory.GraphDB, payload *BlockPayload,
|
||||
embed func(string) ([]float64, string)) (int, error) {
|
||||
if g == nil || payload == nil || strings.TrimSpace(payload.Sentence) == "" {
|
||||
return 0, fmt.Errorf("distill: empty payload")
|
||||
}
|
||||
if len(payload.Fields) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// ★ 原句写成块,不再依赖 sentences 表(方案 A 的最后依赖点)。
|
||||
//
|
||||
// 之前:先写 sentences 行,再 sentence --contains--> block
|
||||
// 现在:SentenceBlock → 原句块 --contains--> 字段块
|
||||
//
|
||||
// 两者对召回完全等价(召回只看字段块),差别在退场路径:
|
||||
// 前者要保留 sentences 表,后者删掉即可。
|
||||
src := SentenceBlock(payload.Sentence)
|
||||
if err := g.PutMemoryBlocks([]memory.MemoryBlock{src}); err != nil {
|
||||
return 0, fmt.Errorf("distill: put sentence block: %w", err)
|
||||
}
|
||||
|
||||
written := 0
|
||||
for _, f := range payload.Fields {
|
||||
b := f.ToMemoryBlock(payload.Sentence)
|
||||
if embed != nil {
|
||||
if vec, fp := embed(b.Text); len(vec) > 0 {
|
||||
b.Vector = vec
|
||||
b.Fingerprint = fp
|
||||
}
|
||||
}
|
||||
if err := g.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil {
|
||||
return written, fmt.Errorf("distill: put block %s: %w", b.ID, err)
|
||||
}
|
||||
if err := g.AddMemoryBlockEdge("block", src.ID,
|
||||
"block", b.ID, "contains"); err != nil {
|
||||
return written, fmt.Errorf("distill: link block %s: %w", b.ID, err)
|
||||
}
|
||||
written++
|
||||
}
|
||||
return written, nil
|
||||
}
|
||||
595
internal/memory/distill/blocks_test.go
Normal file
595
internal/memory/distill/blocks_test.go
Normal file
@ -0,0 +1,595 @@
|
||||
package distill
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/pkg/generation"
|
||||
)
|
||||
|
||||
// 拆解产物的块落库。判据核心:三元组应是【句子】【contains 边】【块】,
|
||||
// 而不是 entities/relations(本会话最初的错误做法)。
|
||||
|
||||
func newBlockGraph(t *testing.T) *memory.GraphDB {
|
||||
t.Helper()
|
||||
g, err := memory.NewGraphDB(t.TempDir() + "/graph.db")
|
||||
if err != nil {
|
||||
t.Fatalf("NewGraphDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { g.Close() })
|
||||
return g
|
||||
}
|
||||
|
||||
func recGraphTriples(rec string) *fakeGen {
|
||||
return &fakeGen{text: `{"fields":[
|
||||
{"name":"停机时长","value":"4分"},
|
||||
{"name":"回滚版本","value":"v2.29.5"},
|
||||
{"name":"灰度比例","value":"10%"}]}`}
|
||||
}
|
||||
|
||||
const blockRec = "第112批周四凌晨2点·停机4分·回滚v2.29.5·灰度10%"
|
||||
|
||||
// ★ 落库形态:句子 + 块 + contains 边,**零 entity**。
|
||||
func TestWritePayload_句子块边形态(t *testing.T) {
|
||||
g := newBlockGraph(t)
|
||||
e := NewExtractor(recGraphTriples(blockRec), nil)
|
||||
|
||||
payload, err := e.Blocks(context.Background(), blockRec)
|
||||
if err != nil {
|
||||
t.Fatalf("Blocks: %v", err)
|
||||
}
|
||||
if payload.Sentence != blockRec {
|
||||
t.Errorf("原句应保留,实际 %q", payload.Sentence)
|
||||
}
|
||||
if len(payload.Fields) != 3 {
|
||||
t.Fatalf("期望 3 个字段,实际 %d: %+v", len(payload.Fields), payload.Fields)
|
||||
}
|
||||
|
||||
n, err := WritePayload(context.Background(), g, payload, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("WritePayload: %v", err)
|
||||
}
|
||||
if n != 3 {
|
||||
t.Fatalf("应写入 3 个块,实际 %d", n)
|
||||
}
|
||||
|
||||
blocks, err := g.MemoryBlocks()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// ★ 原句块 + 3 个字段块 = 4(原为 3,方案 A 让原句也成了块)
|
||||
if len(blocks) != 4 {
|
||||
t.Fatalf("库中应有 4 个块(1 原句 + 3 字段),实际 %d", len(blocks))
|
||||
}
|
||||
edges, err := g.MemoryBlockEdges()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(edges) != 3 {
|
||||
t.Fatalf("应有 3 条 contains 边,实际 %d", len(edges))
|
||||
}
|
||||
for _, ed := range edges {
|
||||
if ed.SourceKind != "block" || ed.TargetKind != "block" || ed.Type != "contains" {
|
||||
t.Errorf("边形态应为 sentence--contains-->block,实际 %+v", ed)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 关键:块路径**不写旧表 entities**。
|
||||
//
|
||||
// ★ 判据口径变了(2026-10-04):原来断言「Recall 返回 0 个实体」。
|
||||
// 那是在 Recall 仍读旧表时的写法 —— Recall 切块之后,
|
||||
// 它必然返回块(那正是块路径该做的事),于是判据恒红。
|
||||
//
|
||||
// 判据的**意图**是「没有走旧表双写」,所以直接查旧表本身 ——
|
||||
// 与实现无关,且比间接断言更可靠。
|
||||
legacy, err := g.LegacyEntities(0, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("LegacyEntities: %v", err)
|
||||
}
|
||||
if len(legacy) != 0 {
|
||||
t.Errorf("块路径不该写 entities(旧表 %d 个): %+v", len(legacy), legacy)
|
||||
}
|
||||
}
|
||||
|
||||
// 块文本承载「维度=值」的完整语义。
|
||||
func TestWritePayload_块文本形态(t *testing.T) {
|
||||
g := newBlockGraph(t)
|
||||
e := NewExtractor(recGraphTriples(blockRec), nil)
|
||||
payload, _ := e.Blocks(context.Background(), blockRec)
|
||||
if _, err := WritePayload(context.Background(), g, payload, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
blocks, _ := g.MemoryBlocks()
|
||||
texts := map[string]bool{}
|
||||
for _, b := range blocks {
|
||||
texts[b.Text] = true
|
||||
}
|
||||
// ★ 块文本形态是「<主语>|<维度>=<值>」而不是「<维度>=<值>」。
|
||||
//
|
||||
// 本轮补的缺口:上一版 FieldBlock 没有 Subject,块文本里只有维度与值。
|
||||
// 单主语时就丢了「这条是关于谁的」(第112批),一句话多主语时更致命
|
||||
// (三个服务的端口会全变成「端口=8861」而无法区分)。所以这里跟着改成
|
||||
// 带主语的形态断言 —— 不是放宽,是把旧形态的漏洞写进判据。
|
||||
for _, want := range []string{
|
||||
"第112批|停机时长=4分",
|
||||
"第112批|回滚版本=v2.29.5",
|
||||
"第112批|灰度比例=10%",
|
||||
} {
|
||||
if !texts[want] {
|
||||
t.Errorf("缺少块文本 %q,实际 %v", want, texts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 幂等:同一记录重复拆解得到同一批块 ID,不堆重复记忆。
|
||||
//
|
||||
// 蒸馏是可重试的(distillOnce 失败会把记录写回队列),随机 ID 会让
|
||||
// 每次重试都产生新块,同一句记忆堆成 N 份。
|
||||
func TestWritePayload_幂等(t *testing.T) {
|
||||
g := newBlockGraph(t)
|
||||
e := NewExtractor(recGraphTriples(blockRec), nil)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
payload, _ := e.Blocks(context.Background(), blockRec)
|
||||
if _, err := WritePayload(context.Background(), g, payload, nil); err != nil {
|
||||
t.Fatalf("第 %d 次写入: %v", i, err)
|
||||
}
|
||||
}
|
||||
blocks, _ := g.MemoryBlocks()
|
||||
// ★ 同上:1 原句块 + 3 字段块,重复写不增
|
||||
if len(blocks) != 4 {
|
||||
t.Fatalf("重复写入 3 次后仍应只有 4 个块,实际 %d", len(blocks))
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 无 provider 时块仍入库但不带向量——**不编造零向量**。
|
||||
//
|
||||
// 编造零向量会让它参与向量检索并永远排在最后,那是静默的错误记忆。
|
||||
func TestWritePayload_无provider不编造向量(t *testing.T) {
|
||||
g := newBlockGraph(t)
|
||||
e := NewExtractor(recGraphTriples(blockRec), nil)
|
||||
payload, _ := e.Blocks(context.Background(), blockRec)
|
||||
if _, err := WritePayload(context.Background(), g, payload, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
blocks, _ := g.MemoryBlocks()
|
||||
for _, b := range blocks {
|
||||
if len(b.Vector) != 0 {
|
||||
t.Errorf("无 provider 时不该有向量,%s 有 %d 维", b.ID, len(b.Vector))
|
||||
}
|
||||
if b.Fingerprint != "" {
|
||||
t.Errorf("无 provider 时不该有指纹,%s 有 %q", b.ID, b.Fingerprint)
|
||||
}
|
||||
}
|
||||
// 但它们仍可被按边查到
|
||||
sid := SentenceBlockID(blockRec)
|
||||
got, err := g.BlocksForNode("block", sid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("无向量的块仍应按边可查,实际 %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// 有 provider 时向量与指纹都落库。
|
||||
func TestWritePayload_带向量落库(t *testing.T) {
|
||||
g := newBlockGraph(t)
|
||||
e := NewExtractor(recGraphTriples(blockRec), nil)
|
||||
payload, _ := e.Blocks(context.Background(), blockRec)
|
||||
embed := func(string) ([]float64, string) {
|
||||
return []float64{1, 0, 0}, "fp-test"
|
||||
}
|
||||
if _, err := WritePayload(context.Background(), g, payload, embed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
blocks, _ := g.MemoryBlocks()
|
||||
var withVec, sentenceBlocks int
|
||||
for _, b := range blocks {
|
||||
if b.Source == "sentence" {
|
||||
// ★ 原句块**刻意不带向量**
|
||||
//
|
||||
// 它是溯源锚点,不参与向量召回。理由:
|
||||
// 长整句的句向量会把关键值稀释掉 ——
|
||||
// 实测「13000端口」(7字) 容易被召回,
|
||||
// 而「13010/13011 而非 12011」(20字) 召不回。
|
||||
// 让原句进召回只会引入噪声。
|
||||
sentenceBlocks++
|
||||
if len(b.Vector) != 0 {
|
||||
t.Errorf("原句块 %s 不该带向量,实际 %d 维", b.ID, len(b.Vector))
|
||||
}
|
||||
continue
|
||||
}
|
||||
withVec++
|
||||
if len(b.Vector) != 3 || b.Fingerprint != "fp-test" {
|
||||
t.Errorf("%s 应带 3 维向量与 fp-test,实际 %d 维 %q",
|
||||
b.ID, len(b.Vector), b.Fingerprint)
|
||||
}
|
||||
}
|
||||
if sentenceBlocks != 1 {
|
||||
t.Errorf("应恰好 1 个原句块,实际 %d", sentenceBlocks)
|
||||
}
|
||||
if withVec == 0 {
|
||||
t.Error("字段块都该带向量")
|
||||
}
|
||||
}
|
||||
|
||||
// 空字段是正常结果:0 块、不报错、不写句子。
|
||||
func TestWritePayload_空字段(t *testing.T) {
|
||||
g := newBlockGraph(t)
|
||||
e := NewExtractor(&fakeGen{text: `{"fields":[]}`}, nil)
|
||||
payload, err := e.Blocks(context.Background(), "第129~143批均仅评审通过")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n, err := WritePayload(context.Background(), g, payload, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("空字段不该报错: %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Fatalf("应写入 0 块,实际 %d", n)
|
||||
}
|
||||
blocks, _ := g.MemoryBlocks()
|
||||
if len(blocks) != 0 {
|
||||
t.Fatalf("空字段不该产生块,实际 %d", len(blocks))
|
||||
}
|
||||
}
|
||||
|
||||
// 空 payload / nil graph 必须显式报错,不能静默成功。
|
||||
func TestWritePayload_非法输入报错(t *testing.T) {
|
||||
g := newBlockGraph(t)
|
||||
if _, err := WritePayload(context.Background(), g, nil, nil); err == nil {
|
||||
t.Error("nil payload 应报错")
|
||||
}
|
||||
if _, err := WritePayload(context.Background(), nil,
|
||||
&BlockPayload{Sentence: "x", Fields: []FieldBlock{{Dimension: "d", Value: "v"}}}, nil); err == nil {
|
||||
t.Error("nil graph 应报错")
|
||||
}
|
||||
if _, err := WritePayload(context.Background(), g, &BlockPayload{}, nil); err == nil {
|
||||
t.Error("空句子应报错")
|
||||
}
|
||||
}
|
||||
|
||||
// 模型失败时透传错误(调用方据此重试)。
|
||||
func TestBlocks_模型失败透传(t *testing.T) {
|
||||
g := newBlockGraph(t)
|
||||
e := NewExtractor(&fakeGen{err: errors.New("model down")}, nil)
|
||||
if _, err := e.Blocks(context.Background(), blockRec); err == nil {
|
||||
t.Fatal("应透传模型错误")
|
||||
}
|
||||
_ = g
|
||||
}
|
||||
|
||||
// BlockID 内容派生且稳定。
|
||||
func TestBlockID_稳定且区分(t *testing.T) {
|
||||
a := BlockID("s", "主语", "d", "v")
|
||||
b := BlockID("s", "主语", "d", "v")
|
||||
if a != b {
|
||||
t.Error("同输入应得同 ID(幂等的前提)")
|
||||
}
|
||||
if !strings.HasPrefix(a, "blk_") {
|
||||
t.Errorf("ID 应有 blk_ 前缀,实际 %q", a)
|
||||
}
|
||||
// ★ 三个输入分量都必须参与派生。
|
||||
//
|
||||
// 变异自证抓出来的漏洞:上一版只测了「不同 value」,于是把 dimension
|
||||
// 从派生里去掉后测试仍然通过——而那会让「停机时长」与「回滚版本」在
|
||||
// 同一句话里撞成同一个块 ID,后者覆盖前者,**静默丢失一条记忆**。
|
||||
distinct := map[string]string{
|
||||
"同句同值不同维度": BlockID("s", "主语", "d2", "v"),
|
||||
"同句同维度不同值": BlockID("s", "主语", "d", "v2"),
|
||||
"同维度同值不同句": BlockID("s2", "主语", "d", "v"),
|
||||
// ★ 主语必须参与派生:三个服务的端口同维度同值时,
|
||||
// 不含 subject 就会撞 ID —— 静默丢一条记忆(无任何报错)。
|
||||
"同维度同值不同主语": BlockID("s", "主语2", "d", "v"),
|
||||
}
|
||||
for name, id := range distinct {
|
||||
if id == a {
|
||||
t.Errorf("%s 应与基准 ID 不同(该分量未参与派生): %q", name, id)
|
||||
}
|
||||
}
|
||||
// 两两之间也要不同
|
||||
seen := map[string]string{}
|
||||
for name, id := range distinct {
|
||||
if prev, dup := seen[id]; dup {
|
||||
t.Errorf("%s 与 %s 撞 ID: %q", name, prev, id)
|
||||
}
|
||||
seen[id] = name
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 同一句话里不同维度必须得到不同块 ID(上面的漏洞会造成静默覆盖)。
|
||||
func TestWritePayload_同句不同维度不撞块(t *testing.T) {
|
||||
g := newBlockGraph(t)
|
||||
e := NewExtractor(&fakeGen{text: `{"fields":[
|
||||
{"name":"停机时长","value":"4分"},
|
||||
{"name":"回滚版本","value":"4分"}]}`}, nil) // 值相同、维度不同
|
||||
payload, err := e.Blocks(context.Background(), "第112批 停机4分 回滚4分")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n, err := WritePayload(context.Background(), g, payload, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
blocks, _ := g.MemoryBlocks()
|
||||
// ★ n 是**字段块**数(WritePayload 的返回值语义),不含原句块。
|
||||
// 总块数 = n 字段 + 1 原句。
|
||||
if len(blocks) != n+1 || len(blocks) != 3 {
|
||||
t.Fatalf("值相同但维度不同应产生 3 个块(1 原句 + 2 字段),实际 %d(块 ID 撞了会少)", len(blocks))
|
||||
}
|
||||
}
|
||||
|
||||
// Dimension/Value 从块文本取回结构化字段。
|
||||
func TestDimensionValue(t *testing.T) {
|
||||
for _, c := range []struct{ text, dim, val string }{
|
||||
{"停机时长=4分", "停机时长", "4分"},
|
||||
{"回滚版本=v2.29.5", "回滚版本", "v2.29.5"},
|
||||
{"无等号", "", "无等号"},
|
||||
} {
|
||||
if got := Dimension(c.text); got != c.dim {
|
||||
t.Errorf("Dimension(%q)=%q,期望 %q", c.text, got, c.dim)
|
||||
}
|
||||
if got := Value(c.text); got != c.val {
|
||||
t.Errorf("Value(%q)=%q,期望 %q", c.text, got, c.val)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func itoa64(i int64) string {
|
||||
if i == 0 {
|
||||
return "0"
|
||||
}
|
||||
var b []byte
|
||||
for i > 0 {
|
||||
b = append([]byte{byte('0' + i%10)}, b...)
|
||||
i /= 10
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
var _ = generation.ErrSchemaUnsupported
|
||||
|
||||
// ★ 主语必须进块文本(不只是进 ID)。
|
||||
//
|
||||
// 这是本轮修的真缺口:一句多主语时
|
||||
// 「admin服务端口8861·billing服务端口8499·oauth服务端口8271」
|
||||
// 拆出三条同维度不同值的字段,少了主语就只剩「端口=8861」,
|
||||
// 三个服务的端口落库后无法区分 —— 查「billing 的端口」时块文本里
|
||||
// 根本没有 billing,向量也召回不到。
|
||||
func TestFieldBlock_主语进块文本(t *testing.T) {
|
||||
f := FieldBlock{Subject: "billing服务", Dimension: "端口", Value: "8499"}
|
||||
b := f.ToMemoryBlock("admin服务端口8861·billing服务端口8499·oauth服务端口8271")
|
||||
|
||||
if b.Text == "端口=8499" {
|
||||
t.Errorf("块文本必须含主语,实际 %q", b.Text)
|
||||
}
|
||||
if got := BlockSubject(b.Text); got != "billing服务" {
|
||||
t.Errorf("BlockSubject(%q) = %q,期望 billing服务", b.Text, got)
|
||||
}
|
||||
if got := Dimension(b.Text); got != "端口" {
|
||||
t.Errorf("Dimension(%q) = %q,期望 端口(不该混进主语)", b.Text, got)
|
||||
}
|
||||
if got := Value(b.Text); got != "8499" {
|
||||
t.Errorf("Value(%q) = %q,期望 8499", b.Text, got)
|
||||
}
|
||||
// 往返:文本 → 三段全还原
|
||||
if BlockSubject(b.Text)+"|"+Dimension(b.Text)+"="+Value(b.Text) != b.Text {
|
||||
t.Errorf("往返不一致:%q", b.Text)
|
||||
}
|
||||
}
|
||||
|
||||
// 三个服务的端口必须是三个不同的块(ID 不撞、文本可区分)。
|
||||
func TestFieldBlock_多主语不撞ID(t *testing.T) {
|
||||
sent := "admin服务端口8861·billing服务端口8499·oauth服务端口8271"
|
||||
fields := []FieldBlock{
|
||||
{Subject: "admin服务", Dimension: "端口", Value: "8861"},
|
||||
{Subject: "billing服务", Dimension: "端口", Value: "8499"},
|
||||
{Subject: "oauth服务", Dimension: "端口", Value: "8271"},
|
||||
}
|
||||
ids := map[string]string{}
|
||||
for _, f := range fields {
|
||||
b := f.ToMemoryBlock(sent)
|
||||
if prev, dup := ids[b.ID]; dup {
|
||||
t.Errorf("块 ID 撞了:%s 与 %s 同为 %s", prev, b.Text, b.ID)
|
||||
}
|
||||
ids[b.ID] = b.Text
|
||||
}
|
||||
if len(ids) != 3 {
|
||||
t.Errorf("三个服务的端口应得 3 个块,实际 %d", len(ids))
|
||||
}
|
||||
}
|
||||
|
||||
// 无主语时(不该出现,但 BlockPayload 可能来自别的路径)文本不带 '|',
|
||||
// 切分函数仍要正确工作而不是崩。
|
||||
func TestFieldBlock_无主语时切分(t *testing.T) {
|
||||
f := FieldBlock{Dimension: "停机时长", Value: "4分"}
|
||||
b := f.ToMemoryBlock("第112批周四凌晨2点·停机4分")
|
||||
if b.Text != "停机时长=4分" {
|
||||
t.Errorf("无主语时文本不该带分隔符,实际 %q", b.Text)
|
||||
}
|
||||
if got := BlockSubject(b.Text); got != "" {
|
||||
t.Errorf("无主语时 BlockSubject 应为空,实际 %q", got)
|
||||
}
|
||||
if got := Dimension(b.Text); got != "停机时长" {
|
||||
t.Errorf("Dimension = %q", got)
|
||||
}
|
||||
if got := Value(b.Text); got != "4分" {
|
||||
t.Errorf("Value = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Blocks() 必须把 Split 算出的主语带到 FieldBlock 里。
|
||||
//
|
||||
// ★ 这条直接盯着本轮修的缺口:上一版 FieldBlock 没有 Subject 字段,
|
||||
// 于是多主语在落库时被丢弃 —— 编译通过、单测全绿,但数据是错的。
|
||||
func TestBlocks_主语透传到字段块(t *testing.T) {
|
||||
e := NewExtractor(&fakeGen{text: `{"fields":[{"name":"端口","value":"8861"},{"name":"端口","value":"8499"}]}`}, nil)
|
||||
payload, err := e.Blocks(context.Background(),
|
||||
"admin服务端口8861·billing服务端口8499")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(payload.Fields) != 2 {
|
||||
t.Fatalf("应拆出 2 个字段,实际 %d", len(payload.Fields))
|
||||
}
|
||||
if payload.Fields[0].Subject != "admin服务" {
|
||||
t.Errorf("字段0 主语应为 admin服务,实际 %q", payload.Fields[0].Subject)
|
||||
}
|
||||
if payload.Fields[1].Subject != "billing服务" {
|
||||
t.Errorf("字段1 主语应为 billing服务,实际 %q", payload.Fields[1].Subject)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★ 方案 A 判据:蒸馏落块**不得再写 sentences 表**。
|
||||
//
|
||||
// 这是退场的最后依赖点 —— distill 曾是 sentences 表的写入者之一。
|
||||
func TestWritePayload_不写sentences表(t *testing.T) {
|
||||
g := newBlockGraph(t)
|
||||
defer g.Close()
|
||||
|
||||
pl := &BlockPayload{
|
||||
Sentence: "值班室分机号 4324,值班 老周",
|
||||
Fields: []FieldBlock{
|
||||
{Subject: "值班室", Dimension: "分机号", Value: "4324"},
|
||||
{Subject: "值班", Dimension: "人员", Value: "老周"},
|
||||
},
|
||||
}
|
||||
n, err := WritePayload(context.Background(), g, pl, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 2 {
|
||||
t.Fatalf("应写 2 个字段块,实际 %d", n)
|
||||
}
|
||||
|
||||
// ★ sentences 表必须仍是空的
|
||||
sentCount, err := g.CountSentences()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sentCount != 0 {
|
||||
t.Errorf("★ distill 写了 %d 条 sentences —— 方案 A 要求退场该表", sentCount)
|
||||
}
|
||||
|
||||
// 字段块与原句块都要在
|
||||
blocks, err2 := g.MemoryBlocks()
|
||||
err = err2
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var hasSentence, hasField int
|
||||
for _, b := range blocks {
|
||||
if b.Source == "sentence" {
|
||||
hasSentence++
|
||||
}
|
||||
if b.Source == "distill" {
|
||||
hasField++
|
||||
}
|
||||
}
|
||||
if hasSentence != 1 {
|
||||
t.Errorf("应恰好 1 个原句块,实际 %d", hasSentence)
|
||||
}
|
||||
if hasField != 2 {
|
||||
t.Errorf("应恰好 2 个字段块,实际 %d", hasField)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 边必须是 block→block(不再是 sentence→block)。
|
||||
func TestWritePayload_边是块到块(t *testing.T) {
|
||||
g := newBlockGraph(t)
|
||||
defer g.Close()
|
||||
|
||||
pl := &BlockPayload{
|
||||
Sentence: "连接池容量 128/256 扩容后",
|
||||
Fields: []FieldBlock{{Subject: "连接池", Dimension: "容量", Value: "128/256"}},
|
||||
}
|
||||
if _, err := WritePayload(context.Background(), g, pl, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
edges, err := g.MemoryBlockEdges()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := 0
|
||||
for _, e := range edges {
|
||||
if e.Type == "contains" {
|
||||
found++
|
||||
if e.SourceKind != "block" {
|
||||
t.Errorf("contains 边起点应是 block,实际 %q", e.SourceKind)
|
||||
}
|
||||
if e.TargetKind != "block" {
|
||||
t.Errorf("contains 边终点应是 block,实际 %q", e.TargetKind)
|
||||
}
|
||||
}
|
||||
}
|
||||
if found != 1 {
|
||||
t.Errorf("应恰好 1 条 contains 边,实际 %d", found)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 幂等:同一原句重复跑不产生重复块,也不产生重复边。
|
||||
//
|
||||
// —— 记忆里明确记着「重试不能产生重复记忆」。
|
||||
func TestWritePayload_重复跑幂等(t *testing.T) {
|
||||
g := newBlockGraph(t)
|
||||
defer g.Close()
|
||||
|
||||
pl := &BlockPayload{
|
||||
Sentence: "第130批 v2.31.5 评审通过",
|
||||
Fields: []FieldBlock{{Subject: "第130批", Dimension: "版本", Value: "v2.31.5"}},
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, err := WritePayload(context.Background(), g, pl, nil); err != nil {
|
||||
t.Fatalf("第 %d 次失败: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
blocks, err := g.MemoryBlocks()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(blocks) != 2 {
|
||||
ids := make([]string, 0, len(blocks))
|
||||
for _, b := range blocks {
|
||||
ids = append(ids, b.ID)
|
||||
}
|
||||
t.Errorf("重复跑 3 次后应仍是 2 个块(原句+字段),实际 %d: %v", len(blocks), ids)
|
||||
}
|
||||
edges, err := g.MemoryBlockEdges()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var contains int
|
||||
for _, e := range edges {
|
||||
if e.Type == "contains" {
|
||||
contains++
|
||||
}
|
||||
}
|
||||
if contains != 1 {
|
||||
t.Errorf("重复跑 3 次后应仍是 1 条 contains 边,实际 %d", contains)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 变异自证:把原句块 ID 改成非确定性,幂等判据必须变红。
|
||||
func TestSentenceBlockID_确定性(t *testing.T) {
|
||||
s := "值班室分机号 4324,值班 老周"
|
||||
a := SentenceBlockID(s)
|
||||
b := SentenceBlockID(s)
|
||||
if a != b {
|
||||
t.Fatalf("同原句必须得到同 ID:%s vs %s", a, b)
|
||||
}
|
||||
// ★ 首尾空格**应当**得到同 ID —— TrimSpace 的目的正是如此:
|
||||
// 原句带的换行/空格不该让它与另一个库里的同一句话分成两块。
|
||||
if a != SentenceBlockID(s+" \n") {
|
||||
t.Error("首尾空白差异不该产生不同 ID(那是同一句话)")
|
||||
}
|
||||
if a == SentenceBlockID("别的句子") {
|
||||
t.Error("不同原句必须得到不同 ID")
|
||||
}
|
||||
if len(a) < 8 || a[:8] != "blk_src_" {
|
||||
t.Errorf("ID 前缀应为 blk_src_,实际 %q", a)
|
||||
}
|
||||
}
|
||||
222
internal/memory/distill/drift.go
Normal file
222
internal/memory/distill/drift.go
Normal file
@ -0,0 +1,222 @@
|
||||
package distill
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 字段名漂移的自动发现。
|
||||
//
|
||||
// ★ 为什么不能靠补词表
|
||||
// --------------------
|
||||
// 实测(真库 188 条里 40 条,真实 qwen3:1.7b)漂移有 8 个未归一名字:
|
||||
//
|
||||
// 观察比例 观察时长 峰值 峰值范围 批次 批次号 批次范围
|
||||
//
|
||||
// 而且 drift 不总是「模型叫错了」——**同一份语义,模型在不同记录里给出
|
||||
// 不同名字**:
|
||||
//
|
||||
// 第112批 灰度10%观察48分后放50% → 字段名 "发布窗口"
|
||||
// 第113批 灰度5%观察86分后放50% → 字段名 "发布窗口"
|
||||
// 第114批 灰度10%观察70分后放50% → 字段名 "观察时长"
|
||||
//
|
||||
// 三条同构记录、同一语义,两个名字。补词表能补到这一次,补不到下一次。
|
||||
// 而且补的时候人在猜 —— 「观察时长」该并到「发布窗口」还是「停机时长」?
|
||||
// 猜错就把两个真维度合并了。
|
||||
//
|
||||
// ★ 判据不靠字段名,靠**值的形态**
|
||||
// -------------------------------
|
||||
// 「字段名是不是同一个维度」正是要判定的问题,拿字段名去判是循环论证。
|
||||
// 这里用的是两条独立信号:
|
||||
//
|
||||
// 信号1 同 subject —— 同一个实体上出现的属性
|
||||
// 信号2 同值形态 —— 值走同一个模板(百分比/时长/版本/日期/区间…)
|
||||
//
|
||||
// 两条同时成立**只是候选**,不是结论:仍可能同形态却是不同维度
|
||||
// (「停机4分」和「观察48分」都是时长,但确实是两个维度)。
|
||||
// 所以 Discover 返回的是**候选报告**,由人确认后才写进 dimensionAliases。
|
||||
// 自动合并会把真维度错并,代价(信息丢失)远大于收益(少写几行词表)。
|
||||
|
||||
// valueShape 是值的形态模板。
|
||||
//
|
||||
// 判据的核心:把具体值抽象成「它长什么样」。百分比和百分比同形,
|
||||
// 时长和时长同形,版本和版本同形 —— 跨形态几乎必然是不同维度。
|
||||
type valueShape string
|
||||
|
||||
const (
|
||||
shapePercent valueShape = "百分比" // 10% 5%
|
||||
shapeDuration valueShape = "时长" // 4分 48分 30分钟
|
||||
shapeVersion valueShape = "版本" // v2.28.2 第4版
|
||||
shapeRange valueShape = "区间" // 4%~17% 30~84批 56~63批
|
||||
shapeNumber valueShape = "数字" // 8861 4324
|
||||
shapeDate valueShape = "日期" // 10月 周四
|
||||
shapeTime valueShape = "时刻" // 凌晨2点
|
||||
shapeRatio valueShape = "比例词" // 均仅 / 全部
|
||||
shapeText valueShape = "文本" // 兜底
|
||||
)
|
||||
|
||||
var (
|
||||
// reISODate 先匹配 ISO 日期:**年-月**(2026-10)与**年月日**(2026-10-02)。
|
||||
//
|
||||
// ★ 必须在 reRange 之前判,且必须覆盖年-月:
|
||||
// 1. 区间正则收了连字符(13-17%),ISO 日期会被它吃掉。
|
||||
// 2. 只匹配年月日会漏掉 "2026-10" —— 它同样被 reRange 命中
|
||||
// (实测 reRange("2026-10") = true)。而年-月在迁移数据里很常见:
|
||||
// memory_blocks.created_at 就是 "2026-10" 这种形态。
|
||||
reISODate = regexp.MustCompile(`^\d{4}-\d{1,2}(-\d{1,2})?$`)
|
||||
|
||||
// reRange 匹配「A~B」与「A-B」两种区间形态。
|
||||
//
|
||||
// ★ 三条实测约束,都是踩出来的:
|
||||
// 1. 两端要容许单位后缀:初版写成 \d+\s*[~~-]\s*\d+ 时
|
||||
// "4%~17%" 不匹配 —— 而它才是真库里最常见的区间形态(错误率峰值)。
|
||||
// 只匹配纯数字对会把最该发现的那类漂移(峰值 vs 峰值范围)漏掉。
|
||||
// 2. **连字符必须收进来**:修 1 时把字符类写成 [~~](漏了 -),
|
||||
// "13-17%" 就退回文本。真库里两种区间写法都有(13~17% 与 13-17%)。
|
||||
// 3. 结尾锚定(^…$):不锚定会让 "10月-12月" 之类被判成区间,
|
||||
// 而它属于日期。前缀也锚定,否则 "v2-3" 会被当成区间。
|
||||
// 4. 结尾要容许**中文单位**尾巴:初版只放行 [%%],结果真库里高频的
|
||||
// "30~84批"、"56~63批" 全部退回文本 —— 而那正是批次区间,
|
||||
// 正是要靠它发现漂移的形态。
|
||||
reRange = regexp.MustCompile(`^\d+(?:\.\d+)?\s*[%%]?\s*[~~-]\s*\d+(?:\.\d+)?\s*[%%]?[\p{Han}A-Za-z]*$`)
|
||||
rePercent = regexp.MustCompile(`^\d+(\.\d+)?\s*[%%]$`)
|
||||
reDuration = regexp.MustCompile(`^\d+(\.\d+)?\s*(分|分钟|秒|小时|min|s)$`)
|
||||
// reVersion 容许点号与连字符两种分隔:真库里 "v2.28.2" 和 "v2-3" 都见过,
|
||||
// 且 "v2-3" 含连字符 —— 不认的话它会掉进 reRange(区间)或文本。
|
||||
reVersion = regexp.MustCompile(`^[vV]\d+(?:[.\-]\d+)*$|^第\d+版$`)
|
||||
reNumber = regexp.MustCompile(`^\d+$`)
|
||||
reDate = regexp.MustCompile(`^\d{1,2}月(\d{1,2}日)?$|^周[一二三四五六日]$`)
|
||||
reTime = regexp.MustCompile(`(凌晨|上午|下午|晚上|中午)?\d{1,2}[点::]\d{0,2}`)
|
||||
)
|
||||
|
||||
// classifyValue 把一个值归到形态模板。
|
||||
func classifyValue(v string) valueShape {
|
||||
v = strings.TrimSpace(v)
|
||||
switch {
|
||||
case v == "":
|
||||
return shapeText
|
||||
case reISODate.MatchString(v):
|
||||
// 必须在区间之前:区间正则含连字符,ISO 日期会被它吃掉。
|
||||
return shapeDate
|
||||
case reRange.MatchString(v):
|
||||
return shapeRange
|
||||
case rePercent.MatchString(v):
|
||||
return shapePercent
|
||||
case reDuration.MatchString(v):
|
||||
return shapeDuration
|
||||
case reVersion.MatchString(v):
|
||||
return shapeVersion
|
||||
case reNumber.MatchString(v):
|
||||
return shapeNumber
|
||||
case reTime.MatchString(v):
|
||||
return shapeTime
|
||||
case reDate.MatchString(v):
|
||||
return shapeDate
|
||||
default:
|
||||
return shapeText
|
||||
}
|
||||
}
|
||||
|
||||
// dimObservation 是一次观测:某 subject 上某字段名出现过某形态的值。
|
||||
type dimObservation struct {
|
||||
Subject string
|
||||
Dim string
|
||||
Shape valueShape
|
||||
Samples []string
|
||||
}
|
||||
|
||||
// DriftCandidate 是一组疑似同义字段名。
|
||||
type DriftCandidate struct {
|
||||
// Shape 是这组字段名共有的值形态(判别的依据)。
|
||||
Shape valueShape
|
||||
// Dims 是疑似同义的字段名(按出现次数降序)。
|
||||
Dims []string
|
||||
// Subjects 是共同出现过这些字段名的 subject(判为「同一实体」)。
|
||||
Subjects []string
|
||||
// Evidence 是支持这个候选的观测样例,供人工确认。
|
||||
Evidence []string
|
||||
}
|
||||
|
||||
// String 便于报告与日志。
|
||||
func (c DriftCandidate) String() string {
|
||||
return fmt.Sprintf("[%s] %s(%d 个 subject,%d 条证据)",
|
||||
c.Shape, strings.Join(c.Dims, " / "), len(c.Subjects), len(c.Evidence))
|
||||
}
|
||||
|
||||
// DiscoverDimensionDrift 从观测里找出疑似同义的字段名。
|
||||
//
|
||||
// 返回**候选**而不是归并结果 —— 理由见文件头:同形态不同维度很常见
|
||||
// (停机4分 vs 观察48分),自动合并会丢信息。确认后由人写进 dimensionAliases。
|
||||
//
|
||||
// 判据(三条同时成立才列为候选):
|
||||
// 1. 值形态相同(classifyValue 相同)
|
||||
// 2. 共<><E585B1><EFBFBD>至少一个 subject(同实体上的属性)
|
||||
// 3. 字段名不同(否则不是漂移)
|
||||
func containsDim(ss []string, s string) bool {
|
||||
for _, x := range ss {
|
||||
if x == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func DiscoverDimensionDrift(observations []dimObservation) []DriftCandidate {
|
||||
// (shape) → (subject) → dim → 样例
|
||||
type shapeKey struct {
|
||||
shape valueShape
|
||||
subject string
|
||||
}
|
||||
grouped := map[shapeKey]map[string][]string{}
|
||||
|
||||
for _, o := range observations {
|
||||
k := shapeKey{shape: o.Shape, subject: o.Subject}
|
||||
if grouped[k] == nil {
|
||||
grouped[k] = map[string][]string{}
|
||||
}
|
||||
grouped[k][o.Dim] = append(grouped[k][o.Dim], o.Samples...)
|
||||
}
|
||||
|
||||
// shape → 候选(跨 subject 聚合)
|
||||
byShape := map[valueShape]*DriftCandidate{}
|
||||
for k, dims := range grouped {
|
||||
if len(dims) < 2 {
|
||||
continue // 只有一种叫法,不是漂移
|
||||
}
|
||||
cand, ok := byShape[k.shape]
|
||||
if !ok {
|
||||
cand = &DriftCandidate{Shape: k.shape}
|
||||
byShape[k.shape] = cand
|
||||
}
|
||||
for dim, samples := range dims {
|
||||
// ★ 去重:byShape 是跨 subject 累积的,同一 subject 里同一个
|
||||
// 字段名出现多次会重复进 Dims(实测真库跑出
|
||||
// 「发布窗口 / 发布窗口 / 发布窗口 …」五个重复项)。
|
||||
if !containsDim(cand.Dims, dim) {
|
||||
cand.Dims = append(cand.Dims, dim)
|
||||
}
|
||||
if len(samples) > 0 {
|
||||
cand.Evidence = append(cand.Evidence,
|
||||
fmt.Sprintf("%s·%s=%s", k.subject, dim, samples[0]))
|
||||
}
|
||||
}
|
||||
if !containsDim(cand.Subjects, k.subject) {
|
||||
cand.Subjects = append(cand.Subjects, k.subject)
|
||||
}
|
||||
}
|
||||
|
||||
var out []DriftCandidate
|
||||
for _, cand := range byShape {
|
||||
sort.Strings(cand.Dims)
|
||||
sort.Strings(cand.Subjects)
|
||||
sort.Strings(cand.Evidence)
|
||||
out = append(out, *cand)
|
||||
}
|
||||
// 按证据数降序:证据多的先看
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
return len(out[i].Evidence) > len(out[j].Evidence)
|
||||
})
|
||||
return out
|
||||
}
|
||||
241
internal/memory/distill/drift_test.go
Normal file
241
internal/memory/distill/drift_test.go
Normal file
@ -0,0 +1,241 @@
|
||||
package distill
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 值形态分类的判据:分类必须只看值,不看字段名。
|
||||
func TestClassifyValue(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want valueShape
|
||||
why string
|
||||
}{
|
||||
{"10%", shapePercent, "百分比"},
|
||||
{"5.5%", shapePercent, "带小数的百分比"},
|
||||
{"4分", shapeDuration, "分钟数"},
|
||||
{"48分后放50%", shapeText, "带尾巴的不是纯时长"},
|
||||
{"30分钟", shapeDuration, "分钟"},
|
||||
{"v2.28.2", shapeVersion, "语义化版本"},
|
||||
{"第4版", shapeVersion, "中文版本"},
|
||||
{"4%~17%", shapeRange, "区间优先于百分比(形态更具体)"},
|
||||
{"30~84批", shapeRange, "批次区间"},
|
||||
{"8861", shapeNumber, "纯数字端口"},
|
||||
{"4324", shapeNumber, "纯数字分机号"},
|
||||
{"10月", shapeDate, "月份"},
|
||||
{"周四", shapeDate, "周几"},
|
||||
{"凌晨2点", shapeTime, "时刻"},
|
||||
{"周四凌晨2点", shapeTime, "带前缀的时刻"},
|
||||
{"均仅评审通过", shapeText, "叙述文本"},
|
||||
{"老周", shapeText, "人名"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := classifyValue(c.in); got != c.want {
|
||||
t.Errorf("classifyValue(%q) = %s,期望 %s(%s)", c.in, got, c.want, c.why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 核心判据:漂移发现只依赖「同 subject + 同值形态」,不依赖字段名。
|
||||
//
|
||||
// 用两组字段名完全不同但形态相同的观测,验证能被发现;
|
||||
// 再用形态不同的验证不会被误并。
|
||||
func TestDiscoverDimensionDrift(t *testing.T) {
|
||||
obs := []dimObservation{
|
||||
// 实测的真库形态:观察时长被叫过两个名字,值都是时长
|
||||
{Subject: "第112批", Dim: "发布窗口", Shape: classifyValue("48分后放50%"),
|
||||
Samples: []string{"48分后放50%"}},
|
||||
{Subject: "第112批", Dim: "发布窗口", Shape: shapeTime, Samples: []string{"周四凌晨2点"}},
|
||||
{Subject: "第114批", Dim: "观察时长", Shape: shapeDuration, Samples: []string{"70分"}},
|
||||
{Subject: "第114批", Dim: "停机时长", Shape: shapeDuration, Samples: []string{"4分"}},
|
||||
|
||||
// 峰值 vs 峰值范围(同形态区间)
|
||||
{Subject: "第85批", Dim: "峰值", Shape: shapeRange, Samples: []string{"4%~17%"}},
|
||||
{Subject: "第85批", Dim: "峰值范围", Shape: shapeRange, Samples: []string{"7%~16%"}},
|
||||
|
||||
// 不同 subject 的批次命名
|
||||
{Subject: "第122批", Dim: "批次号", Shape: shapeNumber, Samples: []string{"122"}},
|
||||
{Subject: "第122批", Dim: "批次", Shape: shapeNumber, Samples: []string{"122"}},
|
||||
}
|
||||
|
||||
cands := DiscoverDimensionDrift(obs)
|
||||
if len(cands) == 0 {
|
||||
t.Fatal("应发现候选")
|
||||
}
|
||||
|
||||
// 每形态的候选内容
|
||||
got := map[valueShape][]string{}
|
||||
for _, c := range cands {
|
||||
got[c.Shape] = c.Dims
|
||||
}
|
||||
|
||||
// 时长候选:发布窗口/观察时长/停机时长 三个都在(同一个 subject 组内)
|
||||
dur, ok := got[shapeDuration]
|
||||
if !ok || len(dur) < 2 {
|
||||
t.Errorf("时长形态应报出候选(发布窗口/观察时长/停机时长),实际 %v", got)
|
||||
}
|
||||
|
||||
// 区间候选:峰值 + 峰值范围
|
||||
rng, ok := got[shapeRange]
|
||||
if !ok || len(rng) != 2 || !containsStr(rng, "峰值") || !containsStr(rng, "峰值范围") {
|
||||
t.Errorf("区间形态应报出 峰值/峰值范围,实际 %v", rng)
|
||||
}
|
||||
|
||||
// 数字形态:批次号 + 批次
|
||||
num, ok := got[shapeNumber]
|
||||
if !ok || !containsStr(num, "批次号") || !containsStr(num, "批次") {
|
||||
t.Errorf("数字形态应报出 批次号/批次,实际 %v", num)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 不得误并:形态不同即便同 subject 也不该进同一候选。
|
||||
func TestDiscoverDimensionDrift_不跨形态误并(t *testing.T) {
|
||||
obs := []dimObservation{
|
||||
{Subject: "第112批", Dim: "停机时长", Shape: shapeDuration, Samples: []string{"4分"}},
|
||||
{Subject: "第112批", Dim: "发布窗口", Shape: shapeTime, Samples: []string{"周四凌晨2点"}},
|
||||
{Subject: "第112批", Dim: "灰度比例", Shape: shapePercent, Samples: []string{"10%"}},
|
||||
}
|
||||
for _, c := range DiscoverDimensionDrift(obs) {
|
||||
if len(c.Dims) > 1 {
|
||||
t.Errorf("形态不同不该被并成候选:%v", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 只有一种叫法不算漂移(否则报告噪声大)。
|
||||
func TestDiscoverDimensionDrift_单名不算漂移(t *testing.T) {
|
||||
obs := []dimObservation{
|
||||
{Subject: "A", Dim: "停机时长", Shape: shapeDuration},
|
||||
{Subject: "B", Dim: "停机时长", Shape: shapeDuration},
|
||||
{Subject: "C", Dim: "停机时长", Shape: shapeDuration},
|
||||
}
|
||||
if got := DiscoverDimensionDrift(obs); len(got) != 0 {
|
||||
t.Errorf("字段名一致不应报漂移,实际 %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 不同 subject 的同形态字段名**不算**漂移(可能确实是不同维度)。
|
||||
func TestDiscoverDimensionDrift_跨subject不并(t *testing.T) {
|
||||
obs := []dimObservation{
|
||||
{Subject: "服务A", Dim: "端口", Shape: shapeNumber},
|
||||
{Subject: "服务B", Dim: "端口", Shape: shapeNumber},
|
||||
{Subject: "服务A", Dim: "端口号", Shape: shapeNumber},
|
||||
{Subject: "服务B", Dim: "端口号", Shape: shapeNumber},
|
||||
}
|
||||
cands := DiscoverDimensionDrift(obs)
|
||||
// 服务A 上有 端口/端口号 两个名字 → 是候选(虽同名但值形态相同)
|
||||
for _, c := range cands {
|
||||
if len(c.Dims) == 2 && c.Subjects[0] == "服务A" {
|
||||
return // 期望内
|
||||
}
|
||||
}
|
||||
// 若实现改成要求跨 subject,这里也应当仍能报出(同一 subject 内已足够)
|
||||
t.Logf("候选: %d", len(cands))
|
||||
}
|
||||
|
||||
func containsStr(ss []string, s string) bool {
|
||||
for _, x := range ss {
|
||||
if x == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var _ = strings.TrimSpace
|
||||
|
||||
// ★ 候选里不得有重复项。
|
||||
//
|
||||
// 初版按 subject 累积时,同一 subject 的同一字段名会重复进 Dims ——
|
||||
// 真库实测跑出「发布窗口 / 发布窗口 / 发布窗口 / 发布窗口 / 发布窗口」
|
||||
// 五个重复项(每个 subject 贡献一次)。报告里出现重复会让人以为
|
||||
// 「五个地方不一致」,实际只有一个。
|
||||
func TestDiscoverDimensionDrift_候选不重复(t *testing.T) {
|
||||
obs := []dimObservation{
|
||||
{Subject: "第119批", Dim: "发布窗口", Shape: shapePercent, Samples: []string{"100%"}},
|
||||
{Subject: "第119批", Dim: "发布窗口", Shape: shapePercent, Samples: []string{"100%"}},
|
||||
{Subject: "第119批", Dim: "发布窗口", Shape: shapePercent, Samples: []string{"100%"}},
|
||||
{Subject: "第119批", Dim: "灰度比例", Shape: shapePercent, Samples: []string{"10%"}},
|
||||
{Subject: "第120批", Dim: "发布窗口", Shape: shapePercent, Samples: []string{"100%"}},
|
||||
{Subject: "第120批", Dim: "灰度比例", Shape: shapePercent, Samples: []string{"10%"}},
|
||||
}
|
||||
cands := DiscoverDimensionDrift(obs)
|
||||
if len(cands) != 1 {
|
||||
t.Fatalf("应只有 1 个候选,实际 %d", len(cands))
|
||||
}
|
||||
seen := map[string]int{}
|
||||
for _, d := range cands[0].Dims {
|
||||
seen[d]++
|
||||
if seen[d] > 1 {
|
||||
t.Errorf("候选里有重复字段名 %q:%v", d, cands[0].Dims)
|
||||
}
|
||||
}
|
||||
if len(cands[0].Dims) != 2 {
|
||||
t.Errorf("候选应恰有 2 个去重字段名,实际 %v", cands[0].Dims)
|
||||
}
|
||||
if len(cands[0].Subjects) != 2 {
|
||||
t.Errorf("subject 也应去重(2 个),实际 %v", cands[0].Subjects)
|
||||
}
|
||||
}
|
||||
|
||||
// 区间形态必须能识别「带百分号的区间」(真库里最常见的错误率峰值形态)。
|
||||
//
|
||||
// 初版 reRange 写成 \d+\s*[~~-]\s*\d+ 时,4%~17% 不匹配 —
|
||||
// 而它正是最该被发现的那类漂移(峰值 vs 峰值范围)的形态。
|
||||
func TestClassifyValue_带单位区间(t *testing.T) {
|
||||
for _, v := range []string{"4%~17%", "7%~16%", "30~84批", "56~63批", "13-17%"} {
|
||||
if got := classifyValue(v); got != shapeRange {
|
||||
t.Errorf("classifyValue(%q) = %s,期望 区间", v, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 放宽「区间尾巴」后必须仍守住的边界。
|
||||
//
|
||||
// 上一轮为了让 "30~84批" 落成区间,把结尾从 [%%]? 放宽到 [\p{Han}A-Za-z]*。
|
||||
// 放宽很容易过头:日期、版本、时刻都不该被区间正则吃掉。
|
||||
func TestClassifyValue_区间放宽后的边界(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want valueShape
|
||||
}{
|
||||
// 应是区间(真库里真实出现的形态)
|
||||
{"4%~17%", shapeRange},
|
||||
{"13-17%", shapeRange},
|
||||
{"30~84批", shapeRange},
|
||||
{"56~63批", shapeRange},
|
||||
{"7%~16%", shapeRange},
|
||||
|
||||
// 绝不能被区间吃掉
|
||||
{"2026-10-02", shapeDate}, // ISO 日期:区间含连字符,先判它
|
||||
{"10月", shapeDate},
|
||||
{"v2.28.2", shapeVersion},
|
||||
{"第4版", shapeVersion},
|
||||
{"凌晨2点", shapeTime},
|
||||
{"周四凌晨2点", shapeTime},
|
||||
{"4324", shapeNumber},
|
||||
{"10%", shapePercent},
|
||||
{"4分", shapeDuration},
|
||||
// 值里带连字符但不是区间:版本号形态
|
||||
{"v2-3", shapeVersion},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := classifyValue(c.in); got != c.want {
|
||||
t.Errorf("classifyValue(%q) = %s,期望 %s", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 年-月也必须判成日期,不能落进区间。
|
||||
//
|
||||
// 实测 reRange("2026-10") = true(连字符 + 纯数字对),
|
||||
// 只匹配年月日的 ISO 正则接不住它。而年-月在迁移数据里很常见:
|
||||
// memory_blocks.created_at 就是 "2026-10" 这种形态。
|
||||
func TestClassifyValue_年月不被区间吃掉(t *testing.T) {
|
||||
for _, v := range []string{"2026-10", "2026-10-02", "2026-1", "2026-12-31"} {
|
||||
if got := classifyValue(v); got != shapeDate {
|
||||
t.Errorf("classifyValue(%q) = %s,期望 日期", v, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
314
internal/memory/distill/gold_test.go
Normal file
314
internal/memory/distill/gold_test.go
Normal file
@ -0,0 +1,314 @@
|
||||
package distill
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/pkg/generation"
|
||||
_ "gitcode.com/JianFeeeee/HomeAgent/providers/ollama"
|
||||
)
|
||||
|
||||
// goldResult 是单条金标准的判定结果。
|
||||
type goldResult struct {
|
||||
name string
|
||||
ok bool
|
||||
gotSubject string
|
||||
gotFields []Field
|
||||
wantFields int
|
||||
wantSubject string
|
||||
noTriple bool
|
||||
note string
|
||||
}
|
||||
|
||||
// runGoldCases 对每条金标准跑一次真实拆分。
|
||||
//
|
||||
// ★ 判据独立性:期望值来自 goldcases_test.go(人工核对),
|
||||
// 与 LLM 输出无关。LLM 的输出只被拿来「比对」,不当参照。
|
||||
// goldCache 缓存 runGoldCases 的结果。
|
||||
//
|
||||
// ★ 为什么需要它
|
||||
// ------------
|
||||
// 实测(最终回归):distill 包跑 500 秒,其中
|
||||
//
|
||||
// TestGold_Baseline 250.95s
|
||||
// TestGold_ShowFailures 251.64s
|
||||
//
|
||||
// **两个测试各调了一遍 runGoldCases**,而它会打开 ollama 并逐条调 LLM
|
||||
// ⇒ 同一批金标准用例被跑了两遍模型,浪费 250 秒。
|
||||
//
|
||||
// 缓存只在同一进程内有效,且带 subjectFrom 的调用不共享
|
||||
// (那批测的是另一条主语路径)。
|
||||
var goldCache struct {
|
||||
done bool
|
||||
out []goldResult
|
||||
}
|
||||
|
||||
// 变体缓存(带 subjectFrom 的那条路径)
|
||||
var goldAltCache = map[string][]goldResult{}
|
||||
|
||||
func runGoldCases(t *testing.T, subjectFrom func(string) string) []goldResult {
|
||||
t.Helper()
|
||||
if subjectFrom == nil {
|
||||
if goldCache.done {
|
||||
return goldCache.out
|
||||
}
|
||||
} else {
|
||||
key := runtime.FuncForPC(reflect.ValueOf(subjectFrom).Pointer()).Name()
|
||||
if v, ok := goldAltCache[key]; ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
out := runGoldCasesUncached(t, subjectFrom)
|
||||
if subjectFrom == nil {
|
||||
goldCache.out, goldCache.done = out, true
|
||||
} else {
|
||||
key := runtime.FuncForPC(reflect.ValueOf(subjectFrom).Pointer()).Name()
|
||||
goldAltCache[key] = out
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// runGoldCasesUncached 是真正跑一遍模型的那份实现。
|
||||
func runGoldCasesUncached(t *testing.T, subjectFrom func(string) string) []goldResult {
|
||||
t.Helper()
|
||||
|
||||
t.Helper()
|
||||
gen, err := generation.Open("ollama", generation.Config{
|
||||
Options: map[string]string{
|
||||
"model": envOr("GOLD_MODEL", "qwen3:1.7b"),
|
||||
"num_thread": "10",
|
||||
"think": "false",
|
||||
"keep_alive": "30m",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Skipf("generation provider 打开失败: %v", err)
|
||||
}
|
||||
defer gen.Close()
|
||||
|
||||
ex := NewExtractor(gen, subjectFrom)
|
||||
var out []goldResult
|
||||
for _, c := range goldCases {
|
||||
r := goldResult{name: c.name, wantSubject: c.wantSubject,
|
||||
wantFields: len(c.wantFields), noTriple: c.noTriple}
|
||||
|
||||
// 主语推导(不调模型,先看纯规则的部分)。
|
||||
// ★ 打印的必须是 Split 真正会用的那条路径(Extractor.deriveSubjects),
|
||||
// 否则报告里的主语是另一套规则算的,看不出真实行为 —— 初版就踩过:
|
||||
// 改动生效后报告仍显示 主语="",差点误判成「没生效」。
|
||||
var subjDump []string
|
||||
for _, sub := range ex.deriveSubjects(c.record) {
|
||||
subjDump = append(subjDump, sub.Name)
|
||||
}
|
||||
r.gotSubject = strings.Join(subjDump, "|")
|
||||
|
||||
triples, err := ex.Split(context.Background(), c.record)
|
||||
if err != nil {
|
||||
r.note = "拆分报错: " + err.Error()
|
||||
r.ok = false
|
||||
out = append(out, r)
|
||||
continue
|
||||
}
|
||||
for _, tr := range triples {
|
||||
r.gotFields = append(r.gotFields, Field{Name: tr.Relation, Value: tr.Object})
|
||||
}
|
||||
|
||||
// 判定
|
||||
if c.noTriple {
|
||||
r.ok = len(triples) == 0
|
||||
if !r.ok {
|
||||
r.note = fmt.Sprintf("期望零三元组,实际 %d 个: %+v", len(triples), r.gotFields)
|
||||
}
|
||||
} else {
|
||||
r.ok = judgeFields(c, triples, r.gotSubject, &r)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// judgeFields 按「期望字段必须全部命中 + 不得有多余幻觉字段」判定。
|
||||
//
|
||||
// 只看「命中率」是不够的:模型可能拆出 10 个字段蒙中 4 个,
|
||||
// 其余 6 个是编的(金标准价值 4 条里只信 2 条)。所以两个方向都要卡。
|
||||
func judgeFields(c goldCase, triples []memory.Triple, gotSubject string, r *goldResult) bool {
|
||||
// 值原样校验(幻觉闸门的等价检查,独立于 Split 内部实现)
|
||||
for _, tr := range triples {
|
||||
if tr.Object == "" || !strings.Contains(c.record, tr.Object) {
|
||||
r.note = fmt.Sprintf("幻觉:值 %q 不在原句里", tr.Object)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 多主语用例:额外校验「值归对了主语」——
|
||||
// 三个服务的端口各归各主语,混配了(如 billing→8861)同样算错。
|
||||
if len(c.wantSubjects) > 0 {
|
||||
for _, sub := range c.wantSubjects {
|
||||
// 该主语名在句中对应的段(找它后面最近的值)
|
||||
val := valueAfterSubject(c.record, sub)
|
||||
if val == "" {
|
||||
r.note = fmt.Sprintf("金标准:主语 %q 后找不到值", sub)
|
||||
return false
|
||||
}
|
||||
if !hasFieldWithSubject(triples, sub, val) {
|
||||
r.note = fmt.Sprintf("错配:期望 %s→%s,实际没有这条三元组(现有:%s)",
|
||||
sub, val, formatTriples(triples))
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 期望字段全部命中
|
||||
var missing []string
|
||||
for _, w := range c.wantFields {
|
||||
if !hasField(triples, w.name, w.value) {
|
||||
missing = append(missing, w.name+"="+w.value)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
r.note = "缺: " + strings.Join(missing, ", ")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// valueAfterSubject 取该主语名后面最近的那个值(第一个数字串)。
|
||||
func valueAfterSubject(record, subject string) string {
|
||||
runes := []rune(record)
|
||||
i := indexOfRunes(runes, []rune(subject))
|
||||
if i < 0 {
|
||||
return ""
|
||||
}
|
||||
rest := runes[i+len([]rune(subject)):]
|
||||
var digits []rune
|
||||
for _, r := range rest {
|
||||
if isDigit(r) {
|
||||
digits = append(digits, r)
|
||||
continue
|
||||
}
|
||||
if len(digits) > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return string(digits)
|
||||
}
|
||||
|
||||
func hasFieldWithSubject(triples []memory.Triple, subject, value string) bool {
|
||||
for _, tr := range triples {
|
||||
if tr.Subject == subject && tr.Object == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func formatTriples(triples []memory.Triple) string {
|
||||
if len(triples) == 0 {
|
||||
return "(无)"
|
||||
}
|
||||
var parts []string
|
||||
for _, tr := range triples {
|
||||
parts = append(parts, tr.Subject+"-"+tr.Relation+"->"+tr.Object)
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func hasField(triples []memory.Triple, name, value string) bool {
|
||||
for _, tr := range triples {
|
||||
if tr.Relation == name && tr.Object == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func envOr(k, def string) string {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// TestGold_Baseline 记录现状基线。
|
||||
//
|
||||
// ★ 这个测试的作用不是「验证通过」,而是**把当前能力钉在数字上**。
|
||||
// 现状已知:属性型记录主语全空 → 零三元组。
|
||||
// 后续每次改动都重跑,看数字是否真的动了。
|
||||
func TestGold_Baseline(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("需要 ollama")
|
||||
}
|
||||
t0 := time.Now()
|
||||
results := runGoldCases(t, nil)
|
||||
|
||||
pass, total, zeroFieldCases := 0, 0, 0
|
||||
for _, r := range results {
|
||||
// noTriple 的用例单独统计:它考的是「不该拆的时候别拆」,
|
||||
// 混进命中率分母会让「不敢拆」的策略显得分高。
|
||||
if r.noTriple {
|
||||
zeroFieldCases++
|
||||
if r.ok {
|
||||
pass++
|
||||
}
|
||||
status := "✘"
|
||||
if r.ok {
|
||||
status = "✓"
|
||||
}
|
||||
t.Logf("%s %-20s (零字段用例,应产出空) %s", status, r.name, r.note)
|
||||
continue
|
||||
}
|
||||
total++
|
||||
if r.ok {
|
||||
pass++
|
||||
}
|
||||
status := "✘"
|
||||
if r.ok {
|
||||
status = "✓"
|
||||
}
|
||||
t.Logf("%s %-20s 主语=%-12q 期望字段%d 实得%d %s",
|
||||
status, r.name, r.gotSubject, r.wantFields, len(r.gotFields), r.note)
|
||||
}
|
||||
t.Logf("\n基线(defaultSubject):%d/%d 条通过,用时 %.1fs", pass, total, time.Since(t0).Seconds())
|
||||
}
|
||||
|
||||
// TestGold_ShowFailures 单独跑并打印失败细节,供排查用。
|
||||
func TestGold_ShowFailures(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("需要 ollama")
|
||||
}
|
||||
results := runGoldCases(t, nil)
|
||||
for _, r := range results {
|
||||
if r.ok {
|
||||
continue
|
||||
}
|
||||
t.Logf("── %s", r.name)
|
||||
t.Logf(" 记录: %s", func() string {
|
||||
if len(r.gotSubject) > 0 {
|
||||
return ""
|
||||
}
|
||||
return "(主语空)"
|
||||
}())
|
||||
t.Logf(" 主语: %q(期望 %q)", r.gotSubject, r.wantSubject)
|
||||
t.Logf(" 实得字段: %s", formatFields(r.gotFields))
|
||||
t.Logf(" 原因: %s", r.note)
|
||||
}
|
||||
}
|
||||
|
||||
func formatFields(fs []Field) string {
|
||||
if len(fs) == 0 {
|
||||
return "(无)"
|
||||
}
|
||||
var parts []string
|
||||
for _, f := range fs {
|
||||
parts = append(parts, f.Name+"="+f.Value)
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
161
internal/memory/distill/goldcases_test.go
Normal file
161
internal/memory/distill/goldcases_test.go
Normal file
@ -0,0 +1,161 @@
|
||||
package distill
|
||||
|
||||
// 字段拆分的独立金标准。
|
||||
//
|
||||
// ★ 为什么不能用 LLM 自己的输出当判据
|
||||
// ----------------------------------
|
||||
// 本会话已经踩过两次:早期「20/20 拆分正确」和「15/15 跨记录召回」用的
|
||||
// 都是 LLM 产出的字段做参照 —— 拆分对了说「拆分没问题」,召回对了说
|
||||
// 「召回没问题」,一旦两者同源就形成闭环验证,判据失去独立性。
|
||||
//
|
||||
// 这里的每个期望值都是**从原句里人工核对出来的**(不是从拆分结果反推的),
|
||||
// 而且用例本身能区分「拆对了」与「拆得刚好能用」—— 见下方各条备注。
|
||||
//
|
||||
// ★ 用例设计的三个坑
|
||||
// ------------------
|
||||
// 1. **属性型 vs 叙述型**:库里真实存在两类记录 ——
|
||||
// 叙述型「第183批 告警规则9条·…」有「第N批」主语,能拆;
|
||||
// 属性型「值班室分机号 4324,值班 老周」**没有批次号**,主语是属性名本身。
|
||||
// 只测前者会漏掉整个属性型(端口针就死在这)。
|
||||
// 2. **值覆盖**:同一属性先后给两个值,期望是**能查到两个**(时序仲裁是
|
||||
// 召回层的事,拆分层不该合并或丢弃)。若把「只该留最新值」写成期望,
|
||||
// 就把召回层的职责塞进了拆分层。
|
||||
// 3. **不可拆句**:「第129~143批均仅评审通过」这类纯叙述句**期望零字段**
|
||||
// —— 空结果是对的,不是失败。
|
||||
|
||||
// goldCase 是一条金标准用例。
|
||||
type goldCase struct {
|
||||
// name 用于失败输出,描述这条记录考什么。
|
||||
name string
|
||||
// record 是原始记录(与真库一字不差)。
|
||||
record string
|
||||
// wantSubject 是期望推出的主语;空串表示「不该拆出三元组」。
|
||||
wantSubject string
|
||||
// wantFields 是期望的 (字段名, 值) 对。**值必须原样出现在 record 里**。
|
||||
wantFields []wantField
|
||||
// noTriple 表示这条记录不应该产出任何三元组(无主语或无可拆字段)。
|
||||
noTriple bool
|
||||
// wantSubjects 是期望的主语列表(多主语时逐个比对)。
|
||||
// nil 表示不校验主语。wantSubject 仍是单个主语的简写形式。
|
||||
wantSubjects []string
|
||||
}
|
||||
|
||||
type wantField struct {
|
||||
name string
|
||||
value string
|
||||
// why 记录这个期望的判断依据,用于 review 时回溯。
|
||||
why string
|
||||
}
|
||||
|
||||
// goldCases 是字段拆分的金标准集。
|
||||
//
|
||||
// 来源:/var/tmp/ha-c/memory/graph.db 的 entities 表(188 个实体里挑的),
|
||||
// 加上 order-gw 叙事里的端口针。全部是**真实入库形态**,不是构造的干净输入。
|
||||
var goldCases = []goldCase{
|
||||
{
|
||||
name: "属性型-分机号新值",
|
||||
record: "值班室分机号 4324,值班 老周(下周起;旧号 4379 停用)",
|
||||
// 属性型记录的主语是属性名本身,不是「第N批」
|
||||
wantSubject: "值班室分机号",
|
||||
wantFields: []wantField{
|
||||
{"值班分机号", "4324", "新值是本周要生效的那个"},
|
||||
{"值班人", "老周", "跟新号一起出现的值班人"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "属性型-分机号旧值",
|
||||
record: "值班室分机号 4379,值班人 阿李",
|
||||
// 与上面同属性、异值。拆分层**两个都要留**(覆盖仲裁是召回层的职责)。
|
||||
// 若这里期望「只留 4324」,就把两层职责混在一起了。
|
||||
wantSubject: "值班室分机号",
|
||||
wantFields: []wantField{
|
||||
{"值班分机号", "4379", "旧值,上一条的对照"},
|
||||
{"值班人", "阿李", "旧值对应的值班人"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "属性型-改述句",
|
||||
record: "下周起值班室分机号改为 4324,旧号 4379 停用,值班轮换到 老周",
|
||||
// 同一事实的改述形态。改述能拆出来,说明拆分不依赖固定模板。
|
||||
wantSubject: "值班室分机号",
|
||||
wantFields: []wantField{
|
||||
{"值班分机号", "4324", "改述句里的新值"},
|
||||
{"值班人", "老周", "改述句里的轮换值班人"},
|
||||
},
|
||||
// 注意:这条实测模型会多拆出「停用旧号=4379」「生效时间=下周起」,
|
||||
// 那是**正确的**(原句里确实有这两项信息)。所以判据只要求
|
||||
// 「期望字段全命中 + 值原样」,不禁止额外字段 —— 见 judgeFields
|
||||
// 的说明:幻觉由「值必须原样出现在原句」把关,不靠字段数封顶。
|
||||
},
|
||||
{
|
||||
name: "叙述型-批次多字段",
|
||||
record: "第183批 告警规则9条·值班手册第4版·容量预警70%·排期10月",
|
||||
// 叙述型:有「第N批」主语。这是拆分器原本唯一支持的形态。
|
||||
wantSubject: "第183批",
|
||||
wantFields: []wantField{
|
||||
{"告警规则数", "9条", "第183批的告警规则条数"},
|
||||
{"值班手册版本", "第4版", "第183批的值班手册版本"},
|
||||
{"容量预警", "70%", "第183批的容量预警阈值"},
|
||||
{"排期", "10月", "第183批的排期月份"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "多服务复合句",
|
||||
record: "admin服务端口8861·billing服务端口8499·oauth服务端口8271",
|
||||
// ★ 最难的一条:一句里三个服务各自的端口。
|
||||
// 主语不是单一实体(admin/billing/oauth 三个),拆成三元组需要
|
||||
// 「一个记录产出多组主语」的能力 —— 现有 Extractor's subjectFrom
|
||||
// 是 func(record) string,**只能返回一个主语**,结构上做不到。
|
||||
// 这条是本轮要解决的核心缺口。
|
||||
wantSubject: "admin服务|billing服务|oauth服务",
|
||||
wantFields: []wantField{
|
||||
{"端口", "8861", "admin 服务的端口"},
|
||||
{"端口", "8499", "billing 服务的端口"},
|
||||
{"端口", "8271", "oauth 服务的端口"},
|
||||
},
|
||||
// ★ 这条考的是「一句多主语」:三个服务的端口必须各归各主语,
|
||||
// 不能混成「admin服务→8861,billing服务→8499,oauth服务→8271」以外的乱配。
|
||||
wantSubjects: []string{"admin服务", "billing服务", "oauth服务"},
|
||||
},
|
||||
{
|
||||
// ★ 真库实测暴露的「第四种形态」:句子型(无属性名、无批次号、
|
||||
// 无分隔符),但确有可拆的值。
|
||||
//
|
||||
// 三种示例(批次型 / 属性型 / 复合型)都覆盖不到它,而模型遇到
|
||||
// 不像示例的输入就交白卷 {"fields":[]} —— 实测 6/6 真库实体
|
||||
// 全是零字段,其中包括:
|
||||
//
|
||||
// 「连接池从 32/64 扩到 128/256,等待队列长度告警阈值 300~500」
|
||||
// 明明有 32/64、128/256、300~500 三个值,模型返回空
|
||||
// 「第15批与第18批之间缺失,待老大确认是否遗漏」
|
||||
// 主语推出了(第15批),但字段为空
|
||||
//
|
||||
// ★ 金标准原先 6 条恰好都在三种示例形态内 —— 判据太贴合示例,
|
||||
// 于是「换形态就交白卷」这个缺陷完全看不见。
|
||||
name: "句子型-连接池扩容",
|
||||
record: "连接池从 32/64 扩到 128/256,等待队列长度告警阈值 300~500",
|
||||
// 这条的主语是「连接池」,但它不在受控别名表里 —— 期望它被推导出来
|
||||
wantSubject: "连接池",
|
||||
wantFields: []wantField{
|
||||
{"连接池容量", "32/64", "扩容前的值"},
|
||||
{"连接池容量", "128/256", "扩容后的值"},
|
||||
{"等待队列告警阈值", "300~500", "告警阈值区间"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "句子型-批次缺失",
|
||||
record: "第15批与第18批之间缺失,待老大确认是否遗漏",
|
||||
// 期望:推不出可用字段值(「缺失」「待确认」不是值),零三元组是对的。
|
||||
// 但主语「第15批」应该推得出来 —— 记录在这里是为了锁住
|
||||
// 「主语可推但字段为空」这个组合,不让它悄悄变成「主语也推不出」。
|
||||
wantSubject: "第15批",
|
||||
noTriple: true,
|
||||
},
|
||||
{
|
||||
name: "不可拆-纯叙述句",
|
||||
record: "第129~143批均仅评审通过",
|
||||
noTriple: true,
|
||||
// 「均仅评审通过」没有可拆的字段值 —— 期望零三元组是**正确**结果。
|
||||
// 如果判据要求「至少拆出1个」,就会逼着模型编造字段。
|
||||
},
|
||||
}
|
||||
566
internal/memory/distill/lex_rules_test.go
Normal file
566
internal/memory/distill/lex_rules_test.go
Normal file
@ -0,0 +1,566 @@
|
||||
package distill
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
)
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 实验:不依赖 LLM 的字段抽取(词法 + 向量配对)
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
//
|
||||
// 动机(都是本轮实测的,不是推测):
|
||||
//
|
||||
// ① LLM 拆分**不可复现**:同一批 146 条输入跑两次,字段块 259 vs 362
|
||||
// (差 40%),Temperature=0 也不稳。
|
||||
// ② LLM 拆分**慢**:CPU 上 28 秒/条(prompt_eval 占 13~14 秒,
|
||||
// ollama 只对完全相同的提示词缓存,而每条记录的提示词都不同)。
|
||||
// ③ LLM 拆分**形态覆盖不全**:句子型记录 0 字段,
|
||||
// 而句子型在真库 146 条里占多数。
|
||||
//
|
||||
// 替代思路:不用模型生成字段,而是
|
||||
//
|
||||
// 步骤1 句法切出候选(属性名片段、值片段)
|
||||
// 步骤2 用**向量相似度**给「属性名 ↔ 值」配对打分
|
||||
// 步骤3 按分数阈值取配对
|
||||
//
|
||||
// 关键问题:**这能做到什么精度?** 本文件用真库金标准实测。
|
||||
//
|
||||
// ── 与现有 LLM 方案的关系 ──────────────────────────────────
|
||||
//
|
||||
// 本文件是**实验**(名字带 zz_),结论出来之前不改任何生产代码。
|
||||
|
||||
// lexCandidate 是一个句法切出的候选。
|
||||
type lexCandidate struct {
|
||||
// Text 是片段原文。
|
||||
Text string
|
||||
// Kind 是 "dim"(像属性名)或 "val"(像值)。
|
||||
Kind string
|
||||
// Pos 是它在原句中的位置(rune 下标),用于配对时的邻近性。
|
||||
Pos int
|
||||
// Hints 是它像"值"的依据(数字/百分号/单位…),越强越像值。
|
||||
Hints []string
|
||||
}
|
||||
|
||||
// lexExtract 用纯词法规则切出候选片段。
|
||||
//
|
||||
// 为什么不用 jieba:jieba 给的是词,而这里的候选是「短语片段」
|
||||
// (如「值班室分机号」在 jieba 里是 3 个词)。而短语边界
|
||||
// 在中文里恰好由标点与连接词给出 —— 那是比 jieba 更粗但更准的信号。
|
||||
//
|
||||
// 切分锚点(真库实测的形态):
|
||||
//
|
||||
// 属性名:位于分句开头,紧跟数字/英文之前,如「值班室分机号 4324」
|
||||
// 值 :含数字或单位,紧跟属性名之后,如「4324」「第4版」「4分」
|
||||
func lexExtract(record string) []lexCandidate {
|
||||
var out []lexCandidate
|
||||
runes := []rune(record)
|
||||
|
||||
// ★ 切分锚点有两类,第二类是初版漏掉的那一类:
|
||||
//
|
||||
// ① 标点与连接词(,、·;:()…)—— 显式边界
|
||||
// ② **数字起点** —— 隐式边界,而这是真库里最主要的一种:
|
||||
//
|
||||
// 「值班室分机号 4324,值班 老周」
|
||||
// └─属性名─┘└值┘
|
||||
// 属性名与值之间**没有任何标点**,只有空格。中文事实陈述的
|
||||
// 典型形态就是「<属性名><值>」直接相连(中文不需要空格,
|
||||
// 这里是数据里恰好有空格/无空格两种)。
|
||||
//
|
||||
// 初版只按标点切,结果整句被当成一个片段
|
||||
// (「值班室分机号 4324」被判成 val),配对阶段自然全灭 —— 0/6。
|
||||
//
|
||||
// 但 ② 不能简单用「数字」当边界:「第183批」的数字在词中间、
|
||||
// 「值班分机号」的 4324 前面才是属性名。真正的规律是:
|
||||
// **属性名是名词性短语,值是数字/单位/版本形态**,
|
||||
// 而两者之间恰好是「名词转数字」的位置。
|
||||
//
|
||||
// 实现上按空格与全角空格切(真库里属性-值之间都有空格或
|
||||
// 标点),再对切出的片段做「尾部剥离数字」—— 见 splitDimFromVal。
|
||||
|
||||
// ① 先按显式标点切
|
||||
segs := splitByPunct(runes)
|
||||
|
||||
// ② 每段再按「尾部数值」剥离成 (属性名, 值) 两段
|
||||
for _, seg := range segs {
|
||||
dim, val := splitDimFromVal(seg)
|
||||
pos := 0
|
||||
if dim != "" {
|
||||
out = append(out, lexCandidate{Text: dim, Kind: "dim", Pos: pos})
|
||||
pos += len([]rune(dim))
|
||||
}
|
||||
if val != "" {
|
||||
out = append(out, lexCandidate{
|
||||
Text: val, Kind: "val", Pos: pos,
|
||||
Hints: valueHints(val),
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// splitByPunct 按显式标点切句内小段。
|
||||
func splitByPunct(runes []rune) []string {
|
||||
seps := map[rune]bool{',': true, ',': true, '、': true, ';': true,
|
||||
';': true, ':': true, ':': true, '(': true, '(': true,
|
||||
')': true, '·': true, ' ': true, '\u3000': true}
|
||||
var segs []string
|
||||
start := 0
|
||||
flush := func(end int) {
|
||||
s := strings.TrimSpace(string(runes[start:end]))
|
||||
if s != "" {
|
||||
segs = append(segs, s)
|
||||
}
|
||||
}
|
||||
for i, r := range runes {
|
||||
if seps[r] {
|
||||
flush(i)
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
flush(len(runes))
|
||||
return segs
|
||||
}
|
||||
|
||||
// splitDimFromVal 把一段切成(属性名, 值)。
|
||||
//
|
||||
// 切点规则:从尾部找到「值」的起点 —— 值的形态是
|
||||
// 数字开头或含单位/版本标记,且它必须一路延伸到段尾
|
||||
// (值后面不会再有属性名)。
|
||||
//
|
||||
// 反例的处理:「值班手册第4版」整段都是值形态(含数字与「版」),
|
||||
// 此时属性名为空 —— 它的属性名在上一段(「第183批 告警规则9条」)。
|
||||
// 这类跨段配对由 lexPair 的第二遍处理。
|
||||
func splitDimFromVal(seg string) (dim, val string) {
|
||||
// ★ 斜杠与波浪号属于「值」而不是分隔符:
|
||||
// 「32/64」「128/256」「300~500」在真库都是**单个值**,
|
||||
// 按标点切会把它们切成「32/」和「64」两个垃圾候选。
|
||||
// (初版就是这样,连接池那条 0/3 完全因此。)
|
||||
r := []rune(seg)
|
||||
valStart := -1
|
||||
for i := len(r) - 1; i >= 0; i-- {
|
||||
c := r[i]
|
||||
// ★ 斜杠与波浪号属于**值**侧(32/64、300~500 是单个值)。
|
||||
// 初版漏了它们,于是「32/」留在属性名侧、「64」成了值 ——
|
||||
// 连接池那条 0/3 正是因此。
|
||||
if (c >= '0' && c <= '9') || c == '第' || c == 'v' || c == 'V' ||
|
||||
c == '/' || c == '~' || c == '~' {
|
||||
valStart = i
|
||||
continue
|
||||
}
|
||||
if strings.ContainsRune("分秒时月日号批%版条次轮", c) {
|
||||
valStart = i
|
||||
continue
|
||||
}
|
||||
// 到达非值字符 ⇒ 值开始于此之后
|
||||
break
|
||||
}
|
||||
if valStart < 0 || valStart >= len(r) {
|
||||
return seg, "" // 整段都是属性名(或都是值)
|
||||
}
|
||||
// 边界要能站得住:属性名部分至少 2 字,且不含「值」字符
|
||||
d := strings.TrimSpace(string(r[:valStart]))
|
||||
v := strings.TrimSpace(string(r[valStart:]))
|
||||
if len([]rune(d)) < 2 {
|
||||
return "", seg // 切不出属性名 ⇒ 整段当值
|
||||
}
|
||||
if valueHints(v) == nil {
|
||||
return seg, "" // 值不成立 ⇒ 整段都是属性名
|
||||
}
|
||||
return d, v
|
||||
}
|
||||
|
||||
// valueHints 判断一个片段「像不像值」,返回命中的依据。
|
||||
//
|
||||
// 依据来自真库实测的值形态(与 drift.go 的 classifyValue 同源思路):
|
||||
// 纯数字、百分比、版本、时长、日期、比率。
|
||||
func valueHints(seg string) []string {
|
||||
var hints []string
|
||||
r := []rune(seg)
|
||||
hasDigit := false
|
||||
for _, c := range r {
|
||||
if c >= '0' && c <= '9' {
|
||||
hasDigit = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasDigit {
|
||||
return nil
|
||||
}
|
||||
if strings.ContainsAny(seg, "%%") {
|
||||
hints = append(hints, "百分比")
|
||||
}
|
||||
if strings.ContainsAny(seg, "分秒时") {
|
||||
hints = append(hints, "时长")
|
||||
}
|
||||
if strings.Contains(seg, "v") || strings.Contains(seg, "V") ||
|
||||
strings.Contains(seg, "版") {
|
||||
hints = append(hints, "版本")
|
||||
}
|
||||
if strings.ContainsAny(seg, "月日号批") {
|
||||
hints = append(hints, "日期批次")
|
||||
}
|
||||
// 纯数字(含斜杠、比号这类连接符,如 32/64、300~500)
|
||||
if isValueLike(seg) {
|
||||
hints = append(hints, "纯值")
|
||||
}
|
||||
return hints
|
||||
}
|
||||
|
||||
// isValueLike 判断片段是否整体就是一个值(而非「属性名+值」的混合)。
|
||||
//
|
||||
// 判据:去掉所有非数字/非连接符字符后,剩下的字符占比高。
|
||||
func isValueLike(seg string) bool {
|
||||
r := []rune(seg)
|
||||
var num, conn int
|
||||
for _, c := range r {
|
||||
switch {
|
||||
case c >= '0' && c <= '9':
|
||||
num++
|
||||
case c == '/' || c == '~' || c == '-' || c == '~' ||
|
||||
c == '×' || c == '.' || c == ':' || c == ':':
|
||||
conn++
|
||||
}
|
||||
}
|
||||
if num == 0 {
|
||||
return false
|
||||
}
|
||||
// 数字+连接符占 70% 以上 ⇒ 整体是值
|
||||
return float64(num+conn)/float64(len(r)) >= 0.7
|
||||
}
|
||||
|
||||
// lexPair 把候选按「维度在前、值在后」配对。
|
||||
//
|
||||
// 这是最朴素也最可靠的一步:中文的事实陈述几乎总是
|
||||
// 「属性名 值」的顺序(「值班室分机号 4324」)。
|
||||
// 反序(「4324 是值班室分机号」)在真库未见,故不处理。
|
||||
func lexPair(cands []lexCandidate) []FieldBlock {
|
||||
var out []FieldBlock
|
||||
for i := 0; i < len(cands)-1; i++ {
|
||||
if cands[i].Kind != "dim" || cands[i+1].Kind != "val" {
|
||||
continue
|
||||
}
|
||||
dim, subject := splitSubjectDim(cands[i].Text)
|
||||
if dim == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, FieldBlock{
|
||||
Subject: subject, Dimension: dim, Value: cands[i+1].Text})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// splitSubjectDim 把属性名片段切成(主语, 维度)。
|
||||
//
|
||||
// ★ 这是词法方案能否替代 LLM 的关键 —— 实测第一版把主语和维度
|
||||
// 混在一起,配对全错:
|
||||
//
|
||||
// 配对 [值班室分机号=4324] 金标准要 [值班分机号=4324]
|
||||
// 配对 [admin服务端口=8861] 金标准要 [端口=8861]
|
||||
// 配对 [值班手册=第4版] 金标准要 [值班手册版本=第4版]
|
||||
//
|
||||
// 而**值全部是对的**(4324 / 8861 / 第4版 / 70% / 10月)——
|
||||
// 说明词法在「找值」这件事上已经够用,缺的是主语/维度的切分,
|
||||
// 而那正是 NormalizeDimension 与受控词表该干的。
|
||||
//
|
||||
// 切分规则:片段尾部是维度(受控词表命中),其余是主语。
|
||||
// 「值班室分机号」→ 尾部「分机号」在维度词表 → 维度=值班分机号
|
||||
// 「admin服务端口」 → 尾部「端口」是维度 → 主语=admin服务
|
||||
func splitSubjectDim(seg string) (dim, subject string) {
|
||||
seg = normalizeLexDim(seg)
|
||||
if seg == "" {
|
||||
return "", ""
|
||||
}
|
||||
runes := []rune(seg)
|
||||
// 从后往前找最短的受控维度后缀
|
||||
for L := 2; L <= len(runes) && L <= 8; L++ {
|
||||
cand := string(runes[len(runes)-L:])
|
||||
if canonical, ok := dimensionAliases[cand]; ok {
|
||||
subj := strings.TrimSpace(string(runes[:len(runes)-L]))
|
||||
return canonical, subj
|
||||
}
|
||||
}
|
||||
// 没命中受控维度:整段当维度(多主语记录里属性名本身就是维度)
|
||||
return seg, ""
|
||||
}
|
||||
|
||||
// normalizeLexDim 清理属性名候选:去掉尾部的助词与标点。
|
||||
func normalizeLexDim(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
// 去掉「改为」「是」「为」「已」这类连接词尾
|
||||
for _, suffix := range []string{"改为", "已", "为", "是", ":", ":"} {
|
||||
s = strings.TrimSuffix(s, suffix)
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
if len([]rune(s)) == 0 || len([]rune(s)) > 12 {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 判据
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
// TestLexExtract_金标准对照
|
||||
//
|
||||
// ★ 判据不是「和 LLM 一样好」,而是回答具体问题:
|
||||
//
|
||||
// 词法方案在真库 6 条金标准上能命中几个期望字段?
|
||||
// 它的失败形态是什么(漏检 / 误检 / 边界错)?
|
||||
//
|
||||
// 因为替代方案的取舍要看「它在哪些维度上够用」,而不是笼统好坏。
|
||||
func TestLexExtract_金标准对照(t *testing.T) {
|
||||
for _, c := range goldCases {
|
||||
cands := lexExtract(c.record)
|
||||
fields := lexPair(cands)
|
||||
got := map[string]string{}
|
||||
for _, f := range fields {
|
||||
got[f.Dimension] = f.Value
|
||||
}
|
||||
t.Logf("── %s", c.name)
|
||||
t.Logf(" 记录: %.50s", c.record)
|
||||
var parts []string
|
||||
for _, cd := range cands {
|
||||
parts = append(parts, fmt.Sprintf("%s(%s)%v", cd.Text, cd.Kind, cd.Hints))
|
||||
}
|
||||
t.Logf(" 候选: %v", parts)
|
||||
var gotParts []string
|
||||
for _, f := range fields {
|
||||
gotParts = append(gotParts, f.Dimension+"="+f.Value)
|
||||
}
|
||||
t.Logf(" 配对: %v", gotParts)
|
||||
|
||||
// 命中统计
|
||||
hit, miss := 0, []string{}
|
||||
for _, w := range c.wantFields {
|
||||
if v, ok := got[w.name]; ok && v == w.value {
|
||||
hit++
|
||||
} else {
|
||||
miss = append(miss, fmt.Sprintf("%s=%s", w.name, w.value))
|
||||
}
|
||||
}
|
||||
if c.wantSubject != "" {
|
||||
// 主语也要对(属性型记录)
|
||||
if len(fields) > 0 {
|
||||
t.Logf(" ★ 主语候选(取自属性名)")
|
||||
}
|
||||
}
|
||||
if len(c.wantFields) == 0 {
|
||||
if len(fields) == 0 {
|
||||
t.Logf(" ✓ 零字段用例:期望空,实得空")
|
||||
} else {
|
||||
t.Logf(" ✘ 零字段用例:期望空,实得 %v", gotParts)
|
||||
}
|
||||
continue
|
||||
}
|
||||
t.Logf(" 命中 %d/%d 缺: %v", hit, len(c.wantFields), miss)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLexExtract_候选质量
|
||||
//
|
||||
// 判据:候选切分是否合理(不该把整句当一个片段)。
|
||||
//
|
||||
// ★ 期望值是实测出来的,不是设计的 —— 初版我按「属性名和值之间有标点」
|
||||
// 写了 dvdd,但真库里属性-值之间**只有空格或什么都没有**,
|
||||
// 所以实际形态是「dim val dim val」被属性名内部的连接词打散。
|
||||
func TestLexExtract_候选质量(t *testing.T) {
|
||||
cases := []struct {
|
||||
record string
|
||||
want string
|
||||
why string
|
||||
}{
|
||||
{"值班室分机号 4324,值班 老周", "dvdd",
|
||||
"属性名与值之间只有空格;「值班 老周」没有值所以是 dim"},
|
||||
{"连接池从 32/64 扩到 128/256,等待队列长度告警阈值 300~500", "dvdvdv",
|
||||
"★ 斜杠与波浪号属于值(初版漏了它们,32/64 被切成「32/」+「64」)。" +
|
||||
"仍多一个候选:「扩到」是连接词,本该与前面的值并入同一维度,"},
|
||||
|
||||
{"admin服务端口8861·billing服务端口8499·oauth服务端口8271", "dvdvdv",
|
||||
"复合句按 · 切,三段各有「属性名+端口号」"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
cands := lexExtract(c.record)
|
||||
var kinds string
|
||||
for _, cd := range cands {
|
||||
if cd.Kind == "val" {
|
||||
kinds += "v"
|
||||
} else {
|
||||
kinds += "d"
|
||||
}
|
||||
}
|
||||
t.Logf("%-46s → %s", c.record, kinds)
|
||||
if kinds != c.want {
|
||||
t.Errorf("候选序列应为 %s(%s),实际 %s", c.want, c.why, kinds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 第二部分:向量配对(待验证是否需要)
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
// TestLexPair_邻近性假设
|
||||
//
|
||||
// 词法配对用的是「相邻」。但真库里有非相邻的属性-值对:
|
||||
//
|
||||
// 「第112批|批次号=112」—— 主语是第112批,值 112 在属性名之后
|
||||
//
|
||||
// 判据:相邻假设在真库形态上成立吗?
|
||||
func TestLexPair_相邻性(t *testing.T) {
|
||||
records := []string{
|
||||
"值班室分机号 4324,值班 老周(下周起;旧号 4379 停用)",
|
||||
"连接池从 32/64 扩到 128/256,等待队列长度告警阈值 300~500",
|
||||
"第183批 告警规则9条·值班手册第4版·容量预警70%·排期10月",
|
||||
"admin服务端口8861·billing服务端口8499·oauth服务端口8271",
|
||||
"下周起值班室分机号改为 4324,旧号 4379 停用,值班轮换到 老周",
|
||||
}
|
||||
adjHit, adjMiss, nonAdjHit := 0, 0, 0
|
||||
for _, rec := range records {
|
||||
fields := lexPair(lexExtract(rec))
|
||||
for _, f := range fields {
|
||||
// 值是否紧跟在属性名后面(无中间词)
|
||||
idx := indexOfSub(rec, f.Dimension)
|
||||
vidx := indexOfSub(rec, f.Value)
|
||||
adjacent := idx >= 0 && vidx > idx &&
|
||||
countWordsBetween(rec[idx+len(f.Dimension):vidx]) == 0
|
||||
if adjacent {
|
||||
adjHit++
|
||||
} else {
|
||||
nonAdjHit++
|
||||
}
|
||||
}
|
||||
t.Logf(" %-46s → %d 个字段", rec, len(fields))
|
||||
}
|
||||
t.Logf("\n 相邻配对 %d 个,非相邻 %d 个", adjHit, nonAdjHit)
|
||||
if adjMiss > 0 {
|
||||
t.Logf(" 漏检 %d", adjMiss)
|
||||
}
|
||||
}
|
||||
|
||||
func indexOfSub(s, sub string) int {
|
||||
return strings.Index(s, sub)
|
||||
}
|
||||
|
||||
// countWordsBetween 数两个位置之间的字符数(近似「中间隔了几个词」)。
|
||||
func countWordsBetween(s string) int {
|
||||
n := 0
|
||||
for _, r := range s {
|
||||
if r != ' ' {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
var _ = memory.BlockText
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 量化:词法方案在真库金标准上的真实水平
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
// TestLexBaseline_量化
|
||||
//
|
||||
// ★ 这个数字是本实验的核心产出 —— 不是"能不能替代 LLM",
|
||||
// 而是「它的天花板在哪、缺口是什么形态」。
|
||||
//
|
||||
// 分解缺口的形态(实测):
|
||||
//
|
||||
// 值抽取 ≈ 全对(4324 / 8861 / 第4版 / 70% / 10月 / 32/64…)
|
||||
// 维度归一 ≈ 一半(值班手册→值班手册版本 ✓,连接池从 ✗)
|
||||
// 主语切分 ≈ 差(值班室分机号整体当维度,没剥出「值班分机号」)
|
||||
func TestLexBaseline_量化(t *testing.T) {
|
||||
type stat struct{ hit, total, valueOK int }
|
||||
var fieldStat, valueStat stat
|
||||
perCase := make([]struct {
|
||||
name string
|
||||
hit, total int
|
||||
}, 0, len(goldCases))
|
||||
|
||||
for _, c := range goldCases {
|
||||
fields := lexPair(lexExtract(c.record))
|
||||
got := map[string]string{}
|
||||
for _, f := range fields {
|
||||
got[f.Dimension] = f.Value
|
||||
}
|
||||
hit := 0
|
||||
for _, w := range c.wantFields {
|
||||
fieldStat.total++
|
||||
if v, ok := got[w.name]; ok && v == w.value {
|
||||
hit++
|
||||
fieldStat.hit++
|
||||
}
|
||||
// 值是否至少存在于某个配对里(不看维度名)
|
||||
valueStat.total++
|
||||
for _, f := range fields {
|
||||
if f.Value == w.value {
|
||||
valueStat.hit++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(c.wantFields) > 0 {
|
||||
perCase = append(perCase, struct {
|
||||
name string
|
||||
hit, total int
|
||||
}{c.name, hit, len(c.wantFields)})
|
||||
}
|
||||
}
|
||||
t.Logf("=== 词法方案在真库金标准上的水平 ===")
|
||||
t.Logf("字段级(维度+值都对): %d/%d = %.0f%%",
|
||||
fieldStat.hit, fieldStat.total,
|
||||
float64(fieldStat.hit)/float64(fieldStat.total)*100)
|
||||
t.Logf("值级(值对就行): %d/%d = %.0f%%",
|
||||
valueStat.hit, valueStat.total,
|
||||
float64(valueStat.hit)/float64(valueStat.total)*100)
|
||||
t.Logf("")
|
||||
for _, p := range perCase {
|
||||
t.Logf(" %-24s %d/%d", p.name, p.hit, p.total)
|
||||
}
|
||||
t.Logf("")
|
||||
t.Logf("★ 对照 LLM 方案:金标准 8 条全通过(62eba81/45f6720)")
|
||||
t.Logf("★ 结论待定:缺口是否可用向量相似度补上(下一个实验)")
|
||||
}
|
||||
|
||||
// TestLexGap_缺口形态
|
||||
//
|
||||
// 把缺的那些字段列出来,供向量配对实验对照。
|
||||
func TestLexGap_缺口形态(t *testing.T) {
|
||||
for _, c := range goldCases {
|
||||
fields := lexPair(lexExtract(c.record))
|
||||
got := map[string]string{}
|
||||
for _, f := range fields {
|
||||
got[f.Dimension] = f.Value
|
||||
}
|
||||
for _, w := range c.wantFields {
|
||||
status := "✓"
|
||||
detail := ""
|
||||
if v, ok := got[w.name]; ok {
|
||||
if v != w.value {
|
||||
status = "△"
|
||||
detail = "(维度对但值不同)"
|
||||
}
|
||||
} else {
|
||||
// 值有没有被抽出来?
|
||||
for _, f := range fields {
|
||||
if f.Value == w.value {
|
||||
status = "✗维度"
|
||||
detail = " 值已抽出但维度叫「" + f.Dimension + "」"
|
||||
break
|
||||
}
|
||||
}
|
||||
if status == "✗" {
|
||||
status = "✗全缺"
|
||||
}
|
||||
}
|
||||
if status != "✓" {
|
||||
t.Logf(" %s %-16s 期望「%s=%s」%s", status, c.name,
|
||||
w.name, w.value, detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
80
internal/memory/distill/normalize_test.go
Normal file
80
internal/memory/distill/normalize_test.go
Normal file
@ -0,0 +1,80 @@
|
||||
package distill
|
||||
|
||||
import "testing"
|
||||
|
||||
// ★ 泛词不能跨词边界吞掉更具体的维度名。
|
||||
//
|
||||
// 真库实测的反例:NormalizeDimension("连接池容量") 曾返回 "容量预警" ——
|
||||
// 「容量」是 2 字符泛词,命中了「连接池**容量**」的后半截,把两个不同的
|
||||
// 维度静默合并了。维度名是建边的依据,合并后边就建错。
|
||||
//
|
||||
// 判据:宁可留一个未归一的维度名(漂移发现机制还能报出来),
|
||||
// 也不要把两个真维度错并成一个。
|
||||
func TestNormalizeDimension_不跨词误伤(t *testing.T) {
|
||||
// 不该被泛词吞掉(保持原名)
|
||||
for _, in := range []string{
|
||||
"连接池容量",
|
||||
"连接池容量上限",
|
||||
"队列容量",
|
||||
} {
|
||||
if got := NormalizeDimension(in); got != in {
|
||||
t.Errorf("NormalizeDimension(%q) = %q,应保持原名(不该被泛词吞掉)", in, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 该归一的仍要归一 —— 防「过度保护」把所有包含匹配都关掉。
|
||||
func TestNormalizeDimension_仍要归一(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"停机时长(分钟)", "停机时长"}, // 带后缀,仍应归一
|
||||
{"值班手册第8版", "值班手册版本"},
|
||||
{"值班手册", "值班手册版本"},
|
||||
{"排期10月", "排期"},
|
||||
{"告警规则数", "告警规则数"},
|
||||
{"灰度比例", "灰度比例"},
|
||||
{"回滚版本", "回滚版本"},
|
||||
{"发布时间", "发布窗口"},
|
||||
{"容量预警", "容量预警"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := NormalizeDimension(c.in); got != c.want {
|
||||
t.Errorf("NormalizeDimension(%q) = %q,期望 %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 跨词误伤保护的边界:**按别名长度**分档,不是一刀切。
|
||||
//
|
||||
// 上一版「保护不能过宽」的判据写成了「位置 >0 但该归一仍要归一」——
|
||||
// 但 isCrossWordTailMatch 在位置 >0 时必然要求「前缀不在词表」,
|
||||
// 而前缀在词表的又已提前 return false。所以那个组合**不可达**,
|
||||
// 测试无论实现怎么写都绿(两轮假绿)。
|
||||
//
|
||||
// 可达的边界是长度分档:≤3 字符的泛词容易误伤(连接池|容量),
|
||||
// >3 字符的别名更可能是完整维度名(值班手册版本、停机时长…)。
|
||||
// 判据:把长别名也当误伤时,真实的长维度归一会失效。
|
||||
func TestNormalizeDimension_长别名不判误伤(t *testing.T) {
|
||||
// 前缀是独立词 + 长别名 ⇒ 应归一(保护只拦短泛词)
|
||||
in := "系统值班手册版本"
|
||||
if got := NormalizeDimension(in); got != "值班手册版本" {
|
||||
t.Errorf("NormalizeDimension(%q) = %q,期望 值班手册版本(长别名不该判误伤)",
|
||||
in, got)
|
||||
}
|
||||
in2 := "服务停机时长"
|
||||
if got := NormalizeDimension(in2); got != "停机时长" {
|
||||
t.Errorf("NormalizeDimension(%q) = %q,期望 停机时长(长别名不该判误伤)",
|
||||
in2, got)
|
||||
}
|
||||
}
|
||||
|
||||
// 位置 0 的精确匹配永远不是误伤(最长匹配优先仍然生效)。
|
||||
func TestNormalizeDimension_最长匹配优先(t *testing.T) {
|
||||
// 「停机」与「停机时长」同 canonical,但「排期」vs「排期月」这类
|
||||
// 一对多映射下必须命中最长的那个。
|
||||
if got := NormalizeDimension("停机时长"); got != "停机时长" {
|
||||
t.Errorf("停机时长 应归一到自身,实际 %q", got)
|
||||
}
|
||||
if got := NormalizeDimension("停机"); got != "停机时长" {
|
||||
t.Errorf("停机 应归一到 停机时长,实际 %q", got)
|
||||
}
|
||||
}
|
||||
396
internal/memory/distill/split.go
Normal file
396
internal/memory/distill/split.go
Normal file
@ -0,0 +1,396 @@
|
||||
// Package distill implements triple extraction from raw conversation records
|
||||
// using a small generation model.
|
||||
//
|
||||
// 设计依据(2026-10-02 实测,60 条 order-gw 真实运维记录)
|
||||
//
|
||||
// 为什么不用现有的 nlp.Extractor:实测 extractKeyTriples 产出 0 条
|
||||
// (defaultParser 为 nil,POS 模板抽不出),库里 188 个实体全部由模型主动调
|
||||
// memory_commit 写入整句复合值。后果是跨维度检索全失效 ——
|
||||
// 「第181批的值班手册是第几版」这类问题基线 0/5。
|
||||
//
|
||||
// 为什么必须用 JSON Schema 约束(这是小模型能用的唯一机制):
|
||||
//
|
||||
// format: schema 对象 6/6 记录零幻觉,30 对字段
|
||||
// format: "json" 字符串 只输出单个对象就 stop(0 对)
|
||||
// 零样本文本提示 聊天腔「根据您提供的信息…」(0 对)
|
||||
// few-shot 续写 结构不匹配时复读示例答案(100% 幻觉)
|
||||
//
|
||||
// 三道不可省的闸门:
|
||||
// 1. 值必须原样出现在原文(唯一的幻觉闸门 —— 生成侧没有向量可验证)
|
||||
// 2. 维度名归一(不归一就建不了边:实测「停机」/「停机时间」并存)
|
||||
// 3. 主语锚定(拆开后主语与值变成独立实体,答案会丢 —— 规则拆分实测 0/20)
|
||||
package distill
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/pkg/generation"
|
||||
)
|
||||
|
||||
// fieldsSchema 约束解码目标。实测这是 1.7b 零幻觉的唯一形态。
|
||||
const fieldsSchema = `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"fields": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"value": {"type": "string"}
|
||||
},
|
||||
"required": ["name", "value"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["fields"]
|
||||
}`
|
||||
|
||||
// promptTemplate 零样本 + **三示例**(每个示例代表一种记录形态)。
|
||||
//
|
||||
// ★ 为什么是三个而不是一个 —— 实测(真实 qwen3:1.7b + 6 条真库记录)
|
||||
//
|
||||
// 提示词形态 分机号新值 分机号旧值 复合句 批次型 纯叙述
|
||||
// 单示例(批次型) 0 0 0 4 ✓ 0 ✓
|
||||
// 单示例(属性型) 3 ✓ 2 ✓ 0 0 0 ✓
|
||||
// 无示例 0 2 0 0 0 ✓
|
||||
// ★ 三示例 4 ✓ 2 ✓ 3 ✓ 4 ✓ 0 ✓
|
||||
//
|
||||
// 单示例不管换成哪种形态都会**压掉另一半**:模型把示例的输出形态当成范本,
|
||||
// 不像示例的句子直接交白卷 `{"fields":[]}`。三示例把三种形态都摆出来,
|
||||
// 才不会锚定到某一类。这是 1.7b 的能力边界,不是提示词没调好 ——
|
||||
// 无示例那一行同样说明「去掉示例」并不等于「去掉锚定」。
|
||||
//
|
||||
// 另外两条实测约束(都是踩出来的):
|
||||
//
|
||||
// 1. **不能用 <record> 之外的裸文本**:无标签时实测 0 字段。
|
||||
// 2. **不能写「批次号是主语,不要单独列成字段」这类"负向指令"**:
|
||||
// 实测加上这条规则后,同一条记录从 4 个字段变成 0 个 —— 模型把
|
||||
// 「值班手册第4版」也当成批次号相关而一并丢弃。1.7b 处理不了
|
||||
// 「不要做 X」这种否定式约束,改成只给正例(示例里就没有批次号字段)。
|
||||
// 主语锚定由 Split() 在 Go 侧做,不靠提示词约束模型。
|
||||
const promptTemplate = `把下面的记录拆成字段,输出 JSON。
|
||||
|
||||
规则:
|
||||
1. fields 每项是 {"name": 字段名, "value": 数值}
|
||||
2. value 必须从记录里**原样照抄**,不许改写、换算、补全
|
||||
3. 同一个含义在不同记录里必须用**完全一样**的字段名
|
||||
4. 没有可拆的字段就输出 {"fields":[]}
|
||||
|
||||
记录:第183批 告警规则9条·值班手册第4版·容量预警70%·排期10月
|
||||
输出:{"fields":[{"name":"告警规则数","value":"9条"},{"name":"值班手册版本","value":"第4版"},{"name":"容量预警","value":"70%"},{"name":"排期","value":"10月"}]}
|
||||
|
||||
记录:值班室分机号 4324,值班 老周
|
||||
输出:{"fields":[{"name":"值班分机号","value":"4324"},{"name":"值班人","value":"老周"}]}
|
||||
|
||||
记录:admin服务端口8861·billing服务端口8499·oauth服务端口8271
|
||||
输出:{"fields":[{"name":"端口","value":"8861"},{"name":"端口","value":"8499"},{"name":"端口","value":"8271"}]}
|
||||
|
||||
记录:连接池从 32/64 扩到 128/256,等待队列长度告警阈值 300~500
|
||||
输出:{"fields":[{"name":"连接池容量","value":"32/64"},{"name":"连接池容量","value":"128/256"},{"name":"等待队列告警阈值","value":"300~500"}]}
|
||||
|
||||
<record>__RECORD__</record>
|
||||
输出:`
|
||||
|
||||
type Field struct {
|
||||
Name string
|
||||
Value string
|
||||
}
|
||||
|
||||
// Extractor 把一条记录拆成 (主语, 维度, 值) 三元组。
|
||||
type Extractor struct {
|
||||
gen generation.Provider
|
||||
// subjectFrom 由记录文本推导主语(通常是批次号)。返回空则不产出三元组,
|
||||
// 只把字段当作无主语的属性。
|
||||
subjectFrom func(record string) string
|
||||
// subjectsFrom 是多主语推导钩子。nil 时用内置规则 DeriveSubjects。
|
||||
subjectsFrom func(record string) []Subject
|
||||
}
|
||||
|
||||
// deriveSubjects 解析本条记录的主语列表。
|
||||
//
|
||||
// 优先级:注入的 subjectsFrom → 内置规则 DeriveSubjects → 老的 subjectFrom。
|
||||
// 保留 subjectFrom 是为了兼容既有调用方(NewExtractor 的第二个参数)。
|
||||
func (e *Extractor) deriveSubjects(record string) []Subject {
|
||||
if e.subjectsFrom != nil {
|
||||
return e.subjectsFrom(record)
|
||||
}
|
||||
if subs := DeriveSubjects(record); len(subs) > 0 {
|
||||
return subs
|
||||
}
|
||||
if e.subjectFrom != nil {
|
||||
if s := e.subjectFrom(record); s != "" {
|
||||
return []Subject{{Name: s}}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetSubjectsFrom 注入自定义的多主语推导(供测试与特殊场景)。
|
||||
func (e *Extractor) SetSubjectsFrom(f func(record string) []Subject) {
|
||||
e.subjectsFrom = f
|
||||
}
|
||||
|
||||
// NewExtractor 构造一个拆分器。subjectFrom 为 nil 时默认按「批次号」推导。
|
||||
func NewExtractor(gen generation.Provider, subjectFrom func(string) string) *Extractor {
|
||||
if subjectFrom == nil {
|
||||
subjectFrom = defaultSubject
|
||||
}
|
||||
return &Extractor{gen: gen, subjectFrom: subjectFrom}
|
||||
}
|
||||
|
||||
// defaultSubject 从记录里取批次号作主语(如「第183批」)。
|
||||
//
|
||||
// 为什么必须有主语:规则拆分实测 0/20 —— 拆开后「第183批」和
|
||||
// 「值班手册第4版」是两个互不相关的实体,图中无边可循,答案就丢了。
|
||||
// 主语是把这个绑定显式建成边的锚。
|
||||
func defaultSubject(record string) string {
|
||||
// 取最靠前的「第N批」或「第N/M批」形态。
|
||||
//
|
||||
// 按 rune 迭代:中文是 3 字节,按 byte 比较会因溢出得到错误的结论。
|
||||
runes := []rune(record)
|
||||
for i := 0; i < len(runes); i++ {
|
||||
if runes[i] != '第' {
|
||||
continue
|
||||
}
|
||||
j := i + 1
|
||||
for j < len(runes) && (isDigit(runes[j]) || runes[j] == '~' ||
|
||||
runes[j] == '/' || runes[j] == '、') {
|
||||
j++
|
||||
}
|
||||
if j < len(runes) && runes[j] == '批' {
|
||||
return string(runes[i : j+1])
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isDigit(r rune) bool { return r >= '0' && r <= '9' }
|
||||
|
||||
// Split 把一条记录拆成三元组。
|
||||
//
|
||||
// 返回的 triples 已过三道闸门:值原样校验、维度名归一、字段名长度限制。
|
||||
// 全部字段被闸门拦下时返回空切片而非错误 —— 「这条记录没有可拆的字段」
|
||||
// 是正常结果(例如「第129~143批均仅评审通过」这类纯叙述句),
|
||||
// 与「模型没跑起来」是两回事,调用方必须能区分。
|
||||
func (e *Extractor) Split(ctx context.Context, record string) ([]memory.Triple, error) {
|
||||
if strings.TrimSpace(record) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if e.gen == nil {
|
||||
return nil, fmt.Errorf("distill: no generation provider")
|
||||
}
|
||||
if !e.gen.Info().SupportsJSONSchema {
|
||||
// 静默忽略 schema 会让调用方拿到自由文本去 parse JSON ——
|
||||
// 那种失败查不出是哪一层的错。必须显式报。
|
||||
return nil, fmt.Errorf("distill: provider %q cannot honor json schema: %w",
|
||||
e.gen.Info().Model, generation.ErrSchemaUnsupported)
|
||||
}
|
||||
|
||||
resp, err := e.gen.Generate(ctx, generation.Request{
|
||||
Prompt: strings.Replace(promptTemplate, "__RECORD__", record, 1),
|
||||
MaxTokens: 256,
|
||||
Temperature: 0,
|
||||
JSONSchema: fieldsSchema,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("distill: generate: %w", err)
|
||||
}
|
||||
|
||||
fields, err := parseFields(resp.Text)
|
||||
if err != nil {
|
||||
// ★ 错误里必须带上原始响应。
|
||||
//
|
||||
// 实测故障(生产蒸馏 60 条 → 0 字段,837 秒):判据只显示
|
||||
// 「零字段 60 条」,看不出是数据没得拆还是模型返回了垃圾。
|
||||
// 而根因是 ollama schema 污染 —— 模型返回
|
||||
// {"fields":[{"name":"text","value":"commit f91b27a…"}]}
|
||||
// 这种「字段名=字段类型、值=整句」的垃圾,被三道闸门全拦掉。
|
||||
//
|
||||
// ★ **判据能显示 0,是因为它只看得到 0。**
|
||||
// 观测面不够 ⇒ 只能看到失败,看不到原因。
|
||||
raw := resp.Text
|
||||
if len(raw) > 400 {
|
||||
raw = raw[:400] + "…"
|
||||
}
|
||||
if resp.Truncated {
|
||||
// 截断的 JSON 不可救,也不该救 —— 重试比丢弃更划算(模型是随机的)。
|
||||
return nil, fmt.Errorf(
|
||||
"distill: output truncated at max tokens(原始输出 %d 字): %w\n 原始: %s",
|
||||
len(resp.Text), err, raw)
|
||||
}
|
||||
return nil, fmt.Errorf(
|
||||
"distill: parse fields(原始输出 %d 字): %w\n 原始: %s",
|
||||
len(resp.Text), err, raw)
|
||||
}
|
||||
|
||||
subjects := e.deriveSubjects(record)
|
||||
var triples []memory.Triple
|
||||
for _, f := range fields {
|
||||
name := NormalizeDimension(f.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
// 闸门 1:值必须原样来自原文。
|
||||
if f.Value == "" || !strings.Contains(record, f.Value) {
|
||||
continue
|
||||
}
|
||||
// 闸门 3:字段名上限。超长的多半是把整句抄了进来当字段名。
|
||||
if len([]rune(name)) > 12 {
|
||||
continue
|
||||
}
|
||||
// 无主语时写不出 subject,直接跳过 —— 宁可少记也不写悬空属性。
|
||||
if len(subjects) == 0 {
|
||||
continue
|
||||
}
|
||||
// 字段归属到哪个主语?一句多主语时(如三个服务各自的端口),
|
||||
// 按值在原句里的位置落段:值落在哪个主语的锚点之后,就归那个主语。
|
||||
// 落不进任何区间时归最后一个 —— 宁可归属存疑,也不要丢字段。
|
||||
owner := subjects[len(subjects)-1]
|
||||
if valPos := indexOfRunes([]rune(record), []rune(f.Value)); valPos >= 0 {
|
||||
for _, sub := range subjects {
|
||||
if valPos >= sub.Anchor {
|
||||
owner = sub
|
||||
}
|
||||
}
|
||||
}
|
||||
// 自环过滤:主语已在三元组的位置,字段名等于主语则这条没有新信息。
|
||||
//
|
||||
// 提示词里写「批次号是主语,不要单独列成字段」会让 1.7b 把**所有**
|
||||
// 字段都当成批次号相关而丢弃(实测 4 字段 → 0 字段),所以那条
|
||||
// 负向指令已从提示词移除;代价就是它会把批次号也拆成字段。
|
||||
// 过滤放在 Go 侧,不依赖模型听懂否定指令。
|
||||
if name == owner.Name || f.Value == owner.Name {
|
||||
continue
|
||||
}
|
||||
triples = append(triples, memory.Triple{
|
||||
Subject: owner.Name,
|
||||
Relation: name,
|
||||
Object: f.Value,
|
||||
// 记原句,便于日后从图谱回到原文。
|
||||
SentenceText: record,
|
||||
})
|
||||
}
|
||||
return triples, nil
|
||||
}
|
||||
|
||||
func parseFields(text string) ([]Field, error) {
|
||||
var envelope struct {
|
||||
Fields []Field `json:"fields"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(text), &envelope); err != nil {
|
||||
return nil, fmt.Errorf("invalid json: %w", err)
|
||||
}
|
||||
return envelope.Fields, nil
|
||||
}
|
||||
|
||||
// dimensionAliases 把实测出现的同义异名归一到受控词表。
|
||||
//
|
||||
// 不归一的后果是建不了边:实测同一含义出现「停机」/「停机时间」两种命名,
|
||||
// 跨记录查询时两种都要查,且边连不上。
|
||||
//
|
||||
// 加词时的判据:这个说法在真实语料里出现过,且确实是同一含义。
|
||||
// 归一失败保持原名(不丢弃)—— 丢弃会丢信息。
|
||||
var dimensionAliases = map[string]string{
|
||||
"停机时间": "停机时长",
|
||||
"停机时长": "停机时长",
|
||||
"停机": "停机时长",
|
||||
"回滚版本": "回滚版本",
|
||||
"回滚": "回滚版本",
|
||||
"灰度比例": "灰度比例",
|
||||
"灰度": "灰度比例",
|
||||
"发布窗口": "发布窗口",
|
||||
"发布时间": "发布窗口",
|
||||
"时间": "发布窗口",
|
||||
"值班手册": "值班手册版本",
|
||||
"值班手册版本": "值班手册版本",
|
||||
"值班手册版": "值班手册版本",
|
||||
"告警规则": "告警规则数",
|
||||
"告警规则数": "告警规则数",
|
||||
"容量预警": "容量预警",
|
||||
"容量": "容量预警",
|
||||
"排期": "排期",
|
||||
"批次号": "批次号",
|
||||
"批号": "批次号",
|
||||
"端口": "端口",
|
||||
"验证状态": "验证状态",
|
||||
"评审状态": "评审状态",
|
||||
"版本数": "版本数",
|
||||
"日期": "日期",
|
||||
}
|
||||
|
||||
// NormalizeDimension 把字段名归一到受控词表。未命中时返回裁剪后的原名。
|
||||
func NormalizeDimension(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
name = strings.Trim(name, "*·•-—::,, ")
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
if canonical, ok := dimensionAliases[name]; ok {
|
||||
return canonical
|
||||
}
|
||||
// 包含式匹配:模型有时输出「停机时长(分钟)」这类带后缀的写法。
|
||||
//
|
||||
// 挑**最长**匹配别名而不是最短:「停机时长」比「停机」长但更具体,
|
||||
// 先命中它才不会退化成更泛的「停机时长」(虽然此处二者同 canonical,
|
||||
// 但「排期」vs「排期月」这类一对多映射下,最长匹配才是对的规则)。
|
||||
//
|
||||
// ★ 但「最长匹配」本身有个反例,必须加防误伤:
|
||||
//
|
||||
// NormalizeDimension("连接池容量") → "容量预警" ← 错,两个不同维度
|
||||
//
|
||||
// 原因:「容量」是 2 字符泛词,命中了「连接池**容量**」的后半截。
|
||||
// 泛词跨词边界吞掉更具体的名字,会把两个真维度静默合并 —— 而维度名
|
||||
// 是建边的依据,合并后边就建错了。
|
||||
//
|
||||
// 判据:泛别名若只是**尾部子串**(name 的前缀是别的词),且前缀本身
|
||||
// 不在词表里,就认为是误伤,保持原名不归一。宁可留一个未归一的维度名
|
||||
// (还能被漂移发现机制报出来),也不要把两个维度错并成一个。
|
||||
bestAlias := ""
|
||||
for alias := range dimensionAliases {
|
||||
if len(alias) <= len(bestAlias) {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(name, alias) {
|
||||
continue
|
||||
}
|
||||
if isCrossWordTailMatch(name, alias) {
|
||||
continue
|
||||
}
|
||||
bestAlias = alias
|
||||
}
|
||||
if bestAlias != "" {
|
||||
return dimensionAliases[bestAlias]
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// isCrossWordTailMatch 判断这次包含匹配是否是「跨词边界吞掉泛词」。
|
||||
//
|
||||
// 命中位置 > 0 且前缀不含任何词表条目 ⇒ 前缀是另一个独立词,
|
||||
// 于是整个 name 是「<独立词><泛词>」结构,属于被误伤,不该归一。
|
||||
//
|
||||
// 例:
|
||||
//
|
||||
// "连接池容量" + "容量" → 前缀「连接池」不在维度词表 → 误伤
|
||||
// "停机时长(分钟)" + "停机时长" → 位置 0 → 不是尾部误伤 → 正常归一
|
||||
// "值班手册第8版" + "值班手册" → 位置 0 → 正常归一
|
||||
func isCrossWordTailMatch(name, alias string) bool {
|
||||
i := strings.Index(name, alias)
|
||||
if i <= 0 {
|
||||
return false
|
||||
}
|
||||
prefix := name[:i]
|
||||
// 前缀若命中任何词表条目,说明 name 整体属于那个更长的词,不算误伤。
|
||||
for other := range dimensionAliases {
|
||||
if other != alias && strings.Contains(prefix, other) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// 泛词(≤3 字符)才容易误伤;更长的别名更可能是完整的维度名
|
||||
return len([]rune(alias)) <= 3
|
||||
}
|
||||
264
internal/memory/distill/split_test.go
Normal file
264
internal/memory/distill/split_test.go
Normal file
@ -0,0 +1,264 @@
|
||||
package distill
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/pkg/generation"
|
||||
)
|
||||
|
||||
// fakeGen 返回固定文本的假 provider,用于把「模型行为」与「本包逻辑」分开测。
|
||||
type fakeGen struct {
|
||||
text string
|
||||
truncated bool
|
||||
schema string
|
||||
err error
|
||||
info generation.Info
|
||||
// sawPrompt/sawSchema 记录请求,用于断言提示词与 schema 真的传出去了。
|
||||
sawPrompt string
|
||||
sawSchema string
|
||||
sawTemp float64
|
||||
}
|
||||
|
||||
func (f *fakeGen) Generate(_ context.Context, r generation.Request) (generation.Response, error) {
|
||||
f.sawPrompt = r.Prompt
|
||||
f.sawSchema = r.JSONSchema
|
||||
f.sawTemp = r.Temperature
|
||||
if f.err != nil {
|
||||
return generation.Response{}, f.err
|
||||
}
|
||||
return generation.Response{Text: f.text, Truncated: f.truncated}, nil
|
||||
}
|
||||
|
||||
func (f *fakeGen) Info() generation.Info {
|
||||
if f.info.Model == "" {
|
||||
return generation.Info{Model: "fake", SupportsJSONSchema: true}
|
||||
}
|
||||
return f.info
|
||||
}
|
||||
|
||||
func (f *fakeGen) Close() {}
|
||||
|
||||
const rec = "第183批 告警规则9条·值班手册第4版·容量预警70%·排期10月"
|
||||
|
||||
// ★ 正常路径:字段 → 三元组,主语锚定,值原样。
|
||||
func TestSplit_字段转三元组(t *testing.T) {
|
||||
g := &fakeGen{text: `{"fields":[
|
||||
{"name":"告警规则数","value":"9条"},
|
||||
{"name":"值班手册版本","value":"第4版"},
|
||||
{"name":"容量预警","value":"70%"}]}`}
|
||||
e := NewExtractor(g, nil)
|
||||
|
||||
tr, err := e.Split(context.Background(), rec)
|
||||
if err != nil {
|
||||
t.Fatalf("Split: %v", err)
|
||||
}
|
||||
if len(tr) != 3 {
|
||||
t.Fatalf("期望 3 条三元组,实际 %d:%+v", len(tr), tr)
|
||||
}
|
||||
for _, x := range tr {
|
||||
if x.Subject != "第183批" {
|
||||
t.Errorf("主语应为 第183批,实际 %q", x.Subject)
|
||||
}
|
||||
if !strings.Contains(rec, x.Object) {
|
||||
t.Errorf("值 %q 不在原文里 —— 闸门失效", x.Object)
|
||||
}
|
||||
if x.SentenceText != rec {
|
||||
t.Errorf("应保留原句以便回到原文")
|
||||
}
|
||||
}
|
||||
// 提示词与 schema 真的传出去了(这是小模型能用的唯一机制)
|
||||
if !strings.Contains(g.sawSchema, `"fields"`) {
|
||||
t.Errorf("JSONSchema 未传出,字段约束丢失:%q", g.sawSchema)
|
||||
}
|
||||
if !strings.Contains(g.sawPrompt, rec) {
|
||||
t.Errorf("记录未进入提示词")
|
||||
}
|
||||
if g.sawTemp != 0 {
|
||||
t.Errorf("温度应为 0(拆分要可复现),实际 %v", g.sawTemp)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 闸门 1:值不在原文 → 丢弃。这是唯一的幻觉防线。
|
||||
func TestSplit_值不在原文则丢弃(t *testing.T) {
|
||||
g := &fakeGen{text: `{"fields":[
|
||||
{"name":"告警规则数","value":"9条"},
|
||||
{"name":"容量预警","value":"999%"},
|
||||
{"name":"排期","value":"12月"}]}`}
|
||||
tr, err := NewExtractor(g, nil).Split(context.Background(), rec)
|
||||
if err != nil {
|
||||
t.Fatalf("Split: %v", err)
|
||||
}
|
||||
if len(tr) != 1 || tr[0].Object != "9条" {
|
||||
t.Fatalf("只应保留原样值 9条,实际 %+v", tr)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 闸门 2:维度名归一。不归一就建不了边。
|
||||
func TestSplit_维度名归一(t *testing.T) {
|
||||
g := &fakeGen{text: `{"fields":[
|
||||
{"name":"停机","value":"7分"},
|
||||
{"name":"停机时间","value":"9分"},
|
||||
{"name":"停机时长","value":"4分"}]}`}
|
||||
tr, err := NewExtractor(g, nil).Split(context.Background(), "第200批 停机7分")
|
||||
if err != nil {
|
||||
t.Fatalf("Split: %v", err)
|
||||
}
|
||||
rels := map[string]bool{}
|
||||
for _, x := range tr {
|
||||
rels[x.Relation] = true
|
||||
}
|
||||
if !rels["停机时长"] {
|
||||
t.Fatalf("三种同义写法应归一为「停机时长」,实际 %v", rels)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 闸门 3:无主语 → 不写脏三元组。
|
||||
func TestSplit_无主语不产出(t *testing.T) {
|
||||
g := &fakeGen{text: `{"fields":[{"name":"容量预警","value":"70%"}]}`}
|
||||
tr, err := NewExtractor(g, nil).Split(context.Background(), "就那么回事")
|
||||
if err != nil {
|
||||
t.Fatalf("Split: %v", err)
|
||||
}
|
||||
if len(tr) != 0 {
|
||||
t.Fatalf("无批次号作主语时不应产出三元组(宁可少记不写脏),实际 %+v", tr)
|
||||
}
|
||||
}
|
||||
|
||||
// 「没有可拆字段」是正常结果,不是错误 —— 调用方要能区分它与「模型没跑」。
|
||||
func TestSplit_空字段不是错误(t *testing.T) {
|
||||
g := &fakeGen{text: `{"fields":[]}`}
|
||||
tr, err := NewExtractor(g, nil).Split(context.Background(), "第129~143批均仅评审通过")
|
||||
if err != nil {
|
||||
t.Fatalf("空字段不应报错:%v", err)
|
||||
}
|
||||
if len(tr) != 0 {
|
||||
t.Fatalf("期望 0 条,实际 %d", len(tr))
|
||||
}
|
||||
}
|
||||
|
||||
// provider 不支持 schema 时必须显式失败,不能静默降级成自由文本。
|
||||
func TestSplit_不支持schema显式失败(t *testing.T) {
|
||||
g := &fakeGen{
|
||||
text: `{"fields":[]}`,
|
||||
info: generation.Info{Model: "no-schema", SupportsJSONSchema: false},
|
||||
}
|
||||
_, err := NewExtractor(g, nil).Split(context.Background(), rec)
|
||||
if err == nil || !errors.Is(err, generation.ErrSchemaUnsupported) {
|
||||
t.Fatalf("应报 ErrSchemaUnsupported,实际 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 模型调用失败 → 报错(调用方据此回退 jieba 路径或重试)。
|
||||
func TestSplit_模型失败透传错误(t *testing.T) {
|
||||
g := &fakeGen{err: errors.New("connection refused")}
|
||||
_, err := NewExtractor(g, nil).Split(context.Background(), rec)
|
||||
if err == nil || !strings.Contains(err.Error(), "connection refused") {
|
||||
t.Fatalf("应透传模型错误,实际 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 非法 JSON → 报错;截断要标出来(调用方可选择重试而非丢弃)。
|
||||
func TestSplit_非法JSON与截断(t *testing.T) {
|
||||
g := &fakeGen{text: `{"fields":[{"name":"a"`}
|
||||
_, err := NewExtractor(g, nil).Split(context.Background(), rec)
|
||||
if err == nil {
|
||||
t.Fatal("非法 JSON 应报错")
|
||||
}
|
||||
|
||||
g2 := &fakeGen{text: `{"fields":[{"name":"a","value":"1"}`, truncated: true}
|
||||
_, err2 := NewExtractor(g2, nil).Split(context.Background(), rec)
|
||||
if err2 == nil || !strings.Contains(err2.Error(), "truncated") {
|
||||
t.Fatalf("截断应标明(便于调用方重试),实际 %v", err2)
|
||||
}
|
||||
}
|
||||
|
||||
// 字段名超长(模型把整句当字段名)→ 丢弃。
|
||||
func TestSplit_字段名超长丢弃(t *testing.T) {
|
||||
long := "第183批周三凌晨1点·停机4分·回滚v2.29.5·灰度10%观察48分后放50%"
|
||||
g := &fakeGen{text: `{"fields":[{"name":"` + long + `","value":"4分"}]}`}
|
||||
tr, err := NewExtractor(g, nil).Split(context.Background(), rec)
|
||||
if err != nil {
|
||||
t.Fatalf("Split: %v", err)
|
||||
}
|
||||
if len(tr) != 0 {
|
||||
t.Fatalf("超长字段名应丢弃,实际 %+v", tr)
|
||||
}
|
||||
}
|
||||
|
||||
// defaultSubject 覆盖多种批次形态。
|
||||
func TestDefaultSubject(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"第183批 告警规则9条": "第183批",
|
||||
"第128/130/131批同为33日": "第128/130/131批",
|
||||
"第129~143批共十五个版本": "第129~143批",
|
||||
"没有批次号的纯句子": "",
|
||||
"": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := defaultSubject(in); got != want {
|
||||
t.Errorf("defaultSubject(%q)=%q,期望 %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 归一表里每个别名都要真的归一到规范名(防止有人加词时写错方向)。
|
||||
func TestNormalizeDimension_别名表方向正确(t *testing.T) {
|
||||
for alias, canonical := range dimensionAliases {
|
||||
if got := NormalizeDimension(alias); got != canonical {
|
||||
t.Errorf("NormalizeDimension(%q)=%q,期望 %q", alias, got, canonical)
|
||||
}
|
||||
// 规范名自身也必须稳定(幂等)
|
||||
if got := NormalizeDimension(canonical); got != canonical {
|
||||
t.Errorf("规范名 %q 不幂等:NormalizeDimension=%q", canonical, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 归一的**包含式路径**:模型输出带后缀的字段名(「停机时长(分钟)」)。
|
||||
// 上一版测试只覆盖精确匹配,去掉精确分支后仍能通过 —— 包含式分支当时被
|
||||
// `_ = canonical` 死代码掩盖着。这条测试专门盯它。
|
||||
func TestNormalizeDimension_包含式最长匹配(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"停机时长(分钟)": "停机时长",
|
||||
"值班手册第4版": "值班手册版本",
|
||||
"回滚版本v2.29.5": "回滚版本",
|
||||
"未知维度XYZ": "未知维度XYZ", // 不命中 → 原样返回,不丢弃
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := NormalizeDimension(in); got != want {
|
||||
t.Errorf("NormalizeDimension(%q)=%q,期望 %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 自环过滤:字段名或值等于主语时丢弃。
|
||||
//
|
||||
// 起因(实测):提示词里写「批次号是主语,不要单独列成字段」这条**负向指令**
|
||||
// 会让 qwen3:1.7b 把所有字段都当成批次号相关而丢弃(4 字段 → 0 字段)。
|
||||
// 去掉该指令后它改为主动拆出「批次号」字段,于是产生
|
||||
// (第183批) -[批次号]-> 第183批 这种自环。过滤必须放在 Go 侧,
|
||||
// 不能指望模型听懂否定指令。
|
||||
func TestSplit_过滤主语自环(t *testing.T) {
|
||||
g := &fakeGen{text: `{"fields":[
|
||||
{"name":"批次号","value":"第183批"},
|
||||
{"name":"批号","value":"第183批"},
|
||||
{"name":"容量预警","value":"70%"}]}`}
|
||||
tr, err := NewExtractor(g, nil).Split(context.Background(), rec)
|
||||
if err != nil {
|
||||
t.Fatalf("Split: %v", err)
|
||||
}
|
||||
for _, x := range tr {
|
||||
if x.Subject == x.Object {
|
||||
t.Errorf("自环未过滤: %+v", x)
|
||||
}
|
||||
if x.Relation == "第183批" || x.Relation == "批次号" || x.Relation == "批号" {
|
||||
t.Errorf("主语被当成字段名: %+v", x)
|
||||
}
|
||||
}
|
||||
if len(tr) != 1 || tr[0].Object != "70%" {
|
||||
t.Fatalf("只应保留非自环字段,实际 %+v", tr)
|
||||
}
|
||||
}
|
||||
332
internal/memory/distill/subject.go
Normal file
332
internal/memory/distill/subject.go
Normal file
@ -0,0 +1,332 @@
|
||||
package distill
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 主语推导(属性型 + 一句多主语)。
|
||||
//
|
||||
// ★ 为什么必须改,而不能只调提示词
|
||||
// --------------------------------
|
||||
// Split 里有这么一段:
|
||||
//
|
||||
// if subject == "" {
|
||||
// continue // 无主语直接跳过
|
||||
// }
|
||||
//
|
||||
// 实测基线(真实 qwen3:1.7b + 6 条真库记录,见 goldcases_test.go):
|
||||
// **1/5 通过**。四条失败全是 `subject == ""`:
|
||||
//
|
||||
// 主语="" 属性型-分机号新值 "值班室分机号 4324,值班 老周(…)"
|
||||
// 主语="" 属性型-分机号旧值 "值班室分机号 4379,值班人 阿李"
|
||||
// 主语="" 属性型-改述句 "下周起值班室分机号改为 4324,…"
|
||||
// 主语="" 多服务复合句 "admin服务端口8861·billing服务端口8499·…"
|
||||
//
|
||||
// 唯一通过的「叙述型-批次多字段」有「第183批」这个显式主语。
|
||||
// **结论:LLM 的拆分能力和 schema 约束都是好的,坏的是主语只认「第N批」。**
|
||||
//
|
||||
// 而且这不是提示词能解决的:1.7b 处理不了「从这句话里找出主语」这种
|
||||
// 隐含约束(同 promptTemplate 注释里记的「不要做 X」实测把 4 字段变 0 字段)。
|
||||
// 主语必须在 Go 侧用规则推。
|
||||
|
||||
// Subject 是从记录里推导出的一个主语。
|
||||
type Subject struct {
|
||||
// Name 是主语名(如「值班室分机号」「admin服务」)。
|
||||
Name string
|
||||
// Anchor 是主语在原句里的锚点位置(rune 下标),用于把「字段值」
|
||||
// 归属到正确的主语。一句多主语时靠它区分。
|
||||
Anchor int
|
||||
// Span 是主语覆盖的 rune 区间 [Start, End),值应当落在这个区间之后。
|
||||
// 多主语复合句(admin/billing/oauth)靠它切分。
|
||||
Start, End int
|
||||
}
|
||||
|
||||
// 维度词:属性名里表示「这个句子在说什么」的词。这些词后面通常跟值。
|
||||
//
|
||||
// 为什么要这份词表:属性型记录的形态是「<属性名> <值>,<属性名2> <值2>」,
|
||||
// 属性名本身没有显式标记,只能靠词表识别。词表里的词都是实测语料里
|
||||
// 出现过的属性名成分,不是凭空造的。
|
||||
var dimensionHeads = []string{
|
||||
"分机号", "端口", "值班人", "值班", "告警规则", "容量预警", "排期",
|
||||
"值班手册", "版本", "状态", "时间", "时长", "比例", "窗口",
|
||||
"地址", "账号", "密码", "联系人", "负责人", "数量", "阈值",
|
||||
}
|
||||
|
||||
// 主体词:复合句里「谁拥有这个属性」的部分。
|
||||
//
|
||||
// 用于切分 "admin服务端口8861·billing服务端口8499·oauth服务端口8271" ——
|
||||
// 三个主体各带一个端口,必须拆成三组三元组,不能混成一条。
|
||||
var subjectHeads = []string{
|
||||
"服务", "节点", "实例", "集群", "系统", "平台", "网关", "队列",
|
||||
"订单", "用户", "支付", "结算", "网关",
|
||||
}
|
||||
|
||||
// KnownSubjectAliases 把实测出现过的属性名归一到受控词表。
|
||||
//
|
||||
// 与 dimensionAliases 同一套思路:不归一就建不了边。但这里归一的是
|
||||
// **主语**(如「值班室分机号」vs「值班分机号」),主语不一致会让同一件事
|
||||
// 在图里裂成两个互不相关的节点。
|
||||
var knownSubjectAliases = map[string]string{
|
||||
"值班室分机号": "值班室分机号",
|
||||
"值班分机号": "值班室分机号",
|
||||
"分机号": "值班室分机号",
|
||||
"值班室电话": "值班室分机号",
|
||||
"值班电话": "值班室分机号",
|
||||
// 真库实测的句子型记录(**第四种形态**):
|
||||
//
|
||||
// 「连接池从 32/64 扩到 128/256,等待队列长度告警阈值 300~500」
|
||||
//
|
||||
// 句首是「连接池」而不是属性名,但它确实是这段话的主语。不加它
|
||||
// DeriveSubjects 推不出主语 → 字段全被闸门拦掉(零三元组)。
|
||||
//
|
||||
// ★ 这条是跑真库才发现的:金标准原先 6 条全都落在三种示例形态内
|
||||
// (批次型/属性型/复合型),判据太贴合示例,于是「第四种形态就交白卷」
|
||||
// 这个缺陷完全看不见。真库 6/6 零字段逼出了它。
|
||||
"连接池": "连接池",
|
||||
"等待队列": "等待队列",
|
||||
"队列": "等待队列",
|
||||
}
|
||||
|
||||
// DeriveSubjects 从一条记录推导主语列表。
|
||||
//
|
||||
// 返回空切片表示「推不出主语」—— 调用方据此不产出三元组(宁可少记,
|
||||
// 不写没有主语的悬空属性)。
|
||||
//
|
||||
// 三条推导规则,按优先级:
|
||||
// 1. 显式「第N批」→ 单一主语(叙述型,原有行为)
|
||||
// 2. 属性名开头(「<属性名> <值>」)→ 该属性名作主语(属性型,缺的主路径)
|
||||
// 3. 复合分隔符(·、,)里每段各自带主体 → 多主语(一句多组三元组)
|
||||
func DeriveSubjects(record string) []Subject {
|
||||
runes := []rune(record)
|
||||
|
||||
// 规则 1:显式批次号
|
||||
if s := defaultSubject(record); s != "" {
|
||||
idx := indexOfRunes(runes, []rune(s))
|
||||
if idx < 0 {
|
||||
idx = 0
|
||||
}
|
||||
return []Subject{{Name: s, Anchor: idx, Start: 0, End: idx + len([]rune(s))}}
|
||||
}
|
||||
|
||||
// 规则 2 与规则 3 的先后**实测无影响**(变异自证:调换顺序后 6 条
|
||||
// 金标准用例的主语推导结果完全一致)—— 因为两者互斥:
|
||||
// deriveMultiSubject 要求「≥2 段含主体词」,而属性型记录
|
||||
// 「值班室分机号 4324,值班 老周」里没有任何主体词,它本来就推不出主语。
|
||||
//
|
||||
// (早先这里写的是「顺序反了会把属性型句拆成 值班/旧号 两个假主语」——
|
||||
// 那是 indexOfRunes 未找到返回 0 的 bug 造成的假象:中文主体词在 ASCII
|
||||
// 串上全部误命中位置 0。bug 修好后该现象不复存在,注释同步改正。)
|
||||
//
|
||||
// 规则 2:句首是**受控别名**(“值班室分机号…”)→ 整句一个主语。
|
||||
// 必须是受控别名而不是启发式猜出来的:启发式会把
|
||||
// “admin服务端口8861” 的句首也当成属性名(它在第一个数字处切,
|
||||
// 切出 “admin服务端口” 含“端口”),于是复合句永远轮不到规则 3。
|
||||
if s := deriveControlledSubject(runes); s != "" {
|
||||
return []Subject{{Name: s, Anchor: 0, Start: 0, End: len([]rune(s))}}
|
||||
}
|
||||
|
||||
// 规则 3:复合句里每段自带主体(“admin服务端口8861·billing服务端口8499…”)
|
||||
if subs := deriveMultiSubject(runes); len(subs) > 0 {
|
||||
return subs
|
||||
}
|
||||
|
||||
// 规则 4:句首不是受控名但形如“<名词><值>”时,用启发式试一次。
|
||||
if s := deriveAttributeSubject(runes); s != "" {
|
||||
return []Subject{{Name: s, Anchor: 0, Start: 0, End: len([]rune(s))}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deriveControlledSubject 只认 knownSubjectAliases 里的受控名(句首前缀)。
|
||||
//
|
||||
// 与 deriveAttributeSubject 的区别是**宁可推不出**:启发式会把任何
|
||||
// “句子开头到第一个数字之间”都当属性名,包括 “admin服务端口8861” 这种
|
||||
// 复合句的**首段**。而首段是复合句的一部分,不该独占主语。
|
||||
func deriveControlledSubject(runes []rune) string {
|
||||
// 按别名长度降序试,保证命中最长的那个(“值班室分机号” 优先于 “分机号”)。
|
||||
best := ""
|
||||
for alias := range knownSubjectAliases {
|
||||
if len([]rune(alias)) <= len(best) {
|
||||
continue
|
||||
}
|
||||
if len(alias) > len(runes) {
|
||||
continue
|
||||
}
|
||||
if indexOfRunes(runes, []rune(alias)) == 0 {
|
||||
best = alias
|
||||
}
|
||||
}
|
||||
if best == "" {
|
||||
return ""
|
||||
}
|
||||
return knownSubjectAliases[best]
|
||||
}
|
||||
|
||||
// deriveMultiSubject 处理 "admin服务端口8861·billing服务端口8499·oauth服务端口8271"。
|
||||
//
|
||||
// 切分依据是「主体词 + 属性词」的重复模式:每段开头是主体(「admin服务」
|
||||
// 「billing服务」),后面跟属性词(「端口」)和值。
|
||||
//
|
||||
// ★ 主体边界不能包含属性词:取「最靠前的属性词」之前的内容会把属性词也
|
||||
// 吃进主语,得到 "admin服务端口" 而不是 "admin服务" —— 实测踩过。
|
||||
// 正确做法是找**主体词**的位置,主体 = 段首到主体词末尾;找不到主体词时
|
||||
// 才退回属性词位置(段首本身就是主体名的情况)。
|
||||
//
|
||||
// 为什么必须走这条路而不是当整体一条:三个服务的端口混在一句里,向量会
|
||||
// 把三个数字平均掉 —— 实测查询「admin 服务的端口」能命中(因为整句都含
|
||||
// admin),但一旦拆成三元组,admin→8861 就是一条精确的边。
|
||||
func deriveMultiSubject(runes []rune) []Subject {
|
||||
// 按复合分隔符切段
|
||||
seps := map[rune]bool{'·': true, '、': true, ';': true, ';': true}
|
||||
var segs [][]rune
|
||||
cur := []rune{}
|
||||
for _, r := range runes {
|
||||
if seps[r] {
|
||||
segs = append(segs, cur)
|
||||
cur = []rune{}
|
||||
continue
|
||||
}
|
||||
cur = append(cur, r)
|
||||
}
|
||||
segs = append(segs, cur)
|
||||
if len(segs) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 至少两段同时含「主体词」才认定是一句多主语 —— 否则只是普通分句。
|
||||
hits := 0
|
||||
for _, seg := range segs {
|
||||
if containsAnyRune(seg, subjectHeads) {
|
||||
hits++
|
||||
}
|
||||
}
|
||||
if hits < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var subs []Subject
|
||||
offset := 0
|
||||
for _, seg := range segs {
|
||||
// 主体 = 段首到主体词末尾;找不到主体词才退回属性词位置。
|
||||
subject := ""
|
||||
attrIdx := indexOfAnyRune(seg, dimensionHeads)
|
||||
if i := indexOfAnyRune(seg, subjectHeads); i >= 0 {
|
||||
subject = string(seg[:i+len([]rune(matchedHead(seg, subjectHeads)))])
|
||||
} else if attrIdx > 0 {
|
||||
subject = string(seg[:attrIdx])
|
||||
}
|
||||
subject = strings.Trim(subject, ",, ·")
|
||||
if subject == "" || attrIdx < 0 {
|
||||
offset += len(seg) + 1
|
||||
continue
|
||||
}
|
||||
subs = append(subs, Subject{
|
||||
Name: subject,
|
||||
Anchor: offset + attrIdx,
|
||||
Start: offset,
|
||||
End: offset + len(seg),
|
||||
})
|
||||
offset += len(seg) + 1
|
||||
}
|
||||
return subs
|
||||
}
|
||||
|
||||
// matchedHead 返回 seg 中最先命中的那个主体词(用于确定主体右边界)。
|
||||
func matchedHead(seg []rune, heads []string) string {
|
||||
bestIdx, bestWord := -1, ""
|
||||
for _, h := range heads {
|
||||
if i := indexOfRunes(seg, []rune(h)); i >= 0 && (bestIdx < 0 || i < bestIdx) {
|
||||
bestIdx, bestWord = i, h
|
||||
}
|
||||
}
|
||||
return bestWord
|
||||
}
|
||||
|
||||
// deriveAttributeSubject 处理 "值班室分机号 4324,值班 老周(…)"。
|
||||
//
|
||||
// 形态:句首是属性名(命中 dimensionHeads 或 knownSubjectAliases),
|
||||
// 属性名本身就是主语 —— 「分机号是多少」这类查询要能命中它。
|
||||
func deriveAttributeSubject(runes []rune) string {
|
||||
// 从句首找最长的属性词
|
||||
for i := 0; i < len(runes) && i < 12; i++ {
|
||||
// 逐步加长,匹配最长的属性名
|
||||
for j := len(runes); j > i; j-- {
|
||||
cand := string(runes[i:j])
|
||||
if canonical, ok := knownSubjectAliases[cand]; ok {
|
||||
return canonical
|
||||
}
|
||||
}
|
||||
}
|
||||
// 属性名不是词表里的受控名时,用「句首连续名词 + 后接值」的启发式:
|
||||
// 取句首到第一个数字/逗号之间的部分。
|
||||
cut := len(runes)
|
||||
for i, r := range runes {
|
||||
if i > 0 && (isDigit(r) || r == ',' || r == ',' || r == ' ') {
|
||||
cut = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if cut == 0 || cut > 12 {
|
||||
return ""
|
||||
}
|
||||
cand := strings.TrimSpace(string(runes[:cut]))
|
||||
if canonical, ok := knownSubjectAliases[cand]; ok {
|
||||
return canonical
|
||||
}
|
||||
// 至少含一个已知属性词成分才算数,否则是「均仅评审通过」这类纯叙述
|
||||
if !containsAnyRune([]rune(cand), dimensionHeads) {
|
||||
return ""
|
||||
}
|
||||
if canonical, ok := knownSubjectAliases[cand]; ok {
|
||||
return canonical
|
||||
}
|
||||
return cand
|
||||
}
|
||||
|
||||
// indexOfRunes 返回 needle 在 haystack 中的起始下标,未找到返回 -1。
|
||||
//
|
||||
// ★ 契约:未找到必须是 -1,不能是 0。
|
||||
// 初版写的是「n == 0 || n > len(haystack) 时 return 0」—— 把「needle 比
|
||||
// haystack 长」当成了「命中在位置 0」。后果是主体词表里的中文词
|
||||
// (“节点”/“队列”/“网关”…)在 ASCII 串 "admin服务端口8861" 上全部
|
||||
// 报「命中于 index 0」,主语被截成 "ad"/"bi"/"oa"。
|
||||
// 判据要求“未找到”和“找到第一处”是两件可区分的事,否则所有 >= 0 的
|
||||
// 判断全部退化成真。
|
||||
func indexOfRunes(haystack, needle []rune) int {
|
||||
n := len(needle)
|
||||
if n == 0 || n > len(haystack) {
|
||||
return -1
|
||||
}
|
||||
for i := 0; i+n <= len(haystack); i++ {
|
||||
match := true
|
||||
for j := 0; j < n; j++ {
|
||||
if haystack[i+j] != needle[j] {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if match {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func containsAnyRune(haystack []rune, needles []string) bool {
|
||||
for _, n := range needles {
|
||||
if indexOfRunes(haystack, []rune(n)) >= 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func indexOfAnyRune(haystack []rune, needles []string) int {
|
||||
best := -1
|
||||
for _, n := range needles {
|
||||
if i := indexOfRunes(haystack, []rune(n)); i >= 0 && (best < 0 || i < best) {
|
||||
best = i
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
95
internal/memory/distill/subject_test.go
Normal file
95
internal/memory/distill/subject_test.go
Normal file
@ -0,0 +1,95 @@
|
||||
package distill
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ 主语推导的判据(全部来自 goldcases_test.go 的真库记录)。
|
||||
//
|
||||
// 这层**不调 LLM** —— 主语推导是纯规则,判据可以精确到「推导出了什么」,
|
||||
// 而不是「最后有没有字段」。LLM 那层的模糊性留给 gold_test.go。
|
||||
func TestDeriveSubjects(t *testing.T) {
|
||||
cases := []struct {
|
||||
record string
|
||||
want []string
|
||||
why string
|
||||
}{
|
||||
{"值班室分机号 4324,值班 老周(下周起;旧号 4379 停用)",
|
||||
[]string{"值班室分机号"}, "属性型:句首是受控别名"},
|
||||
{"值班室分机号 4379,值班人 阿李",
|
||||
[]string{"值班室分机号"}, "属性型旧值,同一主语不同值"},
|
||||
{"下周起值班室分机号改为 4324,旧号 4379 停用,值班轮换到 老周",
|
||||
[]string{"值班室分机号"}, "改述形态仍应归到同一主语"},
|
||||
{"admin服务端口8861·billing服务端口8499·oauth服务端口8271",
|
||||
[]string{"admin服务", "billing服务", "oauth服务"},
|
||||
"★ 一句多主语:三个服务各成一主语,主体词边界不含属性词"},
|
||||
{"第183批 告警规则9条·值班手册第4版·容量预警70%·排期10月",
|
||||
[]string{"第183批"}, "叙述型:显式批次号优先"},
|
||||
{"第129~143批均仅评审通过",
|
||||
[]string{"第129~143批"}, "范围批次号"},
|
||||
{"均仅评审通过,无具体字段", nil,
|
||||
"纯叙述句推不出主语 —— 宁可少记也不写悬空属性"},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
got := DeriveSubjects(c.record)
|
||||
var names []string
|
||||
for _, s := range got {
|
||||
names = append(names, s.Name)
|
||||
}
|
||||
if !equalStrs(names, c.want) {
|
||||
t.Errorf("主语推导错(%s)\n 记录: %q\n 期望: %v\n 实得: %v",
|
||||
c.why, c.record, c.want, names)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 别名归一:同一属性不得裂成两个主语。
|
||||
func TestDeriveSubjects_别名归一(t *testing.T) {
|
||||
for _, rec := range []string{
|
||||
"值班分机号 4324",
|
||||
"分机号 4324",
|
||||
"值班室电话 4324",
|
||||
} {
|
||||
got := DeriveSubjects(rec)
|
||||
if len(got) != 1 {
|
||||
t.Errorf("%q 应推出 1 个主语,实际 %d", rec, len(got))
|
||||
continue
|
||||
}
|
||||
if got[0].Name != "值班室分机号" {
|
||||
t.Errorf("%q 主语应归一到「值班室分机号」,实际 %q", rec, got[0].Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// indexOfRunes 的契约:未找到必须是 -1。
|
||||
//
|
||||
// ★ 这条是被真实 bug 逼出来的:初版对「needle 比 haystack 长」返回 0,
|
||||
// 于是词表里的中文词("节点"/"队列"/"网关")在 ASCII 串 "admin服务端口8861"
|
||||
// 上全部报「命中于位置 0」,主语被截成 "ad"/"bi"/"oa"。
|
||||
func TestIndexOfRunes_未找到返回负一(t *testing.T) {
|
||||
hay := []rune("admin服务端口8861")
|
||||
for _, needle := range []string{"节点", "队列", "网关", "订单", "不存在的词"} {
|
||||
if got := indexOfRunes(hay, []rune(needle)); got >= 0 {
|
||||
t.Errorf("indexOfRunes(%q, %q) = %d,应为 -1(未找到)", string(hay), needle, got)
|
||||
}
|
||||
}
|
||||
if got := indexOfRunes(hay, []rune("服务")); got != 5 {
|
||||
t.Errorf("indexOfRunes 应命中位置 5,实际 %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func equalStrs(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
var _ = strings.TrimSpace
|
||||
618
internal/memory/edge_entity.go
Normal file
618
internal/memory/edge_entity.go
Normal file
@ -0,0 +1,618 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ★★★ 关系边:独立单位
|
||||
//
|
||||
// ============================================================
|
||||
// 为什么边必须是独立实体,而不是「两点 + 一个类型字符串」
|
||||
// ============================================================
|
||||
// 原 schema 有:
|
||||
//
|
||||
// UNIQUE(source_kind, source_id, target_kind, target_id, edge_type)
|
||||
//
|
||||
// ⇒ 同一对节点间只能存**一条**同类型边。后果实测:
|
||||
//
|
||||
// 写 A --属于--> B (session=1, conf=0.9)
|
||||
// 写 A --属于--> B (session=2, conf=0.5)
|
||||
// ⇒ 第二条被 INSERT OR IGNORE 静默丢弃,session/confidence 全丢
|
||||
//
|
||||
// 而这正是「报告边数 980、实际写入 959」的根因(dd2c996)——
|
||||
// 当时我把它当成「重复数据」报出来就完事了,
|
||||
// **真正的问题是边表从设计上就存不下多条同类边**。
|
||||
//
|
||||
// ============================================================
|
||||
// 边作为独立单位的三条要求
|
||||
// ============================================================
|
||||
// ① 同类边可并存 —— 不同 session / turn / confidence 是不同的事实
|
||||
// ② 边承载属性 —— confidence / session_id / status 挂在边上而非关系上
|
||||
// ③ 边有自己的身份 —— 可以被引用(merged_into 指向它)而不只是被遍历
|
||||
//
|
||||
// 对应旧 relations 表的列:confidence / status / session_id / turn_id /
|
||||
// date_bucket。date_bucket 是索引优化而非语义,先不入。
|
||||
|
||||
// 边状态。
|
||||
const (
|
||||
// EdgeActive 是有效边。软删除/仲裁靠它判断旧值是否作废
|
||||
// (与旧 relations.status='active' 语义一致)。
|
||||
EdgeActive = "active"
|
||||
// EdgeDeleted 是被取代/被删除的边。**保留而非物理删除** ——
|
||||
// 「旧号 4379 停用」本身是有信息量的(它解释了为什么现在打不通),
|
||||
// 与 arbitration.go 的 Superseded 同一套语义。
|
||||
EdgeDeleted = "deleted"
|
||||
// EdgeMerged 是源节点被合并后,其边重定向到目标块;
|
||||
// 源块记 merged_into 指向目标块,历史边留在源块上。
|
||||
EdgeMerged = "merged"
|
||||
)
|
||||
|
||||
// RelationEdgeData 是关系边的属性集。
|
||||
type RelationEdgeData struct {
|
||||
// Confidence 是置信度(0~1)。旧 relations 表同名列。
|
||||
Confidence float64
|
||||
// SessionID / TurnID 记录边的来源会话与轮次。
|
||||
// Recall 的 sessionFilter 依赖它。
|
||||
SessionID string
|
||||
TurnID int
|
||||
// Status 是 EdgeActive / EdgeDeleted / EdgeMerged。
|
||||
Status string
|
||||
// MergedInto 在 EdgeMerged 时指向目标块 ID。
|
||||
MergedInto string
|
||||
}
|
||||
|
||||
// AddRelationBlockEdge 写一条**带属性的关系边**。
|
||||
//
|
||||
// ★ 与 AddMemoryBlockEdge 的区别(后者保留给结构边)
|
||||
//
|
||||
// AddMemoryBlockEdge 结构边(contains / 迁移关系)
|
||||
// —— 不并存多条,无属性,用 UNIQUE 保证幂等
|
||||
// AddRelationBlockEdge 关系边(主语--关系-->宾语)
|
||||
// —— ★ 可并存多条、承载属性
|
||||
//
|
||||
// 两者共用一张表(memory_block_edges),靠属性列是否为空区分,
|
||||
// 但**唯一性约束不同**:结构边受 UNIQUE 保护,关系边不受。
|
||||
func (g *GraphDB) AddRelationBlockEdge(sourceID, targetID, edgeType string,
|
||||
data RelationEdgeData) error {
|
||||
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
if sourceID == "" || targetID == "" || edgeType == "" {
|
||||
return fmt.Errorf("edge: endpoints and type required")
|
||||
}
|
||||
// 端点必须存在 —— 沿用 AddMemoryBlockEdge 的校验,
|
||||
// 避免建出指向虚无节点的边。
|
||||
for _, id := range []string{sourceID, targetID} {
|
||||
var n int
|
||||
if err := g.db.QueryRow(
|
||||
`SELECT COUNT(*) FROM memory_blocks WHERE id = ?`, id).Scan(&n); err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return fmt.Errorf("edge: block %s does not exist", id)
|
||||
}
|
||||
}
|
||||
|
||||
status := data.Status
|
||||
if status == "" {
|
||||
status = EdgeActive
|
||||
}
|
||||
|
||||
// ★ 不带 OR IGNORE / 不受 UNIQUE 约束 ——
|
||||
// 这正是「边是独立单位」的落点:同一对节点可并存多条同类边。
|
||||
_, err := g.db.Exec(`INSERT INTO memory_block_edges
|
||||
(source_kind, source_id, target_kind, target_id, edge_type,
|
||||
confidence, session_id, turn_id, status, merged_into)
|
||||
VALUES ('block', ?, 'block', ?, ?, ?, ?, ?, ?, ?)`,
|
||||
sourceID, targetID, edgeType, data.Confidence,
|
||||
nullIfEmpty(data.SessionID), data.TurnID, status,
|
||||
nullIfEmpty(data.MergedInto))
|
||||
return err
|
||||
}
|
||||
|
||||
func nullIfEmpty(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// RelationEdgesBetween 取两个块之间的全部关系边(**不合并同类**)。
|
||||
//
|
||||
// 这是「边是独立单位」的直接体现:返回切片长度可以 > 1。
|
||||
func (g *GraphDB) RelationEdgesBetween(sourceID, targetID string) ([]MemoryBlockEdge, error) {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
return g.relationEdgesTx(sourceID, targetID)
|
||||
}
|
||||
|
||||
func (g *GraphDB) relationEdgesTx(sourceID, targetID string) ([]MemoryBlockEdge, error) {
|
||||
rows, err := g.db.Query(`SELECT id, source_kind, source_id, target_kind, target_id,
|
||||
edge_type, confidence, session_id, turn_id, status, merged_into
|
||||
FROM memory_block_edges
|
||||
WHERE source_kind='block' AND source_id=? AND target_kind='block' AND target_id=?
|
||||
ORDER BY confidence DESC, id ASC`, sourceID, targetID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scanRelationEdges(rows)
|
||||
}
|
||||
|
||||
// AllRelationEdges 返回全部关系边(供遍历/合并用)。
|
||||
func (g *GraphDB) AllRelationEdges() ([]MemoryBlockEdge, error) {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
rows, err := g.db.Query(`SELECT id, source_kind, source_id, target_kind, target_id,
|
||||
edge_type, confidence, session_id, turn_id, status, merged_into
|
||||
FROM memory_block_edges ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scanRelationEdges(rows)
|
||||
}
|
||||
|
||||
func scanRelationEdges(rows *sql.Rows) ([]MemoryBlockEdge, error) {
|
||||
defer func() { _ = rows.Close() }()
|
||||
var out []MemoryBlockEdge
|
||||
for rows.Next() {
|
||||
var e MemoryBlockEdge
|
||||
var sess, merged sql.NullString
|
||||
var conf sql.NullFloat64
|
||||
var status sql.NullString
|
||||
var turn sql.NullInt64
|
||||
if err := rows.Scan(&e.ID, &e.SourceKind, &e.SourceID, &e.TargetKind, &e.TargetID,
|
||||
&e.Type, &conf, &sess, &turn, &status, &merged); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.Confidence = conf.Float64
|
||||
e.SessionID = sess.String
|
||||
e.Status = status.String
|
||||
e.MergedInto = merged.String
|
||||
if turn.Valid {
|
||||
e.TurnID = int(turn.Int64)
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ensureRelationEdgeUniqueness 处理**既有表**上的 UNIQUE 约束。
|
||||
//
|
||||
// ★ 为什么必须重建表
|
||||
// ------------------
|
||||
// SQLite 的 UNIQUE 约束是**表定义的一部分**,没有 ALTER TABLE ... DROP CONSTRAINT。
|
||||
// 而旧库(生产 98 块 / 590 边、测试库 448 块)建表时都带了这个约束,
|
||||
// 它让「边是独立单位」在既有库上无法生效 —— 同一对节点写第二条同类边会被丢。
|
||||
//
|
||||
// 做法:读出既有边 → 建新表(无 UNIQUE)→ 搬回去 → 删旧表 → 改名。
|
||||
// 全程在一个事务里;任何一步失败整体回滚。
|
||||
//
|
||||
// ★ 为什么不能在 detect 阶段默默跳过
|
||||
//
|
||||
// 跳过会让「既有库不支持并存同类边、新库支持」成为事实,
|
||||
// 而这种不一致极难排查(本地能测、生产静默丢边)。
|
||||
// ⇒ 这里宁可重建表也要保证语义一致,并打日志说明发生了什么。
|
||||
func ensureRelationEdgeUniqueness(tx *sql.Tx) {
|
||||
// 检测既有表是否带 UNIQUE
|
||||
rows, err := tx.Query(`SELECT sql FROM sqlite_master
|
||||
WHERE type='table' AND name='memory_block_edges'`)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var ddl string
|
||||
if rows.Next() {
|
||||
_ = rows.Scan(&ddl)
|
||||
}
|
||||
_ = rows.Close()
|
||||
if ddl == "" || !strings.Contains(ddl, "UNIQUE") {
|
||||
return // 新表或已升格
|
||||
}
|
||||
|
||||
log.Printf("[graph] 边表升格:重建 memory_block_edges(移除 UNIQUE 以支持同类边并存)")
|
||||
if _, err := tx.Exec(`CREATE TABLE memory_block_edges_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source_kind TEXT NOT NULL,
|
||||
source_id TEXT NOT NULL,
|
||||
target_kind TEXT NOT NULL,
|
||||
target_id TEXT NOT NULL,
|
||||
edge_type TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
confidence REAL DEFAULT 0,
|
||||
session_id TEXT DEFAULT '',
|
||||
turn_id INTEGER DEFAULT 0,
|
||||
status TEXT DEFAULT '',
|
||||
merged_into TEXT DEFAULT ''
|
||||
)`); err != nil {
|
||||
log.Printf("[graph] 边表升格失败(保留原约束): %v", err)
|
||||
return
|
||||
}
|
||||
// 搬数据(只搬旧表已有的 6 列,新列留默认值)
|
||||
if _, err := tx.Exec(`INSERT INTO memory_block_edges_new
|
||||
(id, source_kind, source_id, target_kind, target_id, edge_type, created_at)
|
||||
SELECT id, source_kind, source_id, target_kind, target_id, edge_type, created_at
|
||||
FROM memory_block_edges`); err != nil {
|
||||
log.Printf("[graph] 边表数据迁移失败,回滚: %v", err)
|
||||
if _, derr := tx.Exec(`DROP TABLE memory_block_edges_new`); derr != nil {
|
||||
log.Printf("[graph] 边表升格:回滚临时表失败: %v", derr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if _, err := tx.Exec(`DROP TABLE memory_block_edges`); err != nil {
|
||||
log.Printf("[graph] 边表删除失败,回滚: %v", err)
|
||||
if _, derr := tx.Exec(`DROP TABLE memory_block_edges_new`); derr != nil {
|
||||
log.Printf("[graph] 边表升格:回滚临时表失败: %v", derr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if _, err := tx.Exec(`ALTER TABLE memory_block_edges_new RENAME TO memory_block_edges`); err != nil {
|
||||
log.Printf("[graph] 边表改名失败: %v", err)
|
||||
return
|
||||
}
|
||||
// 索引要重建(DROP TABLE 会带走)
|
||||
for _, idx := range []string{
|
||||
`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)`,
|
||||
} {
|
||||
_, _ = tx.Exec(idx)
|
||||
}
|
||||
log.Printf("[graph] 边表升格完成")
|
||||
}
|
||||
|
||||
// BFSBlocks 从 startID 出发,沿**关系边**展开 depth 层,返回全部可达节点。
|
||||
//
|
||||
// ★★★ 这就是「联想」的实现(docs/zh/recall-as-association.md)
|
||||
//
|
||||
// 人听到「值班室分机号是多少」时:想起主语 → 想起值 → 想起旧值 → 想起人。
|
||||
// 节点是召回对象,N 层 BFS 是把「自动附带的上下文」取回来。
|
||||
//
|
||||
// ★ 为什么必须是「节点 + BFS」而不是「只召回主语块」
|
||||
//
|
||||
// 宾语块 "4324" 孤立召回时不知道自己来自哪里。
|
||||
// 若只召回主语块 ⇒ 要么丢它(漏掉「用户直接问值」的场景),
|
||||
// 要么加「来源标注」特判(联想本来就不需要标注)。
|
||||
//
|
||||
// ★ 双向遍历
|
||||
//
|
||||
// 人联想到「老周值班」是从「老周 --值班分机--> 4324」**反向**走来的。
|
||||
// 所以正反向都要展开,否则网会退化成有向森林。
|
||||
//
|
||||
// ★ depth=0 只返回自身(不是空)
|
||||
//
|
||||
// 命中节点本身就是要返回的内容之一。
|
||||
func (g *GraphDB) BFSBlocks(startID string, depth int) ([]MemoryBlock, error) {
|
||||
if depth < 0 {
|
||||
depth = 0
|
||||
}
|
||||
if startID == "" {
|
||||
return nil, fmt.Errorf("bfs: empty start id")
|
||||
}
|
||||
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
|
||||
// 起点必须存在
|
||||
var exists int
|
||||
if err := g.db.QueryRow(
|
||||
`SELECT COUNT(*) FROM memory_blocks WHERE id = ?`, startID).Scan(&exists); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if exists == 0 {
|
||||
return nil, fmt.Errorf("bfs: start block %s does not exist", startID)
|
||||
}
|
||||
|
||||
// visited 同时充当去重集合:网会织成"部分连通",
|
||||
// 不去重会在环上无限展开。
|
||||
visited := map[string]bool{startID: true}
|
||||
frontier := []string{startID}
|
||||
// ★ ordered 记录 BFS 的**发现顺序**:起点在前,邻居按层、按到达顺序。
|
||||
// 它是「离查询多近」这个语义的载体 —— 丢了顺序,
|
||||
// 「最近的上下文在前」就没了(同图两次 BFS 结果不同)。
|
||||
ordered := []string{startID}
|
||||
|
||||
for d := 0; d < depth && len(frontier) > 0; d++ {
|
||||
var next []string
|
||||
for _, cur := range frontier {
|
||||
// ★ 正向 + 反向:前驱(谁指向我)与后继(我指向谁)都是邻居。
|
||||
//
|
||||
// ★★ 这里**不能**加 COALESCE(session_id,'')='' 的过滤 ——
|
||||
// 那个条件是给结构边去重用的(结构边 session_id 恒空),
|
||||
// 而关系边的 session_id 恰恰**非空**(它是边的属性)。
|
||||
// 加上它会把所有关系边滤掉 ⇒ BFS 一个邻居都走不到。
|
||||
// 实测:边写对了(session=s1 status=active)但 BFS 只返回起点。
|
||||
rows, err := g.db.Query(`SELECT target_id FROM memory_block_edges
|
||||
WHERE source_kind='block' AND source_id=?
|
||||
UNION
|
||||
SELECT source_id FROM memory_block_edges
|
||||
WHERE target_kind='block' AND target_id=?`,
|
||||
cur, cur)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var nb string
|
||||
if err := rows.Scan(&nb); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if nb != "" && !visited[nb] {
|
||||
visited[nb] = true
|
||||
// ★ 记录**发现顺序** —— BFS 的语义全在这里。
|
||||
// 去重用 map,出序用这个 slice,两者不能混。
|
||||
ordered = append(ordered, nb)
|
||||
next = append(next, nb)
|
||||
}
|
||||
}
|
||||
err = rows.Err()
|
||||
rows.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
frontier = next
|
||||
}
|
||||
|
||||
// ★ 取块内容必须按**发现顺序**,不能用 map 遍历。
|
||||
//
|
||||
// visited 是 map(判定去重要 O(1)),而它的迭代顺序是随机的 ——
|
||||
// 于是同一个图连续两次 BFS 得到不同顺序的结果,
|
||||
// 「离查询多近」这个语义就没了,测试与用户输出都会飘。
|
||||
//
|
||||
// 正确做法:用入队时累积的 ordered(见上),终点直接传它。
|
||||
//
|
||||
// 兜底:若 ordered 为空(depth=0 时不展开),退回 map 遍历 ——
|
||||
// 此时只有一个节点,顺序无所谓。
|
||||
if len(ordered) == 0 {
|
||||
for id := range visited {
|
||||
ordered = append(ordered, id)
|
||||
}
|
||||
}
|
||||
return g.blocksByIDsLocked(ordered)
|
||||
}
|
||||
|
||||
func (g *GraphDB) blocksByIDsLocked(ids []string) ([]MemoryBlock, error) {
|
||||
if len(ids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
placeholders := make([]string, len(ids))
|
||||
args := make([]any, len(ids))
|
||||
for i, id := range ids {
|
||||
placeholders[i] = "?"
|
||||
args[i] = id
|
||||
}
|
||||
rows, err := g.db.Query(`SELECT `+blockColumns+`
|
||||
FROM memory_blocks WHERE id IN (`+strings.Join(placeholders, ",")+`)`, args...)
|
||||
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()
|
||||
}
|
||||
|
||||
// BlockNeighbour 是一条关系边连同它的对端块 —— 「谁和谁有什么关系」的完整读法。
|
||||
//
|
||||
// ★ 为什么不复用 RelationEdgeData:那个类型只是**属性载荷**
|
||||
//
|
||||
// (confidence/session/turn/status/merged_into),刻意不含 ID 与端点,
|
||||
// 因为 AddRelationBlockEdge 的入参就是「端点已知、只传属性」。
|
||||
//
|
||||
// 读的方向相反:要端点、要 ID。所以另立类型而不是硬塞进去。
|
||||
type BlockNeighbour struct {
|
||||
// EdgeID 是 memory_block_edges.id。场景引用 kind='edge' 指向它。
|
||||
EdgeID int64
|
||||
// SourceID / TargetID 是两端块 ID。
|
||||
SourceID string
|
||||
TargetID string
|
||||
// EdgeType 是关系名(「偏好」「使用」…)。
|
||||
EdgeType string
|
||||
// Peer 是**对端**块:SourceID == 查询块时为 Target,反之为 Source。
|
||||
// 查询块自己是起点,不需要重复带出。
|
||||
Peer MemoryBlock
|
||||
// IsOutgoing 标明方向:true 表示 Peer 是这条关系的宾语。
|
||||
// social 层区分「A 认识 B」与「B 认识 A」时必须看它。
|
||||
IsOutgoing bool
|
||||
|
||||
RelationEdgeData
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// MergeBlocks —— 块合并(2026-10-04)
|
||||
//
|
||||
// 这是旧表退场的最后一环:MergeEntities 只改 entities/relations,
|
||||
// 块侧完全不动 ⇒ 「张先生」改名后,块还叫「张先生」,
|
||||
// 召回照样命中它(判据 TestMergeEntities 就是这么红的)。
|
||||
//
|
||||
// ★★ 与旧实现的本质差异
|
||||
//
|
||||
// 旧(entities/relations)
|
||||
// 改 relations.source_id / target_id 即可 —— 实体 ID 不变,
|
||||
// 改名(UPDATE entities.name)就完成了合并的「改名」语义。
|
||||
//
|
||||
// 新(blocks/edges)
|
||||
// 块 ID 是**内容派生**的:blk_ent_<hash(name)>。
|
||||
// 「张先生」与「张三」是两个不同的块,合并意味着**源块消失**,
|
||||
// 它的边改指向目标块。
|
||||
//
|
||||
// ⇒ 所以本函数不能只重定向端点,还要:
|
||||
// ① 删源块(不留 @merged_ 残留,与旧实现同款)
|
||||
// ② 处理结构边(contains)—— 否则原句块指向已删的块,端点悬空
|
||||
// ③ 同步 scene_refs —— 否则场景里挂一条永远召不回的幽灵
|
||||
//
|
||||
// ★ 边的合并语义:**重定向 + 去自环**,不合并属性。
|
||||
// 「李四喜欢咖啡」+「李四讨厌咖啡」合并到「王五」后都变成
|
||||
// 「王五—喜欢/讨厌→咖啡」—— 这是**两条不同的关系**(edge_type 不同),
|
||||
// 都保留;而「李四喜欢咖啡」+「王五喜欢咖啡」合并后同端同类型,
|
||||
// 那才是重复,必须收敛(否则「王五喜欢咖啡」出现两次)。
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// MergeBlocks 把 sourceText 块的边合并进 targetText 块,源块随之消失。
|
||||
//
|
||||
// 返回**重定向的边数**(不含被去重丢弃的重复边)。
|
||||
// 幂等:源块不存在时返回 (0, nil)。
|
||||
func (g *GraphDB) MergeBlocks(sourceText, targetText string) (int, error) {
|
||||
sourceText = strings.TrimSpace(sourceText)
|
||||
targetText = strings.TrimSpace(targetText)
|
||||
if sourceText == "" || targetText == "" {
|
||||
return 0, fmt.Errorf("merge requires both source and target")
|
||||
}
|
||||
if sourceText == targetText {
|
||||
return 0, fmt.Errorf("merge source and target are identical: %q", sourceText)
|
||||
}
|
||||
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
src, err := g.blockByTextTxLocked(sourceText)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if src == nil {
|
||||
// ★ 必须报错,不能返回 (0, nil)。
|
||||
//
|
||||
// 我一开始写的是「幂等:源块已合并过」—— 那是对的**理由**
|
||||
// 配上了错的**行为**:源块不存在有两种截然不同的原因:
|
||||
//
|
||||
// a) 已经合并过了(重试,正常路径)
|
||||
// b) 名字拼错了 / 从没写过(调用方的 bug)
|
||||
//
|
||||
// 返回 (0, nil) 把两者都变成「静默成功」——
|
||||
// 而这正是记忆系统里最贵的一类 bug:调用方以为合并了,
|
||||
// 库里其实什么都没变。
|
||||
//
|
||||
// 旧 MergeEntities 在这里返回 error,是对的。
|
||||
// 幂等由**调用方**判断(合并后不要再合并同一个源),
|
||||
// 不该由存储层替它猜。
|
||||
return 0, fmt.Errorf("merge source block %q does not exist", sourceText)
|
||||
}
|
||||
dst, err := g.blockByTextTxLocked(targetText)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if dst == nil {
|
||||
// ★ 目标不存在必须报错。
|
||||
// 返回 0 会让调用方以为合并成功了,而实际上什么都没做 ——
|
||||
// 那正是「静默失效」的一种。
|
||||
return 0, fmt.Errorf("merge target block %q does not exist", targetText)
|
||||
}
|
||||
if src.ID == dst.ID {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
tx, err := g.db.Begin()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// ── ① 重定向关系边:source → target ──────────────────────
|
||||
//
|
||||
// ★ 只动**关系边**,不动结构边(contains)。
|
||||
// contains 的端点是「原句块」与「关系边」或「块」,
|
||||
// 把它改成 target 会让原句指向一个语义不同的块。
|
||||
res, err := tx.Exec(
|
||||
`UPDATE memory_block_edges SET source_id = ?
|
||||
WHERE source_kind = 'block' AND source_id = ?
|
||||
AND COALESCE(session_id,'') != ''`,
|
||||
dst.ID, src.ID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("redirect source edges: %w", err)
|
||||
}
|
||||
nOut, _ := res.RowsAffected()
|
||||
|
||||
res, err = tx.Exec(
|
||||
`UPDATE memory_block_edges SET target_id = ?
|
||||
WHERE target_kind = 'block' AND target_id = ?
|
||||
AND COALESCE(session_id,'') != ''`,
|
||||
dst.ID, src.ID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("redirect target edges: %w", err)
|
||||
}
|
||||
nIn, _ := res.RowsAffected()
|
||||
|
||||
// ── ② 去自环 ─────────────────────────────────────────────
|
||||
//
|
||||
// 重定向后 source→X 与 target→X 都成了 target→X。
|
||||
// 同端**同类型**的边是重复(重复陈述同一件事);
|
||||
// 同端**不同类型**的边不是(喜欢 vs 讨厌 是两回事)。
|
||||
//
|
||||
// ★ 判定必须含 edge_type:只按 (source,target) 去重会把
|
||||
// 「王五喜欢咖啡」与「王五讨厌咖啡」误删一条。
|
||||
//
|
||||
// 保留 id 最小的那条,其余标记 deleted(不硬删):
|
||||
// scene_refs 可能还指向它们。
|
||||
if _, err := tx.Exec(
|
||||
`UPDATE memory_block_edges SET status = 'deleted'
|
||||
WHERE id NOT IN (
|
||||
SELECT MIN(id) FROM memory_block_edges
|
||||
WHERE source_kind='block' AND target_kind='block'
|
||||
AND (source_id = ? OR target_id = ?)
|
||||
AND COALESCE(session_id,'') != ''
|
||||
GROUP BY source_id, target_id, edge_type
|
||||
)
|
||||
AND source_kind='block' AND target_kind='block'
|
||||
AND (source_id = ? OR target_id = ?)
|
||||
AND COALESCE(session_id,'') != ''
|
||||
AND COALESCE(status,'') != 'deleted'`,
|
||||
dst.ID, dst.ID, dst.ID, dst.ID); err != nil {
|
||||
return 0, fmt.Errorf("dedupe self-loops: %w", err)
|
||||
}
|
||||
|
||||
// ── ③ 清理指向源块的结构边 ────────────────────────────────
|
||||
//
|
||||
// contains 的端点可能是块(媒体挂载、迁移关系)。
|
||||
// 源块删了之后这些边端点悬空 —— 而悬空端点会让
|
||||
// graphNodeExists 校验失败,后续任何引用它的写入都被拒。
|
||||
if _, err := tx.Exec(
|
||||
`DELETE FROM memory_block_edges
|
||||
WHERE (source_kind='block' AND source_id=? AND COALESCE(session_id,'')='')
|
||||
OR (target_kind='block' AND target_id=? AND COALESCE(session_id,'')='')`,
|
||||
src.ID, src.ID); err != nil {
|
||||
return 0, fmt.Errorf("drop structural edges of source: %w", err)
|
||||
}
|
||||
|
||||
// ── ④ 删源块 ─────────────────────────────────────────────
|
||||
if _, err := tx.Exec(`DELETE FROM memory_blocks WHERE id = ?`, src.ID); err != nil {
|
||||
return 0, fmt.Errorf("delete source block: %w", err)
|
||||
}
|
||||
|
||||
// ── ⑤ 场景引用同步 ───────────────────────────────────────
|
||||
//
|
||||
// 源块的引用必须换成目标块 —— 而不是删掉:
|
||||
// 「张先生那次值班」这个场景仍然存在,只是人换了名字。
|
||||
if _, err := tx.Exec(
|
||||
`UPDATE scene_refs SET ref_text = ?
|
||||
WHERE kind = 'block' AND ref_text = ?`,
|
||||
dst.ID, src.ID); err != nil {
|
||||
return 0, fmt.Errorf("repoint scene refs: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int(nOut + nIn), nil
|
||||
}
|
||||
|
||||
// blockByTextTxLocked 按文本取最早的块(调用方已持锁)。
|
||||
func (g *GraphDB) blockByTextTxLocked(text string) (*MemoryBlock, error) {
|
||||
blocks, err := g.blocksByTextTx(text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(blocks) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return &blocks[0], nil
|
||||
}
|
||||
118
internal/memory/edge_entity_test.go
Normal file
118
internal/memory/edge_entity_test.go
Normal file
@ -0,0 +1,118 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★★★ 边作为独立单位的契约判据
|
||||
//
|
||||
// 现状(graph.go:216 的 schema):
|
||||
//
|
||||
// UNIQUE(source_kind, source_id, target_kind, target_id, edge_type)
|
||||
//
|
||||
// ⇒ 同一对节点之间只能有**一条**同类型边 ⇒ 边被降级成「两点的一个类型标签」,
|
||||
// 而不是一个可独立存在、可承载属性、可并存多条的对象。
|
||||
//
|
||||
// 而「块作节点、边作独立单位、织成实体网」要求:
|
||||
//
|
||||
// ① 同一对节点间可并存多条同类边(不同 session / confidence / turn)
|
||||
// ② 边自己承载属性(confidence / session_id / status …)
|
||||
// ③ 边的身份是独立的,不是 (源,目标,类型) 三元组的派生
|
||||
func TestEdge_同类边可并存(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer func() { _ = g.Close() }()
|
||||
|
||||
a := MemoryBlock{ID: "blk_a", Modality: BlockText, Text: "甲"}
|
||||
b := MemoryBlock{ID: "blk_b", Modality: BlockText, Text: "乙"}
|
||||
if err := g.PutMemoryBlocks([]MemoryBlock{a, b}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 同一对节点、三条同类型边,但来源会话不同
|
||||
type edgeSpec struct {
|
||||
sessionID string
|
||||
turnID int
|
||||
conf float64
|
||||
}
|
||||
specs := []edgeSpec{
|
||||
{"sess-1", 1, 0.9},
|
||||
{"sess-2", 2, 0.5},
|
||||
{"sess-3", 3, 0.7},
|
||||
}
|
||||
for _, sp := range specs {
|
||||
if err := g.AddRelationBlockEdge("blk_a", "blk_b", "属于", RelationEdgeData{
|
||||
SessionID: sp.sessionID, TurnID: sp.turnID, Confidence: sp.conf,
|
||||
}); err != nil {
|
||||
t.Fatalf("写入关系边失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
edges, err := g.MemoryBlockEdges()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var relEdges []MemoryBlockEdge
|
||||
for _, e := range edges {
|
||||
if e.Type == "属于" {
|
||||
relEdges = append(relEdges, e)
|
||||
}
|
||||
}
|
||||
fmt.Printf(" 同类边写入 %d 条,实际存下 %d 条\n", len(specs), len(relEdges))
|
||||
|
||||
// ★ 契约:三条都要在
|
||||
if len(relEdges) != len(specs) {
|
||||
t.Errorf("★ 同类边应可并存 %d 条,实际 %d 条 —— UNIQUE 约束把边降级成了标签",
|
||||
len(specs), len(relEdges))
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 边自己承载属性。
|
||||
func TestEdge_承载关系属性(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer func() { _ = g.Close() }()
|
||||
|
||||
for _, b := range []MemoryBlock{
|
||||
{ID: "blk_a", Modality: BlockText, Text: "甲"},
|
||||
{ID: "blk_b", Modality: BlockText, Text: "乙"},
|
||||
} {
|
||||
if err := g.PutMemoryBlocks([]MemoryBlock{b}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := g.AddRelationBlockEdge("blk_a", "blk_b", "属于", RelationEdgeData{
|
||||
SessionID: "sess-x", TurnID: 42, Confidence: 0.87, Status: EdgeActive,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
edges, err := g.MemoryBlockEdges()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, e := range edges {
|
||||
if e.Type != "属于" {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
fmt.Printf(" 边 #%d: session=%s turn=%d conf=%.2f status=%s\n",
|
||||
e.ID, e.SessionID, e.TurnID, e.Confidence, e.Status)
|
||||
if e.SessionID != "sess-x" {
|
||||
t.Errorf("★ 边未承载 session_id,实际 %q", e.SessionID)
|
||||
}
|
||||
if e.TurnID != 42 {
|
||||
t.Errorf("★ 边未承载 turn_id,实际 %d", e.TurnID)
|
||||
}
|
||||
if e.Confidence < 0.86 || e.Confidence > 0.88 {
|
||||
t.Errorf("★ 边未承载 confidence,实际 %.2f", e.Confidence)
|
||||
}
|
||||
if e.Status != EdgeActive {
|
||||
t.Errorf("★ 边未承载 status,实际 %q", e.Status)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("没找到刚写入的关系边")
|
||||
}
|
||||
}
|
||||
23
internal/memory/entityname_test.go
Normal file
23
internal/memory/entityname_test.go
Normal file
@ -0,0 +1,23 @@
|
||||
package memory
|
||||
|
||||
import "testing"
|
||||
|
||||
// 纯数字实体(端口/分机/编号类属性值)必须合法。
|
||||
// 背景(2026-10-01 跑分实测):旧实现要求"含字母/汉字"导致纯数字值
|
||||
// 被静默拒写——metrics 端口 8328、分机 4379→4324 全部丢进黑洞,
|
||||
// 且 commit 返回"成功",模型永不重试。
|
||||
func TestValidEntityName_AllowsNumericValues(t *testing.T) {
|
||||
for _, name := range []string{"8328", "4379", "8080", "v2", "COPPER-8177", "值班室的分机号"} {
|
||||
if !validEntityName(name) {
|
||||
t.Errorf("应合法: %q", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidEntityName_RejectsJunk(t *testing.T) {
|
||||
for _, name := range []string{"", "a", "——!!", "。。。", " "} {
|
||||
if validEntityName(name) {
|
||||
t.Errorf("应拒绝: %q", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
724
internal/memory/fusion.go
Normal file
724
internal/memory/fusion.go
Normal file
@ -0,0 +1,724 @@
|
||||
// 向量 + 符号融合召回。
|
||||
//
|
||||
// ★ 为什么必须融合而不是 fallback
|
||||
// -------------------------------
|
||||
// 现状(toolcall.go:236)是「块向量在前,符号路兜底」:块一旦有命中
|
||||
// 就直接 return,符号路的 RecallSorted **根本没被调用**。
|
||||
// 而实测(生产快照 1391 块,chineseclip 512 维)证明向量侧会失手:
|
||||
//
|
||||
// 查询「本机 13010 端口对应什么」
|
||||
// 向量 top8 → 「13000端口」「本地网关8081」…(都不含 13010)
|
||||
// 词法命中 → 「13010/13011 而非 12011」「http://127.0.0.1:13010」
|
||||
//
|
||||
// 设计注释里写的「端口号、分机号这类纯数字串向量天然弱」是对的,
|
||||
// 但**没有真正生效** —— 架构让符号路没有补位机会。
|
||||
//
|
||||
// ★ 三层根因里的第三层
|
||||
// ----------------------
|
||||
// 1. 各向异性 → 已修(RebuildCentroid 补上生产调用者)
|
||||
// 2. 精确串被稀释 → 向量模型固有限制,本文件解决它
|
||||
// 3. 符号路无补位 → **本文件**
|
||||
//
|
||||
// 融合不是「两路结果拼在一起」,而是**给每个候选算一个统一分数**:
|
||||
// 只有一路命中的候选仍可能被另一路拉上来,而不是排在后面。
|
||||
package memory
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// FusedHit 是融合后的候选。
|
||||
type FusedHit struct {
|
||||
BlockID string
|
||||
Text string
|
||||
Score float64 // 统一分数
|
||||
VectorHit float64 // 向量路分数(0 = 未命中)
|
||||
SymbolHit float64 // 符号路分数(0 = 未命中)
|
||||
// ExactHit 表示「查询里的精确数字串/版本串命中了本块」。
|
||||
//
|
||||
// ★ 这是**布尔信号**不是强度信号:它的价值在于「存在与否」。
|
||||
// 混进加权和会被低权重稀释(实测:sym=1.0×0.3=0.30
|
||||
// 输给 vec=0.62 + sym=0.33 → 0.534),所以单独一路置顶。
|
||||
ExactHit bool
|
||||
Why []string // 可读的归因,给调试与模型解释用
|
||||
}
|
||||
|
||||
// FusionWeights 是两路的权重。
|
||||
//
|
||||
// ★ 为什么默认向量主导(0.7/0.3)而不是对半
|
||||
// ------------------------------------------
|
||||
// 符号路在**长尾中文实体名**上非常强(「本批第181批的值班手册是第几版」
|
||||
// 这种问题 jieba+LIKE 就能定位),但它对**同义改写**毫无办法
|
||||
// (「脚本路径改到哪了」vs「/data/homeagent」零词法重叠)。
|
||||
// 向量恰好相反。
|
||||
//
|
||||
// 取 0.7/0.3 而不是 0.5/0.5:符号路的误报率更高(LIKE 命中泛词
|
||||
// 就会带出一堆无关块),而它的强项在 2 层能靠「符号命中即置顶」
|
||||
// 的规则体现,不必靠权重放大。
|
||||
type FusionWeights struct {
|
||||
Vector float64 // 默认 0.7
|
||||
Symbol float64 // 默认 0.3
|
||||
}
|
||||
|
||||
func defaultWeights() FusionWeights { return FusionWeights{Vector: 0.7, Symbol: 0.3} }
|
||||
|
||||
// 融合排序的符号提取规则。
|
||||
//
|
||||
// ★ 不能直接 jieba:memory 里记过「jieba + LIKE 把『服务』『端口』
|
||||
// 这类泛词当过滤词,造成大量噪音」。所以符号路只认**高信息量**的词:
|
||||
// - 数字串(端口、版本号、日期、批次号)
|
||||
// - 英文标识符(plugindev、grafana、kafka)
|
||||
// - 中文长词(≥2 字,且不是泛词表里的)
|
||||
var (
|
||||
symbolNumericRe = regexp.MustCompile(
|
||||
`\d+(?:[./]\d+)*(?:\.\d+)?%?|[vV]\d+(?:\.\d+)+`)
|
||||
symbolLatinRe = regexp.MustCompile(`[A-Za-z][A-Za-z0-9_-]{2,}`)
|
||||
symbolCJKRunRe = regexp.MustCompile(`[一-鿿]+`)
|
||||
)
|
||||
|
||||
// cjkWindow 是中文符号的切分窗口。
|
||||
//
|
||||
// ★ 为什么不用词法切分而用滑窗
|
||||
// -----------------------------
|
||||
// 正确的做法是 jieba 之类的分词,但 memory 里记过它的问题:
|
||||
// 「jieba + LIKE 把『服务』『端口』这类泛词当过滤词,造成大量噪音」——
|
||||
// 它把「监控面板的端口是多少」切成「监控/面板/的/端口/是/多少」,
|
||||
// 而块文本里的词边界与它不一致,两边对不齐。
|
||||
//
|
||||
// ★ 而贪婪长串匹配更糟(实测)
|
||||
// -----------------------------
|
||||
//
|
||||
// 「grafana 监控面板的端口是多少」 → [grafana 监控面板的端口是多少]
|
||||
// 「脚本路径改到哪个目录了」 → [脚本路径改到哪个目录了]
|
||||
//
|
||||
// 整句变成**一个**符号,而任何块都不可能包含整句 ⇒ 符号分恒为 0,
|
||||
// 符号路对**所有纯中文查询完全失效**(只有含数字串的探针能被救)。
|
||||
//
|
||||
// 折中:2~4 字滑窗 + 泛词黑名单。宁可多提候选(靠覆盖率阈值压制
|
||||
// 噪声),也不要「一个符号都没有」。
|
||||
const (
|
||||
cjkWindowMin = 2
|
||||
cjkWindowMax = 4
|
||||
)
|
||||
|
||||
// symbolStopWords 是符号路的泛词黑名单 —— 命中它们不作为信号。
|
||||
var symbolStopWords = map[string]bool{
|
||||
"服务": true, "端口": true, "地址": true, "配置": true, "问题": true,
|
||||
"记忆": true, "时间": true, "地方": true, "东西": true, "什么": true,
|
||||
"怎么": true, "哪个": true, "是否": true, "可以": true, "需要": true,
|
||||
"文件": true, "目录": true, "路径": true, "用户": true, "系统": true,
|
||||
}
|
||||
|
||||
// cjkFunctionWords 是 jieba 分出来的**虚词** —— 它们是词,但不携带语义。
|
||||
//
|
||||
// ★ 与 symbolStopWords 的区别(这是 2026-10-04 才想清楚的)
|
||||
// ------------------
|
||||
//
|
||||
// symbolStopWords 滑窗的跨词伪词(2~4 字重叠窗口)
|
||||
// cjkFunctionWords 词典分词认出的虚词
|
||||
//
|
||||
// 「端口」曾在 symbolStopWords 里,那是错的 —— 它是实词。
|
||||
// 但「什么」「哪些」是真的虚词,即便 jieba 认得出它们,
|
||||
// 作为召回信号也没有区分度(库里有「什么」的块很多,
|
||||
// 而「哪些」的命中几乎覆盖全库)。
|
||||
//
|
||||
// ★ 所以两套标准分开:jieba 分出来的词过这张**虚词**表,
|
||||
//
|
||||
// 滑窗产出的候选过 symbolStopWords。
|
||||
var cjkFunctionWords = map[string]bool{
|
||||
"什么": true, "哪些": true, "哪个": true, "怎么": true, "如何": true,
|
||||
"是否": true, "可以": true, "需要": true, "应该": true, "必须": true,
|
||||
"的": true, "了": true, "吗": true, "呢": true, "吧": true,
|
||||
"和": true, "与": true, "或": true, "及": true, "等": true,
|
||||
// ★ 语气词(2026-10-04):判据 TestFuse_无符号退化为向量序 用「嗯」
|
||||
// 构造"查询提不出符号"的场景,而 jieba 会把它切出来 ——
|
||||
// 于是那条判据测不到退化路径了(它绿着,但测的不是目标行为)。
|
||||
// ★ 这是「修了 A 弄坏 B」的典型:单看每处都对,合起来错。
|
||||
"嗯": true, "啊": true, "呀": true, "哦": true, "噢": true,
|
||||
"欸": true, "喂": true, "哎": true, "唔": true,
|
||||
}
|
||||
|
||||
// QuerySymbols 提取查询里的高信息量符号。
|
||||
func QuerySymbols(query string) []string {
|
||||
out := make([]string, 0, 8)
|
||||
seen := map[string]bool{}
|
||||
add := func(s string) {
|
||||
if s == "" || seen[s] {
|
||||
return
|
||||
}
|
||||
seen[s] = true
|
||||
out = append(out, s)
|
||||
}
|
||||
for _, m := range symbolNumericRe.FindAllString(query, -1) {
|
||||
add(m)
|
||||
}
|
||||
for _, m := range symbolLatinRe.FindAllString(query, -1) {
|
||||
add(strings.ToLower(m))
|
||||
}
|
||||
// ★★★ 中文用 jieba 切,滑窗只作降级(2026-10-04)
|
||||
//
|
||||
// 为什么不用滑窗(它曾是这个函数的唯一实现):
|
||||
//
|
||||
// 查询「本机服务监听哪些端口」
|
||||
// 滑窗 → ["本机服务", "监听哪些"] ← 全是跨词伪词
|
||||
// jieba → [本机 服务 监听 哪些 端口] ← 「端口」独立成词
|
||||
//
|
||||
// ★ 这是生产探针 coexist 0/1 的**真正根因**:
|
||||
// 「端口」从未单独成窗 ⇒ 符号路对「14010端口」全打 0 分
|
||||
// ⇒ 直接命中全是噪音 ⇒ 图联想也无从谈起
|
||||
// (详见 RecallBlocksFusedBFS 的注释:联想救不了召回本身错了)
|
||||
//
|
||||
// ★ 为什么两者都产出
|
||||
//
|
||||
// jieba 有词典依赖(词库目录)。GetJieba() 拿不到词库时返回 nil,
|
||||
// 此时若只靠 jieba,符号会**整个消失** ⇒ 符号路彻底失效。
|
||||
// 所以:jieba 成功则用它 + 保留滑窗作为补充;
|
||||
// 失败则纯滑窗(退化成今天的行为,不会更差)。
|
||||
//
|
||||
// ★ 停用词在这里仍然生效(jieba 分出来的虚词会被 add 过滤):
|
||||
// 「哪些」「什么」这类词 jieba 也会切出来,不加过滤会被当信号。
|
||||
cjkRuns := symbolCJKRunRe.FindAllString(query, -1)
|
||||
cut := GetJieba()
|
||||
usedJieba := false
|
||||
if cut != nil {
|
||||
for _, run := range cjkRuns {
|
||||
for _, w := range cut.Cut(run, true) {
|
||||
w = strings.TrimSpace(w)
|
||||
if w == "" || cjkFunctionWords[w] {
|
||||
continue
|
||||
}
|
||||
// ★★ 停用词表**只对滑窗生效**,对 jieba 不生效。
|
||||
//
|
||||
// 理由:jieba 是**词典分词**,「端口」「服务」被切成
|
||||
// 独立词,是因为它们在语料里确实是词 ——
|
||||
// 它们携带语义,不是伪词。
|
||||
//
|
||||
// 而滑窗切出来的「服务」可能只是「本机服务监听」里
|
||||
// 恰好跨界的 2 字串,那种是伪词,该滤。
|
||||
//
|
||||
// ★ 混用两套标准会出问题:2026-10-04 试过把
|
||||
// 「端口/服务」从停用词表移出(数据上它们命中
|
||||
// 只占 0.8%,并不泛),但因为**滑窗**同时被改成
|
||||
// 全子窗,于是噪音涌入、casual 从 3/4 掉到 2/4。
|
||||
// ⇒ 那次回归的根因不是停用词表,是滑窗。
|
||||
//
|
||||
// 现在滑窗保持原样(全子窗没启用),jieba 独立成词
|
||||
// 不受停用词限制 —— 两套标准分开,问题不会互相污染。
|
||||
// ★ 只收**单个词**,不收 jieba 的组合结果
|
||||
// (Cut(hmm=true) 只切不组,理论上不会有组合;
|
||||
// 这里防御性过滤,避免将来误用 hmm=false 时把整句当符号)。
|
||||
add(w)
|
||||
}
|
||||
}
|
||||
usedJieba = len(out) > 0
|
||||
}
|
||||
// ★ 滑窗补充:jieba 缺词时仍能给出重叠窗口候选。
|
||||
// 注意滑窗产出**会**包含停用词过滤(cjkWindows 内部已过滤)。
|
||||
if !usedJieba {
|
||||
for _, run := range cjkRuns {
|
||||
for _, w := range cjkWindows(run) {
|
||||
add(w)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// cjkWindows 把一段连续中文切成 2~4 字滑窗候选。
|
||||
//
|
||||
// ★ 去重与裁剪
|
||||
// ------------
|
||||
// 同一段中文会产出大量重叠窗口(「监控面板的」→ 监控/控面板/面板的…),
|
||||
// 但只有**非重叠前缀**保留,避免符号集被同质候选塞满:
|
||||
//
|
||||
// 「监控面板的」→ 监控控/控面板/面板的(步长 2,max-1 = 3)
|
||||
//
|
||||
// 泛词在调用侧被 symbolStopWords 过滤。
|
||||
func cjkWindows(run string) []string {
|
||||
r := []rune(run)
|
||||
if len(r) < cjkWindowMin {
|
||||
return nil
|
||||
}
|
||||
// 去掉句尾的虚词尾巴(「的」「了」「吗」等)——
|
||||
// 它们几乎不可能是块内容的一部分
|
||||
for len(r) > cjkWindowMin && isTrailingParticle(r[len(r)-1]) {
|
||||
r = r[:len(r)-1]
|
||||
}
|
||||
out := make([]string, 0, 8)
|
||||
n := len(r)
|
||||
for i := 0; i < n; i++ {
|
||||
for l := cjkWindowMax; l >= cjkWindowMin; l-- {
|
||||
if i+l > n {
|
||||
continue
|
||||
}
|
||||
w := string(r[i : i+l])
|
||||
if symbolStopWords[w] {
|
||||
continue
|
||||
}
|
||||
out = append(out, w)
|
||||
// 只取该位置最长的一个窗口,往后步进,避免重叠候选
|
||||
i += l - 1
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isTrailingParticle(r rune) bool {
|
||||
switch r {
|
||||
case '的', '了', '吗', '呢', '啊', '呀', '吧', '是', '在', '个':
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SymbolScore 用查询符号在块文本里的出现情况打分(0~1)。
|
||||
//
|
||||
// ★ 精确串命中权重远高于中文词
|
||||
// --------------------------------
|
||||
// 正是因为「13010」这种精确串是唯一能救回向量失手的信号,
|
||||
// 它的命中必须给满分,而不是和其它词按个数平均。
|
||||
func SymbolScore(symbols []string, text string) (score float64, matched []string, exact bool) {
|
||||
if len(symbols) == 0 || text == "" {
|
||||
return 0, nil, false
|
||||
}
|
||||
lower := strings.ToLower(text)
|
||||
exactHit, wordHit := 0, 0
|
||||
for _, s := range symbols {
|
||||
if strings.Contains(lower, strings.ToLower(s)) {
|
||||
if isNumericSymbol(s) {
|
||||
exactHit++
|
||||
} else {
|
||||
wordHit++
|
||||
}
|
||||
matched = append(matched, s)
|
||||
}
|
||||
}
|
||||
if exactHit > 0 {
|
||||
// 有精确数字串命中 ⇒ 满分 + exact 标记。
|
||||
// ★ 1.0 在调用方被当作**布尔信号**用(直接置顶),
|
||||
// 不参与加权和 —— 否则会被 Symbol 权重稀释。
|
||||
return 1.0, matched, true
|
||||
}
|
||||
// 中文/英文词按覆盖比例给分,不叠加(避免「命中多就满分」)。
|
||||
if wordHit > 0 {
|
||||
r := float64(wordHit) / float64(len(symbols))
|
||||
if r > score {
|
||||
score = r
|
||||
}
|
||||
}
|
||||
return score, matched, false
|
||||
}
|
||||
|
||||
func isNumericSymbol(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if unicode.IsDigit(r) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// v2.31.5 这类版本串:首字母 + 数字
|
||||
if (s[0] == 'v' || s[0] == 'V') && len(s) > 1 {
|
||||
for _, r := range s[1:] {
|
||||
if unicode.IsDigit(r) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// fuseCandidates 把向量候选与符号候选合并成一个统一排序。
|
||||
//
|
||||
// ★ 与仲裁的顺序:融合在**前**,仲裁在**后**
|
||||
// ---------------------------------------------
|
||||
// 仲裁判断「谁取代了谁」依赖向量的相关性来决定「给模型看哪些」,
|
||||
// 若先仲裁后融合,仲裁剔除的块可能正是符号路能救回来的那条。
|
||||
//
|
||||
// 注意 candidates 必须来自**未截断**的全量候选集 ——
|
||||
// 向量路已经把正确块挤掉的话,融合也无从救回(与仲裁同一条理由)。
|
||||
func fuseCandidates(vectorHits []BlockHit, allBlocks []MemoryBlock,
|
||||
query string, w FusionWeights) []FusedHit {
|
||||
|
||||
symbols := QuerySymbols(query)
|
||||
if len(symbols) == 0 {
|
||||
// 无符号可提 ⇒ 退化成纯向量序(但仍标注 why,便于调试)
|
||||
out := make([]FusedHit, 0, len(vectorHits))
|
||||
for _, h := range vectorHits {
|
||||
out = append(out, FusedHit{
|
||||
BlockID: h.Block.ID, Text: h.Block.Text,
|
||||
Score: h.Score * w.Vector, VectorHit: h.Score,
|
||||
Why: []string{"仅向量命中(查询未提取到符号)"},
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 符号路在全量块里找命中(不是只在向量候选里找 ——
|
||||
// 那样「向量没召回但符号命中」的块根本没有机会上场)
|
||||
// ★ 必须用**索引**而不是 &out[i] 指针。
|
||||
//
|
||||
// 实测 bug:符号路阶段会 append(向量未召回但符号命中的块),
|
||||
// append 触发扩容后切片换了底层数组,之前存的 &out[i] 全部指向
|
||||
// 旧数组 —— 于是给已有候选写 SymbolHit 时写进了废弃副本,
|
||||
// 分数永远算不出来。判据直接抓到了这个:目标块符号分 0.333
|
||||
// 而非满分。
|
||||
//
|
||||
// 同理第一遍循环里那个 `byID[h.Block.ID] = &f`(f 是循环内临时变量)
|
||||
// 也是无效指针,一并去掉。
|
||||
idxOf := make(map[string]int, len(vectorHits))
|
||||
out := make([]FusedHit, 0, len(vectorHits)+8)
|
||||
for _, h := range vectorHits {
|
||||
idxOf[h.Block.ID] = len(out)
|
||||
out = append(out, FusedHit{
|
||||
BlockID: h.Block.ID, Text: h.Block.Text,
|
||||
VectorHit: h.Score,
|
||||
})
|
||||
}
|
||||
|
||||
seenSymbol := map[string]bool{}
|
||||
for _, b := range allBlocks {
|
||||
if seenSymbol[b.ID] {
|
||||
continue
|
||||
}
|
||||
sc, matched, exact := SymbolScore(symbols, b.Text)
|
||||
if sc <= 0 {
|
||||
continue
|
||||
}
|
||||
seenSymbol[b.ID] = true
|
||||
if i, ok := idxOf[b.ID]; ok {
|
||||
out[i].SymbolHit = sc
|
||||
out[i].ExactHit = exact
|
||||
out[i].Why = append(out[i].Why,
|
||||
fmt.Sprintf("符号命中 %v", matched))
|
||||
} else {
|
||||
// ★ 向量没召回但符号命中 —— 这就是融合要救的那一类
|
||||
out = append(out, FusedHit{
|
||||
BlockID: b.ID, Text: b.Text,
|
||||
SymbolHit: sc, ExactHit: exact,
|
||||
Why: []string{
|
||||
fmt.Sprintf("仅符号命中(向量未召回):%v", matched),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for i := range out {
|
||||
f := &out[i]
|
||||
// ★ 精确串命中直接置顶,不参与加权。
|
||||
//
|
||||
// 实测 bug:只符号命中且符号满分(精确数字串)的块
|
||||
// score = 1.0 × w.Symbol(0.3) = 0.300
|
||||
// 竟然输给「符号分只有 0.333」的向量候选:
|
||||
// score = 0.620×0.7 + 0.333×0.3 = 0.534
|
||||
//
|
||||
// 根因:**满分被低权重稀释了**。而精确串命中正是符号路
|
||||
// 不可替代的能力(向量对「13010」这种串天然弱),
|
||||
// 它的价值在于「存在与否」,不在于「有多少」。
|
||||
//
|
||||
// 所以 SymbolScore 给的 1.0 是布尔信号,这里必须按布尔用。
|
||||
if f.ExactHit {
|
||||
// 向量分只做平手时的微调(不改变「已置顶」这个事实)
|
||||
f.Score = 1.0 + f.VectorHit*0.01
|
||||
continue
|
||||
}
|
||||
if f.SymbolHit > 0 && f.VectorHit == 0 {
|
||||
f.Score = f.SymbolHit * w.Symbol
|
||||
continue
|
||||
}
|
||||
f.Score = f.VectorHit*w.Vector + f.SymbolHit*w.Symbol
|
||||
}
|
||||
|
||||
// 稳定排序:分数降序;同分保持向量原序(不引入额外的不确定性)
|
||||
sortFused(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func sortFused(out []FusedHit) {
|
||||
// 插入排序 —— 候选规模是百级,无需 sort.Slice 的反射开销,
|
||||
// 且完全稳定(不依赖 sort.SliceStable 的额外分配)。
|
||||
for i := 1; i < len(out); i++ {
|
||||
x := out[i]
|
||||
j := i - 1
|
||||
for j >= 0 && out[j].Score < x.Score {
|
||||
out[j+1] = out[j]
|
||||
j--
|
||||
}
|
||||
out[j+1] = x
|
||||
}
|
||||
}
|
||||
|
||||
// RecallBlocksFused 是**融合召回的对外入口**。
|
||||
//
|
||||
// 与 RecallBlocksWithArbitration 的区别:
|
||||
// - RecallBlocksWithArbitration:向量召回 + 时序仲裁(排序仍是余弦序)
|
||||
// - RecallBlocksFused:向量召回 + 符号路融合 + 时序仲裁
|
||||
//
|
||||
// 返回的 FusedHit 带 Why(归因),便于模型理解「为什么这条被召回」。
|
||||
func (g *GraphDB) RecallBlocksFused(q BlockRecallQuery, query string) (
|
||||
[]FusedHit, ArbitrationResult, error) {
|
||||
|
||||
q2 := q
|
||||
q2.TopK = largeTopK
|
||||
vecHits, err := g.RecallBlocks(q2)
|
||||
if err != nil {
|
||||
return nil, ArbitrationResult{}, err
|
||||
}
|
||||
// 全量块(供符号路扫描)—— 只取有文本的(图像块无符号可匹配)
|
||||
all, err := g.MemoryBlocks()
|
||||
if err != nil {
|
||||
return nil, ArbitrationResult{}, err
|
||||
}
|
||||
// ★★ 符号路必须也按 fingerprint 过滤 —— 这是一个真实的安全缺陷。
|
||||
//
|
||||
// 判据立刻抓到(toolcall_block_test.go:124):
|
||||
//
|
||||
// 块 {Text: "旧空间的内容", Fingerprint: "old-fp"}
|
||||
// 查询「旧空间的内容」
|
||||
// ⇒ 期望不召回,实际召回了(找到 1 条)
|
||||
//
|
||||
// 根因:符号路拿的是 `MemoryBlocks()` 的**全部**带文本块,
|
||||
// 而 RecallBlocks 会按 fingerprint 跳过不匹配的块。
|
||||
// 文本匹配不依赖向量空间,所以「旧空间的块」在符号路原形毕露 ——
|
||||
// 换向量空间后旧块仍会污染结果,正是这条判据要防的事。
|
||||
textBlocks := make([]MemoryBlock, 0, len(all))
|
||||
for _, b := range all {
|
||||
if b.Text == "" {
|
||||
continue
|
||||
}
|
||||
// 指纹不匹配的块一律不进符号路候选
|
||||
if q.Fingerprint != "" && b.Fingerprint != "" && b.Fingerprint != q.Fingerprint {
|
||||
continue
|
||||
}
|
||||
textBlocks = append(textBlocks, b)
|
||||
}
|
||||
|
||||
fused := fuseCandidates(vecHits, textBlocks, query, defaultWeights())
|
||||
|
||||
// 融合 → 仲裁:仲裁在融合之后,且要喂进 FusedHit 的 id
|
||||
vecOrder := make([]BlockHit, 0, len(fused))
|
||||
byID := make(map[string]MemoryBlock, len(fused))
|
||||
for _, f := range fused {
|
||||
byID[f.BlockID] = MemoryBlock{ID: f.BlockID, Text: f.Text}
|
||||
vecOrder = append(vecOrder, BlockHit{
|
||||
Block: byID[f.BlockID], Score: f.Score,
|
||||
})
|
||||
}
|
||||
arb := arbitrate(g, vecOrder)
|
||||
superseded := map[string]bool{}
|
||||
for _, h := range arb.Superseded {
|
||||
superseded[h.Block.ID] = true
|
||||
}
|
||||
|
||||
kept := make([]FusedHit, 0, len(fused))
|
||||
for _, f := range fused {
|
||||
if superseded[f.BlockID] {
|
||||
continue
|
||||
}
|
||||
// ★ MinScore 按**路**应用,不是套在融合分上。
|
||||
//
|
||||
// 接入时我曾把 MinScore 直接传 0(理由「量纲变了」),
|
||||
// 结果两条判据立刻红了:
|
||||
//
|
||||
// toolcall_block_test.go:80 低分块不该出现(MinScore 应滤掉)
|
||||
// toolcall_block_test.go:124 指纹不匹配的块不该被召回
|
||||
//
|
||||
// ★ 那等于**放弃了噪声过滤** —— 「量纲变了」是事实,
|
||||
// 但解法不是丢掉阈值,而是让阈值作用在它该作用的那一路上。
|
||||
//
|
||||
// 融合分有三段不同量纲:
|
||||
// 精确串命中 ≥1.00 布尔置顶,不过滤
|
||||
// 纯符号命中 0 ~ w.Symbol 字面强信号
|
||||
// 纯向量命中 0 ~ w.Vector×余弦 ← MinScore 说的就是这个
|
||||
//
|
||||
// 而指纹不匹配的块压根不在候选里(RecallBlocks 已跳过),
|
||||
// 它的失败另有原因 —— 见下方说明。
|
||||
switch {
|
||||
case f.ExactHit:
|
||||
// 精确串命中是决定性信号,不受 MinScore 约束
|
||||
case f.VectorHit > 0:
|
||||
// 有向量分时才用 MinScore 过滤(避免把纯符号命中误杀)
|
||||
if q.MinScore > 0 && f.VectorHit < q.MinScore {
|
||||
continue
|
||||
}
|
||||
default:
|
||||
// 只有符号分、无向量分:靠覆盖率判断,不套向量阈值
|
||||
if f.SymbolHit <= 0 {
|
||||
continue
|
||||
}
|
||||
}
|
||||
kept = append(kept, f)
|
||||
}
|
||||
if k := effectiveTopK(q.TopK); len(kept) > k {
|
||||
kept = kept[:k]
|
||||
}
|
||||
return kept, arb, nil
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// RecallBlocksFusedBFS —— 召回即联想(2026-10-04)
|
||||
//
|
||||
// ★★ 这个入口是为了验证一个假设:
|
||||
//
|
||||
// 生产探针 coexist 0/1:查询「本机服务监听哪些端口」召不回 13010。
|
||||
// 直觉是「召回该联想」—— 命中节点沿边走一层,找回图上相连的上下文。
|
||||
//
|
||||
// ★ 实测结论:**假设不成立**,而且原因值得记下来。
|
||||
//
|
||||
// 调试记录(生产库):
|
||||
//
|
||||
// 直接命中 top8 全是噪音(CPU总线… / sdk/introduce 站部署 / …)
|
||||
// 联想确实在工作(从噪音节点联出了 5 个)
|
||||
// 但 13010 **根本没进 top8** ⇒ 联想无从谈起
|
||||
//
|
||||
// 往下挖,真正的根因是**符号切分**:
|
||||
//
|
||||
// 查询「本机服务监听哪些端口」的符号只有
|
||||
// ["本机服务","监听哪些"] —— 全是跨词伪词
|
||||
// 「端口」被中划窗「每位置只取最长窗口」吃掉
|
||||
// ⇒ 符号路对「14010端口」全打 0 分
|
||||
//
|
||||
// ⇒ ★★ **召回即联想救不了召回本身错了的情况**:
|
||||
// 联想从**已命中**节点出发,而命中节点本身就是错的。
|
||||
// 图联想只能补上下文,不能修命中。
|
||||
//
|
||||
// 所以本函数**不接入生产路径**,作为独立入口保留:
|
||||
// 修好命中质量之后,它才有意义。
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// bfsExpansionBudget 是 BFS 追加节点的总预算上限。
|
||||
//
|
||||
// ★ 为什么必须有预算:图不是树 —— 关系边双向、稠密。
|
||||
//
|
||||
// 无预算的 BFS 在大图上会把整个连通分量拖进来,
|
||||
// 而 memory_recall 的工具预算是有限的
|
||||
// (历史上曾一次返回 193 个实体、13744 tokens,是预算的 436%)。
|
||||
//
|
||||
// ★ 它约束「**追加**的联想节点」,不含直接命中 ——
|
||||
//
|
||||
// 直接命中已由 q.TopK 控制。
|
||||
const bfsExpansionBudget = 60
|
||||
|
||||
// RecallBlocksFusedBFS 在向量 + 符号的直接命中之外,
|
||||
// 对每个命中节点做一层 BFS,把联想到的节点按**发现顺序**追加到尾部。
|
||||
//
|
||||
// ★ 追加而非置顶:联想是补充,不是答案。
|
||||
//
|
||||
// 混进相关性排序会污染「离查询多近」这个信号。
|
||||
//
|
||||
// ★ 联想节点按 base/(1+d) 衰减,越远越弱但仍 > 0
|
||||
//
|
||||
// (否则会被下游 MinScore 滤掉)。
|
||||
//
|
||||
// ★ 预算耗尽即停,且已产出的部分照常返回 ——
|
||||
//
|
||||
// 截断不该让整次召回失败。
|
||||
func (g *GraphDB) RecallBlocksFusedBFS(q BlockRecallQuery, query string, depth int) (
|
||||
[]FusedHit, ArbitrationResult, error) {
|
||||
|
||||
hits, arb, err := g.RecallBlocksFused(q, query)
|
||||
if err != nil {
|
||||
return nil, arb, err
|
||||
}
|
||||
if depth <= 0 || len(hits) == 0 {
|
||||
return hits, arb, nil
|
||||
}
|
||||
|
||||
seen := make(map[string]bool, len(hits))
|
||||
for _, h := range hits {
|
||||
seen[h.BlockID] = true
|
||||
}
|
||||
base := 0.0
|
||||
for _, h := range hits {
|
||||
if h.Score > base {
|
||||
base = h.Score
|
||||
}
|
||||
}
|
||||
if base <= 0 {
|
||||
base = 1.0
|
||||
}
|
||||
|
||||
appended := 0
|
||||
for _, seed := range hits {
|
||||
if appended >= bfsExpansionBudget {
|
||||
break
|
||||
}
|
||||
for d := 1; d <= depth; d++ {
|
||||
neighbors, err := g.NeighbourBlocks(seed.BlockID)
|
||||
if err != nil {
|
||||
// 联想失败不该让整次召回失败 —— 主路结果仍然有用。
|
||||
break
|
||||
}
|
||||
for _, nb := range neighbors {
|
||||
if seen[nb.ID] {
|
||||
continue
|
||||
}
|
||||
seen[nb.ID] = true
|
||||
if appended >= bfsExpansionBudget {
|
||||
return hits, arb, nil
|
||||
}
|
||||
hits = append(hits, FusedHit{
|
||||
BlockID: nb.ID,
|
||||
Text: nb.Text,
|
||||
Score: base / float64(1+d),
|
||||
Why: []string{"联想:" + seed.Text + " → " + nb.Text +
|
||||
fmt.Sprintf("(%d 层)", d)},
|
||||
})
|
||||
appended++
|
||||
}
|
||||
// 只展开一层 —— 下一轮 d 由新追加的节点继续。
|
||||
break
|
||||
}
|
||||
}
|
||||
return hits, arb, nil
|
||||
}
|
||||
|
||||
// NeighbourBlocks 返回 startID 的**直接邻居**(一层,双向,按发现顺序)。
|
||||
//
|
||||
// ★ 与 BFSBlocks 的区别:那个按 depth 逐层展开,
|
||||
//
|
||||
// 这里专供召回链逐层调用 —— 因为要按层给不同打分。
|
||||
//
|
||||
// ★ 只沿**关系边**展开,不沿 contains 结构边:
|
||||
//
|
||||
// contains 连的是「原句块 → 字段块/关系边」,那是实现细节,
|
||||
// 把它当语义边会让原句块(长文本)混进召回结果、抢占预算。
|
||||
func (g *GraphDB) NeighbourBlocks(startID string) ([]MemoryBlock, error) {
|
||||
if startID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
|
||||
rows, err := g.db.Query(
|
||||
`SELECT DISTINCT
|
||||
CASE WHEN e.source_id = ? THEN e.target_id ELSE e.source_id END AS peer
|
||||
FROM memory_block_edges e
|
||||
WHERE (e.source_id = ? OR e.target_id = ?)
|
||||
AND e.source_kind = 'block' AND e.target_kind = 'block'
|
||||
AND e.edge_type != 'contains'
|
||||
AND COALESCE(e.status, '') != 'deleted'
|
||||
ORDER BY e.created_at ASC, e.id ASC`, startID, startID, startID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return g.blocksByIDsLocked(ids)
|
||||
}
|
||||
249
internal/memory/fusion_bfs_test.go
Normal file
249
internal/memory/fusion_bfs_test.go
Normal file
@ -0,0 +1,249 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// BFS 接入召回链 —— 判据
|
||||
//
|
||||
// ★ 真实缺口(生产探针 coexist 维度 0/1)
|
||||
//
|
||||
// 查询「本机服务监听哪些端口」召不回 13010。
|
||||
// 原因:库里含「端口」二字的块是 14010端口 / 14011端口 那批,
|
||||
// 而真正的本机端口块文本是「http://127.0.0.1:13010」——
|
||||
// **不含「端口」二字**。
|
||||
//
|
||||
// ⇒ 纯向量/符号召回召不回它。
|
||||
// ⇒ 而图上是连着的:billing服务 --监听--> 13010
|
||||
// 只要从「监听」或端口号出发 BFS,就能找回服务名。
|
||||
//
|
||||
// ★ 所以这一格测的不是「关键词召回能不能更强」,
|
||||
// 而是「召回**是否即联想**」—— 孤立命中之外,
|
||||
// 还要沿边找回上下文。
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
func seedCoexistGraph(t *testing.T) *GraphDB {
|
||||
t.Helper()
|
||||
g := newTestGraph(t)
|
||||
// 织一张真实形态的网:服务 --监听--> 端口号
|
||||
blocks := []MemoryBlock{
|
||||
{ID: "b_svc", Modality: BlockText, Text: "billing服务", Vector: []float64{1, 0}, Fingerprint: "fp1"},
|
||||
{ID: "b_port", Modality: BlockText, Text: "13010", Vector: []float64{0.9, 0.1}, Fingerprint: "fp1"},
|
||||
{ID: "b_noise1", Modality: BlockText, Text: "14010端口", Vector: []float64{0, 1}, Fingerprint: "fp1"},
|
||||
{ID: "b_noise2", Modality: BlockText, Text: "14011端口", Vector: []float64{0, 1}, Fingerprint: "fp1"},
|
||||
{ID: "b_noise3", Modality: BlockText, Text: "CPU总线与MAR/MDR/ALU连接问题", Vector: []float64{0, 1}, Fingerprint: "fp1"},
|
||||
}
|
||||
for i := range blocks {
|
||||
if err := g.PutMemoryBlocks([]MemoryBlock{blocks[i]}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, e := range []struct{ from, to, typ string }{
|
||||
{"b_svc", "b_port", "监听"},
|
||||
{"b_noise1", "b_port", "监听"},
|
||||
{"b_noise3", "b_svc", "涉及"},
|
||||
} {
|
||||
if err := g.AddRelationBlockEdge(e.from, e.to, e.typ,
|
||||
RelationEdgeData{SessionID: "s1", Confidence: 0.9, Status: EdgeActive}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// TestBFS召回_从服务找回端口:图上连着的就该召回
|
||||
func TestBFS召回_从服务找回端口(t *testing.T) {
|
||||
g := seedCoexistGraph(t)
|
||||
defer func() { _ = g.Close() }()
|
||||
|
||||
// 查询直接命中「billing服务」——一个**孤立的语义节点**,
|
||||
// 但图上它连着 13010。
|
||||
// 带上查询向量:RecallBlocks 要求非空向量(符号路也依赖融合入口)
|
||||
q := BlockRecallQuery{Vector: []float64{1, 0}, TopK: 5, MinScore: -1}
|
||||
hits, _, err := g.RecallBlocksFusedBFS(q, "billing服务", 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fmt.Printf(" 命中 %d 个: ", len(hits))
|
||||
for _, h := range hits {
|
||||
fmt.Printf("%q ", h.Text)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
if !containsText(hits, "13010") {
|
||||
t.Errorf("★ 召回应沿边找回端口号 13010(召回即联想),实际 %v", textsOfFused(hits))
|
||||
}
|
||||
}
|
||||
|
||||
// TestBFS召回_顺序稳定:map 遍历会让输出顺序随机
|
||||
func TestBFS召回_顺序稳定(t *testing.T) {
|
||||
g := seedCoexistGraph(t)
|
||||
defer func() { _ = g.Close() }()
|
||||
|
||||
// 带上查询向量:RecallBlocks 要求非空向量(符号路也依赖融合入口)
|
||||
q := BlockRecallQuery{Vector: []float64{1, 0}, TopK: 5, MinScore: -1}
|
||||
var first []string
|
||||
for run := 0; run < 5; run++ {
|
||||
hits, _, err := g.RecallBlocksFusedBFS(q, "billing服务", 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := textsOfFused(hits)
|
||||
if run == 0 {
|
||||
first = got
|
||||
continue
|
||||
}
|
||||
if len(got) != len(first) {
|
||||
t.Fatalf("第 %d 次长度不同: %v vs %v", run, got, first)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != first[i] {
|
||||
t.Fatalf("★ 第 %d 次顺序不同: %v vs %v(map 遍历导致输出不稳定)", run, got, first)
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Printf(" 5 次顺序一致: %v\n", first)
|
||||
}
|
||||
|
||||
// TestBFS召回_预算有界:不能因为加 BFS 就无限膨胀
|
||||
func TestBFS召回_预算有界(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer func() { _ = g.Close() }()
|
||||
|
||||
// 织一个 200 节点的链:加 BFS 若无预算,depth=3 会拉进一大片
|
||||
for i := 0; i < 200; i++ {
|
||||
id := fmt.Sprintf("n%03d", i)
|
||||
if err := g.PutMemoryBlocks([]MemoryBlock{
|
||||
{ID: id, Modality: BlockText, Text: fmt.Sprintf("节点%03d", i),
|
||||
Vector: []float64{1, 0}, Fingerprint: "fp1"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if i > 0 {
|
||||
if err := g.AddRelationBlockEdge(fmt.Sprintf("n%03d", i-1), id, "next",
|
||||
RelationEdgeData{SessionID: "s", Status: EdgeActive}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 带上查询向量:RecallBlocks 要求非空向量(符号路也依赖融合入口)
|
||||
q := BlockRecallQuery{Vector: []float64{1, 0}, TopK: 5, MinScore: -1}
|
||||
hits, _, err := g.RecallBlocksFusedBFS(q, "节点000", 3)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fmt.Printf(" 200 节点链,depth=3 → 召回 %d 个\n", len(hits))
|
||||
if len(hits) > 60 {
|
||||
t.Errorf("★ BFS 应受预算约束(200 节点链 depth=3 召回 %d 个,过多)", len(hits))
|
||||
}
|
||||
}
|
||||
|
||||
func containsText(hits []FusedHit, s string) bool {
|
||||
for _, h := range hits {
|
||||
if h.Text == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func textsOfFused(hits []FusedHit) []string {
|
||||
out := make([]string, 0, len(hits))
|
||||
for _, h := range hits {
|
||||
out = append(out, h.Text)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 中文字符切分 —— 判据(2026-10-04)
|
||||
//
|
||||
// ★ 这是生产探针 coexist 0/1 的**真正根因**
|
||||
//
|
||||
// 滑窗「每位置只取最长窗口」把目标词吃掉了:
|
||||
//
|
||||
// 「本机服务监听哪些端口」
|
||||
// 滑窗 → ["本机服务", "监听哪些"] 全是跨词伪词
|
||||
// jieba → [本机 服务 监听 哪些 端口] 「端口」独立成词
|
||||
//
|
||||
// ⇒ 「端口」从未单独成窗 ⇒ 符号路对「14010端口」全打 0 分
|
||||
// ⇒ 直接命中全是噪音 ⇒ 图联想也无从谈起
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// TestQuerySymbols_目标词必须独立成词
|
||||
func TestQuerySymbols_目标词必须独立成词(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
query string
|
||||
want []string // 必须出现的词
|
||||
notWant []string // 跨词伪词,出现即失败
|
||||
}{
|
||||
{
|
||||
query: "本机服务监听哪些端口",
|
||||
want: []string{"端口", "服务", "监听"},
|
||||
notWant: []string{"听哪些", "些端口", "务监听哪"},
|
||||
},
|
||||
{
|
||||
query: "grafana 监控面板的端口是多少",
|
||||
want: []string{"端口", "面板", "grafana"},
|
||||
notWant: []string{"控面板的", "板的端口"},
|
||||
},
|
||||
{
|
||||
query: "本机 13010 端口对应什么服务",
|
||||
want: []string{"13010", "端口", "服务"},
|
||||
},
|
||||
} {
|
||||
syms := QuerySymbols(tc.query)
|
||||
set := make(map[string]bool, len(syms))
|
||||
for _, s := range syms {
|
||||
set[s] = true
|
||||
}
|
||||
fmt.Printf(" %q → %v\n", tc.query, syms)
|
||||
for _, w := range tc.want {
|
||||
if !set[w] {
|
||||
t.Errorf("★ %q 的符号应含 %q(目标词被切分吃掉),实际 %v", tc.query, w, syms)
|
||||
}
|
||||
}
|
||||
for _, w := range tc.notWant {
|
||||
if set[w] {
|
||||
t.Errorf("★ %q 的符号不该含跨词伪词 %q,实际 %v", tc.query, w, syms)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuerySymbols_数字与英文精确串不受分词影响
|
||||
func TestQuerySymbols_数字与英文精确串(t *testing.T) {
|
||||
syms := QuerySymbols("本机 13010 端口对应 grafana 9.99.99 吗")
|
||||
set := map[string]bool{}
|
||||
for _, s := range syms {
|
||||
set[s] = true
|
||||
}
|
||||
for _, want := range []string{"13010", "grafana", "9.99.99"} {
|
||||
if !set[want] {
|
||||
t.Errorf("★ 精确串 %q 必须原样保留,实际 %v", want, syms)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSymbolScore_目标词能匹配到对应块:端到端的一环
|
||||
func TestSymbolScore_目标词能匹配到对应块(t *testing.T) {
|
||||
syms := QuerySymbols("本机服务监听哪些端口")
|
||||
for _, tc := range []struct {
|
||||
text string
|
||||
shouldMatch bool
|
||||
}{
|
||||
{"14010端口", true},
|
||||
{"端口", true},
|
||||
{"13010/13011 而非 12011", false}, // 不含目标词
|
||||
} {
|
||||
_, matched, _ := SymbolScore(syms, tc.text)
|
||||
got := len(matched) > 0
|
||||
if got != tc.shouldMatch {
|
||||
t.Errorf("★ %q 是否匹配 %q:期望 %v,实际 %v(matched=%v)",
|
||||
tc.text, "端口", tc.shouldMatch, got, matched)
|
||||
}
|
||||
}
|
||||
}
|
||||
192
internal/memory/fusion_test.go
Normal file
192
internal/memory/fusion_test.go
Normal file
@ -0,0 +1,192 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ QuerySymbols:只提高信息量符号,泛词不进。
|
||||
func TestQuerySymbols_泛词不进(t *testing.T) {
|
||||
got := QuerySymbols("本机 13010 端口对应什么")
|
||||
has := func(s string) bool {
|
||||
for _, g := range got {
|
||||
if g == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if !has("13010") {
|
||||
t.Errorf("必须提到精确数字串 13010,实际 %v", got)
|
||||
}
|
||||
// ★★ 「什么」是虚词,不该作为信号。
|
||||
//
|
||||
// 2026-10-04:原断言是「端口」「什么」都不该进。
|
||||
// 那个断言**部分正确** ——
|
||||
//
|
||||
// 「什么」是虚词 ⇒ 该滤(不变)
|
||||
// 「端口」是实词 ⇒ 不该滤
|
||||
//
|
||||
// 而「端口」此前之所以该滤,是因为滑窗会把它切成跨词伪词
|
||||
// (「本机服务监听哪些端口」→ 只有「本机服务」「监听哪些」)。
|
||||
// 换成 jieba 词典分词后「端口」是独立实词,
|
||||
// 滤掉它就等于**把目标词从符号集里删掉** ——
|
||||
// 生产探针 coexist 0/1 的根因正在于此。
|
||||
//
|
||||
// ★ 判据的价值就在于逼出这个区分:
|
||||
// 「泛词」是个模糊概念,而「虚词 vs 实词」是可判定的。
|
||||
if has("什么") {
|
||||
t.Errorf("虚词 %q 不该进符号集:%v", "什么", got)
|
||||
}
|
||||
// ★ 反向断言:实词必须留下,否则符号路失效。
|
||||
if !has("端口") {
|
||||
t.Errorf("★ 实词 %q 必须留在符号集(否则查询目标词被滤掉):%v", "端口", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ SymbolScore:精确数字串命中 = 满分(这是符号路不可替代的能力)。
|
||||
func TestSymbolScore_精确串命中满分(t *testing.T) {
|
||||
syms := QuerySymbols("本机 13010 端口对应什么")
|
||||
|
||||
// 含精确串的块 ⇒ 满分
|
||||
s, matched, exact := SymbolScore(syms, "13010/13011 而非 12011")
|
||||
if !exact {
|
||||
t.Error("精确串命中必须标记 exact(它是布尔信号,不参与加权)")
|
||||
}
|
||||
if s < 1.0 {
|
||||
t.Errorf("精确串命中应给满分,实际 %.3f(matched=%v)", s, matched)
|
||||
}
|
||||
|
||||
// 不含精确串的块 ⇒ 低分
|
||||
s2, _, _ := SymbolScore(syms, "本地网关8081")
|
||||
if s2 >= 1.0 {
|
||||
t.Errorf("不含 13010 的块不该满分,实际 %.3f", s2)
|
||||
}
|
||||
// ★ 实测里「本地网关8081」曾被向量排进 top8,但它不含 13010 ——
|
||||
// 符号路必须能把它压下去,否则融合没有意义。
|
||||
if s2 > 0.5 {
|
||||
t.Errorf("「本地网关8081」相对查询应低分,实际 %.3f", s2)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 融合的核心判据:向量没召回但符号命中的块必须能进榜。
|
||||
//
|
||||
// 这条钉的是实测故障形态(生产快照 1391 块):
|
||||
//
|
||||
// 查询「本机 13010 端口对应什么」
|
||||
// 向量 top8 → 「13000端口」「本地网关8081」…(都不含 13010)
|
||||
// 词法命中 → 「13010/13011 而非 12011」
|
||||
func TestFuse_向量失手时符号能救回(t *testing.T) {
|
||||
target := MemoryBlock{ID: "b-target", Text: "13010/13011 而非 12011"}
|
||||
noise := MemoryBlock{ID: "b-noise1", Text: "本地网关8081"}
|
||||
noise2 := MemoryBlock{ID: "b-noise2", Text: "本机 443 按 SNI 透传到 192.168.2.106:3080"}
|
||||
|
||||
// 向量路的候选里**没有** target —— 这正是实测形态
|
||||
vecHits := []BlockHit{
|
||||
{Block: noise, Score: 0.6435},
|
||||
{Block: noise2, Score: 0.6201},
|
||||
}
|
||||
out := fuseCandidates(vecHits, []MemoryBlock{target, noise, noise2},
|
||||
"本机 13010 端口对应什么", defaultWeights())
|
||||
|
||||
if len(out) == 0 {
|
||||
t.Fatal("融合后不应为空")
|
||||
}
|
||||
if out[0].BlockID != "b-target" {
|
||||
t.Errorf("含精确串的块应排第一,实际第一是 %q(score=%.3f, why=%v)",
|
||||
out[0].BlockID, out[0].Score, out[0].Why)
|
||||
}
|
||||
if out[0].VectorHit != 0 {
|
||||
t.Errorf("它不该有向量分,实际 %.3f", out[0].VectorHit)
|
||||
}
|
||||
if out[0].SymbolHit < 1.0 {
|
||||
t.Errorf("应有满分符号分,实际 %.3f", out[0].SymbolHit)
|
||||
}
|
||||
// 归因必须可解释
|
||||
if len(out[0].Why) == 0 || out[0].Why[0] == "" {
|
||||
t.Error("必须给出 Why(模型需要知道为什么召回它)")
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 反向:符号不能压过明显更相关的向量命中。
|
||||
func TestFuse_不误伤强向量命中(t *testing.T) {
|
||||
strong := MemoryBlock{ID: "b-strong", Text: "从零开发 HomeAgent QQ 插件:使用 plugindev 工具链"}
|
||||
other := MemoryBlock{ID: "b-other", Text: "AgentMail 投递规则"}
|
||||
|
||||
vecHits := []BlockHit{
|
||||
{Block: strong, Score: 0.80},
|
||||
{Block: other, Score: 0.30},
|
||||
}
|
||||
out := fuseCandidates(vecHits, []MemoryBlock{strong, other},
|
||||
"从零开发 QQ 插件用什么工具链", defaultWeights())
|
||||
|
||||
if out[0].BlockID != "b-strong" {
|
||||
t.Errorf("强向量+符号双命中应第一,实际 %q", out[0].BlockID)
|
||||
}
|
||||
if out[0].Score <= out[1].Score {
|
||||
t.Errorf("排序错误:%.3f vs %.3f", out[0].Score, out[1].Score)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 零符号查询必须退化成纯向量序,且标注 why。
|
||||
func TestFuse_无符号退化为向量序(t *testing.T) {
|
||||
a := MemoryBlock{ID: "a", Text: "随便一块"}
|
||||
b := MemoryBlock{ID: "b", Text: "另一块"}
|
||||
vecHits := []BlockHit{{Block: a, Score: 0.9}, {Block: b, Score: 0.5}}
|
||||
out := fuseCandidates(vecHits, []MemoryBlock{a, b}, "嗯", defaultWeights())
|
||||
|
||||
if len(out) != 2 {
|
||||
t.Fatalf("退化为向量序时不该增删候选,实际 %d", len(out))
|
||||
}
|
||||
if out[0].BlockID != "a" {
|
||||
t.Errorf("应保持向量序,实际 %q", out[0].BlockID)
|
||||
}
|
||||
if len(out[0].Why) == 0 {
|
||||
t.Error("退化时也要给 Why,便于调试「为什么没走符号路」")
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 稳定排序:同分保持向量原序,不引入额外不确定性。
|
||||
func TestFuse_同分保持向量序(t *testing.T) {
|
||||
a := MemoryBlock{ID: "a", Text: "AAA"}
|
||||
b := MemoryBlock{ID: "b", Text: "BBB"}
|
||||
vecHits := []BlockHit{{Block: a, Score: 0.5}, {Block: b, Score: 0.5}}
|
||||
out := fuseCandidates(vecHits, []MemoryBlock{a, b}, "AAA BBB", defaultWeights())
|
||||
if out[0].BlockID != "a" {
|
||||
t.Errorf("同分应保持原序,实际 %q", out[0].BlockID)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 变体自证:把精确串从查询里去掉,救回能力必须消失。
|
||||
//
|
||||
// —— 证明救回来自符号路,而不是「融合什么都返回」。
|
||||
func TestFuse_变异自证_去掉精确串则失效(t *testing.T) {
|
||||
target := MemoryBlock{ID: "b-target", Text: "13010/13011 而非 12011"}
|
||||
noise := MemoryBlock{ID: "b-noise", Text: "本地网关8081"}
|
||||
|
||||
vecHits := []BlockHit{{Block: noise, Score: 0.64}}
|
||||
|
||||
withNum := fuseCandidates(vecHits, []MemoryBlock{target, noise},
|
||||
"本机 13010 端口对应什么", defaultWeights())
|
||||
withoutNum := fuseCandidates(vecHits, []MemoryBlock{target, noise},
|
||||
"本机端口对应什么", defaultWeights())
|
||||
|
||||
posWith, posWithout := -1, -1
|
||||
for i, f := range withNum {
|
||||
if f.BlockID == "b-target" {
|
||||
posWith = i
|
||||
}
|
||||
}
|
||||
for i, f := range withoutNum {
|
||||
if f.BlockID == "b-target" {
|
||||
posWithout = i
|
||||
}
|
||||
}
|
||||
if posWith != 0 {
|
||||
t.Errorf("有精确串时应救回并排第一,实际位置 %d", posWith)
|
||||
}
|
||||
fmt.Printf(" 变异自证: 有数字串→位置 %d,无数字串→位置 %d\n", posWith, posWithout)
|
||||
if posWithout != -1 && posWithout < posWith {
|
||||
t.Error("变异后不该比原形更好")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -2,6 +2,7 @@ package memory
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@ -40,6 +41,15 @@ func TestNewGraphDB(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ★★ Commit 的返回值语义已变(2026-10-04)
|
||||
//
|
||||
// 旧表双写已停 ⇒ ec/rc 恒为 0(它们数的是旧表 entities/relations 行号)。
|
||||
// Commit 的返回签名是 SDK 契约(9 处调用方),改签名代价大,
|
||||
// 所以保留位置并置 0。
|
||||
//
|
||||
// ⇒ 断言「写进了几条」必须改用**块侧**信号
|
||||
//
|
||||
// (MemoryBlocks 的长度 / MemoryBlockCount)。
|
||||
func TestCommitTriples(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer os.Remove(g.dbPath)
|
||||
@ -54,10 +64,10 @@ func TestCommitTriples(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ec != 4 {
|
||||
if ec != 0 { // 旧表停写后恒 0
|
||||
t.Errorf("expected 4 entity ops (张三×2, 编程, 北京), got %d", ec)
|
||||
}
|
||||
if rc != 2 {
|
||||
if rc != 0 { // 旧表停写后恒 0
|
||||
t.Errorf("expected 2 relations, got %d", rc)
|
||||
}
|
||||
}
|
||||
@ -73,8 +83,9 @@ func TestCommitDedupSameSession(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ec != 2 || rc != 1 {
|
||||
t.Fatalf("first commit: want 2/1, got %d/%d", ec, rc)
|
||||
if ec != 0 || rc != 0 {
|
||||
// 旧表停写后 ec/rc 恒 0;真正要验的是块侧写入。
|
||||
t.Fatalf("旧表停写后计数应为 0,实际 %d/%d", ec, rc)
|
||||
}
|
||||
|
||||
// 同一会话重复 commit 同一三元组:关系不再新增
|
||||
@ -87,11 +98,19 @@ func TestCommitDedupSameSession(t *testing.T) {
|
||||
}
|
||||
|
||||
var cnt int
|
||||
if err := g.db.QueryRow(`SELECT COUNT(*) FROM relations`).Scan(&cnt); err != nil {
|
||||
// ★ 改查**关系边**(2026-10-04)
|
||||
//
|
||||
// 旧表 relations 不再增长 ⇒ 查它恒为 0,
|
||||
// 而这条判据要验的是「重复提交不产生重复边」。
|
||||
//
|
||||
// ★ 块侧去重口径:同 (source, target, edge_type, session_id) 收敛;
|
||||
// 跨会话并存(关系边按设计允许多条同类型边,52e4596)。
|
||||
if err := g.db.QueryRow(`SELECT COUNT(*) FROM memory_block_edges
|
||||
WHERE edge_type != 'contains' AND source_kind='block'`).Scan(&cnt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cnt != 1 {
|
||||
t.Errorf("expected exactly 1 relation after duplicate commit, got %d", cnt)
|
||||
t.Errorf("expected exactly 1 relation edge after duplicate commit, got %d", cnt)
|
||||
}
|
||||
}
|
||||
|
||||
@ -108,11 +127,13 @@ func TestCommitDedupDifferentSession(t *testing.T) {
|
||||
}
|
||||
}
|
||||
var cnt int
|
||||
if err := g.db.QueryRow(`SELECT COUNT(*) FROM relations`).Scan(&cnt); err != nil {
|
||||
// ★ 跨会话允许并存(关系边设计),查边而非旧表。
|
||||
if err := g.db.QueryRow(`SELECT COUNT(*) FROM memory_block_edges
|
||||
WHERE edge_type != 'contains' AND source_kind='block'`).Scan(&cnt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cnt != 2 {
|
||||
t.Errorf("different sessions may repeat a triple, expected 2 relations, got %d", cnt)
|
||||
t.Errorf("different sessions may repeat a triple, expected 2 relation edges, got %d", cnt)
|
||||
}
|
||||
}
|
||||
|
||||
@ -329,17 +350,33 @@ func TestArchive(t *testing.T) {
|
||||
defer os.Remove(g.dbPath)
|
||||
defer g.Close()
|
||||
|
||||
// 直接插入一条旧记录
|
||||
g.db.Exec(`INSERT INTO entities (id, name, type) VALUES (1, '旧数据', 'Concept')`)
|
||||
g.db.Exec(`INSERT INTO relations (source_id, target_id, relation_type, created_at)
|
||||
VALUES (1, 1, '包含', datetime('now', '-1 day'))`)
|
||||
// ★ 改造**关系边**而不是旧表行(2026-10-04)
|
||||
//
|
||||
// Archive 已改走块/边体系(旧表停写后,归档旧表等于什么都不做)。
|
||||
// 测试若还造旧表数据,就变成「测一个已不再执行的路径」。
|
||||
g.db.Exec(`INSERT INTO memory_blocks (id, modality, text_content)
|
||||
VALUES ('b_old', 'text', '旧数据')`)
|
||||
g.db.Exec(`INSERT INTO memory_block_edges
|
||||
(source_kind, source_id, target_kind, target_id, edge_type, status, created_at)
|
||||
VALUES ('block','b_old','block','b_old','包含','active', datetime('now','-1 day'))`)
|
||||
|
||||
n, err := g.Archive(0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("expected 1 archived relation, got %d", n)
|
||||
t.Errorf("expected 1 archived relation edge, got %d", n)
|
||||
}
|
||||
|
||||
// ★ 归档后应不再参与召回(读路径按 status='active' 过滤)
|
||||
var status string
|
||||
if err := g.db.QueryRow(
|
||||
`SELECT COALESCE(status,'') FROM memory_block_edges
|
||||
WHERE edge_type='包含'`).Scan(&status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != "archived" {
|
||||
t.Errorf("★ 边状态应为 archived,实际 %q", status)
|
||||
}
|
||||
}
|
||||
|
||||
@ -376,9 +413,22 @@ func TestMergeEntities(t *testing.T) {
|
||||
}, "session", 0)
|
||||
|
||||
// 合并前:两个实体各有关联
|
||||
stats, _ := g.Introspect()
|
||||
if stats["entity_count"].(int) != 5 {
|
||||
t.Fatalf("expected 5 entities (张三, 编程, 北京, 张先生, 字节跳动), got %d", stats["entity_count"])
|
||||
//
|
||||
// ★ 断言改用**块数**(2026-10-04)
|
||||
//
|
||||
// stats["entity_count"] 数的是旧表 entities 行 —— 旧表双写已停,
|
||||
// 它恒为 0。真正要验的是「三元组写进了 5 个实体块」。
|
||||
blocksBefore, err := g.MemoryBlocks()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(blocksBefore) != 5 {
|
||||
names := make([]string, 0, len(blocksBefore))
|
||||
for _, b := range blocksBefore {
|
||||
names = append(names, b.Text)
|
||||
}
|
||||
t.Fatalf("expected 5 entity blocks (张三, 编程, 北京, 张先生, 字节跳动), got %d: %v",
|
||||
len(blocksBefore), names)
|
||||
}
|
||||
|
||||
// 先增加张先生的 mention_count
|
||||
@ -403,22 +453,35 @@ func TestMergeEntities(t *testing.T) {
|
||||
t.Error("张先生 should be merged and hidden")
|
||||
}
|
||||
|
||||
// target 的 mention_count 应合并
|
||||
// 验证 target 还存在(seedEntities 精确查找)
|
||||
// ★ 判据从「mention_count 相加」改成「信息没丢」(2026-10-04)
|
||||
//
|
||||
// 块侧**没有 mention_count 这个概念** —— 它是旧 entities 表的列,
|
||||
// 而 Recall 从不填它(块是内容派生的,不存在「被提及几次」)。
|
||||
//
|
||||
// 原断言 `mention_count >= 2` 在块体系下无意义,且它衡量的
|
||||
// 只是「计数」这个代理指标,不是「信息还在」这件真事。
|
||||
//
|
||||
// 块侧的真实验证:**合并后「张三」应当同时承载两人的关系**。
|
||||
// 合并前张先生有「喜欢→Go」,张三有「负责→billing服务」等;
|
||||
// 合并后两组都必须还在 —— 那才是「没丢信息」。
|
||||
result2, err := g.Recall([]string{"张三"}, nil, 1, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
targets := map[string]bool{}
|
||||
for _, e := range result2.Entities {
|
||||
if e.Name == "张三" {
|
||||
found = true
|
||||
if e.MentionCount < 2 {
|
||||
t.Errorf("expected 张三 mention_count >= 2 after merge, got %d", e.MentionCount)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, r := range result2.Relations {
|
||||
targets[r.TargetName] = true
|
||||
}
|
||||
fmt.Printf(" 合并后「张三」继承 %d 条关系: %v\n", len(result2.Relations), targets)
|
||||
if !targets["Go"] {
|
||||
t.Error("★ 合并丢失了源块的关系「喜欢→Go」(信息丢失,不是计数问题)")
|
||||
}
|
||||
if !found {
|
||||
t.Error("张三 should still exist after merge")
|
||||
}
|
||||
@ -539,3 +602,121 @@ func TestPlaceholders(t *testing.T) {
|
||||
t.Errorf("expected '?,?,?' for n=3, got %s", placeholders(3))
|
||||
}
|
||||
}
|
||||
|
||||
// ★ Introspect 的两个关系计数必须**语义分离**。
|
||||
//
|
||||
// 这是一个被同一个字段承担两种语义踩出来的坑:
|
||||
//
|
||||
// relation_count 活跃数(status='active')—— TestPurgeSoft 依赖它
|
||||
// Purge("soft") 把 status 置 'deleted',软删除后应为 0
|
||||
// relations_total 全表数 —— 迁移报告依赖它
|
||||
// MigrateLegacyTextEntities 按 ORDER BY id 转换全表
|
||||
//
|
||||
// 曾为对齐迁移口径把 relation_count 改成数全表,结果 TestPurgeSoft
|
||||
// 从绿变红(expected 0 active relations, got 1)——
|
||||
// 那次修改为了让一个报告数字准确,破坏了一个真实功能的断言。
|
||||
func TestIntrospect_关系计数语义分离(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer os.Remove(g.dbPath)
|
||||
defer g.Close()
|
||||
|
||||
g.Commit([]Triple{
|
||||
{Subject: "活跃方", Relation: "属于", Object: "测试"},
|
||||
{Subject: "待删方", Relation: "属于", Object: "测试"},
|
||||
}, "session-split", 0)
|
||||
|
||||
before, err := g.Introspect()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
activeBefore := before["relation_count"].(int)
|
||||
totalBefore, ok := before["relations_total"].(int)
|
||||
if !ok {
|
||||
t.Fatalf("Introspect 必须返回 relations_total,实际 keys=%v", keysOf(before))
|
||||
}
|
||||
if activeBefore != totalBefore {
|
||||
t.Errorf("初始应相等:active=%d total=%d", activeBefore, totalBefore)
|
||||
}
|
||||
|
||||
// 软删一条
|
||||
if _, err := g.Purge(map[string]string{"subject_contains": "待删方"}, "soft"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
after, err := g.Introspect()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
activeAfter := after["relation_count"].(int)
|
||||
totalAfter := after["relations_total"].(int)
|
||||
|
||||
if activeAfter != activeBefore-1 {
|
||||
t.Errorf("软删除后活跃数应减 1:%d → %d", activeBefore, activeAfter)
|
||||
}
|
||||
// ★ 全表数**不变** —— 软删除只是打标记,不是物理删除
|
||||
if totalAfter != totalBefore {
|
||||
t.Errorf("软删除后全表数应不变(status 只是标记):%d → %d", totalBefore, totalAfter)
|
||||
}
|
||||
if activeAfter >= totalAfter {
|
||||
t.Errorf("活跃数(%d) 必须小于全表数(%d) —— 否则 status 过滤没生效",
|
||||
activeAfter, totalAfter)
|
||||
}
|
||||
}
|
||||
|
||||
func keysOf(m map[string]interface{}) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ★ Introspect 的 relation_count / hotspots 已改数块侧(2026-10-04)
|
||||
//
|
||||
// 旧实现数旧表,而旧表双写已停 ⇒ 两个字段都恒为 0 / 冻结。
|
||||
// 它是 healthcheck 与 WebUI 状态页的数据源,报告说谎比报错更坏。
|
||||
func TestIntrospect_关系计数与热点走块侧(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer func() { _ = g.Close() }()
|
||||
|
||||
if _, _, err := g.Commit([]Triple{
|
||||
{Subject: "热点甲", Relation: "指向", Object: "中心乙", Confidence: 1.0},
|
||||
{Subject: "热点丙", Relation: "指向", Object: "中心乙", Confidence: 1.0},
|
||||
}, "s", 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stats, err := g.Introspect()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 2 条关系边(都是 active)
|
||||
if rc, _ := stats["relation_count"].(int); rc != 2 {
|
||||
t.Errorf("★ relation_count 应数活跃关系边(2),实际 %v —— "+
|
||||
"数旧表会恒为 0", stats["relation_count"])
|
||||
}
|
||||
if tot, _ := stats["relations_total"].(int); tot < 2 {
|
||||
t.Errorf("★ relations_total(≥2)实际 %v", stats["relations_total"])
|
||||
}
|
||||
|
||||
// hotspots 必须非空且含块文本(不再读旧表 name)
|
||||
hotspots, _ := stats["memory_hotspots"].([]map[string]interface{})
|
||||
if len(hotspots) == 0 {
|
||||
t.Fatal("★ hotspots 为空(旧表不增长 ⇒ 永远是迁移时的快照)")
|
||||
}
|
||||
top, _ := hotspots[0]["name"].(string)
|
||||
fmt.Printf(" 最热块: %q\n", top)
|
||||
if top == "" {
|
||||
t.Error("★ hotspot 的 name 应是块文本,不该是旧表的 name")
|
||||
}
|
||||
// ★ 中心乙被两条边指向 ⇒ 应当是度数最高的块之一
|
||||
found := false
|
||||
for _, h := range hotspots {
|
||||
if n, _ := h["name"].(string); n == "中心乙" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("★ 「中心乙」被两条边指向,应进 hotspots,实际 %v", hotspots)
|
||||
}
|
||||
}
|
||||
|
||||
@ -126,13 +126,25 @@ func (idx *Indexer) syncIfStale() bool {
|
||||
if idx.db == nil {
|
||||
return false
|
||||
}
|
||||
// ★★ 基线改数**块**(2026-10-04)
|
||||
//
|
||||
// 原来数旧表 entities —— 而旧表双写已停,那张表不再增长
|
||||
// ⇒ count 恒为 0 ⇒ expected 恒为 0 ⇒
|
||||
// **首次 syncIfStale 也判成「无需同步」**
|
||||
// ⇒ 索引永远不建立,而没有任何报错。
|
||||
//
|
||||
// 判据 TestSyncIfStaleBaseline 的「首次应建立索引」当场抓住。
|
||||
//
|
||||
// ★ 口径与 Sync 保持一致:Sync 走「无关键词全量召回」,
|
||||
// 实体数被 maxFullRecallEntities 封顶。
|
||||
// ★ 用 COUNT(*) 而非走召回:Sync 内部也用这个封顶口径,
|
||||
// 两边一致才不会「永远不相等」——
|
||||
// 那会导致每 30s 全量重训,把一条读路径变成写放大热点。
|
||||
var count int
|
||||
if err := idx.db.db.QueryRow(`SELECT COUNT(*) FROM entities`).Scan(&count); err != nil {
|
||||
if err := idx.db.db.QueryRow(
|
||||
`SELECT COUNT(*) FROM memory_blocks WHERE text_content != ''`).Scan(&count); err != nil {
|
||||
return false
|
||||
}
|
||||
// 基线口径必须与 Sync 一致:Sync 走的是「无关键词全量召回」,实体数被
|
||||
// maxFullRecallEntities 封顶。直接拿 COUNT(*) 比会在实体数超过上限的大图上
|
||||
// 永远不相等——每 30s 全量重训一次,把一条读路径变成写放大热点。
|
||||
expected := count
|
||||
if expected > maxFullRecallEntities {
|
||||
expected = maxFullRecallEntities
|
||||
@ -288,6 +300,8 @@ func (idx *Indexer) BuildToolPrompt() string {
|
||||
参数:
|
||||
- query_intent: 查询关键词,逗号分隔
|
||||
- depth: 遍历深度(默认2)
|
||||
- sort: 排序方式。relevance=相关性(默认);recent=时间倒序最新在前
|
||||
(问"最近/最新/现在是什么"时必须用 —— 答案常是新值覆盖旧值)
|
||||
|
||||
### memory_commit
|
||||
将三元组写入图记忆。
|
||||
@ -425,8 +439,11 @@ func (idx *Indexer) GetToolDefinitions() []map[string]interface{} {
|
||||
{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "memory_recall",
|
||||
"description": "检索图记忆。输入查询意图关键词,返回相关实体和关系。",
|
||||
"name": "memory_recall",
|
||||
"description": "检索图记忆。输入查询意图关键词,返回相关实体和关系。" +
|
||||
"问『某个具体东西是什么/是多少』用默认相关性排序;" +
|
||||
"问『最近/最新/现在是什么』必须传 sort=recent —— " +
|
||||
"运维场景里答案常是新值覆盖旧值,相关性排序分不出新旧。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
@ -439,6 +456,12 @@ func (idx *Indexer) GetToolDefinitions() []map[string]interface{} {
|
||||
"description": "遍历深度,默认2",
|
||||
"default": 2,
|
||||
},
|
||||
"sort": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "排序方式。relevance=相关性(默认,问具体是什么/多少);" +
|
||||
"recent=时间倒序最新在前(问最近/最新/现在)。",
|
||||
"enum": []string{"relevance", "recent"},
|
||||
},
|
||||
},
|
||||
"required": []string{"query_intent"},
|
||||
},
|
||||
|
||||
@ -44,9 +44,22 @@ func TestIndexerSync(t *testing.T) {
|
||||
if !idx.trained {
|
||||
t.Error("expected indexer to be trained after sync")
|
||||
}
|
||||
if idx.vec.Size() != ec {
|
||||
t.Errorf("expected %d vectors, got %d", ec, idx.vec.Size())
|
||||
// ★ 期望值改用**块数**(2026-10-04)
|
||||
//
|
||||
// 原来用 Commit 返回的 ec(旧表实体计数)——旧表双写已停,
|
||||
// ec 恒为 0,于是判据说「应有 0 个向量」而索引器建了 4 个。
|
||||
//
|
||||
// ★★ 这是**判据与实现都错**的组合:
|
||||
// 判据错在用了一个已失效的信号;实现没错(索引器照块建向量)。
|
||||
blocks, err := db.MemoryBlocks()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if idx.vec.Size() != len(blocks) {
|
||||
t.Errorf("expected %d vectors (one per block), got %d",
|
||||
len(blocks), idx.vec.Size())
|
||||
}
|
||||
_ = ec
|
||||
}
|
||||
|
||||
// TestSyncIfStaleBaseline 钉住增量同步的基线口径:
|
||||
|
||||
130
internal/memory/introspect_count_truth_test.go
Normal file
130
internal/memory/introspect_count_truth_test.go
Normal file
@ -0,0 +1,130 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestIntrospectEntityCountIsNotZeroAfterWrites 钉住「Introspect 不再报告空库」。
|
||||
//
|
||||
// ★ 缺陷来源(2026-10-05 隔离实例实测):
|
||||
//
|
||||
// 一次对话写入 7 块 9 边,工具输出却是:
|
||||
// memory_commit → 已写入 0 个实体和 0 条关系
|
||||
// memory_introspect → map[entity_count:0 memory_hotspots:[map[count:15 name:order-gw]]]
|
||||
//
|
||||
// ⇒ entity_count 读的是**旧表 entities**(停双写后恒 0),
|
||||
// 而 hotspots 已改数块(真实度数 15)。
|
||||
// 同一份输出里自相矛盾,模型据此认为「记忆库是空的」并放弃写入
|
||||
// (实测模型回复:"the write is being rejected")。
|
||||
func TestIntrospectEntityCountIsNotZeroAfterWrites(t *testing.T) {
|
||||
g, err := NewGraphDB(filepath.Join(t.TempDir(), "g.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer g.Close()
|
||||
|
||||
if _, _, err := g.Commit([]Triple{
|
||||
{Subject: "张三", Relation: "负责", Object: "订单网关"},
|
||||
{Subject: "李四", Relation: "负责", Object: "支付网关"},
|
||||
}, "s1", 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
st, err := g.Introspect()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ec, ok := st["entity_count"].(int)
|
||||
if !ok {
|
||||
t.Fatalf("entity_count 类型异常: %T", st["entity_count"])
|
||||
}
|
||||
// ★ 旧表是空的(停双写),所以旧表口径必然给 0。
|
||||
if ec == 0 {
|
||||
var blocks, legacy int
|
||||
g.db.QueryRow("SELECT COUNT(*) FROM memory_blocks WHERE text_content != ''").Scan(&blocks)
|
||||
g.db.QueryRow("SELECT COUNT(*) FROM entities").Scan(&legacy)
|
||||
t.Fatalf("entity_count=0,但库里已有 %d 个块(旧表 %d 行)—— "+
|
||||
"它还在读旧表,模型会据此认为记忆库是空的", blocks, legacy)
|
||||
}
|
||||
// relation_count 已数块,同一份输出里两个字段口径必须一致。
|
||||
rc, _ := st["relation_count"].(int)
|
||||
if rc == 0 {
|
||||
t.Errorf("relation_count=0 与 entity_count=%d 矛盾 —— 同一份输出里两个字段口径不一致", ec)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntrospectCountsBlocksNotLegacyTable 钉住「口径切块」的判据本身。
|
||||
//
|
||||
// ★ 为什么单独立一条:上面那条在「块数为 0」时会假绿
|
||||
//
|
||||
// (新建空库时 entity_count=0 是正确的)。
|
||||
// 这条显式构造「旧表空、块有内容」,把口径问题与数据量分离。
|
||||
func TestIntrospectCountsBlocksNotLegacyTable(t *testing.T) {
|
||||
g, err := NewGraphDB(filepath.Join(t.TempDir(), "g.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer g.Close()
|
||||
|
||||
// 只写块,不碰旧表 —— 这正是停双写后的真实形态。
|
||||
if err := g.PutMemoryBlocks([]MemoryBlock{
|
||||
{ID: "b1", Modality: BlockText, Text: "节点甲"},
|
||||
{ID: "b2", Modality: BlockText, Text: "节点乙"},
|
||||
{ID: "b3", Modality: BlockText, Text: "节点丙"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var legacy int
|
||||
if err := g.db.QueryRow("SELECT COUNT(*) FROM entities").Scan(&legacy); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if legacy != 0 {
|
||||
t.Fatalf("测试前提不成立:旧表不该有行,实际 %d", legacy)
|
||||
}
|
||||
|
||||
st, err := g.Introspect()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ec, _ := st["entity_count"].(int); ec != 3 {
|
||||
t.Errorf("写了 3 个块,entity_count 应为 3,实际 %d —— 仍在读旧表", ec)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMemoryEdgeCountExcludesContains 钉住边数口径。
|
||||
//
|
||||
// memory_commit 的文案用 newEdges 报告「写了几条关系」,
|
||||
// 若把 contains 结构边算进去,数字会随原句数量翻倍 ——
|
||||
// 同一个数在不同批次之间不可比,模型也会误读为「写多了」。
|
||||
func TestMemoryEdgeCountExcludesContains(t *testing.T) {
|
||||
g := openArbTestDB(t)
|
||||
|
||||
// 一句原句 + 一个字段块 = 1 条 contains 结构边、0 条关系边。
|
||||
writeBlockWithSource(t, g, "b_f", "维度=值", "blk_src_1")
|
||||
|
||||
n, err := g.MemoryEdgeCount()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("只有 contains 结构边时 MemoryEdgeCount 应为 0,实际 %d", n)
|
||||
}
|
||||
|
||||
// 加一条真关系边。
|
||||
if err := g.AddMemoryBlockEdge("block", "blk_src_1", "block", "b_f", "关联"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n2, err := g.MemoryEdgeCount()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n2 != 1 {
|
||||
t.Errorf("加了 1 条关系边后应为 1,实际 %d", n2)
|
||||
}
|
||||
}
|
||||
|
||||
// db2Alias 只是为了让上面那行读起来明确(openArbTestDB 已返回 *GraphDB)。
|
||||
func db2Alias(g *GraphDB) *GraphDB { return g }
|
||||
412
internal/memory/legacy_retire_precondition_test.go
Normal file
412
internal/memory/legacy_retire_precondition_test.go
Normal file
@ -0,0 +1,412 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★★ 存量清理的前置判据:清理**之前**必须能证明块侧数据完整。
|
||||
//
|
||||
// 清理是不可逆动作(删表),所以判据必须回答:
|
||||
//
|
||||
// ① 每个 entity 都有对应的块吗?
|
||||
// ② 每条 relation 都有对应的边吗?(去重口径)
|
||||
// ③ 每条 sentence 都有对应的原句块吗?
|
||||
// ④ 有没有边指向将被删除的节点?(悬空边)
|
||||
func TestRetire_清理前置_块侧覆盖完整(t *testing.T) {
|
||||
path := os.Getenv("RETIRE_DB")
|
||||
if path == "" {
|
||||
t.Skip("需要 RETIRE_DB(生产库快照副本)")
|
||||
}
|
||||
g, err := NewGraphDB(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = g.Close() }()
|
||||
|
||||
// ① entities → legacy-entity 块
|
||||
var entCount, entBlocks int
|
||||
mustScalar(t, g, `SELECT COUNT(*) FROM entities`, &entCount)
|
||||
mustScalar(t, g, `SELECT COUNT(*) FROM memory_blocks WHERE source='legacy-entity'`, &entBlocks)
|
||||
fmt.Printf(" ① entities %d → legacy-entity 块 %d %s\n",
|
||||
entCount, entBlocks, markEq(entCount, entBlocks))
|
||||
|
||||
// 每个 entity 的名字都能找到一个块(不只数数量)
|
||||
missing := missingBlocks(t, g)
|
||||
fmt.Printf(" 缺块的 entity: %d 个%s\n", len(missing),
|
||||
excerpt(missing))
|
||||
if len(missing) > 0 {
|
||||
t.Errorf("★ 有 %d 个 entity 没有对应块 —— 清理会丢数据", len(missing))
|
||||
}
|
||||
|
||||
// ② relations → 关系边
|
||||
var relCount, relDistinct int
|
||||
mustScalar(t, g, `SELECT COUNT(*) FROM relations`, &relCount)
|
||||
mustScalar(t, g, `SELECT COUNT(*) FROM (
|
||||
SELECT DISTINCT source_id, target_id, COALESCE(relation_type,'') FROM relations)`,
|
||||
&relDistinct)
|
||||
mustScalar(t, g, `SELECT COUNT(*) FROM memory_block_edges WHERE edge_type<>'contains'`, &relBlocks)
|
||||
fmt.Printf(" ② relations %d(去重后 %d)→ 关系边 %d %s\n",
|
||||
relCount, relDistinct, relBlocks, markEq(relDistinct, relBlocks))
|
||||
if relDistinct != relBlocks {
|
||||
t.Errorf("★ 关系边 %d ≠ 去重后关系数 %d —— 清理会丢边", relBlocks, relDistinct)
|
||||
}
|
||||
|
||||
// ③ sentences → 原句块
|
||||
//
|
||||
// ★ 块 ID 是 sha256(Text[:12]),**SQL 里算不了** ⇒ 在 Go 侧比对。
|
||||
// (SQLite 没有 sha256 函数,尝试 `SentenceBlockID(...)` 会报
|
||||
// "no such function" —— 判据要自己算,别指望 SQL 能做哈希。)
|
||||
allBlocks := map[string]bool{}
|
||||
bs, err := g.MemoryBlocks()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, b := range bs {
|
||||
allBlocks[b.ID] = true
|
||||
}
|
||||
sents := g.sentenceTexts(t)
|
||||
var sentWithBlock int
|
||||
var noBlock []string
|
||||
for _, txt := range sents {
|
||||
if allBlocks[SentenceBlockID(txt)] {
|
||||
sentWithBlock++
|
||||
} else {
|
||||
noBlock = append(noBlock, txt)
|
||||
}
|
||||
}
|
||||
fmt.Printf(" ③ sentences %d → 有原句块的 %d %s%s\n",
|
||||
len(sents), sentWithBlock, markEq(len(sents), sentWithBlock),
|
||||
excerpt(noBlock))
|
||||
if len(noBlock) > 0 {
|
||||
t.Errorf("★ %d 条 sentence 没有原句块 —— 清理会丢原句", len(noBlock))
|
||||
}
|
||||
|
||||
// ④ 悬空边:指向 sentence 表的边
|
||||
var dangling int
|
||||
mustScalar(t, g, `SELECT COUNT(*) FROM memory_block_edges e
|
||||
WHERE e.source_kind='sentence'
|
||||
AND NOT EXISTS (SELECT 1 FROM sentences s WHERE CAST(s.id AS TEXT)=e.source_id)`,
|
||||
&dangling)
|
||||
fmt.Printf(" ④ 悬空的 sentence→块 边: %d\n", dangling)
|
||||
if dangling > 0 {
|
||||
t.Errorf("★ %d 条边指向不存在的 sentence —— 清理后会成为悬空边", dangling)
|
||||
}
|
||||
|
||||
// ⑤ 块边的两端都必须存在(通用悬空检查)
|
||||
var badEnds int
|
||||
mustScalar(t, g, `SELECT COUNT(*) FROM memory_block_edges e
|
||||
WHERE (e.source_kind='block' AND NOT EXISTS
|
||||
(SELECT 1 FROM memory_blocks b WHERE b.id=e.source_id))
|
||||
OR (e.target_kind='block' AND NOT EXISTS
|
||||
(SELECT 1 FROM memory_blocks b WHERE b.id=e.target_id))`, &badEnds)
|
||||
fmt.Printf(" ⑤ 端点不存在的块边: %d\n", badEnds)
|
||||
if badEnds > 0 {
|
||||
t.Errorf("★ %d 条块边的一端不存在", badEnds)
|
||||
}
|
||||
}
|
||||
|
||||
var relBlocks int
|
||||
|
||||
func mustScalar(t *testing.T, g *GraphDB, q string, dst *int) {
|
||||
t.Helper()
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
if err := g.db.QueryRow(q).Scan(dst); err != nil {
|
||||
t.Fatalf("%s: %v", q, err)
|
||||
}
|
||||
}
|
||||
|
||||
// sentenceTexts 读出 sentences 表的原文。
|
||||
func (g *GraphDB) sentenceTexts(t *testing.T) []string {
|
||||
t.Helper()
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
rows, err := g.db.Query(`SELECT text FROM sentences`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var s string
|
||||
_ = rows.Scan(&s)
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// missingBlocks 找出没有对应块的 entity
|
||||
func missingBlocks(t *testing.T, g *GraphDB) []string {
|
||||
t.Helper()
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
rows, err := g.db.Query(`SELECT e.name FROM entities e
|
||||
WHERE NOT EXISTS (SELECT 1 FROM memory_blocks b
|
||||
WHERE b.source='legacy-entity' AND b.text_content = e.name)`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var n string
|
||||
_ = rows.Scan(&n)
|
||||
out = append(out, n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func markEq(a, b int) string {
|
||||
if a == b {
|
||||
return "✓"
|
||||
}
|
||||
return "✘"
|
||||
}
|
||||
|
||||
func excerpt(ss []string) string {
|
||||
if len(ss) == 0 {
|
||||
return ""
|
||||
}
|
||||
s := " 例: " + ss[0]
|
||||
if len(ss) > 1 {
|
||||
s += fmt.Sprintf(" …(共 %d)", len(ss))
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ★★ scene 读方适配:TagSceneByEntityGlob 改走块体系
|
||||
//
|
||||
// 现状(scene.go:260):按实体名 GLOB 查 relations:
|
||||
//
|
||||
// SELECT r.id, r.source_id, r.target_id, r.confidence
|
||||
// FROM relations r JOIN entities e1 ... JOIN entities e2 ...
|
||||
// WHERE r.status='active' AND (e1.name GLOB ? OR e2.name GLOB ?)
|
||||
//
|
||||
// 块体系里等价于:按**块文本** GLOB 找关系边的两端,
|
||||
// 再把两端块都挂到场景上(边本身也挂,kind='edge')。
|
||||
//
|
||||
// ★ 这条路径有真实调用方(cmd/memgc),所以必须有判据。
|
||||
func TestSceneAdapt_ByEntityGlob走块体系(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer func() { _ = g.Close() }()
|
||||
|
||||
// 织一张网:值班室分机号 --是--> 4324
|
||||
for _, b := range []MemoryBlock{
|
||||
{ID: "blk_subject", Modality: BlockText, Text: "值班室分机号"},
|
||||
{ID: "blk_value", Modality: BlockText, Text: "4324"},
|
||||
{ID: "blk_other", Modality: BlockText, Text: "billing服务"},
|
||||
} {
|
||||
if err := g.PutMemoryBlocks([]MemoryBlock{b}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := g.AddRelationBlockEdge("blk_subject", "blk_value", "是",
|
||||
RelationEdgeData{SessionID: "s1", Confidence: 0.9, Status: EdgeActive}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 按名字 GLOB 找关系(dryRun,不真写)
|
||||
n, err := g.TagSceneByEntityGlob("值班场景", "*分机号*", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fmt.Printf(" GLOB '*分机号*' 匹配 %d 条关系\n", n)
|
||||
if n != 1 {
|
||||
t.Errorf("★ 应匹配 1 条(值班室分机号 --是--> 4324),实际 %d", n)
|
||||
}
|
||||
|
||||
// 不匹配的名字不该匹配
|
||||
n2, err := g.TagSceneByEntityGlob("值班场景", "*不存在的名字*", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n2 != 0 {
|
||||
t.Errorf("★ 不该匹配任何关系,实际 %d", n2)
|
||||
}
|
||||
|
||||
// ★ 真写(dryRun=false)后,场景应挂到块上
|
||||
if _, err := g.TagSceneByEntityGlob("值班场景", "*分机号*", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
counts := sceneRefCounts(t, g)
|
||||
fmt.Printf(" 写入后 scene_refs: %v\n", counts)
|
||||
if counts["block"] == 0 {
|
||||
t.Error("★ 场景应挂到块上(kind='block'),实际无")
|
||||
}
|
||||
if counts["edge"] == 0 {
|
||||
t.Error("★ 场景应挂到边上(kind='edge'),实际无")
|
||||
}
|
||||
// ★ 旧表读方不应再被使用 —— 但引用不应退回 entity/relation
|
||||
if counts["entity"] != 0 || counts["relation"] != 0 {
|
||||
t.Errorf("★ 不该产生旧表引用,实际 %v", counts)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★ 悬空引用清理要认 kind='edge'
|
||||
//
|
||||
// 旧实现只清 kind='relation'/'entity'/'block'/'document',
|
||||
// 而 scene_refs 现在多了一种:**kind='edge'**(指向 memory_block_edges.id)。
|
||||
//
|
||||
// ★ 不加这个分支的后果:边被删后那批 edge 引用永远悬空,
|
||||
//
|
||||
// 而且是**静默**的 —— purgeStaleSceneRefs 看着跑成功了,
|
||||
// 实际没清掉任何东西。
|
||||
func TestSceneAdapt_悬空清理认edge(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer func() { _ = g.Close() }()
|
||||
|
||||
if err := g.PutMemoryBlocks([]MemoryBlock{
|
||||
{ID: "blk_a", Modality: BlockText, Text: "甲"},
|
||||
{ID: "blk_b", Modality: BlockText, Text: "乙"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := g.AddRelationBlockEdge("blk_a", "blk_b", "是",
|
||||
RelationEdgeData{SessionID: "s1", Confidence: 0.9}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 挂三条引用:两个有效、一个指向不存在的块
|
||||
g.mu.Lock()
|
||||
for _, ref := range []struct{ kind, text, id string }{
|
||||
{"block", "blk_a", "0"},
|
||||
{"edge", "0", "99999"}, // 指向不存在的边
|
||||
{"block", "blk_gone", "0"},
|
||||
} {
|
||||
if _, err := g.db.Exec(
|
||||
`INSERT INTO scene_refs (scene_id, kind, ref_id, ref_text, weight)
|
||||
VALUES (1, ?, CAST(? AS INTEGER), ?, 1.0)`,
|
||||
ref.kind, ref.id, ref.text); err != nil {
|
||||
g.mu.Unlock()
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
g.mu.Unlock()
|
||||
|
||||
n, err := g.PurgeStaleSceneRefs()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fmt.Printf(" 清理 %d 条悬空引用\n", n)
|
||||
if n != 2 {
|
||||
t.Errorf("★ 应清理 2 条(悬空的 edge 与 block),实际 %d", n)
|
||||
}
|
||||
|
||||
counts := sceneRefCounts(t, g)
|
||||
fmt.Printf(" 清理后: %v\n", counts)
|
||||
if counts["edge"] != 0 {
|
||||
t.Errorf("★ 悬空的 kind='edge' 未被清理(缺该分支)")
|
||||
}
|
||||
if counts["block"] != 1 {
|
||||
t.Errorf("★ 有效引用应保留,实际 %v", counts)
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 停旧表双写 —— 判据(2026-10-04)
|
||||
//
|
||||
// ★ 目标不是「旧表被删」,而是「旧表停止增长、冻结为历史」<E58FB2><E3808D><EFBFBD>
|
||||
// 删表是不可逆的,且读方虽已全切块,仍需要一段时间观察。
|
||||
//
|
||||
// ★ 判据的核心是**增长量**,不是「有没有 INSERT 语句」——
|
||||
// 后者是文本检查,前者才是行为检查。
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// Test停双写_旧表不再增长
|
||||
func Test停双写_旧表不再增长(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer func() { _ = g.Close() }()
|
||||
|
||||
// 先写一批,让旧表有内容
|
||||
if _, _, err := g.Commit([]Triple{
|
||||
{Subject: "初始甲", Relation: "属于", Object: "初始乙", Confidence: 1.0},
|
||||
}, "sess-0", 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snap := legacyCounts(t, g)
|
||||
fmt.Printf(" 首批后(旧表应已冻结): %v\n", snap)
|
||||
|
||||
// ★ 首批之后旧表就应该是 0 —— 双写已停(2026-10-04)。
|
||||
// 原断言是「首批应写入旧表」,那是**停双写之前**的前提,
|
||||
// 改完之后它必然失败 ⇒ 判据自己抓出了自己过期。
|
||||
//
|
||||
// ★ 这正是「行为判据优于文本判据」的又一例:
|
||||
// 我们不是在查「有没有 INSERT 语句」,
|
||||
// 而是在看 Commit 之后旧表**实际长没长**。
|
||||
for _, table := range []string{"entities", "relations", "sentences"} {
|
||||
if snap[table] != 0 {
|
||||
t.Fatalf("★ 首批 Commit 后旧表 %s 仍有 %d 行(双写未停)",
|
||||
table, snap[table])
|
||||
}
|
||||
}
|
||||
|
||||
// 再写一批 —— 旧表**不应**再增长
|
||||
for i := 0; i < 5; i++ {
|
||||
if _, _, err := g.Commit([]Triple{
|
||||
{Subject: fmt.Sprintf("新主体%d", i), Relation: "属性",
|
||||
Object: fmt.Sprintf("值%d", i), Confidence: 1.0,
|
||||
SentenceText: fmt.Sprintf("第 %d 句测试", i)},
|
||||
}, fmt.Sprintf("sess-%d", i+1), i+1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
after := legacyCounts(t, g)
|
||||
fmt.Printf(" 五批后: %v\n", after)
|
||||
for _, table := range []string{"entities", "relations", "sentences"} {
|
||||
if after[table] != snap[table] {
|
||||
t.Errorf("★ 旧表 %s 仍在增长: %d → %d(应冻结)",
|
||||
table, snap[table], after[table])
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 但块侧**必须**照常增长 —— 双写停了,单写不能停
|
||||
if blk, err := g.MemoryBlocks(); err != nil {
|
||||
t.Fatal(err)
|
||||
} else {
|
||||
fmt.Printf(" 块数 %d(应 ≥ 12:首批 2 + 新批 10)\n", len(blk))
|
||||
if len(blk) < 12 {
|
||||
t.Errorf("★ 块侧写入被误伤,只剩 %d 个块", len(blk))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test停双写_召回不受影响:旧表冻结后读方仍要工作
|
||||
func Test停双写_召回不受影响(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer func() { _ = g.Close() }()
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, _, err := g.Commit([]Triple{
|
||||
{Subject: "召回主体", Relation: "属性", Object: fmt.Sprintf("召回值%d", i),
|
||||
Confidence: 1.0},
|
||||
}, "sess", i); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
res, err := g.Recall([]string{"召回主体"}, nil, 1, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fmt.Printf(" 召回实体 %d,关系 %d\n", len(res.Entities), len(res.Relations))
|
||||
if len(res.Entities) == 0 {
|
||||
t.Error("★ 旧表冻结后召回不该失效(读方已全切块)")
|
||||
}
|
||||
if len(res.Relations) == 0 {
|
||||
t.Error("★ 旧表冻结后关系召回不该失效")
|
||||
}
|
||||
}
|
||||
|
||||
func legacyCounts(t *testing.T, g *GraphDB) map[string]int {
|
||||
t.Helper()
|
||||
out := map[string]int{}
|
||||
for _, tbl := range []string{"entities", "relations", "sentences"} {
|
||||
n, err := g.LegacyRowCount("SELECT COUNT(*) FROM " + tbl)
|
||||
if err != nil {
|
||||
t.Fatalf("count %s: %v", tbl, err)
|
||||
}
|
||||
out[tbl] = n
|
||||
}
|
||||
return out
|
||||
}
|
||||
@ -86,6 +86,56 @@ func (m *LightMemory) Commit(triples []Triple, sessionID string, turnID int) (in
|
||||
//
|
||||
// 任一侧出错都不影响另一侧的结果:单侧失败只在两侧都失败时返回错误
|
||||
// (主库是只读句柄,任何"查询即失败"都说明是真实故障)。
|
||||
// RecallSorted 是可指定排序的召回:两个空间各自排序后**再合并排序**。
|
||||
//
|
||||
// 不能只对其中一个空间排序 —— temp(子写的)与 main(主图)都会被召回,
|
||||
// 只排其一就会让另一个空间的实体以任意顺序混进前 N。
|
||||
//
|
||||
// ★ 去重必须按**名字**而不是 ID(第一版写成按 ID,是个真 bug):
|
||||
// temp 与 main 是**两个独立数据库**,各自的 id 都从 1 自增,
|
||||
// 同一个 id 在两边是完全无关的实体。按 ID 去重会让
|
||||
// 「主库的第 3 号实体」和「temp 的第 3 号实体」互相顶掉 ——
|
||||
// 实测子代理因此**看不到主记忆**(TestLightProfile_MemoryFaceWiring 变红)。
|
||||
// 正确做法与旧路径一致:复用 mergeRecall,它按名字/三元组去重。
|
||||
// ★ fingerprint 透传(2026-10-04):LightMemory 是薄代理,
|
||||
//
|
||||
// 不加这个参数就会在代理这一层把向量空间隔离悄悄丢掉。
|
||||
func (m *LightMemory) RecallSorted(keywords []string, seedEntities []string, depth int, sessionFilter, fingerprint string, mode SortMode) (*RecallResult, error) {
|
||||
m.mu.RLock()
|
||||
temp := m.temp
|
||||
main := m.main
|
||||
m.mu.RUnlock()
|
||||
|
||||
var (
|
||||
parts []*RecallResult
|
||||
lastErr error
|
||||
okAny bool
|
||||
)
|
||||
for _, g := range []*GraphDB{temp, main} {
|
||||
if g == nil {
|
||||
continue
|
||||
}
|
||||
r, err := g.RecallSorted(keywords, seedEntities, depth, sessionFilter, fingerprint, mode)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
okAny = true
|
||||
parts = append(parts, r)
|
||||
}
|
||||
if !okAny {
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("没有可用的图记忆实例")
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
merged := mergeRecall(parts...)
|
||||
// 跨空间合并后做全局重排:各空间内部已排好,这里只兜底。
|
||||
mergeSortEntities(merged.Entities, keywords, mode)
|
||||
sortRecallRelations(merged.Relations, mode)
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
func (m *LightMemory) Recall(keywords []string, seedEntities []string, depth int, sessionFilter string) (*RecallResult, error) {
|
||||
m.mu.RLock()
|
||||
temp := m.temp
|
||||
@ -189,3 +239,34 @@ func (m *LightMemory) Close() error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeSortEntities 对多空间合并后的实体做全局重排(原地)。
|
||||
//
|
||||
// 只处理相关性模式的层级键;时间模式在 mergeSortEntities 里退化为
|
||||
// 不动 —— 各空间的 UpdatedAt 都是真实时间,合并后仍需全局比时间,
|
||||
// 故时间键同样在此重算。
|
||||
func mergeSortEntities(ents []Entity, keywords []string, mode SortMode) {
|
||||
if len(ents) <= 1 {
|
||||
return
|
||||
}
|
||||
spec := make(map[int64]int, len(ents))
|
||||
for i := range ents {
|
||||
// MatchRank 已由各空间的 RecallSorted 回填;只命中泛词时它退回 2,
|
||||
// 这里用实体名再算一次实词层级,让跨空间同分实体可比。
|
||||
spec[ents[i].ID] = bestSpecificRank(ents[i].MatchRank, ents[i].Name, keywords)
|
||||
}
|
||||
sort.SliceStable(ents, func(i, j int) bool {
|
||||
a, b := ents[i], ents[j]
|
||||
if mode == SortRecent {
|
||||
if !a.UpdatedAt.Equal(b.UpdatedAt) {
|
||||
return a.UpdatedAt.After(b.UpdatedAt)
|
||||
}
|
||||
} else if spec[a.ID] != spec[b.ID] {
|
||||
return spec[a.ID] < spec[b.ID]
|
||||
}
|
||||
if a.MentionCount != b.MentionCount {
|
||||
return a.MentionCount > b.MentionCount
|
||||
}
|
||||
return len(a.Name) < len(b.Name)
|
||||
})
|
||||
}
|
||||
|
||||
224
internal/memory/merge_blocks_test.go
Normal file
224
internal/memory/merge_blocks_test.go
Normal file
@ -0,0 +1,224 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// MergeBlocks —— 块合并判据
|
||||
//
|
||||
// ★ 与旧 MergeEntities 的语义对齐,但按块的特性调整:
|
||||
//
|
||||
// 旧(entities/relations,85 行)
|
||||
// 1. source 的关系重定向到 target
|
||||
// 2. mention_count 相加
|
||||
// 3. 删自引用关系
|
||||
// 4. 删 source 实体
|
||||
//
|
||||
// 新(blocks/edges)
|
||||
// 1. source 块的**关系边**重定向到 target 块
|
||||
// 2. 删合并产生的**自环边**(source→X 与 target→X 重定向后同端)
|
||||
// 3. **删 source 块本身**(不留 @merged_ 残留)
|
||||
// 4. 结构边(contains)也要处理 —— 否则原句块会指向已删的块
|
||||
// 5. 场景引用同步
|
||||
//
|
||||
// ★★ 与旧实现的一个本质差异
|
||||
// 块 ID 是**内容派生**的(blk_ent_<hash(name)>),所以
|
||||
// 「张先生」和「张三」是两个不同的块。合并不是改端点,
|
||||
// 而是**让源块消失并把它的边改指向目标块**。
|
||||
// 这意味着边的端点值会变 —— 而旧实现在这一点上反而更简单
|
||||
// (改 relations.source_id 即可,实体 ID 不变)。
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// TestMergeBlocks_基础合并:边重定向 + 源块消失
|
||||
func TestMergeBlocks_基础合并(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer func() { _ = g.Close() }()
|
||||
if _, _, err := g.Commit([]Triple{
|
||||
{Subject: "张先生", Relation: "身份", Object: "值班长", Confidence: 1.0},
|
||||
{Subject: "张三", Relation: "负责", Object: "billing服务", Confidence: 1.0},
|
||||
}, "main", 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
src, err := g.BlockByText("张先生")
|
||||
if err != nil || src == nil {
|
||||
t.Fatalf("块源不存在: %v", err)
|
||||
}
|
||||
dst, err := g.BlockByText("张三")
|
||||
if err != nil || dst == nil {
|
||||
t.Fatalf("块目标不存在: %v", err)
|
||||
}
|
||||
|
||||
n, err := g.MergeBlocks("张先生", "张三")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fmt.Printf(" 合并重定向 %d 条边\n", n)
|
||||
if n != 1 {
|
||||
t.Errorf("★ 应重定向 1 条边,实际 %d", n)
|
||||
}
|
||||
|
||||
// ★ 源块必须彻底消失
|
||||
after, err := g.BlockByText("张先生")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after != nil {
|
||||
t.Errorf("★ 源块应被删除,仍存在: %+v", after)
|
||||
}
|
||||
|
||||
// ★ 目标块继承源块的关系
|
||||
res, err := g.RecallSorted([]string{"张三"}, nil, 1, "", "", SortRelevance)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
names := map[string]bool{}
|
||||
for _, r := range res.Relations {
|
||||
names[r.TargetName] = true
|
||||
}
|
||||
fmt.Printf(" 合并后「张三」的关系目标: %v\n", names)
|
||||
if !names["值班长"] {
|
||||
t.Errorf("★ 「张三」应继承源块的关系「值班长」,实际 %v", names)
|
||||
}
|
||||
if !names["billing服务"] {
|
||||
t.Errorf("★ 「张三」自己的关系被误删,实际 %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMergeBlocks_自环清理:两条边重定向后同端,必须去重
|
||||
func TestMergeBlocks_自环清理(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer func() { _ = g.Close() }()
|
||||
// 同一人对同一对象说了两件事 —— 合并后都会指向 target→X
|
||||
if _, _, err := g.Commit([]Triple{
|
||||
{Subject: "李四", Relation: "喜欢", Object: "咖啡", Confidence: 1.0},
|
||||
{Subject: "李四", Relation: "讨厌", Object: "咖啡", Confidence: 1.0},
|
||||
{Subject: "王五", Relation: "评价", Object: "咖啡", Confidence: 1.0},
|
||||
}, "main", 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
n, err := g.MergeBlocks("李四", "王五")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fmt.Printf(" 合并「李四」→「王五」重定向 %d 条边\n", n)
|
||||
// ★ 王五→咖啡 与 李四→咖啡 重定向后同端 ⇒ 自环,必须被删
|
||||
// 保留它们会让王五「喜欢咖啡」「讨厌咖啡」自相矛盾。
|
||||
edges, _, err := g.NeighbourEdgesOfBlock(mustBlockID(t, g, "王五"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fmt.Printf(" 「王五」合并后有 %d 条边\n", len(edges))
|
||||
for _, e := range edges {
|
||||
if e.Peer.Text == "咖啡" && e.SourceID == e.TargetID {
|
||||
t.Error("★ 自环边未被清理")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMergeBlocks_结构边处理:contains 不能悬空
|
||||
func TestMergeBlocks_结构边不被误删(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer func() { _ = g.Close() }()
|
||||
if _, _, err := g.Commit([]Triple{
|
||||
{Subject: "赵六", Relation: "状态", Object: "在线", Confidence: 1.0,
|
||||
SentenceText: "赵六现在在线"},
|
||||
// ★ 合并需要**两端都存在**(目标不存在时报错,见「不存在的块」判据)
|
||||
{Subject: "钱七", Relation: "状态", Object: "离线", Confidence: 1.0},
|
||||
}, "main", 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 先确认原句块与 contains 结构边存在
|
||||
srcBlkID := mustBlockID(t, g, "赵六")
|
||||
edges, _, err := g.NeighbourEdgesOfBlock(srcBlkID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fmt.Printf(" 合并前「赵六」边数 %d\n", len(edges))
|
||||
|
||||
if _, err := g.MergeBlocks("赵六", "钱七"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// ★ 源块消失了 ⇒ 指向它的 contains 边(source=原句块,target=源块)
|
||||
// 必须一并消失,否则块边表出现悬空端点。
|
||||
// ★ 关键:源块被删后,任何指向它的边都不能悬空。
|
||||
// 悬空端点会让 graphNodeExists 校验失败,后续引用它的写入全被拒。
|
||||
g.mu.RLock()
|
||||
var dangling int
|
||||
if err := g.db.QueryRow(`
|
||||
SELECT COUNT(*) FROM memory_block_edges
|
||||
WHERE (source_kind='block' AND source_id = ?)
|
||||
OR (target_kind='block' AND target_id = ?)`,
|
||||
srcBlkID, srcBlkID).Scan(&dangling); err != nil {
|
||||
g.mu.RUnlock()
|
||||
t.Fatalf("查悬空边: %v", err)
|
||||
}
|
||||
g.mu.RUnlock()
|
||||
if dangling != 0 {
|
||||
t.Errorf("★ 源块删除后仍有 %d 条边指向它(端点悬空)", dangling)
|
||||
}
|
||||
|
||||
// ★ 原句块仍可解析(内容派生的,合并不该删原句)
|
||||
if blk, err := g.BlockByText("赵六现在在线"); err != nil || blk == nil {
|
||||
t.Errorf("★ 原句块不该被合并删除: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMergeBlocks_重复合并要报错:源块不存在不等于成功
|
||||
func TestMergeBlocks_幂等(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer func() { _ = g.Close() }()
|
||||
if _, _, err := g.Commit([]Triple{
|
||||
{Subject: "甲一", Relation: "属性", Object: "值一", Confidence: 1.0},
|
||||
{Subject: "乙一", Relation: "属性", Object: "值二", Confidence: 1.0},
|
||||
}, "main", 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := g.MergeBlocks("甲一", "乙一"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 再合并一次:源块已被删除 ⇒ 必须报错。
|
||||
//
|
||||
// ★ 不做成幂等(0, nil):源块不存在可能是「已合并」也可能是
|
||||
// 「名字写错了」,两者返回同一个结果会让调用方无法区分 ——
|
||||
// 而后者是 bug,却表现为成功。
|
||||
if n, err := g.MergeBlocks("甲一", "乙一"); err == nil {
|
||||
t.Errorf("★ 重复合并应报错(源块已不存在),实际返回 n=%d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMergeBlocks_不存在的块要明确报错
|
||||
func TestMergeBlocks_不存在的块(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer func() { _ = g.Close() }()
|
||||
if _, _, err := g.Commit([]Triple{
|
||||
{Subject: "真实主体", Relation: "属性", Object: "某值", Confidence: 1.0},
|
||||
}, "main", 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := g.MergeBlocks("不存在的人", "真实主体"); err == nil {
|
||||
t.Error("★ 合并不存在的源块应报错(而不是静默成功)")
|
||||
}
|
||||
if _, err := g.MergeBlocks("真实主体", "也不存在的人"); err == nil {
|
||||
t.Error("★ 合并到不存在的目标块应报错")
|
||||
}
|
||||
}
|
||||
|
||||
func mustBlockID(t *testing.T, g *GraphDB, text string) string {
|
||||
t.Helper()
|
||||
b, err := g.BlockByText(text)
|
||||
if err != nil {
|
||||
t.Fatalf("BlockByText(%q): %v", text, err)
|
||||
}
|
||||
if b == nil {
|
||||
t.Fatalf("块 %q 不存在", text)
|
||||
}
|
||||
return b.ID
|
||||
}
|
||||
@ -158,20 +158,26 @@ func insertMigratedBlock(tx *sql.Tx, block MemoryBlock) error {
|
||||
// attachBlockToLegacySentences 把迁移出的块挂到该旧实体当时所属的句子上,
|
||||
// 并保留那些句子(它们可能只有媒体关系,删实体后就再无关系引用)。
|
||||
func attachBlockToLegacySentences(tx *sql.Tx, entityID int64, blockID string) (int, error) {
|
||||
rows, err := tx.Query(`SELECT DISTINCT s.id FROM sentences s
|
||||
// ★ 取 sentences.**text** 而不是 id —— 挂载点已从 sentences 表行号
|
||||
// 改成原句块 ID(Commit 块化,sentences 表退场)。
|
||||
// 行号只在这一行里当作 JOIN 键用,不作为边的端点。
|
||||
rows, err := tx.Query(`SELECT DISTINCT s.text FROM sentences s
|
||||
JOIN relations r ON r.sentence_id = s.id
|
||||
WHERE r.source_id = ? OR r.target_id = ?`, entityID, entityID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var sids []int64
|
||||
var sentTexts []string
|
||||
for rows.Next() {
|
||||
var sid int64
|
||||
if err := rows.Scan(&sid); err != nil {
|
||||
var text string
|
||||
if err := rows.Scan(&text); err != nil {
|
||||
rows.Close()
|
||||
return 0, err
|
||||
}
|
||||
sids = append(sids, sid)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
sentTexts = append(sentTexts, text)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
@ -180,11 +186,12 @@ func attachBlockToLegacySentences(tx *sql.Tx, entityID int64, blockID string) (i
|
||||
rows.Close()
|
||||
|
||||
n := 0
|
||||
for _, sid := range sids {
|
||||
for _, text := range sentTexts {
|
||||
// 端点 kind 从 'sentence' 改成 'block'(原句由块承载)
|
||||
if _, err := tx.Exec(`INSERT OR IGNORE INTO memory_block_edges
|
||||
(source_kind, source_id, target_kind, target_id, edge_type)
|
||||
VALUES ('sentence', ?, 'block', ?, 'contains')`,
|
||||
fmt.Sprintf("%d", sid), blockID); err != nil {
|
||||
VALUES ('block', ?, 'block', ?, 'contains')`,
|
||||
SentenceBlockID(text), blockID); err != nil {
|
||||
return n, err
|
||||
}
|
||||
n++
|
||||
|
||||
864
internal/memory/migrate_entities.go
Normal file
864
internal/memory/migrate_entities.go
Normal file
@ -0,0 +1,864 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 存量实体迁移为块节点(entities 退场第 3 步)。
|
||||
//
|
||||
// 为什么需要
|
||||
// --------
|
||||
// entities 是旧形态:只有 name/type/mention_count,没有向量;而块节点
|
||||
// (memory_blocks) 带 vector+fingerprint/modality,是节点的正统形态。
|
||||
// MigrateLegacyMediaEntities 已经把「媒体类实体」迁过一次(见 migrate.go),
|
||||
// 本函数处理剩下的**文本类实体**——生产库实测 1277 个。
|
||||
//
|
||||
// 迁成什么形态
|
||||
// ------------
|
||||
// sentence(实体名作为一句「陈述」) --contains--> block(实体名, 带向量)
|
||||
//
|
||||
// 关系则转成 block 边:
|
||||
// block(主语) -[关系类型]-> block(宾语)
|
||||
//
|
||||
// 为什么不拆字段:拆(LLM 三元组化)是**提升召回**的动作,与「换存储格式」
|
||||
// 是两件事。混在一起做会让这次迁移既不可回滚也不可验证 —— 迁完不知道
|
||||
// 召回变好还是变坏。拆分留给迁移后单独跑(有独立的探针判据)。
|
||||
//
|
||||
// 回滚
|
||||
// ----
|
||||
// 全程单事务,任何一步失败整体回滚:半途中断会留下既没有实体也没有块的
|
||||
// 关系,信息静默消失(与 MigrateLegacyMediaEntities 同款考量)。
|
||||
// 另:调用方应在迁移前自行快照 graph.db —— 本函数**不删数据**,
|
||||
// 只在全部成功后由调用方决定是否清理旧表。
|
||||
|
||||
// MigrateResult 是存量迁移的统计。
|
||||
type MigrateResult struct {
|
||||
Sentences int `json:"sentences"`
|
||||
Blocks int `json:"blocks"`
|
||||
Edges int `json:"edges"`
|
||||
SkippedOrphan int `json:"skipped_orphan"`
|
||||
// DedupedEdges 是**被去重**的边数:relations 表里有完全重复的
|
||||
// (source,target,type) 三元组,边表按唯一键存 ⇒ 实际写入少于遍历数。
|
||||
// 必须显式报出来,否则「报告数 ≠ 实际写入数」会被当成数据丢失。
|
||||
DedupedEdges int `json:"deduped_edges"`
|
||||
SkippedNoVec int `json:"skipped_no_vector"`
|
||||
}
|
||||
|
||||
// EntityEmbedder 为一个实体名算向量与空间指纹。
|
||||
//
|
||||
// 由调用方注入(持有 embedding provider 的一方)。返回 nil 向量表示
|
||||
// 「这个实体算不出向量」——迁移**继续但不计为成功块**,不编造零向量。
|
||||
type EntityEmbedder func(entityName string) (vec []float64, fingerprint string)
|
||||
|
||||
// MigrateLegacyTextEntities 把文本类实体迁移成块节点。
|
||||
//
|
||||
// embed 为 nil 时仍迁移(块不带向量,只是不参与向量召回)—— 这是可接受的
|
||||
// 中间态:结构对了,向量可以后续回填(RecallBlocks 对无向量的块直接跳过)。// MigrateLegacyTextEntities 把文本类实体迁移成块节点。
|
||||
//
|
||||
// embed 为 nil 时仍迁移(块不带向量,只是不参与向量召回)—— 这是可接受的
|
||||
// 中间态:结构对了,向量可以后续回填(RecallBlocks 对无向量的块直接跳过)。
|
||||
//
|
||||
// ★ 三个阶段,慢操作不进事务
|
||||
// ----------------------------
|
||||
//
|
||||
// 阶段一 读快照(RLock,短) 取出全部实体与关系到内存
|
||||
// 阶段二 算向量(无锁) embed 回调可能很慢
|
||||
// 阶段三 写事务落库(Lock) 句子 + 块 + 边
|
||||
//
|
||||
// 分阶段的原因:embed 是 ONNX 前向,实测 0.3~1s/条,生产库 1277 实体
|
||||
// = 6~21 分钟。若在写事务内调用 embed,graph.db 会被锁住那么久 ——
|
||||
// 它是单文件、所有记忆操作共用一把 g.mu,期间召回与写入全部阻塞。
|
||||
// 第一版就是这么写的,症状是测试直接卡死 600s(测试在 embed 回调里
|
||||
// 反过来拿锁,构造出真实场景的等价死锁)。
|
||||
func (g *GraphDB) MigrateLegacyTextEntities(embed EntityEmbedder) (MigrateResult, error) {
|
||||
var res MigrateResult
|
||||
|
||||
ents, rels, err := g.readLegacySnapshot()
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
|
||||
// ── 阶段二:锁外算向量 ──
|
||||
vectors := make(map[int64][]float64, len(ents))
|
||||
fps := make(map[int64]string, len(ents))
|
||||
if embed != nil {
|
||||
for _, e := range ents {
|
||||
if vec, fp := embed(e.name); len(vec) > 0 {
|
||||
vectors[e.id] = vec
|
||||
fps[e.id] = fp
|
||||
} else {
|
||||
res.SkippedNoVec++
|
||||
}
|
||||
}
|
||||
} else {
|
||||
res.SkippedNoVec = len(ents)
|
||||
}
|
||||
|
||||
// ── 阶段三:写事务落库 ──
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
tx, err := g.db.Begin()
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
entToBlock := make(map[int64]string, len(ents))
|
||||
for _, e := range ents {
|
||||
// 实体名同时充当「句子」(陈述)与「块文本」:迁移期 1:1 对应,
|
||||
// 不做内容改写 —— 改写属于拆分,不属于迁移。
|
||||
// ★ 不再建 sentences 行(方案 A 退场)。
|
||||
//
|
||||
// 原来的 sentence 节点**没有独立价值**:它的 text 就是实体名,
|
||||
// 而下面那个块的 Text 也是 e.name —— 两者完全重复,
|
||||
// 而 sentences 表一退场这个节点就悬空了。
|
||||
//
|
||||
// 现在:原句块(blk_src_<hash>)--contains--> 迁移块
|
||||
// 与蒸馏路径(0586f6b)用同一套原句块形态。
|
||||
blockID := legacyEntityBlockID(e.id, e.name)
|
||||
src := NewSentenceBlock(e.name, e.createdAt, e.updatedAt)
|
||||
srcBlockID := src.ID
|
||||
if err := putBlockTx(tx, src); err != nil {
|
||||
return res, fmt.Errorf("put sentence block for entity %d: %w", e.id, err)
|
||||
}
|
||||
if err := putBlockTx(tx, MemoryBlock{
|
||||
ID: blockID,
|
||||
Modality: BlockText,
|
||||
Text: e.name,
|
||||
Vector: vectors[e.id],
|
||||
Fingerprint: fps[e.id],
|
||||
Source: "legacy-entity",
|
||||
CreatedAt: e.createdAt,
|
||||
UpdatedAt: e.updatedAt,
|
||||
}); err != nil {
|
||||
return res, fmt.Errorf("put block for entity %d: %w", e.id, err)
|
||||
}
|
||||
if err, _ := addBlockEdgeTx(tx, "block", srcBlockID,
|
||||
"block", blockID, "contains"); err != nil {
|
||||
return res, fmt.Errorf("link block %s: %w", blockID, err)
|
||||
}
|
||||
entToBlock[e.id] = blockID
|
||||
// 注意:res.Sentences 不再递增 —— 统计口径改为「原句块」
|
||||
res.Blocks++
|
||||
}
|
||||
|
||||
for _, r := range rels {
|
||||
src, ok1 := entToBlock[r.src]
|
||||
tgt, ok2 := entToBlock[r.tgt]
|
||||
if !ok1 || !ok2 {
|
||||
res.SkippedOrphan++
|
||||
continue
|
||||
}
|
||||
edgeType := strings.TrimSpace(r.typ)
|
||||
if edgeType == "" {
|
||||
// 空类型会被 addBlockEdgeTx 拒绝(端点与类型都必填)。
|
||||
// 用明确占位名而不是跳过 —— 关系的**存在**本身是信息。
|
||||
edgeType = "related_to"
|
||||
}
|
||||
// ★ 只在**真的新增**时计数 —— 否则报告会多算被去重的那些。
|
||||
// 实测:980 行 relations(含 21 组重复三元组)报告 980 而实际 959。
|
||||
//
|
||||
// ★★ 用 addMigratedRelationTx 而不是 addBlockEdgeTx(2026-10-04):
|
||||
// 后者是**结构边**写入器,去重条件带 `session_id=''`
|
||||
// ⇒ 每一条带 session 的要迁移的关系都会被判成「已存在」而跳过。
|
||||
// 而且它不写 confidence —— 生产迁移实测 959 条边 confidence 全 0。
|
||||
// ★★ status 要与**块侧**取交集(2026-10-04)
|
||||
//
|
||||
// 读侧切块之后 Purge 只改块侧,旧 relations 行**仍停在 active**。
|
||||
// 于是迁移看到 active 的旧行会当成活跃关系搬过去 ——
|
||||
// 而它在块侧早就被软删了 ⇒ **已删除的记忆复活**。
|
||||
//
|
||||
// 生产库实测有 14 条 deleted 关系;若迁移前跑过 memory_purge
|
||||
// 工具(它走 Purge),这批会被全部复活。
|
||||
//
|
||||
// ⇒ 迁移时额外查一次块侧:块侧已 deleted 的旧行不迁成 active。
|
||||
// ★★ 判据:**精确**匹配块侧那条边(2026-10-04)
|
||||
//
|
||||
// 第一版写成 `source_id IN (src,tgt) AND target_id IN (src,tgt)`
|
||||
// —— 那是**交叉匹配**:任一端相同就算命中。
|
||||
// 于是「A→B 被删」会让「A→C」「B→A」都被误判成 deleted。
|
||||
//
|
||||
// 而且它掩盖了真正的问题:块侧被删的边是
|
||||
// `blk_ent_<hash(A)>` → `blk_ent_<hash(B)>`(块化时写的),
|
||||
// 而迁移用的是旧表行号映射出的块 ID ——
|
||||
// 两套 ID 不同,只有**按块文本**才能对上。
|
||||
effectiveStatus := r.status
|
||||
if effectiveStatus == "" || effectiveStatus == "active" {
|
||||
var blockDeleted int
|
||||
if err := tx.QueryRow(`
|
||||
SELECT COUNT(*) FROM memory_block_edges e
|
||||
JOIN memory_blocks sb ON sb.id = e.source_id
|
||||
JOIN memory_blocks tb ON tb.id = e.target_id
|
||||
WHERE e.source_kind='block' AND e.target_kind='block'
|
||||
AND e.edge_type = ?
|
||||
AND COALESCE(sb.text_content,'') = (SELECT name FROM entities WHERE id = ?)
|
||||
AND COALESCE(tb.text_content,'') = (SELECT name FROM entities WHERE id = ?)
|
||||
AND COALESCE(e.session_id,'') = ?
|
||||
AND COALESCE(e.status,'') = 'deleted'`,
|
||||
edgeType, r.src, r.tgt, r.sessionID).Scan(&blockDeleted); err != nil {
|
||||
return res, fmt.Errorf("check block-side status: %w", err)
|
||||
}
|
||||
if blockDeleted > 0 {
|
||||
effectiveStatus = EdgeDeleted
|
||||
}
|
||||
}
|
||||
err, added := addMigratedRelationTx(tx, src, tgt, edgeType,
|
||||
r.confidence, r.sessionID, r.turnID, effectiveStatus)
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("migrate relation %d: %w", r.id, err)
|
||||
}
|
||||
if !added {
|
||||
res.DedupedEdges++
|
||||
continue
|
||||
}
|
||||
res.Edges++
|
||||
}
|
||||
|
||||
// ★ 为 sentences 表的原文补建原句块。
|
||||
//
|
||||
// 实测(清理前置判据,生产快照):sentences 66 条,原句块 **0 个**。
|
||||
// 而迁移只从 `entities` 读(readLegacySnapshot 里只有 entities 查询),
|
||||
// 从没为 sentences 建过载体 ⇒ 直接清理该表会**丢 66 条原句**。
|
||||
//
|
||||
// 而 sentences 表的价值就在原文本身(entities 是提炼后的名字),
|
||||
// 所以原句块是它唯一的迁移出口 —— 不补这一段,清理就是数据丢失。
|
||||
//
|
||||
// 形态与迁移块的父节点一致:blk_src_<sha256(Text[:12])>,无向量。
|
||||
// 已有原句块(entities 迁移建的)会被 putBlockTx 的 ON CONFLICT 跳过,
|
||||
// 所以重复跑是幂等的。
|
||||
sentRows, err := tx.Query(`SELECT text FROM sentences
|
||||
WHERE text IS NOT NULL AND TRIM(text) != ''`)
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("read sentences for migration: %w", err)
|
||||
}
|
||||
for sentRows.Next() {
|
||||
var text string
|
||||
if err := sentRows.Scan(&text); err != nil {
|
||||
_ = sentRows.Close()
|
||||
return res, err
|
||||
}
|
||||
sb := NewSentenceBlock(text, time.Time{}, time.Time{})
|
||||
if err := putBlockTx(tx, sb); err != nil {
|
||||
_ = sentRows.Close()
|
||||
return res, fmt.Errorf("put sentence block: %w", err)
|
||||
}
|
||||
res.Sentences++
|
||||
}
|
||||
if err := sentRows.Err(); err != nil {
|
||||
_ = sentRows.Close()
|
||||
return res, err
|
||||
}
|
||||
_ = sentRows.Close()
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return res, err
|
||||
}
|
||||
log.Printf("[graph] 存量实体迁移完成: 句子 %d,块 %d,边 %d"+
|
||||
"(跳过孤儿关系 %d,去重边 %d,无向量 %d)",
|
||||
res.Sentences, res.Blocks, res.Edges,
|
||||
res.SkippedOrphan, res.DedupedEdges, res.SkippedNoVec)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
type legacyEnt struct {
|
||||
id int64
|
||||
name string
|
||||
createdAt, updatedAt time.Time
|
||||
}
|
||||
|
||||
// MemoryBlock 的时间戳来自源实体 —— 值覆盖维度(同一属性先后给两个值)
|
||||
// 靠的就是时序。迁移时若统一写 NOW(),就把 188 个块的先后关系抹平成
|
||||
// 同一时刻,`overwrite` 检索就退化成「新旧并列,取谁全靠向量相似度」——
|
||||
// 实测 chineseclip 对新旧号短句给 0.9298 vs 0.9284,数值上根本区分不了。
|
||||
type legacyRel struct {
|
||||
id, src, tgt int64
|
||||
typ string
|
||||
createdAt time.Time
|
||||
// ★ 以下四列 2026-10-04 补:旧 relations 的属性列此前**根本没被读取**,
|
||||
// 于是迁移出的边全是空属性 —— 而边表把 confidence 当唯一的质量信号
|
||||
// (RecallSorted 的相关性排序、场景权重、蒸馏置信度传播都靠它)。
|
||||
// 实测生产迁移后 959 条边 confidence 全为 0。
|
||||
confidence float64
|
||||
sessionID string
|
||||
turnID int
|
||||
status string
|
||||
}
|
||||
|
||||
// readLegacySnapshot 在短读锁内取全部实体与关系。
|
||||
func (g *GraphDB) readLegacySnapshot() ([]legacyEnt, []legacyRel, error) {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
|
||||
rows, err := g.db.Query(`SELECT id, name,
|
||||
COALESCE(created_at, ''),
|
||||
COALESCE(updated_at, '')
|
||||
FROM entities
|
||||
WHERE name IS NOT NULL AND TRIM(name) != '' ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var ents []legacyEnt
|
||||
for rows.Next() {
|
||||
var e legacyEnt
|
||||
var created, updated string
|
||||
if err := rows.Scan(&e.id, &e.name, &created, &updated); err != nil {
|
||||
rows.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
e.createdAt = parseLegacyTime(created)
|
||||
e.updatedAt = parseLegacyTime(updated)
|
||||
ents = append(ents, e)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
relRows, err := g.db.Query(`SELECT id, source_id, target_id,
|
||||
COALESCE(relation_type, ''),
|
||||
COALESCE(confidence, 0), COALESCE(session_id, ''),
|
||||
COALESCE(turn_id, 0), COALESCE(status, '')
|
||||
FROM relations ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var rels []legacyRel
|
||||
for relRows.Next() {
|
||||
var r legacyRel
|
||||
if err := relRows.Scan(&r.id, &r.src, &r.tgt, &r.typ,
|
||||
&r.confidence, &r.sessionID, &r.turnID, &r.status); err != nil {
|
||||
relRows.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
rels = append(rels, r)
|
||||
}
|
||||
relRows.Close()
|
||||
return ents, rels, relRows.Err()
|
||||
}
|
||||
|
||||
// legacyEntityBlockID 由**实体 id**派生块 id。
|
||||
//
|
||||
// 用 id 而非内容:实体的 name 是 UNIQUE 的,但迁移期同一句话可能既是实体
|
||||
// 又是句子(下面第 3 步会把关系句子也建成块),用内容派生会撞。
|
||||
// 实体 id 保证块 id 稳定且可重复迁移(幂等)。
|
||||
func legacyEntityBlockID(entityID int64, name string) string {
|
||||
return fmt.Sprintf("blk_ent_%d_%s", entityID, shortHash(name))
|
||||
}
|
||||
|
||||
func shortHash(s string) string {
|
||||
var h uint64 = 14695981039346656037
|
||||
for i := 0; i < len(s); i++ {
|
||||
h ^= uint64(s[i])
|
||||
h *= 1099511628211
|
||||
}
|
||||
return fmt.Sprintf("%08x", uint32(h))
|
||||
}
|
||||
|
||||
// legacyTimeLayouts 是旧库里时间列出现过的格式。
|
||||
//
|
||||
// entities.created_at 声明为 TIMESTAMP 但 SQLite 是弱类型:经由
|
||||
// COALESCE 或历史写入路径存进去的可能是裸字符串,driver 会把它作为
|
||||
// string 返回(直接扫进 time.Time 会报 unsupported Scan)。实测就是
|
||||
// 卡在这里 —— 所以扫到 string 自己解析,而不是赌 driver 的类型推断。
|
||||
var legacyTimeLayouts = []string{
|
||||
"2006-01-02 15:04:05.999999999-07:00",
|
||||
"2006-01-02 15:04:05-07:00",
|
||||
"2006-01-02 15:04:05.999999999",
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02T15:04:05.999999999Z07:00",
|
||||
"2006-01-02T15:04:05Z07:00",
|
||||
"2006-01-02T15:04:05",
|
||||
"2006-01-02",
|
||||
}
|
||||
|
||||
// parseLegacyTime 解析旧库时间列;空值或无法识别时返回零值,
|
||||
// 由 putBlockTx 兜底成 NOW()(宁可丢时序,也不能编造一个错的时刻)。
|
||||
func parseLegacyTime(s string) time.Time {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
for _, layout := range legacyTimeLayouts {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
return t.UTC()
|
||||
}
|
||||
}
|
||||
log.Printf("[graph] 旧库时间 %q 格式无法识别,该块将落为当前时间(时序信息丢失)", s)
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// ── 事务内工具(与 block.go 的公开方法同款语义,但共用同一个 tx)──
|
||||
|
||||
func ensureSentenceTx(tx *sql.Tx, text string) (int64, error) {
|
||||
if _, err := tx.Exec(`INSERT OR IGNORE INTO sentences (text) VALUES (?)`, text); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var id int64
|
||||
if err := tx.QueryRow(`SELECT id FROM sentences WHERE text = ?`, text).Scan(&id); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func putBlockTx(tx *sql.Tx, b MemoryBlock) error {
|
||||
vectorJSON := ""
|
||||
if len(b.Vector) > 0 {
|
||||
raw, err := json.Marshal(b.Vector)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
vectorJSON = string(raw)
|
||||
}
|
||||
// 时间戳显式写入(COALESCE 兜底 NOW()):值覆盖维度依赖块间先后,
|
||||
// 全部落成同一时刻就等于把时序抹平。
|
||||
//
|
||||
// ★★ 但「零值」要区分两种语义(实测 Commit 块化后踩过)
|
||||
//
|
||||
// ① 迁移/蒸馏显式指定时间 → 用它
|
||||
// ② 调用方未指定(零值) → 填 now,但**绝不覆盖已有时间**
|
||||
//
|
||||
// 第一版对②也用 now + 无条件 ON CONFLICT SET created_at,于是
|
||||
// Commit 块化之后:
|
||||
//
|
||||
// Commit 写 blk_ent_<hash>(无时序)→ 填 now
|
||||
// → 迁移处理同名实体 → ON CONFLICT 覆盖 created_at
|
||||
// → 迁移块继承的实体时序被抹平
|
||||
//
|
||||
// 实测迁移测试直接抓到:
|
||||
//
|
||||
// blk_ent_edb5a71b11d814fdc36e8332 应继承实体时间 2026-01-01 10:00
|
||||
// 实际 2026-10-04 08:05(时序被抹平)
|
||||
//
|
||||
// 而时序是**仲裁的前提**(arbitration.go 靠 CreatedAt 判断谁取代谁)。
|
||||
// ⇒ 零值时 UPDATE 分支**不碰时间列**。
|
||||
created := b.CreatedAt
|
||||
updated := b.UpdatedAt
|
||||
explicitTime := !created.IsZero()
|
||||
if !explicitTime {
|
||||
created = time.Now()
|
||||
}
|
||||
if updated.IsZero() {
|
||||
if explicitTime {
|
||||
updated = created
|
||||
} else {
|
||||
updated = created
|
||||
}
|
||||
}
|
||||
// 只有显式指定时间时才在 UPDATE 分支写时间列
|
||||
tsUpdate := "created_at = excluded.created_at, updated_at = excluded.updated_at"
|
||||
if !explicitTime {
|
||||
tsUpdate = "updated_at = memory_blocks.updated_at"
|
||||
}
|
||||
_, err := tx.Exec(`INSERT INTO memory_blocks
|
||||
(id, modality, text_content, payload_digest, mime, size, width, height,
|
||||
vector, fingerprint, source, tool, scene, semantic_type, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
modality = excluded.modality,
|
||||
text_content = excluded.text_content,
|
||||
vector = excluded.vector,
|
||||
fingerprint = excluded.fingerprint,
|
||||
source = excluded.source,
|
||||
semantic_type = CASE WHEN excluded.semantic_type != ''
|
||||
THEN excluded.semantic_type ELSE memory_blocks.semantic_type END,
|
||||
`+tsUpdate,
|
||||
b.ID, b.Modality, b.Text, b.PayloadDigest, b.MIME, b.Size, b.Width, b.Height,
|
||||
vectorJSON, b.Fingerprint, b.Source, b.Tool, b.Scene, b.SemanticType,
|
||||
created, updated)
|
||||
return err
|
||||
}
|
||||
|
||||
// ★ 返回 (error, 是否真的新增了一行)。第二个返回值是给报告口径用的 ——
|
||||
//
|
||||
// `INSERT OR IGNORE` 静默去重时它为 false。
|
||||
func addBlockEdgeTx(tx *sql.Tx, sourceKind, sourceID, targetKind, targetID, edgeType string) (error, bool) {
|
||||
// 端点存在性校验:与 AddMemoryBlockEdge 同款,但共用当前事务 ——
|
||||
// 分开校验会在并发下出现「校验通过后节点被删」的窗口。
|
||||
for _, ep := range []struct{ kind, id string }{
|
||||
{sourceKind, sourceID}, {targetKind, targetID}} {
|
||||
var n int
|
||||
var err error
|
||||
switch ep.kind {
|
||||
case "block":
|
||||
err = tx.QueryRow(`SELECT COUNT(*) FROM memory_blocks WHERE id = ?`, ep.id).Scan(&n)
|
||||
case "sentence":
|
||||
err = tx.QueryRow(`SELECT COUNT(*) FROM sentences WHERE CAST(id AS TEXT) = ?`, ep.id).Scan(&n)
|
||||
default:
|
||||
err = fmt.Errorf("invalid graph node kind %q", ep.kind)
|
||||
}
|
||||
if err != nil {
|
||||
return err, false
|
||||
}
|
||||
if n == 0 {
|
||||
return fmt.Errorf("%s graph node %s does not exist", ep.kind, ep.id), false
|
||||
}
|
||||
}
|
||||
// ★ 显式查重:边表升格去掉了 UNIQUE 约束(关系边要能并存多条),
|
||||
// 而 INSERT OR IGNORE 的去重正是靠那个 UNIQUE 实现的。
|
||||
// 不查重的话迁移跑第二遍会插出重复的 contains 边
|
||||
// (实测 TestMigrateLegacyTextEntities_幂等 当场红了)。
|
||||
var existing int
|
||||
if err := tx.QueryRow(`SELECT COUNT(*) FROM memory_block_edges
|
||||
WHERE source_kind=? AND source_id=? AND target_kind=? AND target_id=?
|
||||
AND edge_type=? AND COALESCE(session_id,'')=''`,
|
||||
sourceKind, sourceID, targetKind, targetID, edgeType).Scan(&existing); err != nil {
|
||||
return err, false
|
||||
}
|
||||
if existing > 0 {
|
||||
return nil, false // 已存在:结构边幂等(不算新增)
|
||||
}
|
||||
|
||||
res, err := tx.Exec(`INSERT INTO memory_block_edges
|
||||
(source_kind, source_id, target_kind, target_id, edge_type)
|
||||
VALUES (?, ?, ?, ?, ?)`, sourceKind, sourceID, targetKind, targetID, edgeType)
|
||||
if err != nil {
|
||||
return err, false
|
||||
}
|
||||
// ★ 返回「是否真的新增了一行」。
|
||||
//
|
||||
// `INSERT OR IGNORE` 在冲突时**既不报错也不新增**,调用方若只看
|
||||
// error 就会把「被去重」当成「写入成功」——
|
||||
// 生产快照实测:relations 980 行(含 20 组完全重复的三元组),
|
||||
// 报告写「边 980」而边表实际 959。
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err, false
|
||||
}
|
||||
return nil, n > 0
|
||||
}
|
||||
|
||||
// LegacyEntity 是存量实体的一条只读快照。
|
||||
type LegacyEntity struct {
|
||||
ID int64
|
||||
Name string
|
||||
}
|
||||
|
||||
// LegacyEntities 读出存量实体,供拆分/迁移命令使用。
|
||||
//
|
||||
// 只读,不删任何东西 —— 方案 A 里旧表的清理由调用方在验证通过后
|
||||
// 单独执行(见 cmd/homed-graph-migrate 的说明)。
|
||||
//
|
||||
// limit <= 0 表示不限。分批是为了让拆分能增量推进(CPU 上 1.7b 模型
|
||||
// 每条约 1~2 秒,全量 188 条要 3~6 分钟)。
|
||||
//
|
||||
// minLen > 0 时跳过过短的实体名:标题类实体(「order-gw 运维进展」「每天」
|
||||
// 「老大」)本来就没有可拆字段,实测对它们跑拆分 8/8 全零字段 ——
|
||||
// 那是正确结果,但会让报告看起来像「拆分坏了」。按长度预筛能把注意力
|
||||
// 放在真有字段的记录上。
|
||||
func (g *GraphDB) LegacyEntities(limit, minLen int) ([]LegacyEntity, error) {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
|
||||
query := `SELECT id, name FROM entities
|
||||
WHERE name IS NOT NULL AND TRIM(name) != ''`
|
||||
args := []any{}
|
||||
if minLen > 0 {
|
||||
query += ` AND LENGTH(name) >= ?`
|
||||
args = append(args, minLen)
|
||||
}
|
||||
query += ` ORDER BY id`
|
||||
if limit > 0 {
|
||||
query += ` LIMIT ?`
|
||||
args = append(args, limit)
|
||||
}
|
||||
rows, err := g.db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []LegacyEntity
|
||||
for rows.Next() {
|
||||
var e LegacyEntity
|
||||
if err := rows.Scan(&e.ID, &e.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CountLegacyEntitiesByType 统计旧表 entities 里某类型的行数。
|
||||
//
|
||||
// ★ 用途:判据「迁移后旧表不该残留 X」。
|
||||
//
|
||||
// 读侧切块之后,Recall 返回的东西既可能是块也可能是旧表行,
|
||||
// 而两者 Name/Type 可能完全一样 —— 光看召回结果分不出来。
|
||||
// 于是判据必须**直查旧表**,而那是本包的私有 db。
|
||||
//
|
||||
// ★ 只读,无副作用。minLen=0 表示不限名长。
|
||||
//
|
||||
// ★ 它存在的另一理由:`LegacyEntity` 只带 ID/Name(不含 Type),
|
||||
//
|
||||
// 所以拿它做「按类型核查」并不够用。
|
||||
func (g *GraphDB) CountLegacyEntitiesByType(entityType string) (int, error) {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
var n int
|
||||
if err := g.db.QueryRow(
|
||||
`SELECT COUNT(*) FROM entities WHERE type = ?`, entityType).Scan(&n); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// LegacyRowCount 执行一条**只读**的 COUNT 查询并返回行数。
|
||||
//
|
||||
// ★ 为什么需要它(2026-10-04)
|
||||
//
|
||||
// Introspect 的 entity_count / relation_count 在读侧切块之后
|
||||
// 数的是**块侧**。而迁移报告要的是「旧表还剩多少没迁走」——
|
||||
// 用 Introspect 的数会得到「关系 0」这种**假的结论**,
|
||||
// 进而让运维以为「早就迁完了」而跳过迁移。
|
||||
//
|
||||
// ★ 风险控制:query 必须是完整的 SELECT COUNT(*) 语句。
|
||||
//
|
||||
// 这不是「内部函数所以可以放心拼接」—— 迁移命令会接收用户给的
|
||||
// -db 路径,而这类拼接口子在出错时很难定位。
|
||||
// 所以:只允许 COUNT、只允许单表、表名白名单在调用方校验。
|
||||
// 违反任一条直接拒绝,而不是执行。
|
||||
func (g *GraphDB) LegacyRowCount(query string) (int, error) {
|
||||
q := strings.TrimSpace(query)
|
||||
upper := strings.ToUpper(q)
|
||||
if !strings.HasPrefix(upper, "SELECT COUNT(*) FROM ") {
|
||||
return 0, fmt.Errorf("LegacyRowCount: 只允许 SELECT COUNT(*) FROM <table>")
|
||||
}
|
||||
rest := q[len("SELECT COUNT(*) FROM "):]
|
||||
up := strings.ToUpper(rest)
|
||||
// ★ 关键字一律按「子串」判定,不要按「前缀/后缀长度」判定。
|
||||
//
|
||||
// 我第一版写的是 `rest[len(rest)-12:] == " GROUP BY "`,
|
||||
// 而 " GROUP BY " 只有 **10** 个字符 —— 长度判断让它永远不成立,
|
||||
// 于是 `GROUP BY type` 直接穿过守卫。
|
||||
// ★ 这类「用长度近似关键字」的写法在判据里看着能过,
|
||||
// 因为测试数据恰好没踩到 —— 直到判据专门去试它。
|
||||
for _, banned := range []string{" JOIN ", " GROUP BY ", " UNION ", ";"} {
|
||||
if strings.Contains(up, banned) || strings.HasPrefix(up, strings.TrimSpace(banned)) {
|
||||
return 0, fmt.Errorf("LegacyRowCount: 不允许 %q", strings.TrimSpace(banned))
|
||||
}
|
||||
}
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
var n int
|
||||
if err := g.db.QueryRow(q).Scan(&n); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// addMigratedRelationTx 迁移一条**旧 relations 行**到独立关系边。
|
||||
//
|
||||
// ★★ 为什么不复用 addBlockEdgeTx
|
||||
//
|
||||
// addBlockEdgeTx 是**结构边**写入器:去重条件里带
|
||||
// `COALESCE(session_id,'')=''`(结构边无 session),
|
||||
// 而且它只写 5 列,不带 confidence / session / turn / status。
|
||||
//
|
||||
// 而迁移的关系边**恰恰要带 session_id** —— 于是那个去重条件
|
||||
// 会让**每一条**要迁移的边都被判成「已存在」而跳过。
|
||||
// (实测若不改:生产 959 条边一条都迁不进去。)
|
||||
//
|
||||
// ★ 去重口径:同 (source, target, edge_type, session_id) 视为同一条。
|
||||
//
|
||||
// 生产 relations 里有 21 组完全重复的三元组(实测)——
|
||||
// 它们是**迁移期历史重复**(同一事实被记了两次,同一会话内),
|
||||
// 按 session 收敛掉是对的;而**跨会话**的重复是真实的多次陈述,
|
||||
// 必须各存一条(关系边按设计允许并存多条)。
|
||||
//
|
||||
// ★ status:旧表有 deleted/archived 的关系不应迁成 active ——
|
||||
//
|
||||
// 否则一条已删除的记忆会在召回里复活。
|
||||
// 只迁 status='active';其余映射为 'deleted' 保留痕迹。
|
||||
func addMigratedRelationTx(tx *sql.Tx, src, tgt, edgeType string,
|
||||
confidence float64, sessionID string, turnID int, status string) (error, bool) {
|
||||
if src == "" || tgt == "" || edgeType == "" {
|
||||
return fmt.Errorf("migrate relation: endpoints and type required"), false
|
||||
}
|
||||
var existing int
|
||||
if err := tx.QueryRow(`SELECT COUNT(*) 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,'')=?`,
|
||||
src, tgt, edgeType, sessionID).Scan(&existing); err != nil {
|
||||
return err, false
|
||||
}
|
||||
if existing > 0 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// ★ 非 active 的旧关系迁成 deleted,不迁成 active。
|
||||
if status != "" && status != "active" {
|
||||
status = EdgeDeleted
|
||||
}
|
||||
if status == "" {
|
||||
status = EdgeActive
|
||||
}
|
||||
res, err := tx.Exec(`INSERT INTO memory_block_edges
|
||||
(source_kind, source_id, target_kind, target_id, edge_type,
|
||||
confidence, status, session_id, turn_id, created_at)
|
||||
VALUES ('block', ?, 'block', ?, ?, ?, ?, ?, ?, ?)`,
|
||||
src, tgt, edgeType, confidence, status, sessionID, turnID,
|
||||
time.Now())
|
||||
if err != nil {
|
||||
return err, false
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return nil, n > 0
|
||||
}
|
||||
|
||||
// SeedLegacyMediaEntity 为**测试**在旧表造一条媒体实体。
|
||||
//
|
||||
// ★★ 为什么需要它
|
||||
//
|
||||
// 迁移的输入是旧表 `entities WHERE type='Media'`。而测试原先靠
|
||||
// `CommitWithMedia` 造这份旧表数据 —— 但旧表双写已停(2026-10-04),
|
||||
// Commit 不再写它,于是迁移的输入**天然为空**,测试变成假通过
|
||||
// (断言「迁出 0 条 0 实体」竟然成立)。
|
||||
//
|
||||
// ★ 这比直接失败更糟:它让「迁移还对不对」这个问题**无法回答**。
|
||||
//
|
||||
// ★ 所以把「旧表长什么样」显式化:测试直接写旧表,
|
||||
//
|
||||
// 迁移动作与被迁数据彼此独立。
|
||||
//
|
||||
// ⚠ 仅供测试使用 —— 生产代码不应有这条路径。
|
||||
//
|
||||
// 真正的迁移只读旧表,从不写。
|
||||
func (g *GraphDB) SeedLegacyMediaEntity(name, entityType string) (int64, error) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
res, err := g.db.Exec(`INSERT INTO entities (name, type) VALUES (?, ?)`,
|
||||
name, entityType)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// SeedLegacySentenceRow 为**测试**在旧表造一条原句行。
|
||||
//
|
||||
// ⚠ 仅供测试。与 SeedLegacyMediaEntity 同理 ——
|
||||
// 迁移的输入是旧表,测试必须能直接构造它
|
||||
// (Commit 已不写旧表,见那个函数的注释)。
|
||||
func (g *GraphDB) SeedLegacySentenceRow(text string) (int64, error) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
res, err := g.db.Exec(`INSERT OR IGNORE INTO sentences (text) VALUES (?)`, text)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// LinkLegacyEntityToSentence 为**测试**造一条旧 relations 行,
|
||||
// 把媒体实体与原句关联起来(迁移靠它找到「该挂到哪句上」)。
|
||||
func (g *GraphDB) LinkLegacyEntityToSentence(entityID, sentenceID int64) error {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
_, err := g.db.Exec(
|
||||
`INSERT INTO relations (source_id, target_id, relation_type, confidence,
|
||||
session_id, turn_id, date_bucket, sentence_id)
|
||||
VALUES (?, ?, '内容', 1.0, 'legacy', 0, '', ?)`,
|
||||
entityID, sentenceID, sentenceID)
|
||||
return err
|
||||
}
|
||||
|
||||
// SeedLegacyEntity 为**测试**在旧表 entities 造一行。
|
||||
//
|
||||
// ⚠ 仅供测试。迁移的输入是旧表,而旧表双写已停(2026-10-04)
|
||||
//
|
||||
// ⇒ Commit 不再产生这份数据 ⇒ 测试必须能直接构造它。
|
||||
// 否则「迁移输入为空 ⇒ 迁出 0 条」会假通过。
|
||||
//
|
||||
// 见 seedLegacy(migrate_entities_test.go)。
|
||||
func (g *GraphDB) SeedLegacyEntity(name, entityType string) (int64, error) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
var id int64
|
||||
err := g.db.QueryRow(`SELECT id FROM entities WHERE name = ?`, name).Scan(&id)
|
||||
if err == nil {
|
||||
return id, nil
|
||||
}
|
||||
if err != sql.ErrNoRows {
|
||||
return 0, err
|
||||
}
|
||||
res, err := g.db.Exec(`INSERT INTO entities (name, type) VALUES (?, ?)`,
|
||||
name, entityType)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// SeedLegacyRelation 为**测试**在旧表 relations 造一行(按实体名)。
|
||||
//
|
||||
// ⚠ 仅供测试,见 SeedLegacyEntity。
|
||||
func (g *GraphDB) SeedLegacyRelation(subject, object, relType string,
|
||||
confidence float64, sessionID string, turnID int) (int64, int64, error) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
sid, err := g.seedLegacyEntityID(subject)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
tid, err := g.seedLegacyEntityID(object)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
res, err := g.db.Exec(
|
||||
`INSERT INTO relations (source_id, target_id, relation_type, confidence,
|
||||
session_id, turn_id, date_bucket, sentence_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, '', 0)`,
|
||||
sid, tid, relType, confidence, sessionID, turnID)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if _, err := res.LastInsertId(); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return sid, tid, nil
|
||||
}
|
||||
|
||||
func (g *GraphDB) seedLegacyEntityID(name string) (int64, error) {
|
||||
var id int64
|
||||
err := g.db.QueryRow(`SELECT id FROM entities WHERE name = ?`, name).Scan(&id)
|
||||
if err == nil {
|
||||
return id, nil
|
||||
}
|
||||
if err != sql.ErrNoRows {
|
||||
return 0, err
|
||||
}
|
||||
res, err := g.db.Exec(`INSERT INTO entities (name, type) VALUES (?, 'Concept')`, name)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// LinkLegacyTripleToSentence 为**测试**把某条三元组的原句关联上。
|
||||
//
|
||||
// ⚠ 仅供测试。迁移靠 relations.sentence_id 找到
|
||||
// 「原句要建块」与「关系该挂到哪句上」。
|
||||
func (g *GraphDB) LinkLegacyTripleToSentence(subject, object, relType string, sentenceID int64) error {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
sid, err := g.seedLegacyEntityID(subject)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tid, err := g.seedLegacyEntityID(object)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = g.db.Exec(
|
||||
`UPDATE relations SET sentence_id = ?
|
||||
WHERE source_id = ? AND target_id = ? AND relation_type = ?`,
|
||||
sentenceID, sid, tid, relType)
|
||||
return err
|
||||
}
|
||||
923
internal/memory/migrate_entities_test.go
Normal file
923
internal/memory/migrate_entities_test.go
Normal file
@ -0,0 +1,923 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newMigrateGraph(t *testing.T) *GraphDB {
|
||||
t.Helper()
|
||||
g := newTestGraph(t)
|
||||
t.Cleanup(func() { _ = g.Close() })
|
||||
return g
|
||||
}
|
||||
|
||||
// seedLegacy 为迁移测试造**旧表数据**。
|
||||
//
|
||||
// ★★ 2026-10-04:不再用 Commit(2026-10-04)
|
||||
//
|
||||
// 旧表双写已停 ⇒ Commit 不再写 entities/relations/sentences
|
||||
// ⇒ 迁移测试的**输入天然为空**
|
||||
// ⇒ 「迁出 0 块 0 边」竟然通过。
|
||||
//
|
||||
// ★★ 那不是「迁移正确」,是「测不到」——
|
||||
//
|
||||
// 它让「迁移还对不对」这个问题无法回答,
|
||||
// 而这正是判据最不该失效的地方。
|
||||
//
|
||||
// ⇒ 直接写旧表:迁移的输入是旧表,测试也该直接构造旧表。
|
||||
//
|
||||
// ★ 同时写一份块侧数据(用 Commit),因为迁移后的断言查的是块。
|
||||
func seedLegacy(t *testing.T, g *GraphDB, triples []Triple) {
|
||||
t.Helper()
|
||||
|
||||
// 块侧:Commit 现在只写块与边
|
||||
if _, _, err := g.Commit(triples, "s", 1); err != nil {
|
||||
t.Fatalf("Commit: %v", err)
|
||||
}
|
||||
|
||||
// 旧表:迁移的输入,逐条写入
|
||||
for _, tr := range triples {
|
||||
for _, name := range []string{tr.Subject, tr.Object} {
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
typ := tr.SubjectType
|
||||
if name == tr.Object {
|
||||
typ = tr.ObjectType
|
||||
}
|
||||
if typ == "" {
|
||||
typ = "Concept"
|
||||
}
|
||||
if _, err := g.SeedLegacyEntity(name, typ); err != nil {
|
||||
t.Fatalf("seed entity %q: %v", name, err)
|
||||
}
|
||||
}
|
||||
// 关系(source=主语, target=宾语)
|
||||
if _, _, err := g.SeedLegacyRelation(tr.Subject, tr.Object,
|
||||
tr.Relation, tr.Confidence, "s", 1); err != nil {
|
||||
t.Fatalf("seed relation %s/%s: %v", tr.Subject, tr.Relation, err)
|
||||
}
|
||||
// 原句
|
||||
if tr.SentenceText != "" {
|
||||
sid, err := g.SeedLegacySentenceRow(tr.SentenceText)
|
||||
if err != nil {
|
||||
t.Fatalf("seed sentence: %v", err)
|
||||
}
|
||||
if err := g.LinkLegacyTripleToSentence(
|
||||
tr.Subject, tr.Object, tr.Relation, sid); err != nil {
|
||||
t.Fatalf("link sentence: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fixedEmbed = "fp-migrate"
|
||||
|
||||
// ★ 迁移形态:实体 → 句子+块,关系 → 块边。旧表**不删**(由调用方决定)。
|
||||
func TestMigrateLegacyTextEntities_形态(t *testing.T) {
|
||||
g := newMigrateGraph(t)
|
||||
seedLegacy(t, g, []Triple{
|
||||
{Subject: "值班室分机号", Relation: "是", Object: "4324"},
|
||||
})
|
||||
|
||||
res, err := g.MigrateLegacyTextEntities(func(string) ([]float64, string) {
|
||||
return []float64{1, 0, 0}, fixedEmbed
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Migrate: %v", err)
|
||||
}
|
||||
// ★ 2 个实体 → 2 个迁移块 + 2 个原句块(方案 A 后原句也是块)
|
||||
//
|
||||
// res.Sentences 不再递增:统计口径改为「迁移块数」,
|
||||
// 因为 sentences 表已不再被写入。
|
||||
if res.Blocks != 2 {
|
||||
t.Errorf("2 个实体应得 2 个迁移块,实际 %+v", res)
|
||||
}
|
||||
if res.Sentences != 0 {
|
||||
t.Errorf("迁移不应再创建 sentence 记录(方案 A 退场),实际 %d", res.Sentences)
|
||||
}
|
||||
// 1 条关系 → 1 条块边
|
||||
if res.Edges != 1 {
|
||||
t.Errorf("1 条关系应得 1 条边,实际 %d", res.Edges)
|
||||
}
|
||||
|
||||
blocks, err := g.MemoryBlocks()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// ★ 只统计**迁移自己写的块**:legacy-entity(迁移块)与 sentence(原句块)。
|
||||
//
|
||||
// 不能用 len(blocks) —— 因为 seedLegacy 走 Commit,而 Commit 在
|
||||
// 块化之后**也会写块**(source="triple"),那不是迁移的产物。
|
||||
// 实测:未过滤时是 6 = 4 迁移 + 2 Commit 写的值块。
|
||||
if n := countBySource(t, blocks, "legacy-entity"); n != 2 {
|
||||
t.Errorf("应有 2 个迁移块,实际 %d", n)
|
||||
}
|
||||
if n := countBySource(t, blocks, SentenceBlockSource); n != 2 {
|
||||
t.Errorf("应有 2 个原句块,实际 %d", n)
|
||||
}
|
||||
for _, b := range blocks {
|
||||
// ★ 原句块刻意不带向量(溯源锚点,长整句会稀释召回)
|
||||
// ★ 只检查迁移自己写的两种块:原句块(sentence)与迁移块(legacy-entity)。
|
||||
//
|
||||
// 库里有第三种来源 triple —— 那是 seedLegacy 走 Commit 时写的
|
||||
// (Commit 块化之后),**不是迁移的产物**,不该被本测试断言。
|
||||
//
|
||||
// 原句块 blk_src_<hash> 无向量(溯源锚点)
|
||||
// 迁移块 blk_ent_<id>_<hash> 有向量
|
||||
// Commit 块 blk_ent_<hash24> 无向量、无时序
|
||||
//
|
||||
// 三者是**不同语义的东西**,不是同一个东西的重复。
|
||||
switch b.Source {
|
||||
case SentenceBlockSource:
|
||||
if len(b.Vector) != 0 {
|
||||
t.Errorf("原句块 %s 不该带向量,实际 %d 维", b.ID, len(b.Vector))
|
||||
}
|
||||
case "legacy-entity":
|
||||
if len(b.Vector) != 3 || b.Fingerprint != fixedEmbed {
|
||||
t.Errorf("迁移块 %s 应带 3 维向量与 %s,实际 %d 维 %q",
|
||||
b.ID, fixedEmbed, len(b.Vector), b.Fingerprint)
|
||||
}
|
||||
case "triple":
|
||||
// Commit 块化的产物,本测试不关心(见 TestBlockCommit_*)
|
||||
default:
|
||||
t.Errorf("块 %s 来源 %q 既不是迁移产物也不是 Commit 产物", b.ID, b.Source)
|
||||
}
|
||||
}
|
||||
|
||||
// 每块都应有一条 sentence--contains--> 边
|
||||
edges, err := g.MemoryBlockEdges()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
containsCount := 0
|
||||
for _, e := range edges {
|
||||
// ★ 起点是**原句块**(block),不再是 sentence 表行 —— 方案 A
|
||||
if e.Type == "contains" && e.SourceKind == "block" && e.TargetKind == "block" {
|
||||
containsCount++
|
||||
}
|
||||
}
|
||||
if containsCount != 2 {
|
||||
t.Errorf("应有 2 条 contains 边,实际 %d(总边数 %d)", containsCount, len(edges))
|
||||
}
|
||||
|
||||
// ★ 旧表还在(迁移不删数据,可回滚)
|
||||
r, err := g.Recall([]string{"值班室分机号"}, nil, 1, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(r.Entities) == 0 {
|
||||
t.Error("迁移不应删除 entities(由调用方在验证通过后决定清理)")
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 幂等:重复迁移不产生重复块。
|
||||
func TestMigrateLegacyTextEntities_幂等(t *testing.T) {
|
||||
g := newMigrateGraph(t)
|
||||
seedLegacy(t, g, []Triple{
|
||||
{Subject: "主体甲", Relation: "是", Object: "客体乙"},
|
||||
})
|
||||
embed := func(string) ([]float64, string) { return []float64{1, 0}, fixedEmbed }
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, err := g.MigrateLegacyTextEntities(embed); err != nil {
|
||||
t.Fatalf("第 %d 次迁移: %v", i, err)
|
||||
}
|
||||
}
|
||||
blocks, _ := g.MemoryBlocks()
|
||||
// ★ 重复跑不增:迁移块与原句块各 2 个
|
||||
// (不过滤 source='triple' —— 那是 seedLegacy 时 Commit 写的,
|
||||
// 重复跑迁移不会再增加它们,但会出现在 blocks 里)
|
||||
if n := countBySource(t, blocks, "legacy-entity"); n != 2 {
|
||||
t.Fatalf("迁移 3 次后仍应只有 2 个迁移块,实际 %d", n)
|
||||
}
|
||||
edges, _ := g.MemoryBlockEdges()
|
||||
contains := 0
|
||||
for _, e := range edges {
|
||||
if e.Type == "contains" {
|
||||
contains++
|
||||
}
|
||||
}
|
||||
if contains != 2 {
|
||||
t.Fatalf("contains 边应仍是 2 条,实际 %d", contains)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 无 embed 时仍迁移,但块不带向量——不编造零向量。
|
||||
func TestMigrateLegacyTextEntities_无embed不编造(t *testing.T) {
|
||||
g := newMigrateGraph(t)
|
||||
seedLegacy(t, g, []Triple{{Subject: "主体甲", Relation: "是", Object: "客体乙"}})
|
||||
|
||||
res, err := g.MigrateLegacyTextEntities(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Blocks != 2 {
|
||||
t.Fatalf("无 embed 也应迁移,实际 %d 块", res.Blocks)
|
||||
}
|
||||
if res.SkippedNoVec != 2 {
|
||||
t.Errorf("应统计 2 个无向量块,实际 %d", res.SkippedNoVec)
|
||||
}
|
||||
blocks, _ := g.MemoryBlocks()
|
||||
for _, b := range blocks {
|
||||
if len(b.Vector) != 0 {
|
||||
t.Errorf("%s 不该有向量,实际 %d 维", b.ID, len(b.Vector))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// embed 返回 nil 向量时算「算不出」,迁移继续但不计入成功。
|
||||
func TestMigrateLegacyTextEntities_embed失败仍迁移(t *testing.T) {
|
||||
g := newMigrateGraph(t)
|
||||
seedLegacy(t, g, []Triple{{Subject: "主体甲", Relation: "是", Object: "客体乙"}})
|
||||
res, err := g.MigrateLegacyTextEntities(func(string) ([]float64, string) {
|
||||
return nil, "" // 模型加载失败
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("embed 失败不该让迁移失败: %v", err)
|
||||
}
|
||||
if res.Blocks != 2 {
|
||||
t.Fatalf("应仍迁移出 2 块,实际 %d", res.Blocks)
|
||||
}
|
||||
if res.SkippedNoVec != 2 {
|
||||
t.Errorf("应统计 2 个无向量,实际 %d", res.SkippedNoVec)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 孤儿关系(端点实体为空名)被跳过并计数,不编造边。
|
||||
func TestMigrateLegacyTextEntities_孤儿关系跳过(t *testing.T) {
|
||||
g := newMigrateGraph(t)
|
||||
// 直接构造一个指向空名实体的关系是不可能的(upsert 拒绝空名),
|
||||
// 所以这里测的是「关系两端都存在」时边数正确,作为对照基线。
|
||||
seedLegacy(t, g, []Triple{
|
||||
{Subject: "主体甲", Relation: "是", Object: "客体乙"},
|
||||
{Subject: "主体甲", Relation: "属于", Object: "客体丙"},
|
||||
})
|
||||
res, err := g.MigrateLegacyTextEntities(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Edges != 2 {
|
||||
t.Errorf("2 条关系应得 2 条边,实际 %d", res.Edges)
|
||||
}
|
||||
if res.SkippedOrphan != 0 {
|
||||
t.Errorf("端点齐全时不该有跳过,实际 %d", res.SkippedOrphan)
|
||||
}
|
||||
}
|
||||
|
||||
// 空关系类型用占位名而不是被拒绝(关系的**存在**本身是信息)。
|
||||
func TestMigrateLegacyTextEntities_空关系类型用占位(t *testing.T) {
|
||||
g := newMigrateGraph(t)
|
||||
seedLegacy(t, g, []Triple{{Subject: "主体甲", Relation: " ", Object: "客体乙"}})
|
||||
res, err := g.MigrateLegacyTextEntities(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("空关系类型不该让迁移失败: %v", err)
|
||||
}
|
||||
if res.Edges != 1 {
|
||||
t.Fatalf("空关系类型也应建成边,实际 %d", res.Edges)
|
||||
}
|
||||
edges, _ := g.MemoryBlockEdges()
|
||||
found := false
|
||||
for _, e := range edges {
|
||||
if e.Type == "related_to" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("应有占位关系类型的边,实际 %+v", edges)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 事务回滚:迁移中途失败时,**库内容保持迁移前状态**。
|
||||
//
|
||||
// 失败注入方式:给两个实体起相同的块 ID 是不行的(ID 由 entity id 派生,
|
||||
// 天然唯一)。改用「让 putBlockTx 失败」——预先占位一个**非块节点类型**
|
||||
// 的行占住表结构,或直接删掉 memory_blocks 表。
|
||||
//
|
||||
// 删表是最直接的:阶段三第一次 putBlockTx 就会报 no such table,
|
||||
// 此时前面的 ensureSentenceTx 已经在事务里写过句子 —— 若无回滚,
|
||||
// 那些句子会留在库里。
|
||||
func TestMigrateLegacyTextEntities_中途失败整体回滚(t *testing.T) {
|
||||
g := newMigrateGraph(t)
|
||||
seedLegacy(t, g, []Triple{
|
||||
{Subject: "主体甲", Relation: "是", Object: "客体乙"},
|
||||
})
|
||||
beforeBlocks, err := g.MemoryBlocks()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
beforeSentences := countSentences(t, g)
|
||||
|
||||
// 在阶段三开始前破坏块表:此时阶段一(读)已完成、阶段二(embed)正在跑
|
||||
calls := 0
|
||||
_, err = g.MigrateLegacyTextEntities(func(string) ([]float64, string) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
g.mu.Lock()
|
||||
_, dbErr := g.db.Exec(`DROP TABLE memory_blocks`)
|
||||
g.mu.Unlock()
|
||||
if dbErr != nil {
|
||||
t.Fatalf("构造失败: %v", dbErr)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
g.mu.Lock()
|
||||
_, _ = g.db.Exec(ddlMemoryBlocks)
|
||||
g.mu.Unlock()
|
||||
})
|
||||
}
|
||||
return []float64{1, 0}, "fp-x"
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("块表被删后迁移应失败")
|
||||
}
|
||||
t.Logf("迁移如预期失败: %v", err)
|
||||
|
||||
// 关键断言:块与句子都没留下(回滚生效)
|
||||
afterSentences := countSentences(t, g)
|
||||
if afterSentences != beforeSentences {
|
||||
t.Errorf("失败后句子数应不变(回滚),实际 %d → %d",
|
||||
beforeSentences, afterSentences)
|
||||
}
|
||||
_ = beforeBlocks
|
||||
|
||||
// 恢复表后确认库里确实没有残留的块
|
||||
g.mu.Lock()
|
||||
_, dbErr := g.db.Exec(ddlMemoryBlocks)
|
||||
g.mu.Unlock()
|
||||
if dbErr != nil {
|
||||
t.Fatal(dbErr)
|
||||
}
|
||||
afterBlocks, err := g.MemoryBlocks()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(afterBlocks) != 0 {
|
||||
t.Errorf("失败后不应留下块(回滚),实际 %d 块: %+v", len(afterBlocks), afterBlocks)
|
||||
}
|
||||
}
|
||||
|
||||
func countSentences(t *testing.T, g *GraphDB) int {
|
||||
t.Helper()
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
var n int
|
||||
if err := g.db.QueryRow(`SELECT COUNT(*) FROM sentences`).Scan(&n); err != nil {
|
||||
t.Fatalf("count sentences: %v", err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// 块 id 含实体 id 前缀(可读 + 稳定)。
|
||||
func TestLegacyEntityBlockID_稳定可读(t *testing.T) {
|
||||
a := legacyEntityBlockID(42, "值班室分机号")
|
||||
b := legacyEntityBlockID(42, "值班室分机号")
|
||||
c := legacyEntityBlockID(43, "值班室分机号")
|
||||
if a != b {
|
||||
t.Error("同实体应得同块 ID")
|
||||
}
|
||||
if a == c {
|
||||
t.Error("不同实体应得不同块 ID")
|
||||
}
|
||||
if !strings.Contains(a, "blk_ent_42") {
|
||||
t.Errorf("块 ID 应含实体 id 便于排查,实际 %q", a)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 值覆盖维度依赖时序:迁移必须保住实体的先后关系。
|
||||
//
|
||||
// 背景:实测(真实 chineseclip + 真库)同一属性先后给两个值时,
|
||||
// 「值班室分机号 4324」与「值班室分机号 4379」短句向量相似度是
|
||||
// 0.9284 vs 0.9298 —— 数值上区分不了。若迁移把块的时<E79A84><E697B6><EFBFBD>全写成 NOW(),
|
||||
// overwrite 检索就彻底没救了。
|
||||
//
|
||||
// ★ 断言覆盖**全部**块:初版只查了含关键字的那一个,结果把
|
||||
// 「CreatedAt 不传」的变异判成通过 —— 假绿。实际漏掉的是没被 UPDATE
|
||||
// 到的那些实体(它们本就该落 NOW(),但保护要能区分两种情况)。
|
||||
func TestMigrateLegacyTextEntities_保留时序(t *testing.T) {
|
||||
g := newMigrateGraph(t)
|
||||
seedLegacy(t, g, []Triple{
|
||||
{Subject: "值班室分机号", Relation: "是", Object: "四三七九"},
|
||||
})
|
||||
// 给**两个**实体都写同一个历史时刻
|
||||
g.mu.Lock()
|
||||
_, err := g.db.Exec(`UPDATE entities SET created_at = '2026-01-01 10:00:00',
|
||||
updated_at = '2026-01-01 10:00:00'`)
|
||||
g.mu.Unlock()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := g.MigrateLegacyTextEntities(nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
blocks, err := g.MemoryBlocks()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(blocks) == 0 {
|
||||
t.Fatal("无块")
|
||||
}
|
||||
want := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC)
|
||||
for _, b := range blocks {
|
||||
// ★ 只检查**迁移块**(source=legacy-entity)。
|
||||
//
|
||||
// 库里有第三种来源 triple —— seedLegacy 走 Commit 时写的
|
||||
// (Commit 块化之后)。那些块的 created_at 由 PutMemoryBlocks
|
||||
// 填成 now,**本就不该继承实体时间**。
|
||||
//
|
||||
// 第一版没过滤 source,于是断言去检查 Commit 块,
|
||||
// 报「时序被抹平」—— 而被抹平的是 Commit 块,不是迁移块。
|
||||
// ★ 差点误判成「块化破坏了迁移时序」,那会推翻正确的实现。
|
||||
if b.Source != "legacy-entity" {
|
||||
continue
|
||||
}
|
||||
if b.CreatedAt.IsZero() {
|
||||
t.Errorf("迁移块 %s created_at 为零值", b.ID)
|
||||
continue
|
||||
}
|
||||
if !b.CreatedAt.Equal(want) {
|
||||
t.Errorf("迁移块 %s 应继承实体时间 %v,实际 %v(时序被抹平/未传递)",
|
||||
b.ID, want, b.CreatedAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 多个实体的时间戳必须**不同**(全都落成同一时刻等于抹平时序)。
|
||||
func TestMigrateLegacyTextEntities_时序不被抹平(t *testing.T) {
|
||||
g := newMigrateGraph(t)
|
||||
seedLegacy(t, g, []Triple{
|
||||
{Subject: "值班室分机号", Relation: "是", Object: "四三七九"},
|
||||
{Subject: "新分机号码", Relation: "是", Object: "四三二四"},
|
||||
})
|
||||
// 主语(较早)与后写入的实体(较晚)
|
||||
g.mu.Lock()
|
||||
_, err := g.db.Exec(`UPDATE entities SET
|
||||
created_at = CASE WHEN name IN ('值班室分机号', '四三七九')
|
||||
THEN '2026-01-01 10:00:00' ELSE '2026-01-01 11:00:00' END,
|
||||
updated_at = CASE WHEN name IN ('值班室分机号', '四三七九')
|
||||
THEN '2026-01-01 10:00:00' ELSE '2026-01-01 11:00:00' END`)
|
||||
g.mu.Unlock()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := g.MigrateLegacyTextEntities(nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
blocks, _ := g.MemoryBlocks()
|
||||
// 主语+宾语各成一块:「值班室分机号」「新分机号码」「四三七九」「四三二四」
|
||||
// ★ 4 个实体 → 4 迁移块(+ 4 原句块)
|
||||
//
|
||||
// 不能用 len(blocks):seedLegacy 走 Commit,块化之后它也写块
|
||||
// (source="triple",无向量、无实体时序)。实测混进来是 12。
|
||||
// ⇒ 只数迁移自己的两种块。
|
||||
if n := countBySource(t, blocks, "legacy-entity"); n != 4 {
|
||||
t.Fatalf("应得 4 个迁移块,实际 %d(另含 triple 块 %d 个)", n,
|
||||
countBySource(t, blocks, "triple"))
|
||||
}
|
||||
// 判据:4 个块必须分属两个不同时刻(主语+首宾语 10:00,
|
||||
// 次宾语对 11:00)。若全落同一时刻,时序就被抹平了。
|
||||
// ★ 注意:不能断言「排序后首两块不等」—— 10:00 那组本来就有 2 块,
|
||||
// 同刻是正常的。判据是「时刻的分布」而不是「相邻两块是否相等」。
|
||||
// ★ 只统计**迁移块**(source=legacy-entity)。
|
||||
// Commit 块(source=triple)的 created_at 是 PutMemoryBlocks 填的 now
|
||||
// —— 它会把 seen 变成 3 个时刻(多了 2026-10-04)而误判「时序被抹平」。
|
||||
seen := map[string]int{}
|
||||
for _, b := range blocks {
|
||||
if b.Source != "legacy-entity" {
|
||||
continue
|
||||
}
|
||||
seen[b.CreatedAt.UTC().Format("2006-01-02 15:04")]++
|
||||
}
|
||||
if len(seen) != 2 {
|
||||
t.Errorf("块应分属 2 个不同时刻,实际 %d 个:%v", len(seen), seen)
|
||||
}
|
||||
// ★ 现在只数迁移块 ⇒ 每组 2 个实体 = 2 个迁移块
|
||||
// (原句块不参与时序判据:它们的时间是 Commit 填的 now)
|
||||
if seen["2026-01-01 10:00"] != 2 {
|
||||
t.Errorf("10:00 组应有 2 个迁移块,实际 %d", seen["2026-01-01 10:00"])
|
||||
}
|
||||
if seen["2026-01-01 11:00"] != 2 {
|
||||
t.Errorf("11:00 组应有 2 个迁移块,实际 %d", seen["2026-01-01 11:00"])
|
||||
}
|
||||
}
|
||||
|
||||
// 旧库时间格式不认得时:宁可零值兜底,也不编造错的时刻。
|
||||
func TestParseLegacyTime(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want string // 空 = 期望零值
|
||||
}{
|
||||
{"2026-01-01 10:00:00", "2026-01-01 10:00:00"},
|
||||
{"2026-01-01T10:00:00Z", "2026-01-01 10:00:00"},
|
||||
{"2026-01-01 10:00:00.123456", "2026-01-01 10:00:00.123456"},
|
||||
{"2026-01-01", "2026-01-01 00:00:00"},
|
||||
{"", ""},
|
||||
{" ", ""},
|
||||
{"去年某天", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := parseLegacyTime(c.in)
|
||||
if c.want == "" {
|
||||
if !got.IsZero() {
|
||||
t.Errorf("parseLegacyTime(%q) 应为零值,实际 %v", c.in, got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
want, err := time.Parse("2006-01-02 15:04:05", c.want)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !got.Equal(want) {
|
||||
t.Errorf("parseLegacyTime(%q) = %v,期望 %v", c.in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★★ 报告数必须等于实际写入数。
|
||||
//
|
||||
// 生产快照实测(1294 实体 / 980 relations):
|
||||
//
|
||||
// 迁移报告 边 980
|
||||
// 边表实际 959 ← 差 21
|
||||
//
|
||||
// 根因:addBlockEdgeTx 用 `INSERT OR IGNORE` 且**不检查 RowsAffected**,
|
||||
// 而 `res.Edges++` 照加。relations 表里有 20 组 (src,tgt,type) 完全重复
|
||||
// (各 2 次),980 → 去重 959。
|
||||
//
|
||||
// ★ 与 941e6b9 同类:那是 relations 口径(active vs 全表),
|
||||
//
|
||||
// 这是边去重口径。两次都是「报告数 ≠ 实际写入数」,
|
||||
// 而用户会把报告数当承诺。
|
||||
func TestMigrate_报告数等于实际写入数(t *testing.T) {
|
||||
g := newMigrateGraph(t)
|
||||
seedLegacy(t, g, []Triple{
|
||||
{Subject: "值班室分机号", Relation: "是", Object: "4324"},
|
||||
})
|
||||
// ★ 生产库里有 20 组 (src,tgt,type) 完全重复的关系行。
|
||||
// seedLegacy 用 Upsert(第二次覆盖第一次)⇒ 造不出重复,
|
||||
// 所以直接插库 —— 这也是之前判据「通过」却没测到问题的原因。
|
||||
if _, err := g.db.Exec(`INSERT INTO relations
|
||||
(source_id, target_id, relation_type, confidence, status)
|
||||
SELECT source_id, target_id, relation_type, confidence, status
|
||||
FROM relations LIMIT 1`); err != nil {
|
||||
t.Fatalf("插入重复关系: %v", err)
|
||||
}
|
||||
|
||||
embed := func(string) ([]float64, string) { return []float64{1, 0}, fixedEmbed }
|
||||
res, err := g.MigrateLegacyTextEntities(embed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
edges, err := g.MemoryBlockEdges()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// ★ 只数**迁移块之间**的边(两端都是 blk_ent_<entityID>_<hash> 形态)。
|
||||
//
|
||||
// 边表里还有 Commit 块化写的边(source=triple,两端是 blk_ent_<hash24>)
|
||||
// —— 那不是迁移的产物,而 res.Edges 只数迁移自己写的。
|
||||
// 第一版按「非 contains」统计,把 Commit 的边也算进去了 ⇒ 误报。
|
||||
relEdges := 0
|
||||
for _, e := range edges {
|
||||
if e.Type == "contains" {
|
||||
continue
|
||||
}
|
||||
if isLegacyEntityBlockID(e.SourceID) && isLegacyEntityBlockID(e.TargetID) {
|
||||
relEdges++
|
||||
}
|
||||
}
|
||||
|
||||
if res.Edges != relEdges {
|
||||
t.Errorf("★ 报告边数 %d ≠ 实际写入 %d —— 报告会骗人", res.Edges, relEdges)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★ 迁移必须为 sentences 表的原文补建原句块。
|
||||
//
|
||||
// 实测缺口(清理前置判据,生产快照):
|
||||
//
|
||||
// sentences 66 条 → 原句块 0 个
|
||||
//
|
||||
// 而迁移只从 `entities` 读(readLegacySnapshot 里只有 entities 查询),
|
||||
// 从没为 sentences 建过载体。sentences 表的价值就在**原文本身**
|
||||
// (entities 是提炼后的名字)⇒ 直接清理该表会丢 66 条原句。
|
||||
func TestMigrate_sentences补建原句块(t *testing.T) {
|
||||
g := newMigrateGraph(t)
|
||||
// 直接插 sentences(原句),不经过 Commit(Commit 只造 entity)
|
||||
// ★ sentences.text 有 UNIQUE 约束 ⇒ 同一句只能插一次。
|
||||
// 所以幂等性不能靠「重复插入」来测(那是数据库层的约束,
|
||||
// 根本到不了迁移代码)—— 幂等要靠**迁移跑两遍**来测。
|
||||
for _, txt := range []string{
|
||||
"值班室分机号改为 4324,旧号 4379 停用",
|
||||
"第 114 批周日凌晨停机 4 分,回滚 v2.28.4",
|
||||
} {
|
||||
if _, err := g.db.Exec(
|
||||
`INSERT INTO sentences (text) VALUES (?)`, txt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
embed := func(string) ([]float64, string) { return []float64{1, 0}, fixedEmbed }
|
||||
res, err := g.MigrateLegacyTextEntities(embed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Sentences == 0 {
|
||||
t.Error("迁移应统计 sentences 的原句块数(res.Sentences)")
|
||||
}
|
||||
|
||||
blocks, err := g.MemoryBlocks()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 幂等:迁移跑第二遍不应新增原句块
|
||||
if _, err := g.MigrateLegacyTextEntities(embed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var srcBlocks int
|
||||
seen := map[string]bool{}
|
||||
for _, b := range blocks {
|
||||
if b.Source == SentenceBlockSource {
|
||||
if seen[b.ID] {
|
||||
t.Errorf("原句块 ID 重复:%s", b.ID)
|
||||
}
|
||||
seen[b.ID] = true
|
||||
srcBlocks++
|
||||
}
|
||||
}
|
||||
if srcBlocks != 2 {
|
||||
t.Errorf("应有 2 个原句块(迁移两遍仍不增),实际 %d", srcBlocks)
|
||||
}
|
||||
|
||||
// ★ 每条 sentence 的原文都能由内容派生出对应的块
|
||||
for _, txt := range []string{
|
||||
"值班室分机号改为 4324,旧号 4379 停用",
|
||||
"第 114 批周日凌晨停机 4 分,回滚 v2.28.4",
|
||||
} {
|
||||
id := SentenceBlockID(txt)
|
||||
if !seen[id] {
|
||||
t.Errorf("sentence %q 缺原句块 %s", truncT(txt, 24), id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func truncT(s string, n int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
return s
|
||||
}
|
||||
return string(r[:n]) + "…"
|
||||
}
|
||||
|
||||
// countBySource 按 source 统计块数。
|
||||
//
|
||||
// ★ 迁移测试必须用它而不是 len(blocks):块化之后 Commit 也写块
|
||||
//
|
||||
// (source="triple"),那些不是迁移的产物。
|
||||
func countBySource(t *testing.T, blocks []MemoryBlock, src string) int {
|
||||
t.Helper()
|
||||
n := 0
|
||||
for _, b := range blocks {
|
||||
if b.Source == src {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// isLegacyEntityBlockID 判断块 ID 是否是迁移块形态(blk_ent_<entityID>_<hash8>)。
|
||||
//
|
||||
// ★ 与 Commit 块化的 blk_ent_<hash24> 区分:
|
||||
//
|
||||
// 迁移块 blk_ent_1234_abcd1234 (中间有下划线 + 数字行号)
|
||||
// Commit blk_ent_abcdef1234… (纯 hash,无下划线)
|
||||
func isLegacyEntityBlockID(id string) bool {
|
||||
if !strings.HasPrefix(id, "blk_ent_") {
|
||||
return false
|
||||
}
|
||||
rest := strings.TrimPrefix(id, "blk_ent_")
|
||||
return strings.Contains(rest, "_")
|
||||
}
|
||||
|
||||
// ★ LegacyRowCount 的三条约束(2026-10-04)
|
||||
//
|
||||
// 它接收完整 SQL 串,所以必须证明「不该接受的被拒绝」。
|
||||
// 判据直接来自它的三条 guard:前缀 / JOIN+GROUP BY / 分号。
|
||||
func TestLegacyRowCount_只允许单表COUNT(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer func() { _ = g.Close() }()
|
||||
if _, _, err := g.Commit([]Triple{
|
||||
{Subject: "甲一", Relation: "是", Object: "乙一", Confidence: 1.0},
|
||||
}, "s", 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// ★ 用 SeedLegacyEntity 造旧表数据(2026-10-04)
|
||||
// Commit 已不写旧表(原判据用 Commit,断言恒失败)。
|
||||
if _, err := g.SeedLegacyEntity("旧表实体", "Concept"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n, err := g.LegacyRowCount("SELECT COUNT(*) FROM entities")
|
||||
if err != nil {
|
||||
t.Fatalf("合法查询应通过: %v", err)
|
||||
}
|
||||
fmt.Printf(" entities 计数 %d\n", n)
|
||||
if n != 1 {
|
||||
t.Errorf("★ 刚种下一个旧表实体,计数应为 1,实际 %d", n)
|
||||
}
|
||||
|
||||
// ★ 非 COUNT 前缀必须拒绝
|
||||
for _, bad := range []string{
|
||||
"DELETE FROM entities",
|
||||
"DROP TABLE entities",
|
||||
"SELECT * FROM entities",
|
||||
"UPDATE entities SET name = 'x'",
|
||||
"INSERT INTO entities (name) VALUES ('x')",
|
||||
} {
|
||||
if _, err := g.LegacyRowCount(bad); err == nil {
|
||||
t.Errorf("★ 危险查询 %q 被接受了", bad)
|
||||
}
|
||||
}
|
||||
// ★ JOIN / GROUP BY / 分号必须拒绝
|
||||
for _, bad := range []string{
|
||||
"SELECT COUNT(*) FROM entities JOIN relations ON 1=1",
|
||||
"SELECT COUNT(*) FROM entities GROUP BY type",
|
||||
"SELECT COUNT(*) FROM entities; DROP TABLE entities",
|
||||
} {
|
||||
if _, err := g.LegacyRowCount(bad); err == nil {
|
||||
t.Errorf("★ 越界查询 %q 被接受了", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 迁移必须带上旧关系的属性(2026-10-04)
|
||||
//
|
||||
// 缺陷:legacyRel 此前只读 id/src/tgt/type,confidence / session_id /
|
||||
// turn_id / status **根本没被读取**,于是迁出的边全是空属性。
|
||||
//
|
||||
// ★ 生产快照实测后果:959 条边 confidence 全为 0 —— 而边表把
|
||||
// confidence 当唯一的质量信号(RecallSorted 的相关性排序、
|
||||
// 场景权重、蒸馏置信度传播都靠它)。
|
||||
//
|
||||
// ★ 更隐蔽的一层:迁移原先用 addBlockEdgeTx —— 那是**结构边**写入器,
|
||||
// 去重条件带 `COALESCE(session_id,”)=”`。
|
||||
// 而要迁移的关系**恰恰带 session_id** ⇒ 每一条都被判成「已存在」跳过。
|
||||
// 这层缺陷只有在「测试数据带 session」时才暴露 —— 所以判据必须造它。
|
||||
func TestMigrateLegacy_关系属性完整迁移(t *testing.T) {
|
||||
// ★ 用 newTestGraph(干净库)而不是 newMigrateGraph(带种子旧表)——
|
||||
// 后者的种子数据会让边计数翻倍,判据就测不到「跨会话并存」这件事。
|
||||
g := newTestGraph(t)
|
||||
defer func() { _ = g.Close() }()
|
||||
|
||||
// 造旧表数据:同一对实体在**不同会话**各有一条同类型关系。
|
||||
// 迁移后必须是两条独立的边(关系边按设计允许并存)。
|
||||
// ★ 同时写块侧(Commit)与旧表(Seed*)——
|
||||
// 迁移的输入是旧表,而 Commit 已不写旧表(2026-10-04)。
|
||||
for i, sid := range []string{"sess-A", "sess-B"} {
|
||||
conf := 0.3 + 0.4*float64(i)
|
||||
if _, _, err := g.Commit([]Triple{
|
||||
{Subject: "迁移甲", Relation: "维护", Object: "迁移乙", Confidence: conf},
|
||||
}, sid, i+1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := g.SeedLegacyRelation("迁移甲", "迁移乙", "维护",
|
||||
conf, sid, i+1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
res, err := g.MigrateLegacyTextEntities(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("迁移: %v", err)
|
||||
}
|
||||
fmt.Printf(" 迁移 %d 边(去重 %d)\n", res.Edges, res.DedupedEdges)
|
||||
|
||||
// ★ 基线数:Commit 自己已经写了块侧边(块化路径)。
|
||||
// 判据要比的是「迁移之后」而不是「迁移贡献了多少」——
|
||||
// 前者才是「迁移有没有搬过来」这个真问题。
|
||||
var baseEdges int
|
||||
if err := g.db.QueryRow(`SELECT COUNT(*) FROM memory_block_edges
|
||||
WHERE edge_type='维护'`).Scan(&baseEdges); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fmt.Printf(" 迁移后「维护」边共 %d 条(Commit 原有 %d + 迁移 %d)\n",
|
||||
baseEdges, baseEdges-res.Edges, res.Edges)
|
||||
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
|
||||
// ★ 跨会话的两条都必须存在(不能被 session_id 的去重条件吃掉)。
|
||||
//
|
||||
// ★ 只数**迁移新增**的那些:Commit 本身也写了块侧边(块化),
|
||||
// 所以库里本来就有 2 条。判据要测的是「迁移有没有把旧的 2 条
|
||||
// 也搬过来」—— 两者 session_id 不同,共存正是关系边的设计意图。
|
||||
// ★ 关键判据:迁移去重口径按 session。
|
||||
//
|
||||
// 若去重条件像 addBlockEdgeTx 那样带 `session_id=''`
|
||||
// (结构边口径),要迁移的关系会因「带 session」而被判成已存在
|
||||
// ⇒ 每一条都跳过 ⇒ res.Edges 为 0。
|
||||
//
|
||||
// 所以这里的判据是 **res.Edges == 旧表关系数**,
|
||||
// 而不是「库里共有几条边」—— 后者会被 Commit 自己写的块侧边干扰。
|
||||
if res.Edges != 2 {
|
||||
t.Errorf("★ 迁移应新增 2 条边(带 session 的不得被去重吃掉),实际 %d", res.Edges)
|
||||
}
|
||||
|
||||
// ★ 迁移出的边必须带 session_id(Recall 的 sessionFilter 依赖它)
|
||||
var nMigratedSess int
|
||||
if err := g.db.QueryRow(`
|
||||
SELECT COUNT(*) FROM memory_block_edges
|
||||
WHERE edge_type='维护' AND COALESCE(session_id,'') != ''
|
||||
AND confidence > 0`).Scan(&nMigratedSess); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if nMigratedSess < 2 {
|
||||
t.Errorf("★ 迁出的边应带 session_id 与 confidence,实际 %d 条符合", nMigratedSess)
|
||||
}
|
||||
|
||||
// ★ confidence 必须迁过来
|
||||
var confidences []float64
|
||||
rows, err := g.db.Query(`
|
||||
SELECT COALESCE(confidence, 0) FROM memory_block_edges WHERE edge_type = '维护'`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var c float64
|
||||
_ = rows.Scan(&c)
|
||||
confidences = append(confidences, c)
|
||||
}
|
||||
rows.Close()
|
||||
fmt.Printf(" 迁出的 confidence: %v\n", confidences)
|
||||
for _, c := range confidences {
|
||||
if c == 0 {
|
||||
t.Error("★ confidence 未迁移(生产实测 959 条边全 0)")
|
||||
}
|
||||
}
|
||||
|
||||
// ★ session_id / turn_id 必须迁过来(Recall 的 sessionFilter 依赖)
|
||||
}
|
||||
|
||||
// ★ 非 active 的旧关系不得迁成 active(否则已删除的记忆会复活)
|
||||
func TestMigrateLegacy_已删除关系不复活(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer func() { _ = g.Close() }()
|
||||
// ★ 同时写块侧与旧表(Commit 已不写旧表,2026-10-04)
|
||||
if _, _, err := g.Commit([]Triple{
|
||||
{Subject: "待删甲", Relation: "曾经", Object: "待删乙", Confidence: 1.0},
|
||||
}, "sess-X", 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := g.SeedLegacyRelation("待删甲", "待删乙", "曾经",
|
||||
1.0, "sess-X", 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 软删**块侧**(Purge 现在只改块)⇒ 形成判据要验的混合态
|
||||
if _, err := g.Purge(map[string]string{"subject_contains": "待删甲"}, "soft"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// ★★ 判据要造的正是**混合态**:旧表行仍是 active,块侧已 deleted。
|
||||
//
|
||||
// 这不是人为 contrived —— 它是读侧切块后的**真实状态**:
|
||||
// Purge 只改块侧,旧 relations 行停在 active。
|
||||
// 生产库有 14 条 deleted 关系 + memory_purge 工具调用历史,
|
||||
// 两者叠加就是这个形态。
|
||||
//
|
||||
// 若只造「旧表也标 deleted」,测的就只是 status 透传;
|
||||
// 而真正要防的是「旧表说 active、块侧说 deleted,迁移信了谁」。
|
||||
g.mu.Lock()
|
||||
var nStillActive int
|
||||
if err := g.db.QueryRow(
|
||||
`SELECT COUNT(*) FROM relations WHERE status='active'`).Scan(&nStillActive); err != nil {
|
||||
g.mu.Unlock()
|
||||
t.Fatal(err)
|
||||
}
|
||||
if nStillActive == 0 {
|
||||
g.mu.Unlock()
|
||||
t.Fatal("★ 判据前提不成立:旧表不应有 active 行(否则测不到混合态)")
|
||||
}
|
||||
g.mu.Unlock()
|
||||
if _, err := g.MigrateLegacyTextEntities(nil); err != nil {
|
||||
t.Fatalf("迁移: %v", err)
|
||||
}
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
|
||||
var nActive int
|
||||
if err := g.db.QueryRow(`
|
||||
SELECT COUNT(*) FROM memory_block_edges
|
||||
WHERE edge_type='曾经' AND COALESCE(status,'')='active'`).Scan(&nActive); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if nActive != 0 {
|
||||
t.Errorf("★ 已删除的旧关系被迁成 active(记忆会复活),实际 %d 条", nActive)
|
||||
}
|
||||
}
|
||||
35
internal/memory/migrate_testhelp_test.go
Normal file
35
internal/memory/migrate_testhelp_test.go
Normal file
@ -0,0 +1,35 @@
|
||||
package memory
|
||||
|
||||
// 测试脚手架:重建被测试删掉的 memory_blocks 表。
|
||||
// 刻意放在 _test.go 里 —— 它不是生产符号,只是让回滚测试能自造失败。
|
||||
|
||||
// ddlMemoryBlocks 是 graph.go 里同一段 DDL 的副本,仅供测试重建被删的表。
|
||||
//
|
||||
// ★★ 它是**重复的真相源** —— 加列时必须两边都改。
|
||||
//
|
||||
// 2026-10-04 加 semantic_type 时就漏了这里,报
|
||||
// 「no such column: semantic_type」。抽公共常量是正解,
|
||||
// 但 graph.go 的 DDL 是一整段 schema 初始化(多张表),
|
||||
// 拆出来会改动面更大 —— 暂时保留副本,改列时记得同步。
|
||||
//
|
||||
// 刻意不导出成生产符号:它是测试脚手架,不是 API。
|
||||
const ddlMemoryBlocks = `CREATE TABLE IF NOT EXISTS memory_blocks (
|
||||
id TEXT PRIMARY KEY,
|
||||
modality TEXT NOT NULL,
|
||||
text_content TEXT DEFAULT '',
|
||||
payload_digest TEXT DEFAULT '',
|
||||
mime TEXT DEFAULT '',
|
||||
size INTEGER DEFAULT 0,
|
||||
width INTEGER DEFAULT 0,
|
||||
height INTEGER DEFAULT 0,
|
||||
vector TEXT DEFAULT '',
|
||||
fingerprint TEXT DEFAULT '',
|
||||
source TEXT DEFAULT '',
|
||||
tool TEXT DEFAULT '',
|
||||
scene TEXT DEFAULT '',
|
||||
-- ★ semantic_type 必须与 graph.go 的 DDL 同步(2026-10-04 加列时漏过一次,
|
||||
-- 报 'no such column: semantic_type')。改那边记得改这里。
|
||||
semantic_type TEXT DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)`
|
||||
448
internal/memory/near_screen_test.go
Normal file
448
internal/memory/near_screen_test.go
Normal file
@ -0,0 +1,448 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ 近邻粗筛的判据与实验台。
|
||||
//
|
||||
// 与既有机制的关系(照抄 detectEntityMerge 的分层,distill.go:239):
|
||||
//
|
||||
// detectEntityMerge 实体层:全库两两 bigram+TFIDF,>0.75 送 LLM 裁决
|
||||
// dedupeScenes 场景层:只做归一化后完全同名的确定性合并
|
||||
//
|
||||
// 本文件是**块层**的对应物,口径取自后者的原则:
|
||||
// 「去重不是'把像的一律合并'」。
|
||||
//
|
||||
// ── 粗筛 vs 判定 ──────────────────────────────────────
|
||||
//
|
||||
// 用户指出「相似度计算就是为了粗筛相似节点」——这是对的,且它改变了设计:
|
||||
//
|
||||
// 粗筛(召回优先):宁滥勿缺,阈值低一点,误报交给下游过滤
|
||||
// 判定(精确优先):宁缺勿滥,必须确定性
|
||||
//
|
||||
// 实测三种判据在真库 260 块上(组内 12 对 / 跨组 33658 对):
|
||||
//
|
||||
// 判据 全均 组内命中 跨组误报
|
||||
// chineseclip 0.8778 10/12 14812
|
||||
// TF-IDF(300) 0.4074 2/12 3419
|
||||
// bigram 0.1517 6/12 74
|
||||
//
|
||||
// ★ 关键发现:chineseclip 全均 0.878 —— 任意两块都 >0.9,
|
||||
// 所以 0.9 阈值对它**等于没有阈值**(14812/33658 ≈ 44% 全被判为相似)。
|
||||
// 这不是"阈值没调好",是这个向量空间把纯文本压得太扁:
|
||||
//
|
||||
// 「值班室分机号 4324」cos = 0.9284
|
||||
// 「值班室分机号 4379」cos = 0.9298 ← 新旧号差 0.0014
|
||||
//
|
||||
// CLIP 架构是为图文对齐训的,纯文本的细粒度区分度天然低。
|
||||
//
|
||||
// ── 但粗筛恰好能用 ──────────────────────────────────────
|
||||
//
|
||||
// 粗筛要的是「不漏」,而 chineseclip 的组内命中 10/12 是三者最高。
|
||||
// 44% 的误报率对粗筛**可以接受**,因为下游是 (主语,维度) 分组 + 值全等:
|
||||
//
|
||||
// 跨组 33658 对 → 分组后只剩 12 对(组内)
|
||||
// 12 对 → 值全等筛出 5 对真重复,7 对不同值
|
||||
//
|
||||
// 也就是说粗筛这一层的工作量是 33658 次比较,而确定性判据只要 12 次。
|
||||
// 省下的正是最贵的那部分。
|
||||
|
||||
// ★ 粗筛的结果类型:只给候选,不给结论。
|
||||
type NearCandidate struct {
|
||||
A, B MemoryBlock
|
||||
Score float64
|
||||
Judge string // 哪条判据说它们像
|
||||
SameFact bool // 值是否全等(确定性判据,不是相似度)
|
||||
}
|
||||
|
||||
// screenNeighbors 在 blocks 里粗筛相似节点对。
|
||||
//
|
||||
// judge 是相似度函数;阈值 thr 取**召回优先**的值(宁可多报)。
|
||||
// 下游必须用确定性判据复核(SameFact),不能拿 Score 当结论。
|
||||
//
|
||||
// O(n²):实测 260 块 = 33658 次比较,毫秒级。但它自己的注释
|
||||
// (detectEntityMerge)警告过「1 万实体 5000 万次配对、224GB 瞬时分配」——
|
||||
// 所以**不要**把这里用在全库无分组的大集合上。分组是前置条件。
|
||||
func screenNeighbors(blocks []MemoryBlock, judge func(a, b MemoryBlock) float64,
|
||||
thr float64) []NearCandidate {
|
||||
|
||||
var out []NearCandidate
|
||||
for i := 0; i < len(blocks); i++ {
|
||||
for j := i + 1; j < len(blocks); j++ {
|
||||
// 前置分组:不同 (主语,维度) 的对一律跳过。
|
||||
// ★ 这一行是 33658 → 12 的全部原因。去掉它,粗筛量会爆炸
|
||||
// 且误报全部来自「跨属性的形似」(第113批 vs 第133批的评审通过)。
|
||||
sa, da, va, oka := parseFact(blocks[i].Text)
|
||||
sb, db, vb, okb := parseFact(blocks[j].Text)
|
||||
if !oka || !okb || sa != sb || da != db {
|
||||
continue
|
||||
}
|
||||
s := judge(blocks[i], blocks[j])
|
||||
if s < thr {
|
||||
continue
|
||||
}
|
||||
out = append(out, NearCandidate{
|
||||
A: blocks[i], B: blocks[j], Score: s,
|
||||
Judge: "sim", SameFact: va == vb,
|
||||
})
|
||||
}
|
||||
}
|
||||
// 按分数降序,方便看最像的在最前
|
||||
sort.SliceStable(out, func(i, j int) bool { return out[i].Score > out[j].Score })
|
||||
return out
|
||||
}
|
||||
|
||||
// factKey 是事实级标识:(主语,维度,值) 三元组的哈希。
|
||||
//
|
||||
// 用于确定性去重 —— 同一事实从不同原句拆出来会是两个块
|
||||
// (BlockID 含 sentence,拆分期正确但事实层重复),
|
||||
// 实测真库 5 组这种重复:第112批|批次号=112 出现两次,来自
|
||||
// 「第112批 v2.31.0 评审通过·采样10%」与
|
||||
// 「第112批周四凌晨2点·停机4分·回滚v2.29.5…」。
|
||||
func factKey(text string) (string, bool) {
|
||||
sub, dim, val, ok := parseFact(text)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return sub + "\x00" + dim + "\x00" + val, true
|
||||
}
|
||||
|
||||
// ── 判据 ──────────────────────────────────────────────
|
||||
|
||||
// TestScreenNeighbors_确定性判据完全替代相似度
|
||||
//
|
||||
// ★ 这是本文件的核心判据:相似度只用于**粗筛召回**,
|
||||
// 真正的"是不是同一个事实"由三元组全等**确定性**回答。
|
||||
//
|
||||
// 判据分两层:
|
||||
// 1. 粗筛层(相似度)—— 必须**不漏**,所以低阈值
|
||||
// 2. 判定层(三元组全等)—— 必须**准确**,所以零误差
|
||||
//
|
||||
// 混淆这两层是本轮最初犯的错:拿相似度直接当结论,
|
||||
// 结果 chineseclip 44% 误报被当成"向量不可用"。
|
||||
func TestScreenNeighbors_确定性判据完全替代相似度(t *testing.T) {
|
||||
blocks := []MemoryBlock{
|
||||
// 真重复:同一事实,不同原句拆出来的两个块
|
||||
{ID: "a", Text: "第112批|批次号=112"},
|
||||
{ID: "b", Text: "第112批|批次号=112"},
|
||||
// 同属性不同值(覆盖关系)—— 绝不能合并
|
||||
{ID: "c", Text: "连接池|连接池容量=32/64"},
|
||||
{ID: "d", Text: "连接池|连接池容量=128/256"},
|
||||
// 跨属性形似 —— 前置分组就该挡掉
|
||||
{ID: "e", Text: "第113批|评审通过=采样10%"},
|
||||
{ID: "f", Text: "第133批|评审通过=采样10%"},
|
||||
}
|
||||
// 一个「永远通过」的粗筛:只要同组就报
|
||||
screen := func(a, b MemoryBlock) float64 { return 1.0 }
|
||||
cands := screenNeighbors(blocks, screen, 0.5)
|
||||
|
||||
// 只有同 (主语,维度) 的对能进粗筛
|
||||
if len(cands) != 2 {
|
||||
t.Fatalf("粗筛应只产出同属性对(2 对),实际 %d 对:%+v", len(cands), cands)
|
||||
}
|
||||
|
||||
// 判定层:只有值全等的才是同一事实
|
||||
sameFact := 0
|
||||
for _, c := range cands {
|
||||
if c.SameFact {
|
||||
sameFact++
|
||||
}
|
||||
}
|
||||
if sameFact != 1 {
|
||||
t.Errorf("只有 1 对是同一事实(批次号=112),实际 %d", sameFact)
|
||||
}
|
||||
for _, c := range cands {
|
||||
if c.SameFact && (c.A.ID != "a" || c.B.ID != "b") {
|
||||
t.Errorf("同一事实应是 a/b,实际 %s/%s", c.A.ID, c.B.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 粗筛阈值该定多低:看召回曲线。
|
||||
//
|
||||
// 判据:粗筛的召回率必须 100%(真重复一个不漏),
|
||||
// 精确率不管 —— 那是判定层的事。
|
||||
func TestScreenNeighbors_粗筛阈值只需保证召回(t *testing.T) {
|
||||
// 12 对组内对,5 对真重复
|
||||
blocks, truth := buildRealGroupPairs(t)
|
||||
trueTotal := 0
|
||||
for _, v := range truth {
|
||||
if v {
|
||||
trueTotal++
|
||||
}
|
||||
}
|
||||
if trueTotal == 0 {
|
||||
t.Skip("无真重复对")
|
||||
}
|
||||
t.Logf("真库同属性对 %d 组,其中真重复 %d 组", len(truth), trueTotal)
|
||||
|
||||
for _, thr := range []float64{0.3, 0.5, 0.7, 0.9, 0.95} {
|
||||
got := 0
|
||||
hit := 0
|
||||
for i := 0; i < len(blocks); i++ {
|
||||
for j := i + 1; j < len(blocks); j++ {
|
||||
sa, da, _, _ := parseFact(blocks[i].Text)
|
||||
sb, db, _, _ := parseFact(blocks[j].Text)
|
||||
if sa != sb || da != db {
|
||||
continue
|
||||
}
|
||||
got++
|
||||
s := bigramSim(blocks[i].Text, blocks[j].Text)
|
||||
if s >= thr && truth[pairKey(i, j, len(blocks))] {
|
||||
hit++
|
||||
}
|
||||
}
|
||||
}
|
||||
recall := 0.0
|
||||
if trueTotal > 0 {
|
||||
recall = float64(hit) / float64(trueTotal)
|
||||
}
|
||||
t.Logf(" 阈值 %.2f: 粗筛 %d 对,召回 %d/%d = %.0f%%",
|
||||
thr, got, hit, trueTotal, recall*100)
|
||||
}
|
||||
}
|
||||
|
||||
func pairKey(i, j, n int) string {
|
||||
return fmt.Sprintf("%d-%d", i, j)
|
||||
}
|
||||
|
||||
func bigramSim(a, b string) float64 {
|
||||
if a == b {
|
||||
return 1
|
||||
}
|
||||
ra, rb := []rune(a), []rune(b)
|
||||
if len(ra) < 2 || len(rb) < 2 {
|
||||
return 0
|
||||
}
|
||||
sa, sb := map[string]bool{}, map[string]bool{}
|
||||
for i := 0; i < len(ra)-1; i++ {
|
||||
sa[string(ra[i:i+2])] = true
|
||||
}
|
||||
for i := 0; i < len(rb)-1; i++ {
|
||||
sb[string(rb[i:i+2])] = true
|
||||
}
|
||||
in := 0
|
||||
for g := range sa {
|
||||
if sb[g] {
|
||||
in++
|
||||
}
|
||||
}
|
||||
u := len(sa) + len(sb) - in
|
||||
if u == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(in) / float64(u)
|
||||
}
|
||||
|
||||
// buildRealGroupPairs 从真库取同属性对,并标出哪些是真重复(值全等)。
|
||||
func buildRealGroupPairs(t *testing.T) ([]MemoryBlock, map[string]bool) {
|
||||
t.Helper()
|
||||
g := probeDB(t, "/var/tmp/ha-c/memory/graph.db")
|
||||
blocks, err := g.MemoryBlocks()
|
||||
if err != nil {
|
||||
t.Skipf("无真库: %v", err)
|
||||
}
|
||||
var distill []MemoryBlock
|
||||
for _, b := range blocks {
|
||||
if b.Source != "distill" {
|
||||
continue
|
||||
}
|
||||
if _, _, _, ok := parseFact(b.Text); !ok {
|
||||
continue
|
||||
}
|
||||
distill = append(distill, b)
|
||||
}
|
||||
// 只保留出现在同属性组里的
|
||||
groups := map[string][]MemoryBlock{}
|
||||
for _, b := range distill {
|
||||
s, d, _, _ := parseFact(b.Text)
|
||||
groups[s+"|"+d] = append(groups[s+"|"+d], b)
|
||||
}
|
||||
var kept []MemoryBlock
|
||||
truth := map[string]bool{}
|
||||
for _, members := range groups {
|
||||
if len(members) < 2 {
|
||||
continue
|
||||
}
|
||||
base := len(kept)
|
||||
for _, m := range members {
|
||||
kept = append(kept, m)
|
||||
}
|
||||
for i := 0; i < len(members); i++ {
|
||||
for j := i + 1; j < len(members); j++ {
|
||||
_, _, va, _ := parseFact(members[i].Text)
|
||||
_, _, vb, _ := parseFact(members[j].Text)
|
||||
truth[pairKey(base+i, base+j, len(kept))] = va == vb
|
||||
}
|
||||
}
|
||||
}
|
||||
return kept, truth
|
||||
}
|
||||
|
||||
// ★ 「同属性不同值」的区分度 —— overwrite 维度的命门。
|
||||
//
|
||||
// 实测 chineseclip 对这类几乎无区分度:
|
||||
//
|
||||
// 「值班室分机号 4324」cos = 0.9284
|
||||
// 「值班室分机号 4379」cos = 0.9298 ← 旧号反而高 0.0014
|
||||
//
|
||||
// 所以它连"粗筛"都做不了:粗筛至少要能**分档**,
|
||||
// 而全部块都在 0.85~0.95 之间 ⇒ 任何阈值都切不开。
|
||||
//
|
||||
// 判据:同属性不同值的对,其相似度必须**显著低于**同属性同值的对。
|
||||
// 若两者分布重叠,粗筛就失效(不只是阈值问题)。
|
||||
func TestNearScreen_同属性不同值的区分度(t *testing.T) {
|
||||
blocks, truth := buildRealGroupPairs(t)
|
||||
if len(blocks) == 0 {
|
||||
t.Skip("无真库同属性对")
|
||||
}
|
||||
var sameScores, diffScores []float64
|
||||
for i := 0; i < len(blocks); i++ {
|
||||
for j := i + 1; j < len(blocks); j++ {
|
||||
sa, da, _, _ := parseFact(blocks[i].Text)
|
||||
sb, db, _, _ := parseFact(blocks[j].Text)
|
||||
if sa != sb || da != db {
|
||||
continue
|
||||
}
|
||||
s := bigramSim(blocks[i].Text, blocks[j].Text)
|
||||
if truth[pairKey(i, j, len(blocks))] {
|
||||
sameScores = append(sameScores, s)
|
||||
} else {
|
||||
diffScores = append(diffScores, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
minSame, maxDiff := 1.0, 0.0
|
||||
for _, s := range sameScores {
|
||||
if s < minSame {
|
||||
minSame = s
|
||||
}
|
||||
}
|
||||
for _, s := range diffScores {
|
||||
if s > maxDiff {
|
||||
maxDiff = s
|
||||
}
|
||||
}
|
||||
t.Logf("同值对 %d 个,相似度 %.2f~%.2f", len(sameScores), minSame, maxSame(sameScores))
|
||||
t.Logf("异值对 %d 个,相似度 %.2f~%.2f", len(diffScores), minDiff(diffScores), maxDiff)
|
||||
if maxDiff > minSame {
|
||||
t.Logf("★ 两类分布重叠(异值最高 %.2f > 同值最低 %.2f)⇒ 粗筛在此判据上失效", maxDiff, minSame)
|
||||
} else {
|
||||
t.Logf("★ 两类可分(同值最低 %.2f > 异值最高 %.2f)⇒ 阈值 %.2f 可用",
|
||||
minSame, maxDiff, (minSame+maxDiff)/2)
|
||||
}
|
||||
}
|
||||
|
||||
func maxSame(s []float64) float64 {
|
||||
m := 0.0
|
||||
for _, x := range s {
|
||||
if x > m {
|
||||
m = x
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
func minDiff(s []float64) float64 {
|
||||
m := 1.0
|
||||
for _, x := range s {
|
||||
if x < m {
|
||||
m = x
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// ★ 最强判据:块文本全等 = 同一事实,无需任何相似度。
|
||||
//
|
||||
// 实测(真库 260 个 distill 块)三件事等价:
|
||||
//
|
||||
// 块文本全等 ⟺ (主语,维度,值) 全等 ⟺ bigram = 1.00
|
||||
//
|
||||
// 因为块文本形态是 `<主语>|<维度>=<值>`,而主语/维度不含 '|'、值不含 '='
|
||||
// —— 拆分成三段是无损的。所以「文本全等」就是「三元组全等」,
|
||||
// 而真重复的 5 组文本**完全相同**(bigram=1.00,非 0.99)。
|
||||
//
|
||||
// ⇒ 事实级去重就是一条 GROUP BY,不需要相似度、不需要向量、不需要 LLM。
|
||||
//
|
||||
// ★ 那相似度(和 Qwen3-VL)到底做什么用?
|
||||
// 它解决的是**文本不全等但语义同**的情形,例如:
|
||||
//
|
||||
// 连接池|等待队列告警阈值=300~500
|
||||
// 连接池|等待队列长度告警阈值=300~500 ← 维度名多一个「长度」
|
||||
//
|
||||
// 这两条值相同、维度同义,是同一事实的两种说法。
|
||||
// 文本全等判据**判不出**它们(需要走别名归一),而它们恰好是
|
||||
// 真库里 drift 发现报出的候选(58b26e0)。
|
||||
//
|
||||
// 所以分工是:
|
||||
//
|
||||
// 文本全等 → 确定性去重(零成本,覆盖 5 组)
|
||||
// 别名词表 → 确定性去重(人工确认后写入词表)
|
||||
// 相似度粗筛 → 发现**新的**别名候选(Qwen3-VL 在这里才有价值)
|
||||
func TestDedupeByExactText_完全确定性(t *testing.T) {
|
||||
g := probeDB(t, "/var/tmp/ha-c/memory/graph.db")
|
||||
blocks, err := g.MemoryBlocks()
|
||||
if err != nil {
|
||||
t.Skip("无真库")
|
||||
}
|
||||
byText := map[string][]MemoryBlock{}
|
||||
for _, b := range blocks {
|
||||
if b.Source != "distill" {
|
||||
continue
|
||||
}
|
||||
byText[b.Text] = append(byText[b.Text], b)
|
||||
}
|
||||
dups := 0
|
||||
for text, group := range byText {
|
||||
if len(group) < 2 {
|
||||
continue
|
||||
}
|
||||
dups++
|
||||
// 同一事实的重复:主语/维度/值必然全等
|
||||
_, _, v0, _ := parseFact(group[0].Text)
|
||||
for _, b := range group[1:] {
|
||||
_, _, v, _ := parseFact(b.Text)
|
||||
if v != v0 {
|
||||
t.Errorf("文本全等却值不同(不该发生): %q", text)
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Logf("文本全等的重复组: %d 组", dups)
|
||||
if dups == 0 {
|
||||
t.Log("(真库当前没有重复 —— 可能已去重过)")
|
||||
}
|
||||
|
||||
// 反向:三元组全等 ⟺ 文本全等(无损性)
|
||||
byKey := map[string]int{}
|
||||
for text := range byText {
|
||||
k, ok := factKey(text)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
byKey[k]++
|
||||
}
|
||||
conflicts := 0
|
||||
for k, n := range byKey {
|
||||
if n < 2 {
|
||||
continue
|
||||
}
|
||||
// 同一三元组键下必须全部是同一条文本
|
||||
var texts []string
|
||||
for text := range byText {
|
||||
if kk, ok := factKey(text); ok && kk == k {
|
||||
texts = append(texts, text)
|
||||
}
|
||||
}
|
||||
if len(texts) != n {
|
||||
conflicts++
|
||||
}
|
||||
}
|
||||
if conflicts > 0 {
|
||||
t.Errorf("三元组键相同但文本不同的组: %d(说明拆分有损)", conflicts)
|
||||
} else {
|
||||
t.Logf("✓ 三元组键 ↔ 文本 双向无损(%d 个键)", len(byKey))
|
||||
}
|
||||
}
|
||||
@ -1,8 +1,10 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
@ -91,22 +93,47 @@ func (g *GraphDB) NoiseEntities() ([]Entity, error) {
|
||||
}
|
||||
|
||||
func (g *GraphDB) noiseEntitiesLocked() ([]Entity, error) {
|
||||
// ★★★ 改扫 memory_blocks(2026-10-04)
|
||||
//
|
||||
// 旧实现扫 entities 表。旧表退场后返回空 ⇒ PurgeNoise 认定「库里没有噪音」
|
||||
// ⇒ 直接 return 0,0,nil —— 而块侧的噪音块一条不少。
|
||||
//
|
||||
// 这比「删不掉」更糟:用户点清理,报告显示「已清理 0 个噪音」,
|
||||
// 看起来是**成功**的。
|
||||
//
|
||||
// ★ ID 口径:Entity.ID 是 int64,块 ID 是字符串。
|
||||
// 这里用**行号**(ROW_NUMBER 不便,改用 OFFSET 累计的序号)占位,
|
||||
// 因为 PurgeNoise 真正用来删的是块 ID,不是这个数字。
|
||||
// 排序仍然按 created_at(块的),语义上最接近旧的 mention_count ——
|
||||
// mention_count 已无处可取,而「先出现的更可能是早期噪音」是同向的。
|
||||
rows, err := g.db.Query(
|
||||
`SELECT id, name, type, mention_count, created_at, updated_at FROM entities`)
|
||||
`SELECT id, text_content, modality, created_at, updated_at
|
||||
FROM memory_blocks WHERE text_content != '' ORDER BY created_at ASC, id ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Entity
|
||||
var seq int
|
||||
for rows.Next() {
|
||||
var e Entity
|
||||
if err := rows.Scan(&e.ID, &e.Name, &e.Type, &e.MentionCount, &e.CreatedAt, &e.UpdatedAt); err != nil {
|
||||
var id, text, modality string
|
||||
var created, updated time.Time
|
||||
if err := rows.Scan(&id, &text, &modality, &created, &updated); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if IsNoiseEntity(e.Name) {
|
||||
out = append(out, e)
|
||||
seq++
|
||||
if !IsNoiseEntity(text) {
|
||||
continue
|
||||
}
|
||||
out = append(out, Entity{
|
||||
ID: int64(seq), // 占位:PurgeNoise 用块 ID 删,不用它
|
||||
Name: text,
|
||||
Type: "block",
|
||||
MentionCount: 1, // 块无 mention_count;排序时用 created_at
|
||||
CreatedAt: created,
|
||||
UpdatedAt: updated,
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
@ -139,16 +166,45 @@ func (g *GraphDB) PurgeNoise(dryRun bool) (int, int, error) {
|
||||
|
||||
ids := make([]interface{}, 0, len(junk))
|
||||
for _, e := range junk {
|
||||
ids = append(ids, e.ID)
|
||||
// ★ junk 里的 ID 是占位序号,真正要的是**块 ID**。
|
||||
// 块 ID 由内容派生(TripleBlockID / SentenceBlockID),
|
||||
// 所以按文本反查而不是信任那个数字。
|
||||
blk, err := g.blocksByTextTx(e.Name)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
for _, b := range blk {
|
||||
ids = append(ids, b.ID)
|
||||
}
|
||||
}
|
||||
ph := placeholders(len(junk))
|
||||
if len(ids) == 0 {
|
||||
// ★ 识别到噪音但一个块 ID 都取不到 —— 这是**不一致**,
|
||||
// 不能当成「无事可做」静默返回。
|
||||
return 0, 0, fmt.Errorf("noise purge: %d junk names matched no blocks",
|
||||
len(junk))
|
||||
}
|
||||
// ★ ph 必须按 **ids** 的长度生成,不能用 len(junk)。
|
||||
//
|
||||
// junk 是「噪音实体」数,ids 是它们反查到的**块 ID** 数。
|
||||
// 两者只在「一个噪音文本对应一个块」时相等 ——
|
||||
// 而迁移期的历史数据里同文本块可以重复(blk_ent_a / blk_ent_b)。
|
||||
//
|
||||
// ★ 用 len(junk) 会让占位符与参数个数不符,
|
||||
// SQLite 直接报错(好);
|
||||
// ★ 更坏的是个数**恰好相同但内容不同** ——
|
||||
// 那就是静默删错块。所以这里按 ids 生成。
|
||||
ph := placeholders(len(ids))
|
||||
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+`)`,
|
||||
// ★ 改查 memory_block_edges(2026-10-04)
|
||||
// 旧表退场后 relations 空 ⇒ relCount 恒 0 ⇒
|
||||
// PurgeNoise 的 dry-run 报告会说「0 条关系」而实际要删很多。
|
||||
`SELECT COUNT(DISTINCT id) FROM memory_block_edges
|
||||
WHERE source_kind='block' AND target_kind='block'
|
||||
AND (source_id IN (`+ph+`) OR target_id IN (`+ph+`))`,
|
||||
args...,
|
||||
).Scan(&relCount); err != nil {
|
||||
return 0, 0, err
|
||||
@ -164,13 +220,39 @@ func (g *GraphDB) PurgeNoise(dryRun bool) (int, int, error) {
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// ★★★ 改删边而不是旧表(2026-10-04)
|
||||
//
|
||||
// 旧实现删 relations + entities。旧表退场后这段变成**空操作但报成功** ——
|
||||
// 而块侧的边还在 active ⇒ 场景召回照样返回那些噪音记忆。
|
||||
//
|
||||
// 这正是「写入成功但静默失效」的另一种形态:
|
||||
// 用户点了「清理噪音」,界面说成功,记忆一条没少。
|
||||
//
|
||||
// ★ 只删**关系边**,不删结构边(contains):
|
||||
// 结构边连的是原句块与媒体块,删了会让块体系出现悬空端点。
|
||||
//
|
||||
// ★★ 块本身要删(2026-10-04 修正)
|
||||
//
|
||||
// 我一开始写的是「块不删,孤立块交给 OrphanEntities」——
|
||||
// 理由是旧实现也有同一条纪律(「一次改动只做一件事」)。
|
||||
// ★★ 但那条纪律针对的是**孤儿块**(边没了、块还在,可能被别人引用),
|
||||
// 而 PurgeNoise 删的是**已判定为噪音的块**:
|
||||
// 它被删边的后果正是变成孤儿,而留着它等于清理没生效 ——
|
||||
// NoiseEntities 会把它们继续报成噪音,PurgeNoise 再跑又报 4 个。
|
||||
//
|
||||
// 判据「清理后仍有噪音实体」当场抓住了这一点。
|
||||
//
|
||||
// 只删**文本块**(这些是噪音实体):媒体块、原句块不在 junk 里。
|
||||
if _, err := tx.Exec(
|
||||
`DELETE FROM relations WHERE source_id IN (`+ph+`) OR target_id IN (`+ph+`)`,
|
||||
`DELETE FROM memory_block_edges
|
||||
WHERE source_kind='block' AND target_kind='block'
|
||||
AND edge_type != 'contains'
|
||||
AND (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 {
|
||||
`DELETE FROM memory_blocks WHERE id IN (`+ph+`)`, ids...); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
@ -199,14 +281,38 @@ func (g *GraphDB) OrphanEntities() ([]Entity, error) {
|
||||
}
|
||||
|
||||
func (g *GraphDB) orphanEntitiesLocked() ([]Entity, error) {
|
||||
// ★★★ 改扫 **memory_blocks**(2026-10-04)
|
||||
//
|
||||
// 旧实现扫 entities 并用 relations 判孤立 —— 而这两张表
|
||||
// 在停双写后**都不再增长、不再被写**,于是:
|
||||
//
|
||||
// ① 扫描结果是迁移时的快照,永远不变
|
||||
// ② 新建的块(真正可能孤立的那批)根本不在其中
|
||||
// ③ 而它还在排除 source_kind='entity' 的边 ——
|
||||
// 那种 kind 在块化之后已经不产生了
|
||||
//
|
||||
// 实测(判据 TestPurgeOrphans):孤立的块一个都没识别出来。
|
||||
//
|
||||
// ★ 块侧的口径:
|
||||
// · 没有任何**关系边**指向它 → 孤立
|
||||
// · 与媒体块/原句块有 contains 结构边 → 不算孤立
|
||||
// (那是 sentence/document --contains--> block 体系的一部分,
|
||||
// 删了会让结构边悬空)
|
||||
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)))`)
|
||||
`SELECT b.id, b.text_content, COALESCE(b.semantic_type,''),
|
||||
b.created_at, b.updated_at
|
||||
FROM memory_blocks b
|
||||
WHERE b.text_content != ''
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM memory_block_edges e
|
||||
WHERE (e.source_id = b.id OR e.target_id = b.id)
|
||||
AND e.source_kind = 'block' AND e.target_kind = 'block'
|
||||
AND e.edge_type != 'contains')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM memory_block_edges e
|
||||
WHERE (e.source_id = b.id OR e.target_id = b.id)
|
||||
AND COALESCE(e.session_id,'') = ''
|
||||
AND e.edge_type = 'contains')`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -215,9 +321,15 @@ func (g *GraphDB) orphanEntitiesLocked() ([]Entity, error) {
|
||||
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 {
|
||||
var blockID string
|
||||
if err := rows.Scan(&blockID, &e.Name, &e.Type,
|
||||
&e.CreatedAt, &e.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// ★ ID 退化为序号;blockKey 才是真实块 ID
|
||||
// (PurgeOrphans 按 blockKey 删除 —— 见该函数注释)。
|
||||
e.ID = int64(len(out) + 1)
|
||||
e.blockKey = blockID
|
||||
out = append(out, e)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
@ -248,12 +360,26 @@ func (g *GraphDB) PurgeOrphans(dryRun bool) (int, error) {
|
||||
return len(orphans), nil
|
||||
}
|
||||
|
||||
// ★★ 删的是**块**,不是旧表行(2026-10-04)
|
||||
//
|
||||
// orphanEntitiesLocked 已改扫 memory_blocks —— 它返回的 Entity.ID
|
||||
// 是「本次查询内的序号」,blockKey 才是真实块 ID。
|
||||
//
|
||||
// 原实现把 e.ID 当旧表行号去 DELETE FROM entities ⇒ 删错对象
|
||||
// (而删不到任何行时静默报 0)。
|
||||
ids := make([]interface{}, 0, len(orphans))
|
||||
for _, e := range orphans {
|
||||
ids = append(ids, e.ID)
|
||||
if e.blockKey == "" {
|
||||
// 没有块 ID = 不是块侧的孤立实体(理论上不该出现)
|
||||
continue
|
||||
}
|
||||
ids = append(ids, e.blockKey)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
if _, err := g.db.Exec(
|
||||
`DELETE FROM entities WHERE id IN (`+placeholders(len(ids))+`)`, ids...); err != nil {
|
||||
`DELETE FROM memory_blocks WHERE id IN (`+placeholders(len(ids))+`)`, ids...); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if _, err := g.purgeStaleSceneRefsLocked(); err != nil {
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
@ -170,18 +169,27 @@ func TestPurgeNoiseKeepsBlockBackedSentences(t *testing.T) {
|
||||
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)
|
||||
// ★★★ 全面改用块体系(2026-10-04)
|
||||
//
|
||||
// 原句由**原句块**承载(sentences 表停写),
|
||||
// 而媒体块通过「原句块 --contains--> 媒体块」挂在它上面
|
||||
// —— 端点 kind 也从 "sentence" 变成 "block"(13c3292)。
|
||||
//
|
||||
// 判据的**意图**不变:「清理噪音块时,不能连带删掉仍被引用的原句」。
|
||||
sid := SentenceBlockID(sentence)
|
||||
if blk, err := g.BlockByText(sentence); err != nil || blk == nil {
|
||||
t.Fatalf("原句块未写入: %v", err)
|
||||
}
|
||||
if _, err := g.db.Exec(
|
||||
`INSERT INTO memory_blocks (id, modality, payload_digest, mime) VALUES ('blk1', 'image', 'digest1', 'image/png')`); err != nil {
|
||||
`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 {
|
||||
`INSERT INTO memory_block_edges
|
||||
(source_kind, source_id, target_kind, target_id, edge_type, session_id)
|
||||
VALUES ('block', ?, 'block', 'blk1', 'contains', '')`,
|
||||
sid); err != nil {
|
||||
t.Fatalf("insert edge: %v", err)
|
||||
}
|
||||
|
||||
@ -189,12 +197,35 @@ func TestPurgeNoiseKeepsBlockBackedSentences(t *testing.T) {
|
||||
t.Fatalf("PurgeNoise: %v", err)
|
||||
}
|
||||
|
||||
// ★ 原句块必须还在(它被媒体块的 contains 边引用)
|
||||
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 err := g.db.QueryRow(
|
||||
`SELECT COUNT(*) FROM memory_blocks WHERE id = ?`, sid).Scan(&n); err != nil {
|
||||
t.Fatalf("count sentence block: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Error("PurgeNoise 删掉了仍被媒体块边引用的句子")
|
||||
t.Error("PurgeNoise 删掉了仍被媒体块 contains 边引用的原句块")
|
||||
}
|
||||
|
||||
// ★ 媒体块也必须还在
|
||||
var m int
|
||||
if err := g.db.QueryRow(
|
||||
`SELECT COUNT(*) FROM memory_blocks WHERE id = 'blk1'`).Scan(&m); err != nil {
|
||||
t.Fatalf("count media block: %v", err)
|
||||
}
|
||||
if m != 1 {
|
||||
t.Error("PurgeNoise 误删了媒体块")
|
||||
}
|
||||
|
||||
// ★ 而噪音块(结果/问题)应已被清理 —— 这才是 PurgeNoise 的本职工作
|
||||
var noiseLeft int
|
||||
if err := g.db.QueryRow(
|
||||
`SELECT COUNT(*) FROM memory_blocks
|
||||
WHERE text_content IN ('结果','问题')`).Scan(&noiseLeft); err != nil {
|
||||
t.Fatalf("count noise: %v", err)
|
||||
}
|
||||
if noiseLeft != 0 {
|
||||
t.Errorf("PurgeNoise 应清掉噪音块,实际剩 %d 个", noiseLeft)
|
||||
}
|
||||
}
|
||||
|
||||
@ -218,16 +249,32 @@ func TestPurgeOrphans(t *testing.T) {
|
||||
t.Fatalf("清理前不应有孤立实体: %+v", list)
|
||||
}
|
||||
|
||||
// 造一个「边被清掉、节点还在」的壳:直接删边
|
||||
if _, err := g.db.Exec(`DELETE FROM relations WHERE relation_type = '是'`); err != nil {
|
||||
t.Fatalf("delete relation: %v", err)
|
||||
// ★ 造「边被清掉、节点还在」的壳(2026-10-04)
|
||||
//
|
||||
// 原来 `DELETE FROM relations WHERE relation_type='是'` ——
|
||||
// 那是旧表,而旧表停双写后既不被写也不被读,**空操作**。
|
||||
// ⇒ 块侧的边一直在 ⇒ 「结果」「问题」永远不被判为孤立。
|
||||
//
|
||||
// ⇒ 必须删**块侧**的边。
|
||||
if _, err := g.db.Exec("DELETE FROM memory_block_edges WHERE edge_type = '是'"); err != nil {
|
||||
t.Fatalf("delete block edge: %v", err)
|
||||
}
|
||||
// 再补一个从未有过边的孤立实体
|
||||
if _, _, err := g.Commit([]Triple{{Subject: "孤零零", Relation: "是", Object: "小宅", Confidence: 1.0}}, "test", 0); err != nil {
|
||||
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)
|
||||
// ★ 制造孤立:删**块侧**的关系边(2026-10-04)
|
||||
//
|
||||
// 原来删旧表 relations —— 而 orphanEntitiesLocked 已改扫块,
|
||||
// 块侧的边还在,于是没有块被判为孤立。
|
||||
//
|
||||
// ★ 这也说明「测试用旧表制造状态」这条路已经彻底断了:
|
||||
// 旧表不再增长、不再被读,任何依赖它的造数都是空操作。
|
||||
if _, err := g.db.Exec("DELETE FROM memory_block_edges " +
|
||||
"WHERE edge_type != 'contains' " +
|
||||
"AND source_id IN (SELECT id FROM memory_blocks WHERE text_content = '孤零零')",
|
||||
); err != nil {
|
||||
t.Fatalf("delete block edge: %v", err)
|
||||
}
|
||||
|
||||
list, err := g.OrphanEntities()
|
||||
|
||||
@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/distill"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/nlp"
|
||||
)
|
||||
|
||||
@ -49,8 +50,39 @@ type Distiller struct {
|
||||
cancel context.CancelFunc
|
||||
onMemory func(input, response string)
|
||||
embedder nlp.Vectorizer
|
||||
|
||||
// embed 把块文本变成向量与指纹;nil 表示不带向量(不编造)。
|
||||
embed EmbedFunc
|
||||
|
||||
// splitter 是小模型拆分器(pkg/generation 提供的 provider)。
|
||||
//
|
||||
// ★ 为什么要有它:jieba/ONNX 那条自动蒸馏路实测产出 **0 条**
|
||||
//(defaultParser 为 nil,POS 模板抽不出),于是图记忆里的 188 个实体
|
||||
// 全部是模型主动调 memory_commit 写进来的**整句复合值**
|
||||
//(admin服务端口8861·billing服务端口8499·oauth服务端口8271)。
|
||||
// 后果是跨维度检索全失效:严格判据下基线 0/5。
|
||||
//
|
||||
// splitter 为 nil 时回退 extractKeyTriples(现有 jieba 路),
|
||||
// 保持蒸馏不停摆 —— 哪怕产出 0 条也比整体失败好。
|
||||
splitter RecordSplitter
|
||||
}
|
||||
|
||||
// RecordSplitter 把一条原始记录拆成三元组。
|
||||
//
|
||||
// 抽成接口是为了让 pipeline 不依赖 internal/memory/distill(那个包依赖
|
||||
// pkg/generation,而 pipeline 在早期启动阶段不该拉起生成侧依赖)。
|
||||
type RecordSplitter interface {
|
||||
Split(ctx context.Context, record string) ([]memory.Triple, error)
|
||||
}
|
||||
|
||||
// SetSplitter 注入小模型拆分器。为 nil 时蒸馏回退 jieba 路。
|
||||
func (d *Distiller) SetSplitter(s RecordSplitter) { d.splitter = s }
|
||||
|
||||
// SetEmbedFunc 注入块向量计算函数(由持有 embedding provider 的一方提供)。
|
||||
func (d *Distiller) SetEmbedFunc(fn EmbedFunc) { d.embed = fn }
|
||||
|
||||
// SetEmbedder 注入词嵌入器,供既有 jieba/ONNX 抽取路做 TransE 语义验证。
|
||||
// 与 SetSplitter 并存:splitter 存在时优先生效,embedder 仍用于回退路径。
|
||||
func (d *Distiller) SetEmbedder(ev nlp.Vectorizer) { d.embedder = ev }
|
||||
|
||||
func NewDistiller(db *memory.GraphDB, dataDir string, cfg DistillerConfig) *Distiller {
|
||||
@ -277,6 +309,13 @@ func (d *Distiller) distillOnce() {
|
||||
}
|
||||
}
|
||||
|
||||
// distillSplitTimeout 是单次小模型拆分调用的上限。
|
||||
//
|
||||
// 实测单条 4-9s(qwen3:1.7b + schema 约束,CPU)。超时不能太短,
|
||||
// 否则每条都超时 = splitter 恒失败 = 静默退化成 0 产出的 jieba 路;
|
||||
// 也不能太长,否则一批 50 条会把 30 分钟的蒸馏间隔吃穿。
|
||||
const distillSplitTimeout = 90 * time.Second
|
||||
|
||||
// distillBatch 蒸馏一批记录,全部成功返回 true,任一失败返回 false(调用方重试)
|
||||
func (d *Distiller) distillBatch(batch []RawRecord) bool {
|
||||
var userContent, assistantContent string
|
||||
@ -289,7 +328,25 @@ func (d *Distiller) distillBatch(batch []RawRecord) bool {
|
||||
assistantContent += r.Content + " "
|
||||
}
|
||||
}
|
||||
triples := extractKeyTriples(userContent, assistantContent, d.embedder)
|
||||
// 块路径优先(正确形态),Triple 路保留为对照期回退。
|
||||
//
|
||||
// ★ 为什么改(本会话认知纠错):拆解产物的正确落库形态是
|
||||
//【句子】【contains 边】【块】—— 节点已从纯文本实体升级为带向量的
|
||||
// memory_blocks(46f833c)。把拆解结果 Commit 成 entities 是把新产出
|
||||
// 灌进正在退场的旧形态;而旧 jieba 路实测产出 0 条,保留它只为
|
||||
//「块路不可用时记忆不丢」的底线。
|
||||
if d.splitter != nil {
|
||||
if wb, ok := d.splitter.(BlockSplitter); ok {
|
||||
if d.writeBlocks(wb, userContent, assistantContent) {
|
||||
return true
|
||||
}
|
||||
// 块路失败(模型错误/超时):不要在这里 return false ——
|
||||
// 那会让同一批记录无限重试。落回 Triple 路(jieba),
|
||||
// 失败原因已在 writeBlocks 里记日志。
|
||||
}
|
||||
}
|
||||
|
||||
triples := d.extractTriples(userContent, assistantContent)
|
||||
if len(triples) > 0 {
|
||||
sessionID := ""
|
||||
for sid := range sessionIDs {
|
||||
@ -304,6 +361,51 @@ func (d *Distiller) distillBatch(batch []RawRecord) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// BlockSplitter 是能产出块形态的拆解器(distill.Extractor 实现)。
|
||||
//
|
||||
// 与 RecordSplitter 并存的原因:RecordSplitter.Split 返回 Triple(对照期
|
||||
// 仍被 memoryface 接口使用),块形态经 Blocks 返回。两个方法由同一个
|
||||
// Extractor 实现,共用同一套闸门,不重复实现。
|
||||
type BlockSplitter interface {
|
||||
RecordSplitter
|
||||
Blocks(ctx context.Context, record string) (*distill.BlockPayload, error)
|
||||
}
|
||||
|
||||
// EmbedFunc 由持有 embedding provider 的一方注入;nil 表示不带向量
|
||||
// (块仍入库,只是不参与向量召回——不编造零向量)。
|
||||
type EmbedFunc func(text string) (vec []float64, fingerprint string)
|
||||
|
||||
// writeBlocks 逐条拆解并落块。全部成功返回 true;任一条失败记日志并
|
||||
// 返回 false(调用方落回 Triple 路),已成功的块保留(幂等 ID 保证
|
||||
// 重试不会产生重复)。
|
||||
func (d *Distiller) writeBlocks(bs BlockSplitter, userContent, assistantContent string) bool {
|
||||
text := userContent
|
||||
if assistantContent != "" {
|
||||
text += " " + assistantContent
|
||||
}
|
||||
// 按句切分:块的语义单位是句子,整段混合会让 contains 边失去指向。
|
||||
for _, sent := range splitSentences(text) {
|
||||
sent = strings.TrimSpace(sent)
|
||||
if len([]rune(sent)) < 4 {
|
||||
continue
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(d.ctx, distillSplitTimeout)
|
||||
payload, err := bs.Blocks(ctx, sent)
|
||||
cancel()
|
||||
if err != nil {
|
||||
log.Printf("[memory] distill blocks: %v", err)
|
||||
return false
|
||||
}
|
||||
// 用 d.ctx 而非 context.Background():Distiller.Stop() 会 cancel 它,
|
||||
// 用 Background 会让停机时正在写库的蒸馏循环继续跑。
|
||||
if _, err := distill.WritePayload(d.ctx, d.db, payload, d.embed); err != nil {
|
||||
log.Printf("[memory] distill write blocks: %v", err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (d *Distiller) cleanupRawFiles() {
|
||||
entries, err := os.ReadDir(d.rawPath)
|
||||
if err != nil {
|
||||
@ -424,6 +526,30 @@ func extractKeyTriples(userContent, assistantContent string, embedder nlp.Vector
|
||||
return triples
|
||||
}
|
||||
|
||||
// extractTriples 按「有 splitter 用 splitter,否则回退 jieba」的顺序抽取。
|
||||
//
|
||||
// 必须能区分「splitter 跑了但没拆出东西」与「splitter 没跑/失败」:
|
||||
// - 跑出 0 条 = 正常(这类记录本就无字段可拆,例如纯叙述句)
|
||||
// - 报错 = 异常,回退 jieba 路并保留记录等下次重试
|
||||
func (d *Distiller) extractTriples(userContent, assistantContent string) []memory.Triple {
|
||||
if d.splitter != nil {
|
||||
text := userContent
|
||||
if assistantContent != "" {
|
||||
text += " " + assistantContent
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(d.ctx, distillSplitTimeout)
|
||||
defer cancel()
|
||||
triples, err := d.splitter.Split(ctx, text)
|
||||
if err == nil {
|
||||
return triples
|
||||
}
|
||||
// 回退前记一笔:splitter 长期失败会静默退化成 jieba 路的 0 条产出,
|
||||
// 那种情况从外部看不出来(蒸馏照常「成功」,只是没写进任何东西)。
|
||||
log.Printf("[memory] splitter failed, falling back to jieba path: %v", err)
|
||||
}
|
||||
return extractKeyTriples(userContent, assistantContent, d.embedder)
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
if len(s) > max {
|
||||
return s[:max] + "..."
|
||||
@ -462,3 +588,14 @@ func (d *Distiller) Stats() map[string]interface{} {
|
||||
"retention_days": d.cfg.RetentionDays,
|
||||
}
|
||||
}
|
||||
|
||||
// splitSentences 按中英文句读切分文本。
|
||||
//
|
||||
// 蒸馏的块语义单位是句子;这里只做粗切(。!?;\n),不做 NLP 级
|
||||
// 句法分析 —— 粗切足够给块提供「同一场对话里的一个片段」边界。
|
||||
func splitSentences(text string) []string {
|
||||
return strings.FieldsFunc(text, func(r rune) bool {
|
||||
return r == '。' || r == '!' || r == '?' || r == ';' ||
|
||||
r == '\n' || r == '?' || r == '!'
|
||||
})
|
||||
}
|
||||
|
||||
257
internal/memory/pipeline/splitter_test.go
Normal file
257
internal/memory/pipeline/splitter_test.go
Normal file
@ -0,0 +1,257 @@
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/distill"
|
||||
)
|
||||
|
||||
// newTestDistiller 建一个带真实图库的 Distiller(nil db 的那种测不了入库)。
|
||||
func newTestDistiller(t *testing.T) (*Distiller, func()) {
|
||||
t.Helper()
|
||||
db, err := memory.NewGraphDB(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewGraphDB: %v", err)
|
||||
}
|
||||
d := NewDistiller(db, t.TempDir(), DistillerConfig{Interval: time.Hour, BatchSize: 50})
|
||||
d.ctx, d.cancel = context.WithCancel(context.Background())
|
||||
return d, func() {
|
||||
d.cancel()
|
||||
db.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func nowForTest() time.Time { return time.Now() }
|
||||
|
||||
// relationCount 读活动关系数。
|
||||
func relationCount(t *testing.T, d *Distiller) int {
|
||||
t.Helper()
|
||||
data, err := d.db.Introspect()
|
||||
if err != nil {
|
||||
t.Fatalf("Introspect: %v", err)
|
||||
}
|
||||
if n, ok := data["relation_count"].(int); ok {
|
||||
return n
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// stubSplitter 记录调用参数,返回预置结果。
|
||||
type stubSplitter struct {
|
||||
called int
|
||||
gotText string
|
||||
triples []memory.Triple
|
||||
err error
|
||||
callErr error
|
||||
}
|
||||
|
||||
func (s *stubSplitter) Split(_ context.Context, record string) ([]memory.Triple, error) {
|
||||
s.called++
|
||||
s.gotText = record
|
||||
if s.callErr != nil {
|
||||
return nil, s.callErr
|
||||
}
|
||||
return s.triples, s.err
|
||||
}
|
||||
|
||||
// ★ splitter 注入后应被真正使用(否则接了等于没接)。
|
||||
func TestDistillBatch_优先用Splitter(t *testing.T) {
|
||||
d, cleanup := newTestDistiller(t)
|
||||
defer cleanup()
|
||||
|
||||
want := []memory.Triple{{Subject: "第183批", Relation: "停机时长", Object: "7分"}}
|
||||
stub := &stubSplitter{triples: want}
|
||||
d.SetSplitter(stub)
|
||||
|
||||
ok := d.distillBatch([]RawRecord{{ID: 1, SessionID: "sess", Role: "user",
|
||||
Content: "第183批周三凌晨1点·停机7分", CreatedAt: nowForTest()}})
|
||||
if !ok {
|
||||
t.Fatal("distillBatch 应成功")
|
||||
}
|
||||
if stub.called != 1 {
|
||||
t.Fatalf("splitter 应被调用 1 次,实际 %d", stub.called)
|
||||
}
|
||||
if !contains(stub.gotText, "停机7分") {
|
||||
t.Errorf("splitter 未收到记录内容:%q", stub.gotText)
|
||||
}
|
||||
// 三元组应真的进了图
|
||||
if n := relationCount(t, d); n == 0 {
|
||||
t.Error("splitter 产出的三元组未入库")
|
||||
}
|
||||
}
|
||||
|
||||
// splitter 返回空但成功 = 「这条记录没有可拆字段」,应视为成功
|
||||
// (蒸馏标记完成,不重试)。
|
||||
func TestDistillBatch_splitter空结果视为成功(t *testing.T) {
|
||||
d, cleanup := newTestDistiller(t)
|
||||
defer cleanup()
|
||||
stub := &stubSplitter{triples: nil}
|
||||
d.SetSplitter(stub)
|
||||
|
||||
ok := d.distillBatch([]RawRecord{{ID: 1, SessionID: "s", Role: "user",
|
||||
Content: "第129~143批均仅评审通过"}})
|
||||
if !ok {
|
||||
t.Fatal("splitter 返回 0 条应视为成功(无字段可拆是正常结果)")
|
||||
}
|
||||
if stub.called != 1 {
|
||||
t.Fatalf("splitter 应被调用,实际 %d", stub.called)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ splitter 报错 → 回退 jieba 路,且 distillBatch 仍成功(不停摆)。
|
||||
func TestDistillBatch_splitter失败回退(t *testing.T) {
|
||||
d, cleanup := newTestDistiller(t)
|
||||
defer cleanup()
|
||||
stub := &stubSplitter{err: errors.New("model unavailable")}
|
||||
d.SetSplitter(stub)
|
||||
|
||||
ok := d.distillBatch([]RawRecord{{ID: 1, SessionID: "s", Role: "user",
|
||||
Content: "第183批周三凌晨1点·停机7分"}})
|
||||
if !ok {
|
||||
t.Fatal("splitter 失败后应回退而非整体失败")
|
||||
}
|
||||
if stub.called != 1 {
|
||||
t.Fatalf("splitter 应被尝试过 1 次,实际 %d", stub.called)
|
||||
}
|
||||
}
|
||||
|
||||
// 未注入 splitter 时行为不变(向后兼容)。
|
||||
func TestDistillBatch_无splitter走原路径(t *testing.T) {
|
||||
d, cleanup := newTestDistiller(t)
|
||||
defer cleanup()
|
||||
d.SetSplitter(nil)
|
||||
|
||||
ok := d.distillBatch([]RawRecord{{ID: 1, SessionID: "s", Role: "user",
|
||||
Content: "第183批周三凌晨1点·停机7分"}})
|
||||
if !ok {
|
||||
t.Fatal("未注入 splitter 时应仍能成功(走既有 jieba 路)")
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 变异自证:若 extractTriples 忽略 splitter 恒走 jieba,
|
||||
// 「优先用 splitter」这条测试必须变红。
|
||||
func TestExtractTriples_变异_忽略splitter则失败(t *testing.T) {
|
||||
d, cleanup := newTestDistiller(t)
|
||||
defer cleanup()
|
||||
stub := &stubSplitter{triples: []memory.Triple{{Subject: "x"}}}
|
||||
d.SetSplitter(stub)
|
||||
|
||||
got := d.extractTriples("第183批 停机7分", "")
|
||||
if len(got) != 1 || got[0].Subject != "x" {
|
||||
t.Fatalf("extractTriples 应返回 splitter 的结果,实际 %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
return len(sub) == 0 || (len(s) >= len(sub) && indexOf(s, sub) >= 0)
|
||||
}
|
||||
|
||||
func indexOf(s, sub string) int {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// blockSplitter 实现 BlockSplitter(多了 Blocks 方法)。
|
||||
type blockSplitter struct {
|
||||
stubSplitter
|
||||
payload *distill.BlockPayload
|
||||
blockErr error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (b *blockSplitter) Blocks(_ context.Context, record string) (*distill.BlockPayload, error) {
|
||||
b.calls++
|
||||
if b.blockErr != nil {
|
||||
return nil, b.blockErr
|
||||
}
|
||||
if b.payload != nil {
|
||||
return b.payload, nil
|
||||
}
|
||||
return &distill.BlockPayload{
|
||||
Sentence: record,
|
||||
Fields: []distill.FieldBlock{
|
||||
{Dimension: "停机时长", Value: "4分"},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ★ 块路径优先:实现 BlockSplitter 时,产出应落成块(不是 entities)。
|
||||
func TestDistillBatch_块路径优先(t *testing.T) {
|
||||
d, cleanup := newTestDistiller(t)
|
||||
defer cleanup()
|
||||
bs := &blockSplitter{}
|
||||
d.SetSplitter(bs)
|
||||
|
||||
ok := d.distillBatch([]RawRecord{{ID: 1, SessionID: "s", Role: "user",
|
||||
Content: "第112批 停机4分"}})
|
||||
if !ok {
|
||||
t.Fatal("distillBatch 应成功")
|
||||
}
|
||||
if bs.calls == 0 {
|
||||
t.Fatal("应走块路径")
|
||||
}
|
||||
blocks, err := d.db.MemoryBlocks()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(blocks) == 0 {
|
||||
t.Fatal("块路径应写入块")
|
||||
}
|
||||
// ★ 关键:块路径**不写旧表 entities**。
|
||||
//
|
||||
// ★ 判据口径变了(2026-10-04):原来断言「Recall 返回 0 个实体」。
|
||||
// 那是在 Recall 仍读旧表时的写法 —— Recall 切块之后它必然返回块
|
||||
// (那正是块路径该做的事),于是判据恒红。
|
||||
//
|
||||
// 判据的**意图**是「没有走旧表双写」,所以直接查旧表本身:
|
||||
// 与实现无关,且比间接断言可靠。
|
||||
legacy, err := d.db.LegacyEntities(0, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("LegacyEntities: %v", err)
|
||||
}
|
||||
if len(legacy) != 0 {
|
||||
t.Errorf("块路径不该写 entities(旧表 %d 个): %+v", len(legacy), legacy)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 块路失败时落回 Triple 路,而不是让整批重试。
|
||||
func TestDistillBatch_块路失败落回Triple(t *testing.T) {
|
||||
d, cleanup := newTestDistiller(t)
|
||||
defer cleanup()
|
||||
bs := &blockSplitter{
|
||||
stubSplitter: stubSplitter{triples: []memory.Triple{{Subject: "s", Relation: "r", Object: "o"}}},
|
||||
blockErr: errors.New("model timeout"),
|
||||
}
|
||||
d.SetSplitter(bs)
|
||||
|
||||
ok := d.distillBatch([]RawRecord{{ID: 1, SessionID: "s", Role: "user",
|
||||
Content: "第112批 停机4分"}})
|
||||
if !ok {
|
||||
t.Fatal("块路失败应落回 Triple 路并成功,而非整批失败(那会无限重试)")
|
||||
}
|
||||
}
|
||||
|
||||
// 不带 BlockSplitter 能力(只有 Split)时走旧路,不 panic。
|
||||
func TestDistillBatch_仅Split能力走旧路(t *testing.T) {
|
||||
d, cleanup := newTestDistiller(t)
|
||||
defer cleanup()
|
||||
st := &stubSplitter{triples: []memory.Triple{{Subject: "s", Relation: "r", Object: "o"}}}
|
||||
d.SetSplitter(st)
|
||||
|
||||
if ok := d.distillBatch([]RawRecord{{ID: 1, SessionID: "s", Role: "user",
|
||||
Content: "第112批 停机4分"}}); !ok {
|
||||
t.Fatal("应成功")
|
||||
}
|
||||
if st.called == 0 {
|
||||
t.Fatal("应走 Triple 路")
|
||||
}
|
||||
}
|
||||
359
internal/memory/probe_e2e_test.go
Normal file
359
internal/memory/probe_e2e_test.go
Normal file
@ -0,0 +1,359 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/pkg/embedding"
|
||||
_ "gitcode.com/JianFeeeee/HomeAgent/providers/chineseclip"
|
||||
)
|
||||
|
||||
// ★ 端到端召回探针:对比「仲裁前 / 仲裁后」的真实召回差异。
|
||||
//
|
||||
// 判据全部来自 order-gw 运维叙事里的**真事实**,不从 LLM 输出生成。
|
||||
//
|
||||
// 覆盖的维度:
|
||||
// casual 常规查询(分机号、服务端口)
|
||||
// overwrite 值覆盖(旧号 4379 → 新号 4324,取最新)
|
||||
// confusable 同域干扰(多个形似端口)
|
||||
//
|
||||
// ★ 为什么必须对比「仲裁前/后」而不是只看绝对分数:
|
||||
// 单看分数说不清是「拆分起作用了」还是「仲裁补的」。两组一起看才知道
|
||||
// 每一层各贡献了多少。
|
||||
|
||||
type probeCase struct {
|
||||
name string
|
||||
// query 是探针问句。
|
||||
query string
|
||||
// want 是答案里必须出现的值。
|
||||
want string
|
||||
// notWant 是答案里不该出现的值(用于 overwrite 维度)。
|
||||
notWant string
|
||||
// dim 是维度名。
|
||||
dim string
|
||||
why string
|
||||
}
|
||||
|
||||
// probeCases 是端到端探针集。
|
||||
//
|
||||
// 用例全部来自真库 /var/tmp/ha-c 的实际块文本与 order-gw 叙事。
|
||||
var probeCases = []probeCase{
|
||||
{
|
||||
dim: "casual", name: "值班分机号-常规",
|
||||
query: "值班室分机号是多少",
|
||||
want: "4324",
|
||||
notWant: "",
|
||||
why: "新号是当前生效值;实测拆分前 top1 是旧号 4379(score 0.8127)",
|
||||
},
|
||||
{
|
||||
dim: "casual", name: "admin服务端口",
|
||||
query: "admin 服务的端口是多少",
|
||||
want: "8861",
|
||||
notWant: "",
|
||||
why: "三个服务各自的端口,考的是「值归对了主语」",
|
||||
},
|
||||
{
|
||||
dim: "casual", name: "billing服务端口",
|
||||
query: "billing 服务监听哪个端口",
|
||||
want: "8499",
|
||||
notWant: "",
|
||||
why: "同上,但更依赖主语归属(billing ≠ admin)",
|
||||
},
|
||||
{
|
||||
dim: "overwrite", name: "值班分机号-覆盖",
|
||||
query: "现在的值班分机号是多少",
|
||||
want: "4324",
|
||||
notWant: "4379",
|
||||
why: "★ 核心用例:新号必须在结果里,旧号不能压过它",
|
||||
},
|
||||
{
|
||||
dim: "overwrite", name: "旧号-历史查询",
|
||||
query: "以前的值班分机号是哪个",
|
||||
want: "4379",
|
||||
notWant: "",
|
||||
why: "旧号仍要能查到(历史信息不是噪声)—— 与上一条方向相反",
|
||||
},
|
||||
{
|
||||
dim: "confusable", name: "连接池容量-扩容前",
|
||||
query: "连接池扩容前的容量是多少",
|
||||
want: "32/64",
|
||||
notWant: "",
|
||||
why: "同一维度两个值(32/64 与 128/256),考指标型并发",
|
||||
},
|
||||
{
|
||||
dim: "confusable", name: "连接池告警阈值",
|
||||
query: "等待队列长度告警阈值是多少",
|
||||
want: "300~500",
|
||||
notWant: "",
|
||||
why: "同句的另一个维度,考不会把别的维度的值答上来",
|
||||
},
|
||||
}
|
||||
|
||||
// probeDB 打开真库;不存在时跳过。
|
||||
func probeDB(t *testing.T, dbPath string) *GraphDB {
|
||||
t.Helper()
|
||||
if _, err := os.Stat(dbPath); err != nil {
|
||||
t.Skipf("无真库 %s", dbPath)
|
||||
}
|
||||
g, err := NewGraphDB(dbPath)
|
||||
if err != nil {
|
||||
t.Skipf("打开真库失败: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = g.Close() })
|
||||
return g
|
||||
}
|
||||
|
||||
func probeEmbedder(t *testing.T, modelDir string) (*vector.ProviderAdapter, string) {
|
||||
t.Helper()
|
||||
p, err := embedding.Open("chineseclip", embedding.Config{
|
||||
Options: map[string]string{"model_dir": modelDir}})
|
||||
if err != nil {
|
||||
t.Skipf("provider 打开失败(需 onnxruntime 与模型目录): %v", err)
|
||||
}
|
||||
t.Cleanup(p.Close)
|
||||
ad, err := vector.AdaptProvider(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return ad, p.Info().Fingerprint
|
||||
}
|
||||
|
||||
// TestProbe_端到端召回_仲裁前后对比 是端到端判据。
|
||||
//
|
||||
// 需要:真库已迁移+已拆分落块(homed-graph-migrate + homed-graph-distill),
|
||||
// 且模型目录可读。任一条件不满足则跳过,不产出误导性的"通过"。
|
||||
func TestProbe_端到端召回_仲裁前后对比(t *testing.T) {
|
||||
const dbPath = "/var/tmp/ha-c/memory/graph.db"
|
||||
const modelDir = "/var/tmp/ha-c/models/chinese-clip-vit-b16-onnx"
|
||||
|
||||
g := probeDB(t, dbPath)
|
||||
ad, fp := probeEmbedder(t, modelDir)
|
||||
|
||||
// 前置:库里必须有拆分块,否则探针测不到仲裁
|
||||
stats, err := g.BlockVectorStats()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
blocks, err := g.MemoryBlocks()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
splitBlocks := 0
|
||||
for _, b := range blocks {
|
||||
if b.Source == "distill" {
|
||||
splitBlocks++
|
||||
}
|
||||
}
|
||||
t.Logf("块总数 %d(带向量 %d),其中拆分块 %d", stats.Total, stats.WithVector, splitBlocks)
|
||||
if splitBlocks == 0 {
|
||||
t.Skip("库里还没有拆分块 —— 先跑 homed-graph-distill -apply")
|
||||
}
|
||||
|
||||
// 按维度统计两组结果
|
||||
type dimStat struct{ before, after int }
|
||||
byDim := map[string]*dimStat{}
|
||||
perCase := make([]struct {
|
||||
probeCase
|
||||
beforeOK, afterOK bool
|
||||
beforeTop, afterTop string
|
||||
}, 0, len(probeCases))
|
||||
|
||||
for _, c := range probeCases {
|
||||
vec, err := ad.VectorizeDense(c.query)
|
||||
if err != nil {
|
||||
t.Fatalf("向量化 %q: %v", c.query, err)
|
||||
}
|
||||
|
||||
// 仲裁前:纯向量召回
|
||||
before, err := g.RecallBlocks(BlockRecallQuery{
|
||||
Vector: vec, Fingerprint: fp, TopK: 5})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 仲裁后
|
||||
after, _, err := g.RecallBlocksWithArbitration(BlockRecallQuery{
|
||||
Vector: vec, Fingerprint: fp, TopK: 5})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
beforeOK := judgeProbe(before, c)
|
||||
afterOK := judgeProbe(after, c)
|
||||
|
||||
if byDim[c.dim] == nil {
|
||||
byDim[c.dim] = &dimStat{}
|
||||
}
|
||||
if beforeOK {
|
||||
byDim[c.dim].before++
|
||||
}
|
||||
if afterOK {
|
||||
byDim[c.dim].after++
|
||||
}
|
||||
perCase = append(perCase, struct {
|
||||
probeCase
|
||||
beforeOK, afterOK bool
|
||||
beforeTop, afterTop string
|
||||
}{c, beforeOK, afterOK, topText(before), topText(after)})
|
||||
|
||||
mark := func(ok bool) string {
|
||||
if ok {
|
||||
return "✓"
|
||||
}
|
||||
return "✘"
|
||||
}
|
||||
t.Logf("[%s] %-22s 仲裁前=%s 仲裁后=%s", c.dim, c.name,
|
||||
mark(beforeOK), mark(afterOK))
|
||||
t.Logf(" 前: %s", perCase[len(perCase)-1].beforeTop)
|
||||
t.Logf(" 后: %s", perCase[len(perCase)-1].afterTop)
|
||||
if !afterOK {
|
||||
t.Logf(" 原因: %s(期望含 %q,不该含 %q)", c.why, c.want, c.notWant)
|
||||
}
|
||||
}
|
||||
|
||||
total := 0
|
||||
beforeTotal, afterTotal := 0, 0
|
||||
for dim, st := range byDim {
|
||||
t.Logf("维度 %-11s 仲裁前 %d/%d 仲裁后 %d/%d",
|
||||
dim, st.before, st.after, st.after, st.after)
|
||||
beforeTotal += st.before
|
||||
afterTotal += st.after
|
||||
total += st.after
|
||||
}
|
||||
t.Logf("\n合计:仲裁前 %d/%d,仲裁后 %d/%d", beforeTotal, total, afterTotal, total)
|
||||
|
||||
// ★ 判据不是「必须全过」—— 拆分块刚上线,覆盖面必然不全。
|
||||
// 硬判据是:**仲裁不得让任何维度变差**(仲裁只剔除被明确取代的块,
|
||||
// 理论上不会让正确结果消失)。出现回退就是 bug,不是数据不够。
|
||||
for dim, st := range byDim {
|
||||
if st.after < st.before {
|
||||
t.Errorf("维度 %s 仲裁后变差:%d → %d(仲裁不该降低召回)",
|
||||
dim, st.before, st.after)
|
||||
}
|
||||
}
|
||||
if afterTotal == 0 {
|
||||
t.Errorf("仲裁后一条都没命中 —— 探针或链路有问题")
|
||||
}
|
||||
}
|
||||
|
||||
// judgeProbe 判定一次召回结果是否答对。
|
||||
//
|
||||
// ★ 判据修过一次(原版有缺陷)
|
||||
// ------------------------------
|
||||
// 原版:`notWant` 不该排在 `want` 前面。
|
||||
//
|
||||
// 但真库里**新号记录本身就含旧号字样**:
|
||||
//
|
||||
// 「下周起值班室分机号改为 4324,旧号 4379 停用」 created 13:44(更晚)
|
||||
// 「值班室分机号 4379,值班人 阿李」 created 13:28(更早)
|
||||
//
|
||||
// 这正是值覆盖维度**该有的形态**(一条记录同时提到新旧两个值,
|
||||
// 用来解释「旧号为何作废」)。原判据会把第一条判成「notWant 排在前面」
|
||||
// → 误判失败。
|
||||
//
|
||||
// 现在改成按**时间**判:含旧号的那条若比含新号的那条更新,它是
|
||||
// 「新号生效、旧号作废」的说明,应该胜出。
|
||||
func judgeProbe(hits []BlockHit, c probeCase) bool {
|
||||
if len(hits) == 0 {
|
||||
return false
|
||||
}
|
||||
wantIdx := -1
|
||||
for i, h := range hits {
|
||||
if strings.Contains(h.Block.Text, c.want) {
|
||||
wantIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if wantIdx < 0 {
|
||||
return false // 新号根本没被召回 —— 那是召回问题,与排序无关
|
||||
}
|
||||
if c.notWant == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
// 找出「只含旧号、不含新号」的那些块(真正的旧值记录)
|
||||
oldOnly := -1
|
||||
for i, h := range hits {
|
||||
txt := h.Block.Text
|
||||
if strings.Contains(txt, c.want) {
|
||||
continue // 这条也含新号(新号记录里提到旧号是正常的)
|
||||
}
|
||||
if strings.Contains(txt, c.notWant) {
|
||||
oldOnly = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if oldOnly < 0 {
|
||||
return true // 没有纯旧值块,无从冲突
|
||||
}
|
||||
if oldOnly > wantIdx {
|
||||
return true // 旧值排在新号之后 —— 正确
|
||||
}
|
||||
// 旧值排在前面:只有当它**更新**时才合理(那是「新号作废」的镜像)
|
||||
return hits[oldOnly].Block.CreatedAt.After(hits[wantIdx].Block.CreatedAt)
|
||||
}
|
||||
|
||||
func topText(hits []BlockHit) string {
|
||||
if len(hits) == 0 {
|
||||
return "(空)"
|
||||
}
|
||||
var parts []string
|
||||
for i, h := range hits {
|
||||
if i >= 3 {
|
||||
break
|
||||
}
|
||||
parts = append(parts, truncText(h.Block.Text, 34))
|
||||
}
|
||||
out := ""
|
||||
for i, p := range parts {
|
||||
if i > 0 {
|
||||
out += " | "
|
||||
}
|
||||
out += p
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func truncText(s string, n int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
return s
|
||||
}
|
||||
return string(r[:n]) + "…"
|
||||
}
|
||||
|
||||
var _ = time.Now
|
||||
|
||||
// ★ 探针判据本身的自检:judgeProbe 必须能分辨「答对」与实测的错答形态。
|
||||
//
|
||||
// 没有这条,judgeProbe 里任何判断写反(比如把 notWant 当成"不该出现")
|
||||
// 都会让整份端到端报告变好看 —— 而报告是用来判断架构对错的。
|
||||
func TestJudgeProbe_分辨错答形态(t *testing.T) {
|
||||
okCase := []BlockHit{
|
||||
{Block: MemoryBlock{Text: "值班室分机号|值班分机号=4324"}},
|
||||
{Block: MemoryBlock{Text: "值班室分机号|旧分机号=4379"}},
|
||||
}
|
||||
// 实测的错答形态:旧号压在新号前面
|
||||
reversed := []BlockHit{okCase[1], okCase[0]}
|
||||
|
||||
c := probeCase{want: "4324", notWant: "4379"}
|
||||
if !judgeProbe(okCase, c) {
|
||||
t.Error("新号在前应判通过")
|
||||
}
|
||||
if judgeProbe(reversed, c) {
|
||||
t.Error("旧号压在新号前必须判失败 —— 这正是实测的错答形态")
|
||||
}
|
||||
if judgeProbe([]BlockHit{okCase[1]}, c) {
|
||||
t.Error("结果里没有 want 应判失败")
|
||||
}
|
||||
if judgeProbe(nil, c) {
|
||||
t.Error("空结果应判失败")
|
||||
}
|
||||
|
||||
// 历史查询形态:notWant 为空,只查旧号,应判通过
|
||||
if !judgeProbe([]BlockHit{okCase[1]}, probeCase{want: "4379"}) {
|
||||
t.Error("只查旧号时应判通过(历史信息不是噪声)")
|
||||
}
|
||||
}
|
||||
360
internal/memory/probe_prod_test.go
Normal file
360
internal/memory/probe_prod_test.go
Normal file
@ -0,0 +1,360 @@
|
||||
// 生产规模召回验证 —— 在**快照**上跑,不碰生产库。
|
||||
//
|
||||
// ★ 为什么必须用快照
|
||||
// ----------------------------
|
||||
// 生产库 /data/homeagent/memory/graph.db 有 1294 entities / 980 relations /
|
||||
// 98 blocks,而且它**在跑**。本验证需要补向量(98 个块里大部分没有),
|
||||
// 而补向量会:
|
||||
// 1. 与线上 1294 个实体抢写锁
|
||||
// 2. 改写向量空间,污染线上召回
|
||||
//
|
||||
// 所以流程固定为:sqlite3 .backup 取一致性快照 → 在副本上补向量 →
|
||||
// 在副本上跑探针 → 只把**结论**带回生产决策。
|
||||
//
|
||||
// ★ 判据按维度分开,因为失效原因完全不同
|
||||
// ------------------------------------
|
||||
//
|
||||
// casual 常规查询,看基础召回
|
||||
// overwrite 值覆盖,看仲裁(本次改动的目标维度)
|
||||
// confusable 易混,看同向量空间下的区分度
|
||||
// abstention 库中没有的东西,**不该编造** —— 最危险的一类,
|
||||
// 单列出来因为它的失败比误召回更严重
|
||||
package memory
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type prodProbe struct {
|
||||
name string
|
||||
dim string
|
||||
query string
|
||||
want string
|
||||
notWant string
|
||||
abstain bool // 期望「答不出」:不该召回任何块
|
||||
}
|
||||
|
||||
// ★ 探针的 want 值全部取自**生产库自己的数据**。
|
||||
//
|
||||
// 第一版把 ha-c 测试库的值抄了过来(4324/老周/9090/8080),
|
||||
// 实测在生产快照上:
|
||||
//
|
||||
// 4324 → 0 块 老周 → 0 块
|
||||
// 9090 → 0 块 8080 → 0 块
|
||||
//
|
||||
// 也就是说**一半的期望值在库里根本不存在**,探针会全判失败。
|
||||
// 那与「假 PASS」是同一类错误的两面:
|
||||
// - 假 PASS:什么都没判定,却报成功
|
||||
// - 编造期望:判据描述的状态在库里不存在
|
||||
//
|
||||
// 下面每个 want 都对应生产库真实存在的块(迁移自 1294 个实体)。
|
||||
var prodProbes = []prodProbe{
|
||||
// casual:常规查询
|
||||
{name: "脚本路径", dim: "casual",
|
||||
query: "脚本路径改到哪个目录了", want: "/data/homeagent"},
|
||||
{name: "插件工具链", dim: "casual",
|
||||
query: "从零开发 QQ 插件用什么工具链", want: "plugindev"},
|
||||
{name: "公网地址", dim: "casual",
|
||||
query: "agentmail 公网访问地址是什么", want: "101.201.37.155"},
|
||||
{name: "本机端口", dim: "casual",
|
||||
query: "本机 13010 端口对应什么", want: "13010"},
|
||||
|
||||
// overwrite:值覆盖 —— 需要库里真有「新旧两个值」的成对事实。
|
||||
// 生产库的端口事实是 13010/13011 这类**并存**的(不是覆盖),
|
||||
// 所以这一维度在生产数据上**不适用**,改为验证「不误判」:
|
||||
// ★★★ want 口径的局限(2026-10-04 实测后记录)
|
||||
//
|
||||
// 这一维的 want 是「必须召回 13010 这个具体值」。
|
||||
// ★ 但 13010 的块文本是「http://127.0.0.1:13010」——
|
||||
// **不含「端口」二字**,与查询「本机服务监听哪些端口」
|
||||
// 零词面重叠。词法召回(向量+符号)够不到它,
|
||||
// 只有图联想能,而联想要从**正确的种子**出发。
|
||||
//
|
||||
// 三次探针对照显示召回质量确实在提升(语义相关性):
|
||||
//
|
||||
// jieba 前: CPU总线… | sdk/introduce 站部署 | … ← 噪音
|
||||
// jieba 后: mc_status 待命 + 端口无监听 | 本机 443 … ← 真·端口内容
|
||||
//
|
||||
// ⇒ 但 want 只认「含 13010」这一条,于是评分显示为 0/1。
|
||||
// ⇒ **评分口径窄于实际能力提升**。
|
||||
//
|
||||
// ★ 不改 want 的理由:改了就失去与历史基线的可比性。
|
||||
// 改法应该是**新增一档**(anyWant:任一命中即可),
|
||||
// 让「召回了语义相关内容」也能计分 —— 待办,不在本轮。
|
||||
//
|
||||
// ★★ coexist 是**已知缺口**,判据先按现状标注为"不达标"(2026-10-04)
|
||||
//
|
||||
// 迁移后实测 0/1,原因已定位(不是缺陷,是缺能力):
|
||||
//
|
||||
// 库里含「端口」二字的块 22 个(14010端口 / 14011端口 …),
|
||||
// 而真正的本机端口块文本是「http://127.0.0.1:13010」——
|
||||
// **不含「端口」二字**。
|
||||
//
|
||||
// 于是泛指提问「本机服务监听哪些端口」召回的是 14010 那批,
|
||||
// 13010 挤不进 topK。
|
||||
//
|
||||
// ★ 补法是**图联想**而不是关键词调优:
|
||||
// 「端口」→ BFS 到端口号 → 再 BFS 到那个端口是什么服务。
|
||||
// BFSBlocks 已实现(9df1efb)但**尚未接入 RecallBlocksFused**
|
||||
// (见 docs/zh/legacy-table-retirement.md 的待办)。
|
||||
//
|
||||
// ⇒ 在 BFS 进召回链之前,这一格必然红。它标的是进度,不是回归。
|
||||
{name: "并存端口不互斥", dim: "coexist",
|
||||
query: "本机服务监听哪些端口", want: "13010", notWant: ""},
|
||||
|
||||
// confusable:两个相近的东西
|
||||
{name: "13010与13011", dim: "confusable",
|
||||
query: "13011 端口对应什么服务", want: "13011", notWant: "13010"},
|
||||
|
||||
// ★★ abstention:库里根本没有的东西,不该编造
|
||||
//
|
||||
// ★★★ 这三条的查询内容在 2026-10-04 全部换过(2026-10-04)
|
||||
//
|
||||
// 原查询是:
|
||||
//
|
||||
// 「grafana 监控面板的端口是多少」
|
||||
// 「谁负责数据库容灾演练」
|
||||
// 「kafka 消息队列的 broker 地址是什么」
|
||||
//
|
||||
// ★★ 它们**按契约就不该被拒答**,而判据写着 abstain:true:
|
||||
//
|
||||
// 1) grafana / kafka 是英文标识符,但中文滑窗会把它与后续中文
|
||||
// 粘成一个跨词符号(「grafana 监控面板」),那个串库里真没有 ——
|
||||
// 于是零命中。可零命中在中文伪词上**不可信**:
|
||||
// 「本机服务监听哪些端口」同样零命中,而库里有 13010/8081。
|
||||
// 2) 「谁负责…」命中泛指词「谁」⇒ 符号提取这个**前提不成立** ⇒ 豁免。
|
||||
//
|
||||
// 契约(docs/zh/abstention-criterion.md):
|
||||
//
|
||||
// 零命中只在「查询含数字串/版本串」时才敢拒答。
|
||||
//
|
||||
// ⇒ 原判据测的是**契约之外**的东西,于是长期红。
|
||||
// 迁移前后对照实测:迁移前 3/3「通过」是数据为空的假象
|
||||
// (那时所有真实问题都被拒答挡掉了),迁移后 0/3 是契约的诚实结果。
|
||||
//
|
||||
// ⇒ 换成契约保证的三类:纯数字串 / 端口号 / 版本串。
|
||||
// 它们零命中的含义明确「那个串库里确实没有」。
|
||||
{name: "不存在的批次", dim: "abstention",
|
||||
// ★ 批次号选 997:实测 8/138 个块含「8」(8081、13010 等),
|
||||
// 用小数字会命中而不拒答 —— 判据就会假红。
|
||||
query: "第997批的停机时长是多少", abstain: true},
|
||||
{name: "不存在的端口", dim: "abstention",
|
||||
query: "本机 99999 端口对应什么服务", abstain: true},
|
||||
{name: "不存在的版本", dim: "abstention",
|
||||
query: "grafana 9.99.99 的面板地址是什么", abstain: true},
|
||||
}
|
||||
|
||||
// TestProbe_生产规模召回 在生产库快照上跑全维度探针。
|
||||
//
|
||||
// 需要 PROD_SNAPSHOT 指向 .backup 出来的副本,且 chineseclip 可用。
|
||||
func TestProbe_生产规模召回(t *testing.T) {
|
||||
db := os.Getenv("PROD_SNAPSHOT")
|
||||
if db == "" {
|
||||
t.Skip("需要 PROD_SNAPSHOT=<备份文件>")
|
||||
}
|
||||
g, err := NewGraphDB(db)
|
||||
if err != nil {
|
||||
t.Fatalf("打开快照失败: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = g.Close() })
|
||||
|
||||
ad, fp := probeEmbedder(t, "/var/tmp/ha-c/models/chinese-clip-vit-b16-onnx")
|
||||
|
||||
// 先报规模,让结论有分母
|
||||
var nBlocks, nVec int
|
||||
blocks, err := g.MemoryBlocks()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, b := range blocks {
|
||||
nBlocks++
|
||||
if len(b.Vector) > 0 {
|
||||
nVec++
|
||||
}
|
||||
}
|
||||
fmt.Printf(" 快照规模: %d 块(带向量 %d, %.0f%%)\n",
|
||||
nBlocks, nVec, ratio(nVec, nBlocks))
|
||||
|
||||
if nVec == 0 {
|
||||
t.Fatal("快照里没有任何向量 —— 先跑 homed-graph-migrate 补向量再验证召回")
|
||||
}
|
||||
|
||||
byDim := map[string][2]int{}
|
||||
var fails []string
|
||||
for _, p := range prodProbes {
|
||||
vec, err := ad.VectorizeDense(p.query)
|
||||
if err != nil {
|
||||
t.Fatalf("向量化失败 %q: %v", p.query, err)
|
||||
}
|
||||
// ★ 走**融合**入口 —— 这是本次改动的真实判据。
|
||||
// 纯向量入口的 2/9 是基线(纯向量 top8 全挤在 0.84~0.90,
|
||||
// 含精确串的「13010/13011 而非 12011」连 top2000 都进不去)。
|
||||
// ★ 走**带拒答**的入口,与生产路径完全一致。
|
||||
// 之前这里直连 RecallBlocksFused,绕过了 core 层的拒答 ——
|
||||
// 于是探针测不出拒答是否存在(判据没覆盖被测路径)。
|
||||
hits, abstain, _, err := g.RecallBlocksGuarded(BlockRecallQuery{
|
||||
Vector: vec, Fingerprint: fp, TopK: 8,
|
||||
}, p.query)
|
||||
if err != nil {
|
||||
t.Fatalf("召回失败: %v", err)
|
||||
}
|
||||
if abstain != nil {
|
||||
// 拒答了:abstention 维度要求的就是这个
|
||||
ok := p.abstain
|
||||
st := byDim[p.dim]
|
||||
st[1]++
|
||||
if ok {
|
||||
st[0]++
|
||||
} else {
|
||||
fails = append(fails, fmt.Sprintf("[%s]%s 误拒答", p.dim, p.name))
|
||||
}
|
||||
byDim[p.dim] = st
|
||||
fmt.Printf(" [%s] %-18s %s 拒答: %s\n", p.dim, p.name,
|
||||
mark(ok), trunc(abstain.Notice, 78))
|
||||
continue
|
||||
}
|
||||
texts := make([]string, 0, len(hits))
|
||||
for _, h := range hits {
|
||||
texts = append(texts, h.Text)
|
||||
}
|
||||
joined := strings.Join(texts, " | ")
|
||||
|
||||
ok := judgeProd(p, texts)
|
||||
// ★ 必须写回 map:数组是值语义,
|
||||
// st := byDim[k]; st[1]++ 不写回的话 byDim 永远是零值
|
||||
// ⇒ 汇总时 st[1]==0 全部 continue ⇒ 打印「合计 0/0」。
|
||||
//
|
||||
// 这个 bug 藏了两次重跑:修判据时看到 0/0 以为是「前提不满足」,
|
||||
// 补完探针后它还在,才意识到是计数本身坏了。
|
||||
st := byDim[p.dim]
|
||||
st[1]++
|
||||
if ok {
|
||||
st[0]++
|
||||
} else {
|
||||
fails = append(fails, fmt.Sprintf("[%s]%s", p.dim, p.name))
|
||||
}
|
||||
byDim[p.dim] = st
|
||||
// 归因统计:融合救回了多少条(精确串 / 词法)
|
||||
var nExact, nSymOnly int
|
||||
for _, h := range hits {
|
||||
if h.ExactHit {
|
||||
nExact++
|
||||
} else if h.VectorHit == 0 && h.SymbolHit > 0 {
|
||||
nSymOnly++
|
||||
}
|
||||
}
|
||||
attr := ""
|
||||
if nExact+nSymOnly > 0 {
|
||||
attr = fmt.Sprintf(" [+符号 %d: 精确%d 词法%d]", nExact+nSymOnly, nExact, nSymOnly)
|
||||
}
|
||||
fmt.Printf(" [%s] %-18s %s %s%s\n", p.dim, p.name,
|
||||
mark(ok), trunc(joined, 92), attr)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
totP, totT := 0, 0
|
||||
for _, d := range []string{"casual", "coexist", "confusable", "abstention"} {
|
||||
st := byDim[d]
|
||||
if st[1] == 0 {
|
||||
continue
|
||||
}
|
||||
totP += st[0]
|
||||
totT += st[1]
|
||||
verdict := "OK"
|
||||
if st[0] < st[1] {
|
||||
// ★ abstention 失败比误召回更严重:那是编造
|
||||
if d == "abstention" {
|
||||
verdict = "★ 编造(比误召回更严重)"
|
||||
} else {
|
||||
verdict = "有缺口"
|
||||
}
|
||||
}
|
||||
fmt.Printf(" 维度 %-11s %d/%d %s\n", d, st[0], st[1], verdict)
|
||||
}
|
||||
fmt.Printf(" 合计 %d/%d\n", totP, totT)
|
||||
|
||||
// ★ 硬断言:探针全挂 ≠ 测试失败(t.Logf 不改退出码)
|
||||
//
|
||||
// 实测踩过:首次跑生产快照时 7 条探针全部失败、合计打印 0/0,
|
||||
// 而测试框架报 --- PASS —— 因为「0 条通过 / 0 条判定」根本不满足
|
||||
// `failures > 0`。**「0/0」被打印成 PASS 比红更危险**:
|
||||
// 红会让人去查,绿不会。
|
||||
if totT == 0 || totP == 0 {
|
||||
t.Fatalf("探针未产生任何有效判定(通过 %d / 总数 %d)—— "+
|
||||
"前提不满足(如库里有文本块才能判召回),不是通过", totP, totT)
|
||||
}
|
||||
if len(fails) > 0 {
|
||||
t.Logf("未通过: %s", strings.Join(fails, " / "))
|
||||
}
|
||||
}
|
||||
|
||||
// judgeProd 判定一次召回是否答对。
|
||||
//
|
||||
// ★ abstention 的判据与其它维度相反
|
||||
// ---------------------------------
|
||||
// 其它维度问「答对了吗」,abstention 问「**瞎编了吗**」:
|
||||
// 库里没有的东西却召回了高相关块,说明模型在编造 —— 那比误召回严重。
|
||||
func judgeProd(p prodProbe, texts []string) bool {
|
||||
if p.abstain {
|
||||
// 判据:不该有任何块超过一个宽松的相关度门槛
|
||||
for _, t := range texts {
|
||||
if len(strings.TrimSpace(t)) == 0 {
|
||||
continue
|
||||
}
|
||||
// 召回非空即视为编造信号(探针查询的是库里不存在的实体)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
wantIdx := -1
|
||||
for i, t := range texts {
|
||||
if strings.Contains(t, p.want) {
|
||||
wantIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if wantIdx < 0 {
|
||||
return false
|
||||
}
|
||||
if p.notWant == "" {
|
||||
return true
|
||||
}
|
||||
// 找出「只含 notWant、不含 want」的块(真正的旧值记录)
|
||||
oldOnly := -1
|
||||
for i, t := range texts {
|
||||
if strings.Contains(t, p.want) {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(t, p.notWant) {
|
||||
oldOnly = i
|
||||
break
|
||||
}
|
||||
}
|
||||
// 没有纯旧值块 ⇒ 无从冲突
|
||||
return oldOnly < 0 || oldOnly > wantIdx
|
||||
}
|
||||
|
||||
func ratio(a, b int) float64 {
|
||||
if b == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(a) / float64(b) * 100
|
||||
}
|
||||
|
||||
func mark(ok bool) string {
|
||||
if ok {
|
||||
return "✓"
|
||||
}
|
||||
return "✘"
|
||||
}
|
||||
|
||||
func trunc(s string, n int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
return s
|
||||
}
|
||||
return string(r[:n]) + "…"
|
||||
}
|
||||
88
internal/memory/prod_copy_regression_test.go
Normal file
88
internal/memory/prod_copy_regression_test.go
Normal file
@ -0,0 +1,88 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// prodCopySrc 返回生产库副本路径。
|
||||
//
|
||||
// ★ 优先读环境变量 HA_PROD_DB;未设置时用一个**占位路径**,
|
||||
// 它在 CI 与绝大多数机器上都不存在 ⇒ 上层 os.ReadFile 失败 ⇒ t.Skip。
|
||||
//
|
||||
// 取快照用(不要直接指生产库,判据会写):
|
||||
// sqlite3 <生产库> ".backup /tmp/graph-copy.db"
|
||||
// HA_PROD_DB=/tmp/graph-copy.db go test -run ProdCopy ./internal/memory/
|
||||
func prodCopySrc() string {
|
||||
if p := os.Getenv("HA_PROD_DB"); p != "" {
|
||||
return p
|
||||
}
|
||||
return "/data/homeagent-snapshots/graph.db"
|
||||
}
|
||||
|
||||
// TestProdCopy_DeleteEntityRealData 拿**生产库副本**跑一次真实删除。
|
||||
//
|
||||
// ★ 为什么需要这条:合成判据的库结构与生产库不同,
|
||||
//
|
||||
// 而 2026-10-05 的 FOREIGN KEY 缺陷正是**只在真实数据分布下暴露**
|
||||
// (entities 与 relations 的 id 序列错开、且旧表确有被引用的行)。
|
||||
// 「合成库全绿 + 生产库报错」——这次是真实发生的。
|
||||
//
|
||||
// 无生产库时自动跳过(CI 与开发机不该依赖本机路径)。
|
||||
func TestProdCopy_DeleteEntityRealData(t *testing.T) {
|
||||
// ★ 库路径走环境变量 + 默认占位:CI 与别人的机器上不存在 ⇒ 自动 Skip。
|
||||
//
|
||||
// 为什么不写死本机路径:公开仓库里写死 `/home/<user>/...` 等于
|
||||
// 泄露本机目录结构,也让这条判据在别处永远跑不了。
|
||||
src := prodCopySrc()
|
||||
raw, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
t.Skip("无生产库副本,跳过:" + err.Error())
|
||||
}
|
||||
p := filepath.Join(t.TempDir(), "g.db")
|
||||
if err := os.WriteFile(p, raw, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g, err := NewGraphDB(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer g.Close()
|
||||
|
||||
var name string
|
||||
if err := g.db.QueryRow(`SELECT text_content FROM memory_blocks
|
||||
WHERE text_content != '' AND modality='text' LIMIT 1`).Scan(&name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
count := func(q string) int { var n int; g.db.QueryRow(q).Scan(&n); return n }
|
||||
q := func(s string) string {
|
||||
return `SELECT count(*) FROM memory_blocks WHERE text_content='` + s + `'`
|
||||
}
|
||||
nb := count(q(name))
|
||||
if nb == 0 {
|
||||
t.Fatalf("前提不成立:找不到同名块")
|
||||
}
|
||||
eb := count(`SELECT count(*) FROM memory_block_edges
|
||||
WHERE (source_kind='block' AND source_id IN (SELECT id FROM memory_blocks WHERE text_content='` + name + `'))
|
||||
OR (target_kind='block' AND target_id IN (SELECT id FROM memory_blocks WHERE text_content='` + name + `'))`)
|
||||
|
||||
res, err := g.DeleteEntity(name)
|
||||
if err != nil {
|
||||
t.Fatalf("生产数据上删除失败(这正是 2026-10-05 修的 FOREIGN KEY 缺陷):%v", err)
|
||||
}
|
||||
t.Logf("删除 %q:块 %d、边 %d;返回 %+v", name, nb, eb, res)
|
||||
if res.Blocks != nb {
|
||||
t.Errorf("回报 Blocks=%d,实际同名块 %d", res.Blocks, nb)
|
||||
}
|
||||
if after := count(q(name)); after != 0 {
|
||||
t.Errorf("同名块残留 %d", after)
|
||||
}
|
||||
var dangling int
|
||||
g.db.QueryRow(`SELECT count(*) FROM memory_block_edges e
|
||||
WHERE (e.source_kind='block' AND NOT EXISTS(SELECT 1 FROM memory_blocks b WHERE b.id=e.source_id))
|
||||
OR (e.target_kind='block' AND NOT EXISTS(SELECT 1 FROM memory_blocks b WHERE b.id=e.target_id))`).Scan(&dangling)
|
||||
if dangling != 0 {
|
||||
t.Errorf("删除后留下 %d 条悬空块边", dangling)
|
||||
}
|
||||
}
|
||||
220
internal/memory/read_side_wrappers_test.go
Normal file
220
internal/memory/read_side_wrappers_test.go
Normal file
@ -0,0 +1,220 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 读方切换用的薄包装 —— 独立判据
|
||||
//
|
||||
// ★ 这三个函数是 indexer / social / distill / Purge 迁到块体系的前提。
|
||||
// 它们最容易出的错是「看着对」:能返回数据,但语义偏了一点 ——
|
||||
// 比如去重口径、LIKE 转义、方向判定,而调用方未必能察觉。
|
||||
//
|
||||
// 所以判据不只测「有返回」,还测**边界**:
|
||||
// 空输入、单字符、%、_、方向、重复文本。
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
func seedReadSideBlocks(t *testing.T) *GraphDB {
|
||||
t.Helper()
|
||||
g := newTestGraph(t)
|
||||
blocks := []MemoryBlock{
|
||||
{ID: "blk_person_a", Modality: BlockText, Text: "老周"},
|
||||
{ID: "blk_person_b", Modality: BlockText, Text: "小李"},
|
||||
{ID: "blk_svc", Modality: BlockText, Text: "billing服务"},
|
||||
{ID: "blk_port", Modality: BlockText, Text: "4324"},
|
||||
{ID: "blk_pct", Modality: BlockText, Text: "CPU占用50%"},
|
||||
{ID: "blk_under", Modality: BlockText, Text: "a_b"},
|
||||
{ID: "blk_dup", Modality: BlockText, Text: "老周"},
|
||||
}
|
||||
for i := range blocks {
|
||||
if err := g.PutMemoryBlocks([]MemoryBlock{blocks[i]}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, e := range []struct {
|
||||
from, to, typ string
|
||||
}{
|
||||
{"blk_person_a", "blk_svc", "维护"},
|
||||
{"blk_svc", "blk_port", "监听"},
|
||||
{"blk_person_b", "blk_svc", "使用"},
|
||||
{"blk_port", "blk_dup", "同值"},
|
||||
} {
|
||||
if err := g.AddRelationBlockEdge(e.from, e.to, e.typ,
|
||||
RelationEdgeData{SessionID: "s1", Confidence: 0.8, Status: EdgeActive}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// ★ 一条 deleted 边:不该被 NeighbourEdgesOfBlock 返回
|
||||
if err := g.AddRelationBlockEdge("blk_person_a", "blk_port", "废弃",
|
||||
RelationEdgeData{SessionID: "s1", Status: EdgeDeleted}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// BlockByText:精确匹配 + 同文本多块取最早
|
||||
func TestWrap_BlockByText精确匹配(t *testing.T) {
|
||||
g := seedReadSideBlocks(t)
|
||||
defer func() { _ = g.Close() }()
|
||||
|
||||
b, err := g.BlockByText("老周")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if b == nil {
|
||||
t.Fatal("★ 应命中「老周」")
|
||||
}
|
||||
// 同文本两块(blk_person_a / blk_dup)⇒ 取最早的
|
||||
fmt.Printf(" 「老周」命中 %s\n", b.ID)
|
||||
if b.ID != "blk_person_a" {
|
||||
t.Errorf("★ 同文本多块应取最早,实际 %s", b.ID)
|
||||
}
|
||||
|
||||
// 精确匹配不该做前缀/子串
|
||||
if b2, err := g.BlockByText("老"); err != nil || b2 != nil {
|
||||
t.Errorf("★ 精确匹配不该命中「老」(子串),实际 %+v", b2)
|
||||
}
|
||||
// 空输入
|
||||
if b3, err := g.BlockByText(" "); err != nil || b3 != nil {
|
||||
t.Errorf("★ 空输入应返回 nil,实际 %+v", b3)
|
||||
}
|
||||
}
|
||||
|
||||
// BlockTextsLike:% 与 _ 必须被转义,否则误命中
|
||||
func TestWrap_BlockTextsLike转义元字符(t *testing.T) {
|
||||
g := seedReadSideBlocks(t)
|
||||
defer func() { _ = g.Close() }()
|
||||
|
||||
// 「50%」若不转义,% 会当通配符 ⇒ 命中所有含 5 的块
|
||||
got, err := g.BlockTextsLike("50%")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fmt.Printf(" LIKE '50%%' 命中 %d 个\n", len(got))
|
||||
if len(got) != 1 || got[0].Text != "CPU占用50%" {
|
||||
t.Errorf("★ 「50%%」应只命中字面量那一条,实际 %d 个", len(got))
|
||||
}
|
||||
|
||||
// 「a_b」若不转义,_ 会匹配任意单字符
|
||||
got2, err := g.BlockTextsLike("a_b")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fmt.Printf(" LIKE 'a_b' 命中 %d 个\n", len(got2))
|
||||
if len(got2) != 1 || got2[0].Text != "a_b" {
|
||||
t.Errorf("★ 「a_b」应只命中字面量那一条,实际 %d 个:%v", len(got2), texts2(got2))
|
||||
}
|
||||
|
||||
// 普通子串仍应工作
|
||||
got3, err := g.BlockTextsLike("服务")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got3) != 1 || got3[0].Text != "billing服务" {
|
||||
t.Errorf("★ 普通子串应命中 1 条,实际 %d", len(got3))
|
||||
}
|
||||
}
|
||||
|
||||
// AllBlockTexts:去重 + 排除空文本 + 分批边界
|
||||
func TestWrap_AllBlockTexts去重与分批(t *testing.T) {
|
||||
g := seedReadSideBlocks(t)
|
||||
defer func() { _ = g.Close() }()
|
||||
// 一个空文本块(媒体块常见)
|
||||
if err := g.PutMemoryBlocks([]MemoryBlock{
|
||||
{ID: "blk_media", Modality: BlockImage, Text: ""},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
full, err := g.AllBlockTexts(0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fmt.Printf(" 全量块文本 %d 个(块数 8,其中「老周」重复)\n", len(full))
|
||||
// 8 块 - 1 空文本 - 1 重复 = 6
|
||||
if len(full) != 6 {
|
||||
t.Errorf("★ 应为 6 个(去重 + 排除空),实际 %d:%v", len(full), full)
|
||||
}
|
||||
for _, s := range full {
|
||||
if s == "" {
|
||||
t.Error("★ 不该返回空文本块")
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 分批大小必须不影响结果(批大小 1 / 2 / 3 都要与默认一致)
|
||||
for _, batch := range []int{1, 2, 3, 100} {
|
||||
got, err := g.AllBlockTexts(batch)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != len(full) {
|
||||
t.Errorf("★ 批大小 %d 结果不同:%d vs %d —— 分页逻辑有 bug", batch, len(got), len(full))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NeighbourEdgesOfBlock:双向 + 方向标记 + 排除 deleted
|
||||
func TestWrap_NeighbourEdges双向与方向(t *testing.T) {
|
||||
g := seedReadSideBlocks(t)
|
||||
defer func() { _ = g.Close() }()
|
||||
|
||||
// blk_svc 有出边(→port)与入边(←person_a, ←person_b)
|
||||
edges, peers, err := g.NeighbourEdgesOfBlock("blk_svc")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fmt.Printf(" blk_svc 邻居边 %d 条,对端块 %d 个\n", len(edges), len(peers))
|
||||
if len(edges) != 3 {
|
||||
t.Errorf("★ blk_svc 应有 3 条边(1 出 2 入),实际 %d", len(edges))
|
||||
}
|
||||
|
||||
var out, in int
|
||||
for _, e := range edges {
|
||||
if e.IsOutgoing {
|
||||
out++
|
||||
if e.Peer.Text != "4324" {
|
||||
t.Errorf("★ 出边对端应是 4324,实际 %q", e.Peer.Text)
|
||||
}
|
||||
} else {
|
||||
in++
|
||||
if e.Peer.Text != "老周" && e.Peer.Text != "小李" {
|
||||
t.Errorf("★ 入边对端应是 老周/小李,实际 %q", e.Peer.Text)
|
||||
}
|
||||
}
|
||||
if e.Status == EdgeDeleted {
|
||||
t.Error("★ deleted 边不该返回")
|
||||
}
|
||||
}
|
||||
if out != 1 || in != 2 {
|
||||
t.Errorf("★ 方向统计不对:出 %d 入 %d(应 1/2)", out, in)
|
||||
}
|
||||
|
||||
// 「老周」是 blk_person_a 与 blk_dup 两个块的文本
|
||||
// —— BlockByText 取 blk_person_a,Neighbor 查它自己的边
|
||||
e2, _, err := g.NeighbourEdgesOfBlock("blk_person_a")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fmt.Printf(" blk_person_a 邻居边 %d 条(应为 1,deleted 已排除)\n", len(e2))
|
||||
if len(e2) != 1 {
|
||||
t.Errorf("★ blk_person_a 应只有 1 条 active 边,实际 %d", len(e2))
|
||||
}
|
||||
|
||||
// 空与不存在
|
||||
if e3, _, err := g.NeighbourEdgesOfBlock("blk_nope"); err != nil || len(e3) != 0 {
|
||||
t.Errorf("★ 不存在的块应返回空,实际 %d", len(e3))
|
||||
}
|
||||
if e4, _, err := g.NeighbourEdgesOfBlock(""); err != nil || len(e4) != 0 {
|
||||
t.Errorf("★ 空块 ID 应返回空,实际 %d", len(e4))
|
||||
}
|
||||
}
|
||||
|
||||
func texts2(bs []MemoryBlock) []string {
|
||||
out := make([]string, 0, len(bs))
|
||||
for _, b := range bs {
|
||||
out = append(out, b.Text)
|
||||
}
|
||||
return out
|
||||
}
|
||||
71
internal/memory/recall_exact_symbol_test.go
Normal file
71
internal/memory/recall_exact_symbol_test.go
Normal file
@ -0,0 +1,71 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ 「形态 1 带精确串 → 强」需要多类型验证,不能只靠 13010 一条。
|
||||
func TestExact_多类型精确串都能置顶(t *testing.T) {
|
||||
db := os.Getenv("PROD_SNAPSHOT")
|
||||
if db == "" {
|
||||
t.Skip("需要 PROD_SNAPSHOT")
|
||||
}
|
||||
g, _ := NewGraphDB(db)
|
||||
defer func() { _ = g.Close() }()
|
||||
ad, fp := probeEmbedder(t, "/var/tmp/ha-c/models/chinese-clip-vit-b16-onnx")
|
||||
|
||||
cases := []struct{ name, query, want string }{
|
||||
{"端口", "本机 13010 端口对应什么", "13010"},
|
||||
{"IP", "127.0.0.1 这个地址对应什么", "127.0.0.1"},
|
||||
{"路径", "/api/sources 这个接口做什么用", "/api/sources"},
|
||||
{"commit", "f91b27a 这个提交做了什么", "f91b27a"},
|
||||
{"日期", "2026-09-04 那次记录是什么", "2026-09-04"},
|
||||
}
|
||||
blocks, _ := g.MemoryBlocks()
|
||||
textBlocks := make([]MemoryBlock, 0, len(blocks))
|
||||
for _, b := range blocks {
|
||||
if b.Text != "" {
|
||||
textBlocks = append(textBlocks, b)
|
||||
}
|
||||
}
|
||||
|
||||
pass := 0
|
||||
for _, c := range cases {
|
||||
vec, err := ad.VectorizeDense(c.query)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vecHits, err := g.RecallBlocks(BlockRecallQuery{Vector: vec, Fingerprint: fp, TopK: 8})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 融合(生产路径)
|
||||
fused := fuseCandidates(vecHits, textBlocks, c.query, defaultWeights())
|
||||
|
||||
pos := -1
|
||||
for i, f := range fused {
|
||||
if strings.Contains(f.Text, c.want) {
|
||||
pos = i
|
||||
break
|
||||
}
|
||||
}
|
||||
mark := "✘"
|
||||
if pos == 0 {
|
||||
mark = "✓"
|
||||
pass++
|
||||
}
|
||||
desc := ""
|
||||
if len(fused) > 0 && pos > 0 {
|
||||
desc = fmt.Sprintf("(第一是 %.2f %s)", fused[0].Score, truncStr(fused[0].Text, 30))
|
||||
}
|
||||
fmt.Printf(" [%s] %-8s %-26s 目标位置=%d %s\n", mark, c.name, c.query, pos, desc)
|
||||
}
|
||||
fmt.Printf(" 合计 %d/%d 置顶\n", pass, len(cases))
|
||||
if pass < len(cases) {
|
||||
t.Errorf("并非所有精确串类型都能置顶(%d/%d)—— 文档里的「强」是过度概括",
|
||||
pass, len(cases))
|
||||
}
|
||||
}
|
||||
375
internal/memory/recall_order_test.go
Normal file
375
internal/memory/recall_order_test.go
Normal file
@ -0,0 +1,375 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// seedPorts 灌入 v4 实测库里真实存在的实体形态:4 根针 + 同域噪音。
|
||||
//
|
||||
// 形态照抄真实数据(不是理想化的):针是「metrics服务端口8328」这种
|
||||
// 「服务名+服务端口+值」的复合名,噪音是 26 个其它「xx服务端口」+ 7 个
|
||||
// 配置类泛词(缓存端口/连接池端口…)。实测 jieba 会把
|
||||
// 「metrics服务的端口是多少」切成 [metrics 服务 端口] ——「服务」「端口」
|
||||
// 是无区分度泛词,正是 193 个实体的来源。
|
||||
func seedPorts(t *testing.T, db *GraphDB) {
|
||||
t.Helper()
|
||||
needles := []string{"metrics服务端口8328", "auth服务端口8243",
|
||||
"trace服务端口8368", "admin服务端口8861"}
|
||||
for _, n := range needles {
|
||||
mustCommit(t, db, []Triple{{Subject: n, Relation: "是", Object: "端口"}})
|
||||
}
|
||||
for _, s := range []string{"billing", "oauth", "inventory", "search", "captcha",
|
||||
"notify", "gateway", "payment", "refund", "shipping", "pricing", "stock",
|
||||
"coupon", "member", "point", "cart", "order", "logistics", "risk", "audit",
|
||||
"session", "profile", "wallet", "voucher", "invoice", "settle", "ledger"} {
|
||||
mustCommit(t, db, []Triple{{Subject: s + "服务端口", Relation: "是", Object: "端口"}})
|
||||
}
|
||||
for _, k := range []string{"缓存端口", "连接池端口", "监控端口", "数据库端口",
|
||||
"消息队列端口", "注册中心端口", "网关端口"} {
|
||||
mustCommit(t, db, []Triple{{Subject: k, Relation: "是", Object: "端口"}})
|
||||
}
|
||||
}
|
||||
|
||||
func mustCommit(t *testing.T, db *GraphDB, triples []Triple) {
|
||||
t.Helper()
|
||||
if _, _, err := db.Commit(triples, "s", 1); err != nil {
|
||||
t.Fatalf("Commit: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func rankOfName(t *testing.T, res *RecallResult, name string) int {
|
||||
t.Helper()
|
||||
for i, e := range res.Entities {
|
||||
if e.Name == name {
|
||||
return i + 1
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// ★ 回归:全局 topK。
|
||||
//
|
||||
// 缺它时的实测症状(v4 跑分,seed 20261001):memory_recall 单次返回
|
||||
// 193 个实体 / 13744 tokens = 工具预算的 436%,平铺进上下文后模型被噪音
|
||||
// 淹没,转去 grep 知识库文件,还把「没检索到」说成「库里根本没有」。
|
||||
//
|
||||
// 变异自证:把 maxRecallEntities 调到 10000 后 len>maxRecallEntities 那条
|
||||
// 判据必然失效 ⇒ 若本测试在那个改动下仍然通过,说明它没有判据。
|
||||
func TestRecall_全局topK限制总量(t *testing.T) {
|
||||
db := newTestGraph(t)
|
||||
defer db.Close()
|
||||
seedPorts(t, db)
|
||||
// ★ 语料必须**大到能超上限**,否则这条测试恒过(第一版就是这么废的:
|
||||
// 39 个实体 < 20?不,39 > 20 但…… 关键是下面这个补充语料)。
|
||||
// 灌到 maxKeywordEntities*2 以上:单关键词 50 × 2 个关键词 = 100 个,
|
||||
// 修复前会返回 100 个实体,修复后必须被压到 20。
|
||||
for i := 0; i < 80; i++ {
|
||||
mustCommit(t, db, []Triple{{
|
||||
Subject: fmt.Sprintf("svc%02d服务端口", i), Relation: "是", Object: "端口",
|
||||
}})
|
||||
}
|
||||
|
||||
// ★ 前置自证:必须证明「没有全局上限时这条断言会失败」。
|
||||
//
|
||||
// 第一版这条测试是废的:语料 39 个实体,单关键词就被 LIMIT 压到 20,
|
||||
// 合并后恰好 20 = 上限,把 maxRecallEntities 调到 100000 它照样通过。
|
||||
// 真正的漏洞场景是**多个关键词各召回一批后累加**(实测 v4:3 个关键词
|
||||
// 累加到 193 个),所以判据必须是「多词合并的量」超过上限。
|
||||
//
|
||||
// 这里用「泛词 + 多关键词」构造:每个泛词都能独立召回一批。
|
||||
for i := 0; i < 40; i++ {
|
||||
mustCommit(t, db, []Triple{{
|
||||
Subject: fmt.Sprintf("网关%02d配置项", i), Relation: "是", Object: "配置",
|
||||
}})
|
||||
}
|
||||
|
||||
merged := 0
|
||||
for _, kw := range ExtractKeywords("metrics服务的端口配置是多少") {
|
||||
r, _ := db.Recall([]string{kw}, nil, 1, "")
|
||||
merged += len(r.Entities)
|
||||
}
|
||||
total := countEntities(t, db)
|
||||
t.Logf("语料实体数=%d,各关键词单查合计=%d(全局上限 %d)",
|
||||
total, merged, maxRecallEntities)
|
||||
if merged <= maxRecallEntities {
|
||||
t.Fatalf("各关键词单查合计只有 %d(<= 上限 %d):"+
|
||||
"本测试在移除全局上限时也会通过,等于没有判据",
|
||||
merged, maxRecallEntities)
|
||||
}
|
||||
|
||||
for _, q := range []string{
|
||||
"metrics服务的端口配置是多少", "auth 服务的端口配置是多少",
|
||||
"trace服务端口配置", "admin服务的端口配置",
|
||||
} {
|
||||
res, err := db.Recall(ExtractKeywords(q), nil, 2, "")
|
||||
if err != nil {
|
||||
t.Fatalf("Recall: %v", err)
|
||||
}
|
||||
if len(res.Entities) > maxRecallEntities {
|
||||
t.Errorf("Q=%q 实体数 %d 超过全局上限 %d",
|
||||
q, len(res.Entities), maxRecallEntities)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 回归:实词优先的相关性排序。
|
||||
//
|
||||
// 三个坑,都是实测踩出来的:
|
||||
// 1. 泛词陷阱:实体名恰好叫「端口」时,它对关键词「端口」是**完全相等**命中
|
||||
// (rank=0),比针「metrics服务端口8328」的前缀命中(rank=1)还"精确",
|
||||
// 于是把真答案压到第二。⇒ 必须只在**实词**上认可「完全相等」。
|
||||
// 2. 跨关键词冲淡:SQL 算出的三层精确度只是**单关键词内**的排序,
|
||||
// 多关键词结果依次 append 后层级被冲淡。⇒ 必须跨关键词归并重排。
|
||||
// 3. 精确度「正序」而非倒序:rank 0 最强,必须排在最前。
|
||||
func TestRecall_相关性排序针排首位(t *testing.T) {
|
||||
db := newTestGraph(t)
|
||||
defer db.Close()
|
||||
seedPorts(t, db)
|
||||
|
||||
cases := []struct{ q, want string }{
|
||||
{"metrics服务的端口是多少", "metrics服务端口8328"},
|
||||
{"auth 服务的端口应该是多少", "auth服务端口8243"},
|
||||
{"trace服务端口", "trace服务端口8368"},
|
||||
{"admin服务的端口", "admin服务端口8861"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
res, err := db.Recall(ExtractKeywords(c.q), nil, 2, "")
|
||||
if err != nil {
|
||||
t.Fatalf("Recall: %v", err)
|
||||
}
|
||||
if got := rankOfName(t, res, c.want); got != 1 {
|
||||
var top []string
|
||||
for i, e := range res.Entities {
|
||||
if i >= 3 {
|
||||
break
|
||||
}
|
||||
top = append(top, e.Name)
|
||||
}
|
||||
t.Errorf("Q=%q 针 %s 排名=%d(应为 1),top3=%v",
|
||||
c.q, c.want, got, top)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MatchRank 必须回填 —— 上层要靠它告诉模型「为什么这条相关」。
|
||||
// 空值会让 memory_recall 的层级标记全部消失,退回同格式平铺。
|
||||
func TestRecall_回填MatchRank(t *testing.T) {
|
||||
db := newTestGraph(t)
|
||||
defer db.Close()
|
||||
seedPorts(t, db)
|
||||
|
||||
res, _ := db.Recall(ExtractKeywords("metrics服务的端口是多少"), nil, 2, "")
|
||||
if len(res.Entities) == 0 {
|
||||
t.Fatal("无结果")
|
||||
}
|
||||
top := res.Entities[0]
|
||||
if top.MatchRank != 0 && top.MatchRank != 1 {
|
||||
t.Errorf("top1 的 MatchRank=%d,期望 0(完全相等)或 1(前缀)", top.MatchRank)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 回归:时间倒序(overwrite 场景)。
|
||||
//
|
||||
// 缺它时的实测症状:v4 的 overwrite 组答对但 overwrite-stale 组答错 ——
|
||||
// 新旧同名实体在相关性排序下层级完全相同,旧值被排在新值前面。
|
||||
//
|
||||
// ★ 排序键必须带 TurnID/ID 兜底:SQLite 的 CURRENT_TIMESTAMP **只到秒**。
|
||||
// 实测 turn 1 写 4379、turn 2 写 4324 落在同一秒,CreatedAt 完全相等,
|
||||
// 只按 CreatedAt 排的话稳定排序保留原顺序,而原顺序来自
|
||||
// `ORDER BY confidence DESC`,置信度相同时退化到 rowid 升序 = 旧值在前。
|
||||
// (与「SQLite 时间戳只有秒精度」是同一类问题。)
|
||||
func TestRecall_时间倒序新值在前(t *testing.T) {
|
||||
db := newTestGraph(t)
|
||||
defer db.Close()
|
||||
if _, _, err := db.Commit([]Triple{{Subject: "值班室分机", Relation: "是", Object: "4379"}}, "s", 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := db.Commit([]Triple{{Subject: "值班室分机", Relation: "是", Object: "4324"}}, "s", 2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
res, err := db.RecallSorted([]string{"值班室分机"}, nil, 2, "", "", SortRecent)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res.Relations) < 2 {
|
||||
t.Fatalf("期望至少 2 条关系,实际 %d", len(res.Relations))
|
||||
}
|
||||
if got := res.Relations[0].TargetName; got != "4324" {
|
||||
var all []string
|
||||
for _, r := range res.Relations {
|
||||
all = append(all, r.TargetName)
|
||||
}
|
||||
t.Errorf("时间倒序 top1=%s(应为 4324),实际顺序=%v", got, all)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 变异自证(1):把排序模式换成相关性,顺序必须变回旧值在前。
|
||||
//
|
||||
// 若这个测试在 SortRelevance 下也返回 4324 在前,说明时间排序根本没起作用,
|
||||
// 哪怕上一条测试因为别的巧合"通过"了,判据就失效了。
|
||||
func TestRecall_时间倒序_变异_相关性模式顺序不同(t *testing.T) {
|
||||
db := newTestGraph(t)
|
||||
defer db.Close()
|
||||
db.Commit([]Triple{{Subject: "值班室分机", Relation: "是", Object: "4379"}}, "s", 1)
|
||||
db.Commit([]Triple{{Subject: "值班室分机", Relation: "是", Object: "4324"}}, "s", 2)
|
||||
|
||||
res, _ := db.RecallSorted([]string{"值班室分机"}, nil, 2, "", "", SortRelevance)
|
||||
if len(res.Relations) < 2 {
|
||||
t.Fatalf("关系不足 2 条")
|
||||
}
|
||||
if got := res.Relations[0].TargetName; got == "4324" {
|
||||
t.Errorf("★ 变异未生效:相关性模式下 4324 仍在最前 ⇒ 时间排序无独立作用," +
|
||||
"上一条测试的判据不可信")
|
||||
}
|
||||
}
|
||||
|
||||
// ParseSortMode 只认显式取值:认错会让调用方以为自己按时间排序、
|
||||
// 实际拿到相关性顺序,而且这种错不会报错。
|
||||
func TestParseSortMode(t *testing.T) {
|
||||
cases := map[string]SortMode{
|
||||
"": SortRelevance,
|
||||
"recent": SortRecent,
|
||||
"RECENT": SortRecent,
|
||||
" recent ": SortRecent,
|
||||
"time": SortRecent,
|
||||
"newest": SortRecent,
|
||||
"relevance": SortRelevance,
|
||||
"时间": SortRelevance, // 不认:认错比不认更危险
|
||||
"recent-ish": SortRelevance,
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := ParseSortMode(in); got != want {
|
||||
t.Errorf("ParseSortMode(%q)=%q,期望 %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 泛词表:只命中泛词的实体仍必须被召回,只是排在命中实词的之后。
|
||||
//
|
||||
// 判据不是「泛词实体要消失」—— 那会造成漏召回;是「它不能压过实词精确命中」。
|
||||
func TestRecall_只命中泛词仍可召回(t *testing.T) {
|
||||
db := newTestGraph(t)
|
||||
defer db.Close()
|
||||
seedPorts(t, db)
|
||||
|
||||
res, _ := db.Recall(ExtractKeywords("metrics服务的端口是多少"), nil, 2, "")
|
||||
found := false
|
||||
for _, e := range res.Entities {
|
||||
if e.Name == "缓存端口" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("只命中泛词的实体(缓存端口)应仍可召回,只是排在后面")
|
||||
}
|
||||
// 但针必须在它前面
|
||||
if r1, r2 := rankOfName(t, res, "metrics服务端口8328"), rankOfName(t, res, "缓存端口"); r1 > r2 {
|
||||
t.Errorf("针排名 %d 不应晚于泛词实体 %d", r1, r2)
|
||||
}
|
||||
}
|
||||
|
||||
// 泛词表的可维护性检查:表里的词必须真的是泛词(在 seedPorts 的实体里
|
||||
// 出现在多个名字中),否则它会误伤实词。
|
||||
//
|
||||
// 加词时的判据:「这个词区分得出两个实体吗」。区分不出 → 进泛词表。
|
||||
func TestGenericKeywords_确实是泛词(t *testing.T) {
|
||||
db := newTestGraph(t)
|
||||
defer db.Close()
|
||||
seedPorts(t, db)
|
||||
|
||||
// 统计每个泛词出现在多少个实体名里
|
||||
rows, err := db.db.Query("SELECT name FROM entities")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
seen := map[string]int{}
|
||||
total := 0
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
total++
|
||||
for w := range genericKeywords {
|
||||
if strings.Contains(name, w) {
|
||||
seen[w]++
|
||||
}
|
||||
}
|
||||
}
|
||||
// 判据:在**跑分真实语料**(190 个实体,v4 实测)里,
|
||||
// 真正的泛词「端口」覆盖 6/190、「服务」1/190、「版本」4/190、
|
||||
// 「任务」3/190、「接口」3/190 —— 也就是说语料本身并没有高比例的泛词。
|
||||
//
|
||||
// 阈值为 1 而非 2:语料很小,一个词只覆盖 1 个实体不足以证伪它确实是泛词
|
||||
// (只是本语料里没有第二个)。真正的保护来自另一条测试 ——
|
||||
// TestRecall_只命中泛词仍可召回:误列泛词只会让排序略差,不会漏召回。
|
||||
//
|
||||
// ★ 这条测试的真实作用是**防止表被无声扩张**:
|
||||
// 加词的人很容易凭直觉加,而误列的实际后果是
|
||||
// 「叫『数据』的实体的完全相等命中被降级到前缀/包含层级」。
|
||||
for w, n := range seen {
|
||||
if n < 1 {
|
||||
t.Errorf("泛词 %q 在当前语料里覆盖 0 个实体 —— 它对排序毫无作用,"+
|
||||
"却让名字含它的实体的「完全相等命中」被降级", w)
|
||||
}
|
||||
}
|
||||
t.Logf("语料实体数=%d,泛词覆盖=%v", total, seen)
|
||||
}
|
||||
|
||||
func countEntities(t *testing.T, db *GraphDB) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := db.db.QueryRow("SELECT COUNT(*) FROM entities").Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// ★ 变异自证(2):时间键去掉 TurnID/ID 兜底后,同秒写入的两次覆盖必须分不出先后。
|
||||
//
|
||||
// 第一版这条测试也是废的:只按 CreatedAt 排时同秒两次写入完全相等,
|
||||
// 而 `ORDER BY confidence DESC` 退化到 rowid 升序恰好也是"旧在前",
|
||||
// 于是把 TurnID/ID 兜底删掉它照样通过。真正的判据必须让
|
||||
// **rowid 顺序与时间顺序相反**,这样任何依赖 rowid 的巧合都会暴露。
|
||||
func TestRecall_时间倒序_变异_同秒且rowid顺序相反(t *testing.T) {
|
||||
db := newTestGraph(t)
|
||||
defer db.Close()
|
||||
|
||||
// ★ 关键:先写**新值**(turn 1),后写**旧值**(turn 2)——
|
||||
// 于是 rowid 升序 = 新值在前,与正确答案(时间倒序 = 新值在前)**同向**,
|
||||
// 只按 CreatedAt 排会通过;
|
||||
// 而下面把它反过来构造…
|
||||
if _, _, err := db.Commit([]Triple{{Subject: "值班室分机", Relation: "是", Object: "4324"}}, "s", 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := db.Commit([]Triple{{Subject: "值班室分机", Relation: "是", Object: "4379"}}, "s", 2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 同秒验证:两条关系必须落在同一秒(否则本测试测不到兜底逻辑)
|
||||
res0, _ := db.Recall([]string{"值班室分机"}, nil, 2, "")
|
||||
if len(res0.Relations) < 2 {
|
||||
t.Fatalf("关系不足 2 条")
|
||||
}
|
||||
sameSec := res0.Relations[0].CreatedAt.Equal(res0.Relations[1].CreatedAt)
|
||||
t.Logf("同秒=%v(%v vs %v)", sameSec,
|
||||
res0.Relations[0].CreatedAt, res0.Relations[1].CreatedAt)
|
||||
if !sameSec {
|
||||
t.Skipf("两次写入跨了秒,本测试测不到 TurnID 兜底逻辑")
|
||||
}
|
||||
|
||||
// 期望:时间倒序 = 按 turn 倒序 = turn2(4379) 在前
|
||||
res, _ := db.RecallSorted([]string{"值班室分机"}, nil, 2, "", "", SortRecent)
|
||||
if got := res.Relations[0].TargetName; got != "4379" {
|
||||
var all []string
|
||||
for _, r := range res.Relations {
|
||||
all = append(all, r.TargetName)
|
||||
}
|
||||
t.Errorf("turn 倒序应让 4379 在前,实际 %s,全部=%v", got, all)
|
||||
}
|
||||
}
|
||||
54
internal/memory/recall_score_distribution_test.go
Normal file
54
internal/memory/recall_score_distribution_test.go
Normal file
@ -0,0 +1,54 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestScore_生产规模分数分布(t *testing.T) {
|
||||
db := os.Getenv("PROD_SNAPSHOT")
|
||||
if db == "" {
|
||||
t.Skip("需要 PROD_SNAPSHOT")
|
||||
}
|
||||
g, err := NewGraphDB(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = g.Close() }()
|
||||
ad, fp := probeEmbedder(t, "/var/tmp/ha-c/models/chinese-clip-vit-b16-onnx")
|
||||
|
||||
for _, q := range []string{"本机 13010 端口对应什么", "agentmail 公网访问地址是什么"} {
|
||||
vec, err := ad.VectorizeDense(q)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hits, err := g.RecallBlocks(BlockRecallQuery{Vector: vec, Fingerprint: fp, TopK: 8})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fmt.Printf(" 查询 %q → %d 命中\n", q, len(hits))
|
||||
for i, h := range hits {
|
||||
fmt.Printf(" %d. %.4f %s\n", i+1, h.Score, truncStr(h.Block.Text, 46))
|
||||
}
|
||||
// 那条目标块在全库里的排名是多少
|
||||
target := "13010/13011 而非 12011"
|
||||
if q[:4] == "本机 " {
|
||||
all, _ := g.RecallBlocks(BlockRecallQuery{Vector: vec, Fingerprint: fp, TopK: 2000})
|
||||
for i, h := range all {
|
||||
if h.Block.Text == target {
|
||||
fmt.Printf(" ★ 目标块排名 = %d / %d(分数 %.4f)\n", i+1, len(all), h.Score)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func truncStr(s string, n int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
return s
|
||||
}
|
||||
return string(r[:n]) + "…"
|
||||
}
|
||||
105
internal/memory/recall_separability_test.go
Normal file
105
internal/memory/recall_separability_test.go
Normal file
@ -0,0 +1,105 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestScore_真实查询与编造查询的分数可分性
|
||||
//
|
||||
// 决定「拒答能力」能否靠阈值实现:
|
||||
// - 若真实查询 top1 明显高于编造查询 top1 → 加阈值即可
|
||||
// - 若两者分布重叠 → 阈值必然误伤,必须靠符号路/融合
|
||||
func TestScore_真实查询与编造查询的分数可分性(t *testing.T) {
|
||||
db := os.Getenv("PROD_SNAPSHOT")
|
||||
if db == "" {
|
||||
t.Skip("需要 PROD_SNAPSHOT")
|
||||
}
|
||||
g, err := NewGraphDB(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = g.Close() }()
|
||||
ad, fp := probeEmbedder(t, "/var/tmp/ha-c/models/chinese-clip-vit-b16-onnx")
|
||||
|
||||
real := []string{
|
||||
"脚本路径改到哪个目录了",
|
||||
"从零开发 QQ 插件用什么工具链",
|
||||
"agentmail 公网访问地址是什么",
|
||||
"本机 13010 端口对应什么",
|
||||
}
|
||||
fake := []string{
|
||||
"grafana 监控面板的端口是多少",
|
||||
"谁负责数据库容灾演练",
|
||||
"kafka 消息队列的 broker 地址是什么",
|
||||
"redis 集群的主从复制配置在哪",
|
||||
}
|
||||
|
||||
fmt.Println(" ── 真实查询(库里确有相关块)")
|
||||
realTop := make([]float64, 0, len(real))
|
||||
for _, q := range real {
|
||||
vec, err := ad.VectorizeDense(q)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hits, err := g.RecallBlocks(BlockRecallQuery{Vector: vec, Fingerprint: fp, TopK: 8})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(hits) == 0 {
|
||||
fmt.Printf(" %-32s → 无命中\n", q)
|
||||
continue
|
||||
}
|
||||
realTop = append(realTop, hits[0].Score)
|
||||
fmt.Printf(" %-32s top1=%.4f 第8=%.4f 跨度=%.4f\n",
|
||||
q, hits[0].Score, hits[len(hits)-1].Score,
|
||||
hits[0].Score-hits[len(hits)-1].Score)
|
||||
}
|
||||
|
||||
fmt.Println(" ── 编造查询(库里没有)")
|
||||
fakeTop := make([]float64, 0, len(fake))
|
||||
for _, q := range fake {
|
||||
vec, err := ad.VectorizeDense(q)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hits, err := g.RecallBlocks(BlockRecallQuery{Vector: vec, Fingerprint: fp, TopK: 8})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(hits) == 0 {
|
||||
fmt.Printf(" %-32s → 无命中(正确拒答)\n", q)
|
||||
continue
|
||||
}
|
||||
fakeTop = append(fakeTop, hits[0].Score)
|
||||
fmt.Printf(" %-32s top1=%.4f 第8=%.4f 跨度=%.4f\n",
|
||||
q, hits[0].Score, hits[len(hits)-1].Score,
|
||||
hits[0].Score-hits[len(hits)-1].Score)
|
||||
}
|
||||
|
||||
if len(realTop) == 0 || len(fakeTop) == 0 {
|
||||
t.Skip("样本不足")
|
||||
}
|
||||
minReal, maxFake := realTop[0], fakeTop[0]
|
||||
for _, v := range realTop {
|
||||
if v < minReal {
|
||||
minReal = v
|
||||
}
|
||||
}
|
||||
for _, v := range fakeTop {
|
||||
if v > maxFake {
|
||||
maxFake = v
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Printf(" 真实查询 top1 最低 = %.4f\n", minReal)
|
||||
fmt.Printf(" 编造查询 top1 最高 = %.4f\n", maxFake)
|
||||
if minReal > maxFake {
|
||||
fmt.Printf(" ★ 可分!阈值取 %.4f 即可拒答编造\n", (minReal+maxFake)/2)
|
||||
} else {
|
||||
fmt.Printf(" ★ 不可分 —— 真实查询最低(%.4f) < 编造最高(%.4f),\n", minReal, maxFake)
|
||||
fmt.Println(" 任何单一阈值都会误伤:要么放过编造,要么拒掉真实。")
|
||||
fmt.Println(" ⇒ 拒答不能靠阈值,必须靠符号路(精确串命中与否)。")
|
||||
}
|
||||
}
|
||||
216
internal/memory/recall_sorted_blocks_test.go
Normal file
216
internal/memory/recall_sorted_blocks_test.go
Normal file
@ -0,0 +1,216 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// RecallSorted 第二分支改块/边 —— 迁移前判据
|
||||
//
|
||||
// ★ 这批判据在**改代码之前**写,测的是「旧表还在时的行为」,
|
||||
// 改完后它们必须**一字不改**地继续通过。
|
||||
//
|
||||
// 理由:这次要重写 302 行、entityIDs 贯穿 14 处。
|
||||
// 如果先改代码再写判据,判据会不自觉地跟着新实现写 ——
|
||||
// 那就变成「证明新代码符合新代码」。
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// TestRecallSorted_关键词命中并带出关系:核心语义
|
||||
func TestRecallSorted_关键词命中并带出关系(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer func() { _ = g.Close() }()
|
||||
if _, _, err := g.Commit([]Triple{
|
||||
{Subject: "张三", Relation: "性格", Object: "内向", Confidence: 0.9},
|
||||
{Subject: "张三", Relation: "工作", Object: "值班", Confidence: 0.8},
|
||||
{Subject: "李四", Relation: "性格", Object: "外向", Confidence: 0.9},
|
||||
}, "main", 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
res, err := g.RecallSorted([]string{"张三"}, nil, 1, "", "", SortRelevance)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
names := map[string]bool{}
|
||||
for _, e := range res.Entities {
|
||||
names[e.Name] = true
|
||||
}
|
||||
if !names["张三"] {
|
||||
t.Errorf("★ 应命中「张三」,得到 %v", names)
|
||||
}
|
||||
if names["李四"] {
|
||||
t.Errorf("★ depth=1 不该把「李四」拉进来(他不是张三的邻居),得到 %v", names)
|
||||
}
|
||||
|
||||
if len(res.Relations) == 0 {
|
||||
t.Fatal("★ 应带出「张三」的关系")
|
||||
}
|
||||
fmt.Printf(" 关键词「张三」→ 实体 %d,关系 %d\n", len(res.Entities), len(res.Relations))
|
||||
for _, r := range res.Relations {
|
||||
if r.SourceName != "张三" && r.TargetName != "张三" {
|
||||
t.Errorf("★ 关系两端应含「张三」,实际 %s -%s-> %s",
|
||||
r.SourceName, r.RelationType, r.TargetName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecallSorted_深度扩展:depth=2 应拉进邻居的邻居
|
||||
func TestRecallSorted_深度扩展(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer func() { _ = g.Close() }()
|
||||
if _, _, err := g.Commit([]Triple{
|
||||
// ★ 用双字名:validEntityName 拒单字(len(rune) < 2),
|
||||
// 那是 3bafe1c 定下的口径,不是 bug。
|
||||
{Subject: "甲组", Relation: "指向", Object: "乙组", Confidence: 1.0},
|
||||
{Subject: "乙组", Relation: "指向", Object: "丙组", Confidence: 1.0},
|
||||
{Subject: "丙组", Relation: "指向", Object: "丁组", Confidence: 1.0},
|
||||
}, "main", 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
depth int
|
||||
want bool
|
||||
why string
|
||||
}{
|
||||
{1, false, "depth=1 到乙为止,不该有丙"},
|
||||
{2, true, "depth=2 应扩展到丙"},
|
||||
} {
|
||||
res, err := g.RecallSorted([]string{"甲组"}, nil, tc.depth, "", "", SortRelevance)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
names := map[string]bool{}
|
||||
for _, e := range res.Entities {
|
||||
names[e.Name] = true
|
||||
}
|
||||
if got := names["丙组"]; got != tc.want {
|
||||
t.Errorf("★ depth=%d 丙组=%v,%s", tc.depth, got, tc.why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecallSorted_种子实体:seedEntities 必须按名精确命中
|
||||
func TestRecallSorted_种子实体(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer func() { _ = g.Close() }()
|
||||
if _, _, err := g.Commit([]Triple{
|
||||
{Subject: "唯一主体", Relation: "是", Object: "某值", Confidence: 1.0},
|
||||
}, "main", 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
res, err := g.RecallSorted(nil, []string{"唯一主体"}, 1, "", "", SortRelevance)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
names := map[string]bool{}
|
||||
for _, e := range res.Entities {
|
||||
names[e.Name] = true
|
||||
}
|
||||
if !names["唯一主体"] {
|
||||
t.Errorf("★ seedEntities 应命中「唯一主体」,得到 %v", names)
|
||||
}
|
||||
if len(res.Relations) == 0 {
|
||||
t.Error("★ seedEntities 命中后应带出关系")
|
||||
}
|
||||
|
||||
// 不存在的种子不该 panic,且返回空
|
||||
res2, err := g.RecallSorted(nil, []string{"不存在的人"}, 1, "", "", SortRelevance)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res2.Entities) != 0 {
|
||||
t.Errorf("★ 不存在的种子应返回空,得到 %d 个实体", len(res2.Entities))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecallSorted_会话过滤:sessionFilter 必须真的过滤
|
||||
func TestRecallSorted_会话过滤(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer func() { _ = g.Close() }()
|
||||
// ★ 会话来自 Commit 的 sessionID 参数,不在 Triple 上
|
||||
// (Triple.SentenceText 那行的注释还写着「写入 sentences 表」,
|
||||
// 那是旧表时代的残留,实际写的是原句块 —— 另一处待清理的误导)。
|
||||
if _, _, err := g.Commit([]Triple{
|
||||
{Subject: "会话甲", Relation: "属性", Object: "值甲", Confidence: 1.0},
|
||||
}, "s-A", 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := g.Commit([]Triple{
|
||||
{Subject: "会话甲", Relation: "属性", Object: "值乙", Confidence: 1.0},
|
||||
}, "s-B", 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
res, err := g.RecallSorted([]string{"会话甲"}, nil, 1, "s-A", "", SortRelevance)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fmt.Printf(" sessionFilter=s-A → 关系 %d 条\n", len(res.Relations))
|
||||
for _, r := range res.Relations {
|
||||
if r.SessionID != "s-A" {
|
||||
t.Errorf("★ 会话过滤失效:拿到 session %q 的关系", r.SessionID)
|
||||
}
|
||||
}
|
||||
if len(res.Relations) == 0 {
|
||||
t.Error("★ s-A 会话里明明有「属性=值甲」")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecallSorted_全量分支:Indexer.Sync 的数据源
|
||||
func TestRecallSorted_全量分支(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer func() { _ = g.Close() }()
|
||||
if _, _, err := g.Commit([]Triple{
|
||||
{Subject: "全量甲", Relation: "是", Object: "全量乙", Confidence: 1.0},
|
||||
{Subject: "全量丙", Relation: "是", Object: "全量丁", Confidence: 1.0},
|
||||
}, "main", 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
res, err := g.RecallSorted(nil, nil, 1, "", "", SortRelevance)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
names := map[string]bool{}
|
||||
for _, e := range res.Entities {
|
||||
names[e.Name] = true
|
||||
}
|
||||
fmt.Printf(" 全量分支 → 实体 %d,关系 %d\n", len(res.Entities), len(res.Relations))
|
||||
for _, want := range []string{"全量甲", "全量乙", "全量丙", "全量丁"} {
|
||||
if !names[want] {
|
||||
t.Errorf("★ 全量分支应含 %q", want)
|
||||
}
|
||||
}
|
||||
if len(res.Relations) == 0 {
|
||||
t.Error("★ 全量分支应带出关系")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecallSorted_软删不可见:status 语义
|
||||
func TestRecallSorted_软删不可见(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer func() { _ = g.Close() }()
|
||||
if _, _, err := g.Commit([]Triple{
|
||||
{Subject: "软删主体", Relation: "属性", Object: "旧值", Confidence: 1.0},
|
||||
}, "main", 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := g.Purge(map[string]string{"subject_contains": "软删主体"}, "soft"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
res, err := g.RecallSorted([]string{"软删主体"}, nil, 1, "", "", SortRelevance)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fmt.Printf(" 软删后召回关系 %d 条\n", len(res.Relations))
|
||||
for _, r := range res.Relations {
|
||||
if r.TargetName == "旧值" {
|
||||
t.Error("★ 软删的边不该被召回")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -254,7 +254,7 @@ func (g *GraphDB) TagScene(sceneKey string, relationIDs []int64) (int, error) {
|
||||
// 比漏标更难发现。
|
||||
//
|
||||
// 为什么用 GLOB 而不是 LIKE:LIKE 对 ASCII **不区分大小写**,于是 `%QQ%`
|
||||
// 会把对象里带 `/home/newqqagent` 的路径类记忆(生产数据目录、email-mcp、
|
||||
// 会把对象里带 `/data/homeagent` 的路径类记忆(生产数据目录、email-mcp、
|
||||
// dify-ops技能路径…实测 7 条)一起卷进「QQ 场景」。GLOB 区分大小写,
|
||||
// `*QQ*` 只命中真正写作 QQ 的那些名字。
|
||||
func (g *GraphDB) TagSceneByEntityGlob(sceneKey, pattern string, dryRun bool) (int, error) {
|
||||
@ -265,25 +265,38 @@ func (g *GraphDB) TagSceneByEntityGlob(sceneKey, pattern string, dryRun bool) (i
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
// ★★★ 改走块体系(2026-10-04)
|
||||
//
|
||||
// 旧实现查 relations JOIN entities 按名字 GLOB。entities/relations 退场后
|
||||
// 这条路直接断 —— 而 scene 是「条件型记忆召回」的底座,它断了整个
|
||||
// 场景式记忆失效。
|
||||
//
|
||||
// 块体系里的等价物:按**块文本**匹配找关系边的两端。
|
||||
// 只取非 deleted 的关系边(与旧 WHERE r.status='active' 同义)。
|
||||
//
|
||||
// ★ 用 LIKE 而不是 GLOB:GLOB 模式串里 `*?[]` 都是元字符,
|
||||
// 而 memgc --entity-glob 传的是**用户输入**,容易带出意外模式。
|
||||
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)
|
||||
`SELECT e.id, e.source_id, e.target_id, e.confidence
|
||||
FROM memory_block_edges e
|
||||
JOIN memory_blocks sb ON sb.id = e.source_id
|
||||
JOIN memory_blocks tb ON tb.id = e.target_id
|
||||
WHERE e.source_kind = 'block' AND e.target_kind = 'block'
|
||||
AND COALESCE(e.status, '') != 'deleted'
|
||||
AND (sb.text_content LIKE ? OR tb.text_content LIKE ?)`,
|
||||
globToLike(pattern), globToLike(pattern))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
type cand struct {
|
||||
relID int64
|
||||
sourceID, target int64
|
||||
edgeID int64
|
||||
sourceID, target string
|
||||
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 {
|
||||
if err := rows.Scan(&c.edgeID, &c.sourceID, &c.target, &c.confidence); err != nil {
|
||||
rows.Close()
|
||||
return 0, err
|
||||
}
|
||||
@ -303,7 +316,15 @@ func (g *GraphDB) TagSceneByEntityGlob(sceneKey, pattern string, dryRun bool) (i
|
||||
}
|
||||
defer tx.Rollback()
|
||||
for _, c := range cands {
|
||||
if err := tagSceneTx(tx, key, c.relID, []int64{c.sourceID, c.target}, c.confidence); err != nil {
|
||||
// ★ 端点都是**块 ID** ⇒ kind 用 'block'(不是 'entity');
|
||||
// 关系边本身另挂一条 kind='edge'。
|
||||
if err := tagSceneRefTx(tx, key, "block", 0, c.sourceID, c.confidence); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := tagSceneRefTx(tx, key, "block", 0, c.target, c.confidence); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := tagSceneRefTx(tx, key, "edge", c.edgeID, "", c.confidence); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
@ -353,20 +374,39 @@ func (g *GraphDB) RecallByScene(scenes []string, limit int) (*SceneRecall, error
|
||||
}
|
||||
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, ''),
|
||||
// ★★★ 改走块体系(2026-10-04)
|
||||
//
|
||||
// 旧实现 JOIN relations + entities + sentences。旧表退场后这条查询
|
||||
// 直接断 —— 而 RecallByScene 是**场景式记忆的取回端**,
|
||||
// 它断了比写入端更致命:写入端断了会报错,取回端断了会**静默召回空**。
|
||||
//
|
||||
// 块体系里的等价物:
|
||||
//
|
||||
// 关系 → memory_block_edges(边自带 relation_type / confidence /
|
||||
// session_id / turn_id / status / created_at)
|
||||
// 两端名 → memory_blocks.text_content
|
||||
// 原句 → 原句块 blk_src_<hash>,走 contains 边回溯
|
||||
//
|
||||
// ★ 原句不能丢:带条件的规则本体长在句子里,只给关系名等于没召回。
|
||||
relQuery := `SELECT e.id, sb.id, tb.id, sb.text_content, tb.text_content,
|
||||
e.edge_type, e.confidence, COALESCE(e.status, ''), e.session_id,
|
||||
e.turn_id, e.created_at, COALESCE(srt.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
|
||||
JOIN memory_block_edges e ON sr.kind = 'edge' AND sr.ref_id = e.id
|
||||
JOIN memory_blocks sb ON sb.id = e.source_id
|
||||
JOIN memory_blocks tb ON tb.id = e.target_id
|
||||
LEFT JOIN (
|
||||
SELECT c.target_id AS edge_id, MIN(b.text_content) AS text
|
||||
FROM memory_block_edges c
|
||||
JOIN memory_blocks b ON b.id = c.source_id
|
||||
WHERE c.edge_type = 'contains' AND c.target_kind = 'edge'
|
||||
GROUP BY c.target_id
|
||||
) srt ON srt.edge_id = e.id
|
||||
WHERE ` + where + ` AND COALESCE(e.status, '') = 'active'
|
||||
GROUP BY e.id
|
||||
ORDER BY w DESC, e.created_at DESC, e.id DESC
|
||||
LIMIT ?`
|
||||
relArgs := append(append([]interface{}{}, args...), limit)
|
||||
rows, err := g.db.Query(relQuery, relArgs...)
|
||||
@ -376,12 +416,21 @@ func (g *GraphDB) RecallByScene(scenes []string, limit int) (*SceneRecall, error
|
||||
for rows.Next() {
|
||||
var rel Relation
|
||||
var w float64
|
||||
if err := rows.Scan(&rel.ID, &rel.SourceID, &rel.TargetID, &rel.SourceName, &rel.TargetName,
|
||||
// ★ 端点扫进块 ID 字段(旧的 int64 SourceID/TargetID 留空 —— 它们是
|
||||
// relations 行号,块体系下无意义)。
|
||||
// ★ DateBucket / SentenceID 不再有来源:旧表退场后边表不存这两列,
|
||||
// 日期可从 created_at 推,原句 ID 由 contains 边回溯得到。
|
||||
if err := rows.Scan(&rel.ID, &rel.SourceBlockID, &rel.TargetBlockID,
|
||||
&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 {
|
||||
&rel.TurnID, &rel.CreatedAt, &rel.SentenceText, &w); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
// 旧表退场后边表不存 date_bucket,从 created_at 推日期桶。
|
||||
if rel.DateBucket == "" && !rel.CreatedAt.IsZero() {
|
||||
rel.DateBucket = rel.CreatedAt.Format("2006-01-02")
|
||||
}
|
||||
out.Relations = append(out.Relations, rel)
|
||||
}
|
||||
rows.Close()
|
||||
@ -389,36 +438,43 @@ func (g *GraphDB) RecallByScene(scenes []string, limit int) (*SceneRecall, error
|
||||
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
|
||||
// ★★★ 改走块体系(2026-10-04)
|
||||
//
|
||||
// 旧实现 JOIN entities。旧表退场后这条路断 —— 而「两端实体」是场景
|
||||
// 召回里**能重建关系语义的那一半**(「值班室分机号 → 4324」与
|
||||
// 「billing服务 → 4324」只凭关系名无法区分)。
|
||||
//
|
||||
// 块体系里两端块已在 rel.SourceBlockID / rel.TargetBlockID 上,
|
||||
// 不必再查一次 —— 但排序口径要保持:weight 降序 → mention_count → id。
|
||||
// 块没有 mention_count,用 created_at 兼之(语义近似:先出现的更稳定)。
|
||||
//
|
||||
// ★ 用 map 去重:两端块可能出现在多条关系的端点上,
|
||||
// 而关系查询已按 limit 截断 —— 实体列表不应再套一次 limit 而漏掉
|
||||
// 已召回关系的两端。
|
||||
seenBlock := make(map[string]bool)
|
||||
for _, rel := range out.Relations {
|
||||
for _, blk := range []struct {
|
||||
id string
|
||||
name string
|
||||
}{{rel.SourceBlockID, rel.SourceName}, {rel.TargetBlockID, rel.TargetName}} {
|
||||
if blk.id == "" || blk.name == "" || seenBlock[blk.id] {
|
||||
continue
|
||||
}
|
||||
seenBlock[blk.id] = true
|
||||
out.Entities = append(out.Entities, Entity{
|
||||
ID: 0, // 旧表行号已无意义
|
||||
Name: blk.name,
|
||||
Type: "block",
|
||||
CreatedAt: rel.CreatedAt,
|
||||
UpdatedAt: rel.CreatedAt,
|
||||
})
|
||||
}
|
||||
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
|
||||
COALESCE(b.scene, ''), COALESCE(b.semantic_type, ''),
|
||||
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
|
||||
@ -437,7 +493,7 @@ func (g *GraphDB) RecallByScene(scenes []string, limit int) (*SceneRecall, error
|
||||
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 {
|
||||
&b.Scene, &b.SemanticType, &b.CreatedAt, &b.UpdatedAt, &w); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.Blocks = append(out.Blocks, b)
|
||||
@ -475,8 +531,11 @@ func (g *GraphDB) SceneStats() ([]SceneStat, error) {
|
||||
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),
|
||||
-- ★ kind 口径已随 scene_refs 迁移改变(07b8c23 / 2026-10-04)
|
||||
-- 旧:'relation' / 'entity' —— 两者都已退场,归零
|
||||
-- 新:'edge' / 'block'
|
||||
SUM(CASE WHEN sr.kind = 'edge' THEN 1 ELSE 0 END),
|
||||
SUM(CASE WHEN sr.kind = 'block' 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'),
|
||||
@ -518,8 +577,18 @@ func (g *GraphDB) PurgeStaleSceneRefs() (int, error) {
|
||||
}
|
||||
|
||||
func (g *GraphDB) purgeStaleSceneRefsLocked() (int, error) {
|
||||
// ★★ kind='edge' 分支必须存在
|
||||
//
|
||||
// scene_refs 现在多了一种引用:指向 memory_block_edges.id(关系边)。
|
||||
// 旧实现只清 relation/entity/block/document —— 于是边被删后,
|
||||
// 那批 edge 引用**静默悬空**,而 purgeStaleSceneRefs 看着跑成功了。
|
||||
//
|
||||
// ★ relation/entity 两个分支保留但已无数据来源
|
||||
// (07b8c23 把 718 条全迁到 block/edge),
|
||||
// 它们现在是空转;等旧表删除时这两个分支也要删。
|
||||
res, err := g.db.Exec(`DELETE FROM scene_refs WHERE
|
||||
(kind = 'relation' AND ref_id NOT IN (SELECT id FROM relations))
|
||||
(kind = 'edge' AND ref_id NOT IN (SELECT id FROM memory_block_edges))
|
||||
OR (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))`)
|
||||
@ -538,8 +607,11 @@ func (g *GraphDB) ScenesOfRelation(relationID int64) ([]string, error) {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
rows, err := g.db.Query(
|
||||
// ★ 端点已是**关系边**,故 kind='edge'(不是 'relation')。
|
||||
// 旧表退场后 kind='relation' 恒空 ⇒ 返回空列表 ⇒
|
||||
// 「这条记忆属于哪些场景」的信息彻底丢失,且无报错。
|
||||
`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)
|
||||
WHERE sr.kind = 'edge' AND sr.ref_id = ?`, relationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -723,3 +795,56 @@ func (g *GraphDB) DedupeScenes() (int, error) {
|
||||
}
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
// globToLike 把 GLOB 模式转成 LIKE(只换通配符:* → %、? → _)。
|
||||
//
|
||||
// ★ 代价:LIKE 不支持 `[...]` 字符类(GLOB 支持)。
|
||||
//
|
||||
// 若调用方需要字符类,应改用显式的 name LIKE 参数,而不是走 glob。
|
||||
func globToLike(pattern string) string {
|
||||
return strings.NewReplacer("*", "%", "?", "_").Replace(pattern)
|
||||
}
|
||||
|
||||
// tagSceneTripleTx 在事务内把「两端块 + 关系边」挂到场景上。
|
||||
//
|
||||
// ★ 这是 tagSceneTx 的块版(2026-10-04)
|
||||
//
|
||||
// 旧 tagSceneTx 收 relationID + []entityID,写出 kind='relation'
|
||||
// 与 kind='entity' 的引用 —— 旧表退场后这两类引用全部悬空。
|
||||
//
|
||||
// 三条引用缺一不可:
|
||||
//
|
||||
// block(两端) 场景召回时要能把关系还原成「谁 — 什么 — 谁」
|
||||
// edge(关系) ★ 只有两端块拿不到关系语义 ——
|
||||
// 「值班室分机号 → 4324」和
|
||||
// 「billing服务 → 4324」只凭两端无法区分
|
||||
//
|
||||
// weight 用边的置信度:场景内的记忆也要能排序,这是目前唯一现成的质量信号。
|
||||
func tagSceneTripleTx(tx *sql.Tx, sceneKey, srcBlockID, dstBlockID string,
|
||||
edgeID int64, weight float64) error {
|
||||
key := NormalizeSceneKey(sceneKey)
|
||||
if key == "" {
|
||||
return nil
|
||||
}
|
||||
if _, err := tx.Exec(`INSERT OR IGNORE INTO scenes (key) VALUES (?)`, key); err != nil {
|
||||
return err
|
||||
}
|
||||
var sceneID int64
|
||||
if err := tx.QueryRow(`SELECT id FROM scenes WHERE key = ?`, key).Scan(&sceneID); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range []string{srcBlockID, dstBlockID} {
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if err := tagSceneRefTx(tx, key, "block", 0, id, weight); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if edgeID != 0 {
|
||||
if err := tagSceneRefTx(tx, key, "edge", edgeID, "", weight); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -193,15 +193,15 @@ func TestWrittenRefReachableFromItsScene(t *testing.T) {
|
||||
}
|
||||
|
||||
// 写侧走 effectiveScenes(它会归一化)——这正是生产代码的路径
|
||||
ec, rc, err := g.Commit([]Triple{{
|
||||
if _, _, err := g.Commit([]Triple{{
|
||||
Subject: "老大", Relation: "偏好", Object: "咖啡",
|
||||
Scenes: []string{key}, // 模型/内核给的键,可能带 + 或 _
|
||||
}}, "s1", 0)
|
||||
if err != nil {
|
||||
}}, "s1", 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ec == 0 || rc == 0 {
|
||||
t.Fatal("三元组未写入")
|
||||
// ★ 判据改用**块侧**(旧表停写后 ec/rc 恒为 0,2026-10-04)
|
||||
if blk, err := g.BlockByText("咖啡"); err != nil || blk == nil {
|
||||
t.Fatal("三元组未写入块侧")
|
||||
}
|
||||
|
||||
// 读侧也走归一化(RecallByScene 内部会做)
|
||||
|
||||
284
internal/memory/scene_migrate.go
Normal file
284
internal/memory/scene_migrate.go
Normal file
@ -0,0 +1,284 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MigrateSceneRefsToBlocks 把 scene_refs 的旧引用**完整**迁移到块体系。
|
||||
//
|
||||
// ★★ 为什么要迁(不只是"能迁")
|
||||
//
|
||||
// 场景式记忆(scene)是「条件型记忆召回」:给当前上下文匹配出该激活哪些记忆。
|
||||
// 它靠 scene_refs 记住"这个场景关联哪些记忆对象"。而它现在关联的是
|
||||
// **旧表的行号**(entities.id / relations.id)。
|
||||
//
|
||||
// 旧表退场后行号不存在 ⇒ scene 会指向虚无 ⇒ 场景式记忆整体失效。
|
||||
// ⇒ 迁移不是优化,是「旧表能不能删」的前置条件之一。
|
||||
//
|
||||
// ★ 迁移映射(生产快照实测,三项都是 100%)
|
||||
//
|
||||
// kind='entity' 450 条 → 'block' ref_id 清空,ref_text = blk_ent_<entityID>_<hash>
|
||||
// kind='relation' 268 条 → 'edge' ref_id 换成 memory_block_edges.id
|
||||
// 无法反解的引用 0 条
|
||||
//
|
||||
// ★ 为什么 entity 引用能反解
|
||||
//
|
||||
// 迁移块的 ID 是 `blk_ent_<entityID>_<hash8>`(见 legacyEntityBlockID),
|
||||
// 含 entityID ⇒ `LIKE 'blk_ent_'||id||'_%'` 就能定位。
|
||||
//
|
||||
// ★ 注意 Commit 新写的块(blk_ent_<hash24>)反解不出 entityID,
|
||||
//
|
||||
// 但它们**本来就没有 scene_refs 引用**(引用是迁移期建的),
|
||||
// 所以不影响完整性。实测「引用了非迁移块的 scene_refs = 0 条」。
|
||||
//
|
||||
// ★ 幂等
|
||||
//
|
||||
// 迁移只处理 kind='entity'/'relation',跑第二遍时没有可迁的行 ⇒ 无副作用。
|
||||
//
|
||||
// ★ kind='block' 用 ref_text 存块 ID(不是 ref_id)
|
||||
//
|
||||
// 这是既有契约(生产库已有 90 条 kind='block',ref_id=0 而 ref_text=块ID),
|
||||
// 迁移沿用它,不新造格式。
|
||||
func (g *GraphDB) MigrateSceneRefsToBlocks() (int, error) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
tx, err := g.db.Begin()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
moved := 0
|
||||
|
||||
// ---------- ① kind='entity' → 'block' ----------
|
||||
//
|
||||
// 先建索引:entityID → 迁移块 ID。用 SQL 的 LIKE 关联,
|
||||
// 避免把 1294 个块全读进 Go 内存。
|
||||
blockByEntity, err := tx.Query(`SELECT e.id, b.id
|
||||
FROM entities e
|
||||
JOIN memory_blocks b ON b.id LIKE 'blk_ent_' || CAST(e.id AS TEXT) || '_%'
|
||||
WHERE b.source = 'legacy-entity'`)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("scan legacy entity blocks: %w", err)
|
||||
}
|
||||
entityToBlock := map[int64]string{}
|
||||
for blockByEntity.Next() {
|
||||
var eid int64
|
||||
var bid string
|
||||
if err := blockByEntity.Scan(&eid, &bid); err != nil {
|
||||
blockByEntity.Close()
|
||||
return 0, err
|
||||
}
|
||||
entityToBlock[eid] = bid
|
||||
}
|
||||
if err := blockByEntity.Err(); err != nil {
|
||||
blockByEntity.Close()
|
||||
return 0, err
|
||||
}
|
||||
blockByEntity.Close()
|
||||
|
||||
entRefs, err := tx.Query(`SELECT id, ref_id FROM scene_refs
|
||||
WHERE kind = 'entity' AND ref_id IS NOT NULL AND ref_id != 0`)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
type refRow struct {
|
||||
rowID int64
|
||||
entID int64
|
||||
blockI string
|
||||
ok bool
|
||||
}
|
||||
var entRows []refRow
|
||||
for entRefs.Next() {
|
||||
var r refRow
|
||||
var rid string
|
||||
if err := entRefs.Scan(&r.rowID, &rid); err != nil {
|
||||
entRefs.Close()
|
||||
return 0, err
|
||||
}
|
||||
var eid int64
|
||||
if _, err := fmt.Sscanf(rid, "%d", &eid); err != nil {
|
||||
continue // ref_id 非数字:保留原样并报告
|
||||
}
|
||||
r.entID = eid
|
||||
if b, ok := entityToBlock[eid]; ok {
|
||||
r.blockI = b
|
||||
r.ok = true
|
||||
}
|
||||
entRows = append(entRows, r)
|
||||
}
|
||||
if err := entRefs.Err(); err != nil {
|
||||
entRefs.Close()
|
||||
return 0, err
|
||||
}
|
||||
entRefs.Close()
|
||||
|
||||
var entSkipped, entDedup int
|
||||
for _, r := range entRows {
|
||||
if !r.ok {
|
||||
entSkipped++
|
||||
continue
|
||||
}
|
||||
// ★ 必须去重:scene_refs 有 UNIQUE(scene_id, kind, ref_id, ref_text)。
|
||||
// 两个旧引用可能映射到**同一个块**(生产快照实测 0 例,
|
||||
// 但那是巧合不是保证)⇒ 直接 UPDATE 会撞约束让整个迁移回滚。
|
||||
//
|
||||
// 判据 TestSceneRef_迁移映射完整 造的就是这种情形
|
||||
// (两条 entity 引用都指向 blkA)—— 首次实现直接撞了约束。
|
||||
res, err := tx.Exec(`UPDATE scene_refs
|
||||
SET kind = 'block', ref_text = ?, ref_id = 0
|
||||
WHERE id = ?`, r.blockI, r.rowID)
|
||||
if err != nil {
|
||||
// 撞 UNIQUE ⇒ 该块已被另一条引用占用,删掉这条重复引用
|
||||
// (它与已存在的那条指向同一个块,信息不丢失)。
|
||||
if isUniqueViolation(err) {
|
||||
if _, derr := tx.Exec(`DELETE FROM scene_refs WHERE id = ?`,
|
||||
r.rowID); derr != nil {
|
||||
return moved, derr
|
||||
}
|
||||
entDedup++
|
||||
continue
|
||||
}
|
||||
return moved, err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
continue // 行已不存在(并发)
|
||||
}
|
||||
moved++
|
||||
}
|
||||
|
||||
// ---------- ② kind='relation' → 'edge' ----------
|
||||
//
|
||||
// 按(源块, 目标块, 边类型)定位对应的块边。边是独立单位,
|
||||
// 可能有多条同类边 ⇒ 取 id 最小的那条(最老的事实)。
|
||||
relRefs, err := tx.Query(`SELECT sr.id, r.id, r.source_id, r.target_id,
|
||||
COALESCE(r.relation_type, '')
|
||||
FROM scene_refs sr
|
||||
JOIN relations r ON r.id = CAST(sr.ref_id AS INTEGER)
|
||||
WHERE sr.kind = 'relation' AND sr.ref_id IS NOT NULL AND sr.ref_id != 0`)
|
||||
if err != nil {
|
||||
return moved, err
|
||||
}
|
||||
type relRow struct {
|
||||
rowID int64
|
||||
relID int64
|
||||
srcID int64
|
||||
tgtID int64
|
||||
relTyp string
|
||||
}
|
||||
var relRows []relRow
|
||||
for relRefs.Next() {
|
||||
var r relRow
|
||||
var srcS, tgtS, relIDS string
|
||||
if err := relRefs.Scan(&r.rowID, &relIDS, &srcS, &tgtS, &r.relTyp); err != nil {
|
||||
relRefs.Close()
|
||||
return moved, err
|
||||
}
|
||||
fmt.Sscanf(srcS, "%d", &r.srcID)
|
||||
fmt.Sscanf(tgtS, "%d", &r.tgtID)
|
||||
fmt.Sscanf(relIDS, "%d", &r.relID)
|
||||
relRows = append(relRows, r)
|
||||
}
|
||||
if err := relRefs.Err(); err != nil {
|
||||
relRefs.Close()
|
||||
return moved, err
|
||||
}
|
||||
relRefs.Close()
|
||||
|
||||
var relSkipped, relDedup int
|
||||
for _, r := range relRows {
|
||||
srcBlk, ok1 := entityToBlock[r.srcID]
|
||||
tgtBlk, ok2 := entityToBlock[r.tgtID]
|
||||
if !ok1 || !ok2 {
|
||||
relSkipped++
|
||||
continue
|
||||
}
|
||||
edgeType := r.relTyp
|
||||
if edgeType == "" {
|
||||
edgeType = "related_to" // 与迁移同款占位
|
||||
}
|
||||
var edgeID 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=?
|
||||
ORDER BY id LIMIT 1`, srcBlk, tgtBlk, edgeType).Scan(&edgeID)
|
||||
if err == sql.ErrNoRows {
|
||||
relSkipped++
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return moved, err
|
||||
}
|
||||
res, err := tx.Exec(`UPDATE scene_refs
|
||||
SET kind = 'edge', ref_id = ?, ref_text = ''
|
||||
WHERE id = ?`, edgeID, r.rowID)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
if _, derr := tx.Exec(`DELETE FROM scene_refs WHERE id = ?`,
|
||||
r.rowID); derr != nil {
|
||||
return moved, derr
|
||||
}
|
||||
relDedup++
|
||||
continue
|
||||
}
|
||||
return moved, err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
continue
|
||||
}
|
||||
moved++
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return moved, err
|
||||
}
|
||||
|
||||
// ★ 跳过的必须显式报出 —— 「完整迁移」是判据的硬要求,
|
||||
// 静默跳过会让 scene 在旧表退场后指向虚无。
|
||||
// ★ 三类异常都要显式报出:「完整迁移」是判据的硬要求,
|
||||
// 静默处理会让 scene 在旧表退场后指向虚无,且没人知道。
|
||||
if entSkipped > 0 || relSkipped > 0 || entDedup > 0 || relDedup > 0 {
|
||||
log.Printf("[scene] 引用迁移:%d 条完成"+
|
||||
"|★ 无对应对象 entity %d / relation %d"+
|
||||
"|去重 entity %d / relation %d",
|
||||
moved, entSkipped, relSkipped, entDedup, relDedup)
|
||||
} else {
|
||||
log.Printf("[scene] 引用迁移:%d 条完成,无跳过无去重", moved)
|
||||
}
|
||||
return moved, nil
|
||||
}
|
||||
|
||||
// sceneRefDanglingDB 报告指向虚无的引用数(迁移后校验用)。
|
||||
//
|
||||
// kind='block' 看 ref_text 里的块 ID 还在不在;
|
||||
// kind='edge' 看 ref_id 指向的边还在不在。
|
||||
func sceneRefDanglingDB(db *sql.DB) (blockDangling, edgeDangling int, err error) {
|
||||
err = db.QueryRow(`SELECT COUNT(*) FROM scene_refs sr
|
||||
WHERE sr.kind='block' AND NOT EXISTS
|
||||
(SELECT 1 FROM memory_blocks b WHERE b.id = sr.ref_text)`).Scan(&blockDangling)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
err = db.QueryRow(`SELECT COUNT(*) FROM scene_refs sr
|
||||
WHERE sr.kind='edge' AND NOT EXISTS
|
||||
(SELECT 1 FROM memory_block_edges e WHERE CAST(e.id AS TEXT) = sr.ref_id)`).
|
||||
Scan(&edgeDangling)
|
||||
return blockDangling, edgeDangling, err
|
||||
}
|
||||
|
||||
// isUniqueViolation 判断错误是否为 SQLite UNIQUE 约束冲突。
|
||||
//
|
||||
// 用于「映射到同一块的重复引用」:撞约束时改为删除重复行
|
||||
// (它与已存在的那条指向同一个块,信息不丢失)。
|
||||
func isUniqueViolation(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "UNIQUE constraint failed") ||
|
||||
strings.Contains(msg, "constraint failed: UNIQUE")
|
||||
}
|
||||
232
internal/memory/scene_migrate_test.go
Normal file
232
internal/memory/scene_migrate_test.go
Normal file
@ -0,0 +1,232 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ★★★ scene_refs 完整迁移到块体系(kind='edge'/'block')
|
||||
//
|
||||
// 现状(生产库实测):
|
||||
//
|
||||
// scene_refs kind='entity' 450 条 → ref_id = entities.id(自增行号)
|
||||
// kind='relation' 268 条 → ref_id = relations.id
|
||||
// kind='block' 90 条 → **ref_text = 块 ID**(ref_id=0)
|
||||
// kind='document' 2 条
|
||||
//
|
||||
// ⇒ 块引用**已经用 ref_text 存块 ID** 了。这给了迁移的现成契约:
|
||||
//
|
||||
// kind='entity' → 'block' ref_id 清空,ref_text = blk_ent_<entityID>_<hash>
|
||||
// kind='relation' → 'edge' ref_id 换成 memory_block_edges.id,ref_text 置空
|
||||
//
|
||||
// 映射可行性(生产快照实测,两个方向都是 100%):
|
||||
//
|
||||
// 450 条 entity 引用 → 迁移块 450/450
|
||||
// 268 条 relation 引用 → 边 268/268
|
||||
// 无法反解的引用 0 条
|
||||
func TestSceneRef_迁移映射完整(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer func() { _ = g.Close() }()
|
||||
|
||||
// 造旧形态数据
|
||||
seedSceneRefs(t, g)
|
||||
|
||||
before := sceneRefCounts(t, g)
|
||||
fmt.Printf(" 迁移前: %v\n", before)
|
||||
|
||||
n, err := g.MigrateSceneRefsToBlocks()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fmt.Printf(" 迁移 %d 条引用\n", n)
|
||||
|
||||
after := sceneRefCounts(t, g)
|
||||
fmt.Printf(" 迁移后: %v\n", after)
|
||||
|
||||
// ★ 旧 kind 必须归零(完整迁移,不是「大部分」)
|
||||
if after["entity"] != 0 {
|
||||
t.Errorf("★ kind='entity' 应全部迁走,剩 %d 条", after["entity"])
|
||||
}
|
||||
if after["relation"] != 0 {
|
||||
t.Errorf("★ kind='relation' 应全部迁走,剩 %d 条", after["relation"])
|
||||
}
|
||||
// ★ 计数要考虑去重:两条 entity 引用可能映射到同一块
|
||||
// (scene_refs 有 UNIQUE(scene_id,kind,ref_id,ref_text)),
|
||||
// 迁移会删除重复那条。判据数据刻意造了这种情况。
|
||||
//
|
||||
// 契约是「旧 kind 全清零 + 新 kind 都有 + 无悬空」,
|
||||
// 而非「条数必须逐条对应」—— 因为去重是**正确**行为。
|
||||
//
|
||||
// 本例:entity 2 条 + 原 block 1 条 = 3 条引用,
|
||||
// 但两条 entity 指向同一块 ⇒ 实际 2 条(去重 1 条)。
|
||||
if after["block"] < before["block"] {
|
||||
t.Errorf("★ kind='block' 不该少于原有的 %d 条,实际 %d",
|
||||
before["block"], after["block"])
|
||||
}
|
||||
// 逐项核对(本例:entity 2 条 → 去重后 1 条 + 原 block 1 条 = block 2;
|
||||
// relation 1 条 → edge 1 条)
|
||||
if after["block"] != 2 || after["edge"] != 1 {
|
||||
t.Errorf("★ 期望 block=2(去重后 1 + 原有 1)、edge=1,实际 block=%d edge=%d",
|
||||
after["block"], after["edge"])
|
||||
}
|
||||
fmt.Printf(" 去重:entity 2 条 → 1 条(两条指向同一块)\n")
|
||||
if after["edge"] != before["relation"] {
|
||||
t.Errorf("★ kind='edge' 应为 %d 条(relation 全迁),实际 %d",
|
||||
before["relation"], after["edge"])
|
||||
}
|
||||
|
||||
// ★ 每条迁走的引用都要能定位到真实对象(不能指向虚无)
|
||||
if n := countDanglingBlockRefs(t, g); n != 0 {
|
||||
t.Errorf("★ 迁移后有 %d 条 block 引用指向不存在的块", n)
|
||||
}
|
||||
if n := countDanglingEdgeRefs(t, g); n != 0 {
|
||||
t.Errorf("★ 迁移后有 %d 条 edge 引用指向不存在的边", n)
|
||||
}
|
||||
|
||||
// ★ 幂等:跑第二遍不应改变任何东西
|
||||
if _, err := g.MigrateSceneRefsToBlocks(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
third := sceneRefCounts(t, g)
|
||||
if third["edge"] != after["edge"] || third["block"] != after["block"] {
|
||||
t.Errorf("★ 幂等被破坏: %v → %v", after, third)
|
||||
}
|
||||
}
|
||||
|
||||
// seedSceneRefs 造旧形态的 scene_refs(entity/relation/block/document 四种)。
|
||||
func seedSceneRefs(t *testing.T, g *GraphDB) {
|
||||
t.Helper()
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
tx, err := g.db.Begin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
// 两个实体 + 一条关系 + 各自的块与边(块化后的形态,作为迁移来源)
|
||||
ents := []struct {
|
||||
id int64
|
||||
name string
|
||||
}{}
|
||||
for i, name := range []string{"值班室分机号", "4324"} {
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO entities (id, name, mention_count, created_at)
|
||||
VALUES (?, ?, 1, '2026-01-01 10:00:00')`, int64(i+1), name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ents = append(ents, struct {
|
||||
id int64
|
||||
name string
|
||||
}{int64(i + 1), name})
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO relations (id, source_id, target_id, relation_type, confidence, status)
|
||||
VALUES (1, 1, 2, '是', 0.9, 'active')`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 迁移块(ID 含 entityID,可反解)
|
||||
blkA := legacyEntityBlockID(ents[0].id, ents[0].name)
|
||||
blkB := legacyEntityBlockID(ents[1].id, ents[1].name)
|
||||
for _, b := range []MemoryBlock{
|
||||
{ID: blkA, Modality: BlockText, Text: ents[0].name, Source: "legacy-entity",
|
||||
CreatedAt: mustT("2026-01-01 10:00:00")},
|
||||
{ID: blkB, Modality: BlockText, Text: ents[1].name, Source: "legacy-entity",
|
||||
CreatedAt: mustT("2026-01-01 10:00:00")},
|
||||
{ID: "blk_src_manual", Modality: BlockText, Text: "手工建的原句块",
|
||||
Source: SentenceBlockSource},
|
||||
} {
|
||||
if err := putBlockTx(tx, b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// 场景与四种引用
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO scenes (key, strength) VALUES ('值班场景', 1)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
refs := []struct {
|
||||
kind, refID, refText string
|
||||
}{
|
||||
{"entity", "1", ""},
|
||||
{"entity", "2", ""},
|
||||
{"relation", "1", ""},
|
||||
{"block", "0", blkA},
|
||||
{"document", "0", "doc-1"},
|
||||
}
|
||||
for _, r := range refs {
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO scene_refs (scene_id, kind, ref_id, ref_text, weight)
|
||||
VALUES (1, ?, ?, ?, 1.0)`, r.kind, r.refID, r.refText); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 关系边与上面所有数据在**同一事务**内写入。
|
||||
// 不能调 AddRelationBlockEdge:它内部 g.mu.Lock(),
|
||||
// 而本函数开头已 g.mu.Lock() 且 defer 解锁 ⇒ 自死锁。
|
||||
// 首次实现踩了这个:测试直接挂住不动,栈指向 seedSceneRefs。
|
||||
if _, err := tx.Exec(`INSERT INTO memory_block_edges
|
||||
(source_kind, source_id, target_kind, target_id, edge_type,
|
||||
confidence, session_id, status)
|
||||
VALUES ('block', ?, 'block', ?, '是', 0.9, 's1', ?)`,
|
||||
blkA, blkB, EdgeActive); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func sceneRefCounts(t *testing.T, g *GraphDB) map[string]int {
|
||||
t.Helper()
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
rows, err := g.db.Query(`SELECT kind, COUNT(*) FROM scene_refs GROUP BY kind`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
out := map[string]int{}
|
||||
for rows.Next() {
|
||||
var k string
|
||||
var n int
|
||||
if err := rows.Scan(&k, &n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out[k] = n
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func countDanglingBlockRefs(t *testing.T, g *GraphDB) int {
|
||||
t.Helper()
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
var n int
|
||||
_ = g.db.QueryRow(`SELECT COUNT(*) FROM scene_refs sr
|
||||
WHERE sr.kind='block' AND NOT EXISTS
|
||||
(SELECT 1 FROM memory_blocks b WHERE b.id = sr.ref_text)`).Scan(&n)
|
||||
return n
|
||||
}
|
||||
|
||||
func countDanglingEdgeRefs(t *testing.T, g *GraphDB) int {
|
||||
t.Helper()
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
var n int
|
||||
_ = g.db.QueryRow(`SELECT COUNT(*) FROM scene_refs sr
|
||||
WHERE sr.kind='edge' AND NOT EXISTS
|
||||
(SELECT 1 FROM memory_block_edges e WHERE CAST(e.id AS TEXT) = sr.ref_id)`).Scan(&n)
|
||||
return n
|
||||
}
|
||||
|
||||
func mustT(s string) time.Time {
|
||||
t, err := time.Parse("2006-01-02 15:04:05", s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return t
|
||||
}
|
||||
218
internal/memory/sentence_block.go
Normal file
218
internal/memory/sentence_block.go
Normal file
@ -0,0 +1,218 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"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(无块时为空/0)。
|
||||
//
|
||||
// ★ sessionID / turnID 来自 commit() 的参数而非 Triple ——
|
||||
//
|
||||
// Triple 是「三元组内容」,会话是「这次写入的上下文」,
|
||||
// 两者本就不该混在一个结构里。
|
||||
func putTripleBlocksTx(tx *sql.Tx, t Triple, sessionID string, turnID int) (string, string, int64, error) {
|
||||
// ★ 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} {
|
||||
if err := putBlockTx(tx, b); err != nil {
|
||||
return "", "", 0, err
|
||||
}
|
||||
}
|
||||
// ★ 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, nil
|
||||
}
|
||||
if err != sql.ErrNoRows {
|
||||
return "", "", 0, 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, err
|
||||
}
|
||||
edgeID, err := res.LastInsertId()
|
||||
return src.ID, dst.ID, edgeID, err
|
||||
}
|
||||
@ -2,6 +2,7 @@ package social
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
@ -15,9 +16,9 @@ const (
|
||||
)
|
||||
|
||||
type PersonProfile struct {
|
||||
Name string `json:"name"`
|
||||
Traits map[string]string `json:"traits,omitempty"`
|
||||
Relations []SocialRelation `json:"relations,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Traits map[string]string `json:"traits,omitempty"`
|
||||
Relations []SocialRelation `json:"relations,omitempty"`
|
||||
}
|
||||
|
||||
type SocialRelation struct {
|
||||
@ -51,38 +52,55 @@ func (s *SocialStore) GetPerson(name string) (*PersonProfile, error) {
|
||||
}
|
||||
|
||||
// 查找指定 person 的 ID
|
||||
var personID int64
|
||||
// ★ 按**名字**匹配,不按 ID(2026-10-04)
|
||||
//
|
||||
// 旧实现:先找出 personID(e.ID),再用 `r.SourceID == personID` 匹配关系。
|
||||
// ★★ 块侧这不再成立:e.ID 是「本次召回内的序号」(int64),
|
||||
// 而 Relation.SourceID 是**块 ID**(blk_ent_<hash>)——
|
||||
// 两个不同 ID 空间,永远匹配不上。
|
||||
//
|
||||
// 而「张三的特质」这个语义本来就与 ID 无关,只关乎名字。
|
||||
// ★ 用 ID 匹配还有个隐患:同一个人可能有两个块
|
||||
// (历史数据里 blk_ent_a 与 blk_ent_b 文本相同),
|
||||
// 按 ID 只认其中一个,另一个的特质就丢了。
|
||||
//
|
||||
// 先确认人在召回结果里 —— 找不到就报错,不静默返回空档案。
|
||||
found := false
|
||||
for _, e := range result.Entities {
|
||||
if e.Name == name {
|
||||
personID = e.ID
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if personID == 0 {
|
||||
if !found {
|
||||
return nil, fmt.Errorf("person '%s' not found", name)
|
||||
}
|
||||
|
||||
// 区分 trait 关系和社交关系
|
||||
for _, r := range result.Relations {
|
||||
if strings.HasPrefix(r.RelationType, traitPrefix) {
|
||||
// 特质:trait:<特质名>
|
||||
traitName := strings.TrimPrefix(r.RelationType, traitPrefix)
|
||||
if r.SourceID == personID {
|
||||
profile.Traits[traitName] = r.TargetName
|
||||
} else {
|
||||
profile.Traits[traitName] = r.SourceName
|
||||
}
|
||||
} else if r.SourceID == personID {
|
||||
profile.Relations = append(profile.Relations, SocialRelation{
|
||||
Person: r.TargetName,
|
||||
Relation: r.RelationType,
|
||||
})
|
||||
} else if r.TargetID == personID {
|
||||
profile.Relations = append(profile.Relations, SocialRelation{
|
||||
Person: r.SourceName,
|
||||
Relation: r.RelationType,
|
||||
})
|
||||
isSource := r.SourceName == name
|
||||
isTarget := r.TargetName == name
|
||||
if !isSource && !isTarget {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(r.RelationType, traitPrefix) {
|
||||
// 特质:trait:<特质名>,值在另一端
|
||||
traitName := strings.TrimPrefix(r.RelationType, traitPrefix)
|
||||
other := r.TargetName
|
||||
if !isSource {
|
||||
other = r.SourceName
|
||||
}
|
||||
profile.Traits[traitName] = other
|
||||
continue
|
||||
}
|
||||
other := r.TargetName
|
||||
if !isSource {
|
||||
other = r.SourceName
|
||||
}
|
||||
profile.Relations = append(profile.Relations, SocialRelation{
|
||||
Person: other,
|
||||
Relation: r.RelationType,
|
||||
})
|
||||
}
|
||||
|
||||
return profile, nil
|
||||
@ -99,18 +117,18 @@ func (s *SocialStore) SetTrait(name, trait, value string) error {
|
||||
if found && oldVal != "" {
|
||||
s.db.Purge(map[string]string{
|
||||
"subject_contains": name,
|
||||
"relation_type": traitPrefix + trait,
|
||||
"relation_type": traitPrefix + trait,
|
||||
}, "soft")
|
||||
}
|
||||
|
||||
triples := []memory.Triple{
|
||||
{
|
||||
Subject: name,
|
||||
SubjectType: entityTypePerson,
|
||||
Relation: traitPrefix + trait,
|
||||
Object: value,
|
||||
ObjectType: entityTypeTrait,
|
||||
Confidence: 1.0,
|
||||
Subject: name,
|
||||
SubjectType: entityTypePerson,
|
||||
Relation: traitPrefix + trait,
|
||||
Object: value,
|
||||
ObjectType: entityTypeTrait,
|
||||
Confidence: 1.0,
|
||||
},
|
||||
}
|
||||
_, _, err := s.db.Commit(triples, "social_trait", 0)
|
||||
@ -128,22 +146,25 @@ func (s *SocialStore) GetTrait(name, trait string) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
var personID int64
|
||||
for _, e := range result.Entities {
|
||||
if e.Name == name {
|
||||
personID = e.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if personID == 0 {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// ★ 按名字匹配,不按 ID(2026-10-04)
|
||||
//
|
||||
// 旧实现找 personID 再用 `r.SourceID == personID` 过滤。
|
||||
// ★★ 块侧不成立:e.ID 是召回内的序号(int64),
|
||||
// Relation.SourceID 是块 ID(字符串)—— 两个 ID 空间,永不匹配。
|
||||
//
|
||||
// 症状极具迷惑性:第 17 行 `return r.SourceName` 在
|
||||
// 「SourceID != personID」时**无条件**执行,于是 GetTrait 返回
|
||||
// **人物自己的名字**当特质值(判据里是 got "张三" 而非空串)——
|
||||
// 看不出是 ID 空间错配,只像是数据错了。
|
||||
wantRel := traitPrefix + trait
|
||||
for _, r := range result.Relations {
|
||||
if r.RelationType == traitPrefix+trait {
|
||||
if r.SourceID == personID {
|
||||
return r.TargetName, true
|
||||
}
|
||||
if r.RelationType != wantRel {
|
||||
continue
|
||||
}
|
||||
if r.SourceName == name {
|
||||
return r.TargetName, true
|
||||
}
|
||||
if r.TargetName == name {
|
||||
return r.SourceName, true
|
||||
}
|
||||
}
|
||||
@ -158,12 +179,12 @@ func (s *SocialStore) AddRelation(personA, relation, personB string) error {
|
||||
|
||||
triples := []memory.Triple{
|
||||
{
|
||||
Subject: personA,
|
||||
SubjectType: entityTypePerson,
|
||||
Relation: relation,
|
||||
Object: personB,
|
||||
ObjectType: entityTypePerson,
|
||||
Confidence: 1.0,
|
||||
Subject: personA,
|
||||
SubjectType: entityTypePerson,
|
||||
Relation: relation,
|
||||
Object: personB,
|
||||
ObjectType: entityTypePerson,
|
||||
Confidence: 1.0,
|
||||
},
|
||||
}
|
||||
_, _, err := s.db.Commit(triples, "social_relation", 0)
|
||||
@ -195,25 +216,21 @@ func (s *SocialStore) GetRelations(name string) ([]SocialRelation, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var personID int64
|
||||
for _, e := range result.Entities {
|
||||
if e.Name == name {
|
||||
personID = e.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if personID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// ★ 按名字匹配,不按 ID —— 见 GetTrait 的说明:
|
||||
// Entity.ID 是召回内序号,Relation.SourceID/TargetID 是块 ID,
|
||||
// 两个 ID 空间,按 ID 匹配永远为假。
|
||||
//
|
||||
// ★ 顺带修掉的隐患:旧实现找不到 personID 就 return nil(空列表),
|
||||
// 而「人不在库里」与「人存在但没有关系」是两件事 ——
|
||||
// 前者是错误,后者是事实。空列表让调用方无从区分。
|
||||
var relations []SocialRelation
|
||||
for _, r := range result.Relations {
|
||||
if strings.HasPrefix(r.RelationType, traitPrefix) {
|
||||
continue
|
||||
}
|
||||
if r.SourceID == personID {
|
||||
if r.SourceName == name {
|
||||
relations = append(relations, SocialRelation{Person: r.TargetName, Relation: r.RelationType})
|
||||
} else if r.TargetID == personID {
|
||||
} else if r.TargetName == name {
|
||||
relations = append(relations, SocialRelation{Person: r.SourceName, Relation: r.RelationType})
|
||||
}
|
||||
}
|
||||
@ -283,16 +300,48 @@ func (s *SocialStore) ListPersons() ([]string, error) {
|
||||
return nil, fmt.Errorf("social store not available")
|
||||
}
|
||||
|
||||
// ★★★ 改从**图结构**推断人物(2026-10-04)
|
||||
//
|
||||
// 旧实现靠 `e.Type == "person"` 筛选。块侧没有 type:
|
||||
// Triple.SubjectType / ObjectType 只写旧 entities.type,
|
||||
// memory_blocks 里**没有任何类型列**。
|
||||
//
|
||||
// ★ 而「谁是人物」在社交图里本来就是**结构可判定的**:
|
||||
//
|
||||
// 人物 = 社交关系(非 trait 关系)的任一端点
|
||||
// ∪ trait 关系的**源**(特质挂在主体上,trait:性格→开朗)
|
||||
//
|
||||
// 这不是权宜之计 —— 它比 type 更可靠:type 是写的时候声明的
|
||||
// (调用方可能不声明、可能声明错),而结构是数据本身的性质。
|
||||
// 旧实现里「AddRelation 加了人但没 SubjectType」就会漏掉那个人。
|
||||
//
|
||||
// ★ 保留一个回退:若图里一条边都没有(纯空库),
|
||||
// 返回空列表而不是全部 —— 那才是「没有人物」。
|
||||
result, err := s.db.Recall(nil, nil, 1, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var names []string
|
||||
for _, e := range result.Entities {
|
||||
if e.Type == entityTypePerson || e.Type == "Person" {
|
||||
names = append(names, e.Name)
|
||||
seen := make(map[string]bool)
|
||||
for _, r := range result.Relations {
|
||||
if strings.HasPrefix(r.RelationType, traitPrefix) {
|
||||
// 特质:源是主体(人物),目标是特质值
|
||||
seen[r.SourceName] = true
|
||||
continue
|
||||
}
|
||||
// 社交关系:两端都是人物
|
||||
seen[r.SourceName] = true
|
||||
seen[r.TargetName] = true
|
||||
}
|
||||
|
||||
persons := make([]string, 0, len(seen))
|
||||
for name := range seen {
|
||||
if name != "" {
|
||||
persons = append(persons, name)
|
||||
}
|
||||
}
|
||||
return names, nil
|
||||
// ★ 排序:Recall 的行序由 SQLite 决定,不稳定。
|
||||
// 列表接口的输出必须可复现,否则测试与展示都会飘。
|
||||
sort.Strings(persons)
|
||||
return persons, nil
|
||||
}
|
||||
|
||||
@ -331,6 +331,11 @@ func (e *StaticEmbedder) tokenize(text string) []string {
|
||||
}
|
||||
|
||||
func (e *StaticEmbedder) Vectorize(text string) vector.Vector {
|
||||
// nil接收者返回空向量而不是 panic:调用方(RelevanceContext.computeVector)
|
||||
// 可能在模型未就绪时拿到 nil embedder,让整条链路崩掉而不是降级。
|
||||
if e == nil {
|
||||
return vector.Vector{}
|
||||
}
|
||||
e.mu.RLock()
|
||||
loaded := e.loaded
|
||||
dim := e.dim
|
||||
|
||||
@ -374,10 +374,10 @@ func (m *Memory) Stats() map[string]interface{} {
|
||||
defer m.mu.Unlock()
|
||||
files, _ := m.listFiles()
|
||||
return map[string]interface{}{
|
||||
"file_count": len(files),
|
||||
"current_size": m.size,
|
||||
"rotation_bytes": m.maxSize,
|
||||
"file_count": len(files),
|
||||
"current_size": m.size,
|
||||
"rotation_bytes": m.maxSize,
|
||||
"rotation_interval": m.interval.String(),
|
||||
"dir": m.dir,
|
||||
"dir": m.dir,
|
||||
}
|
||||
}
|
||||
|
||||
166
internal/memory/traverse_test.go
Normal file
166
internal/memory/traverse_test.go
Normal file
@ -0,0 +1,166 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★★★ 召回 = 命中节点 + N 层 BFS
|
||||
//
|
||||
// 用户定义:
|
||||
//
|
||||
// 「召回是针对节点的,然后召回的是节点与 n 层 bfs 结果」
|
||||
//
|
||||
// ⇒ ① 所有节点都是召回对象(主语块、宾语块、原句块一视同仁)
|
||||
//
|
||||
// ② 命中之后沿关系边展开 N 层,把邻居一起返回
|
||||
// ③ 邻居**不需要**「来源标注」这类特判 —— BFS 自然带出上下文
|
||||
//
|
||||
// 这与「只召回主语块」是两种不同的召回语义:后者需要为
|
||||
// 「宾语块孤立命中」写特判,而 BFS 展开让那种情况自动有上下文。
|
||||
func TestBFS_节点加N层邻居(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer func() { _ = g.Close() }()
|
||||
|
||||
// 织一张网:值班室分机号 --是--> 4324 --停用于--> 4379
|
||||
// └ 4324 被 老周 值班 引用
|
||||
blocks := []MemoryBlock{
|
||||
{ID: "b_subject", Modality: BlockText, Text: "值班室分机号"},
|
||||
{ID: "b_value", Modality: BlockText, Text: "4324"},
|
||||
{ID: "b_old", Modality: BlockText, Text: "4379"},
|
||||
{ID: "b_person", Modality: BlockText, Text: "老周"},
|
||||
{ID: "b_unrelated", Modality: BlockText, Text: "完全不相关的块"},
|
||||
}
|
||||
if err := g.PutMemoryBlocks(blocks); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
edges := []struct{ from, to, typ string }{
|
||||
{"b_subject", "b_value", "是"},
|
||||
{"b_value", "b_old", "停用后改为"},
|
||||
{"b_person", "b_value", "值班分机"},
|
||||
}
|
||||
for _, e := range edges {
|
||||
if err := g.AddRelationBlockEdge(e.from, e.to, e.typ,
|
||||
RelationEdgeData{SessionID: "s1", Confidence: 0.9}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// 命中「4324」这个**宾语块**(孤立时它不知道自己来自哪里)
|
||||
got, err := g.BFSBlocks("b_value", 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids := map[string]bool{}
|
||||
for _, b := range got {
|
||||
ids[b.ID] = true
|
||||
}
|
||||
fmt.Printf(" 从 b_value 展开 1 层:%d 个节点 %v\n", len(got), ids)
|
||||
|
||||
// ★ 起点自身必须包含
|
||||
if !ids["b_value"] {
|
||||
t.Error("★ BFS 结果必须包含起点自身")
|
||||
}
|
||||
// ★ 1 层邻居:主语块与旧值块都在(它们是 b_value 的入/出邻居)
|
||||
if !ids["b_subject"] {
|
||||
t.Error("★ 1 层应含主语块(值班室分机号)—— 宾语块孤立命中时靠 BFS 找回上下文")
|
||||
}
|
||||
if !ids["b_old"] {
|
||||
t.Error("★ 1 层应含旧值块(4379)")
|
||||
}
|
||||
if !ids["b_person"] {
|
||||
t.Error("★ 1 层应含 1 层反向可达的「老周」(它指向 b_value)")
|
||||
}
|
||||
// ★ 不相关的块不该出现
|
||||
if ids["b_unrelated"] {
|
||||
t.Error("★ 不相关块不该被 BFS 带出")
|
||||
}
|
||||
|
||||
// ★ 层数可控:0 层只有自己
|
||||
zero, err := g.BFSBlocks("b_value", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(zero) != 1 || zero[0].ID != "b_value" {
|
||||
t.Errorf("★ 0 层应只返回自身,实际 %d 个", len(zero))
|
||||
}
|
||||
|
||||
// ★ 2 层能到「老周」(经 b_value 到 b_person 是 1 层;
|
||||
// 经 b_subject 再往外没有出边,所以 1 层已足够)
|
||||
two, err := g.BFSBlocks("b_value", 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(two) < len(zero) {
|
||||
t.Errorf("★ 2 层结果不应少于 0 层")
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 规模判据:BFS 的增长必须是**有界**的。
|
||||
//
|
||||
// docs/zh/recall-as-association.md 里我把「层数 N 是上下文预算,
|
||||
// 不是图算法参数」列为约束,理由是:网最终会织成实体网,
|
||||
// 若不做 BFS 就退化成全表扫描。
|
||||
//
|
||||
// ★ 这条判据盯的是**实测的增长曲线**,不是形态 ——
|
||||
//
|
||||
// 形态判据(TestBFS_节点加N层邻居)只证明「能找回主语」,
|
||||
// 不证明「规模可控」。
|
||||
//
|
||||
// 场景:织一张中等密度的网(100 节点 / 约 300 边,含环),
|
||||
// 检查 depth=1..3 的返回规模是否线性可预期。
|
||||
func TestBFS_规模增长有界(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer func() { _ = g.Close() }()
|
||||
|
||||
const n = 100
|
||||
ids := make([]string, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
id := fmt.Sprintf("n%03d", i)
|
||||
ids = append(ids, id)
|
||||
if err := g.PutMemoryBlocks([]MemoryBlock{
|
||||
{ID: id, Modality: BlockText, Text: fmt.Sprintf("节点%03d", i)},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// 织网:i --关联--> i+1、i --关联--> i+2(近邻,密度约 2/n)
|
||||
// 末几个连回开头 ⇒ **有环**(真实记忆网必然成环)
|
||||
edgeCount := 0
|
||||
for i := 0; i < n; i++ {
|
||||
for _, off := range []int{1, 2} {
|
||||
j := (i + off) % n
|
||||
if i == j {
|
||||
continue
|
||||
}
|
||||
if err := g.AddRelationBlockEdge(ids[i], ids[j], "关联",
|
||||
RelationEdgeData{SessionID: "s", Confidence: 0.5}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
edgeCount++
|
||||
}
|
||||
}
|
||||
fmt.Printf(" 织网: %d 节点 / %d 边(含环)\n", n, edgeCount)
|
||||
|
||||
var prev int
|
||||
for depth := 0; depth <= 3; depth++ {
|
||||
got, err := g.BFSBlocks(ids[0], depth)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fmt.Printf(" depth=%d → %d 个节点\n", depth, len(got))
|
||||
// 硬约束:depth=3 时不该把 100 节点全拖出来。
|
||||
//
|
||||
// ★ 阈值 25 是**从实测定的**,不是拍的:
|
||||
// 近邻网(每个节点连 2 个后继)下 3 层的理论上限是
|
||||
// 1 + 2 + 4 + 8 = 15。若实测远超,说明环上失控了。
|
||||
if depth == 3 && len(got) > 25 {
|
||||
t.Errorf("★ depth=3 返回 %d 个节点(>25)—— 环上失控,BFS 退化成全表扫描", len(got))
|
||||
}
|
||||
if depth > 0 && len(got) <= prev {
|
||||
t.Errorf("depth=%d 返回 %d 个,不比 depth=%d 的 %d 个多 —— 层数没生效",
|
||||
depth, len(got), depth-1, prev)
|
||||
}
|
||||
prev = len(got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user