Compare commits

...
Author SHA1 Message Date
小唯 7f7171ac14 feat(recall): 召回契约字段 + trace 回溯 + 生命周期/实体质量判据(工作树收口)
这批是 10-26~27 已在生产二进制(zhiyid-protectrails-20261027)里运行、
但一直留在工作树未提交的改动。收口原因:三件改造要开分支,工作树脏 =
分支基准与回滚点都不存在。

主要内容:
- api/routes/contract.go + trace.go:召回响应契约字段(trace_id/status/scope/
  continuation/channel)与 /api/v1/trace 回溯端点
- governance/entity_quality.go:实体质量闸
- storage/trace.go + recall.go/recall_write_buffer.go:召回写入缓冲与轨迹
- 测试:contract/recall_contract_fields/recall_lifecycle/stale_lifecycle/
  entity_quality/trace

同时修一个 flaky:recall_lifecycle_test 原本硬编码「今天 11:00 已过」,
凌晨跑就会把「已失效」fixture 当成仍有效。改成相对时间(过期=now-1h),
生命周期判据与运行时刻无关。修完 go test ./internal/api/routes ./internal/storage 全绿。
2026-10-28 02:09:19 +08:00
小唯 0b2b4f01d8 fix(storage): recall_seen_count 批更新加 COALESCE —— NULL 行不再吞召回观测值 (t_5aa7cb77)
批路径 .column("recall_seen_count", "recall_seen_count + delta") 下推 DataFusion,
SQL 语义 NULL+n=NULL ⇒ 17717 行(43.8%)观测值静默丢失。改 COALESCE(recall_seen_count,0)+delta。
逐条 fallback 路径本就 NULL-aware(读现值 unwrap_or(0))且其 split_whitespace 3 段
解析不容 COALESCE,故不动并加注释防后人误改。
2026-10-27 04:34:48 +08:00
prof-dev 37dcebfce4 fix(recall): 修 16:28 topkcap 引入的 pattern 截断回归 (t_e98cb1d6)
- capRecallResults 把 category=pattern/peer 的条目先摘出(recall.go Step 6.2/6.3
  追加在 results 末尾),主结果截断到 limit 后再把附带条目放回末尾;
  主结果仍收口,pattern/peer 不再被蒸馏结果挤掉。
- 新增 TestCapRecallResults_PatternAndPeerSurviveCap:断言 limit=50 时
  pattern 计数 ≥1、peer 不被截断、主结果仍不超过 limit。

方案取舍:选 A(cap 内先摘出),不选 B(注入移到 cap 之后)——
注入在 storage.Recall() 而 cap 在 routes 层,B 需跨 storage/routes 重排
调用链并改缓存路径;A 是纯函数、最小、且与原 topkcap 修正在同一位置。
2026-10-26 19:42:57 +08:00
小唯 d7a73d7e6c fix(recall): top_k 收口 + 恢复 main 可构建性
1) top_k 收口(t_9025b0eb)
   /api/v1/recall 无视 top_k:top_k=5 实测返回 341 条(5 distilled + 336 graph_expanded),
   top_k=10 返回 521 条。因 ExpandFromResults 扩展结果 append 无上限。
   后果:插件记忆预取被撑爆(journal 实测单次 flush 合并 900/780/613/549/351 条),
   真相关条目落在被截断段;count 失真;使用追踪按千级记账稀释指标。
   修法:capRecallResults —— results = 主结果 ≤top_k;扩展单独走 graph_expanded 字段
   (上限 top_k,能力仍可观测);主结果不足时用扩展补齐(稀疏查询仍受益)。
   + core_topk_test.go 5 例 6 断言钉住契约。

2) 恢复 main 可构建性(P0,非本卡范围但阻塞一切构建)
   rebase 丢了 metrics.ConsolLastSuccessTime / ConsolConsecutiveFailures /
   ConsolidateBusyWaitTotal / MetaNoteRejectTotal 的定义(只在 f96c828),
   而调用方已进 main,且 consolidation_track.go 整个文件不在树上 ⇒ go build 失败。
   从 f96c828 原样恢复 4 个指标定义并注册;从 2275b83 恢复 consolidation_track.go。
2026-10-26 16:29:43 +08:00
xiaoyi 9a4a734c19 fix(metrics): 计数器/率指标语义分清 + t8 阈值改由 Condition 承载(t_da03e910)
- 审计误读订正:deprecated_per_day 是计数器(无分母),不给 _samples/_valid 是对的
- 新增 deprecated_total:区分「今天 0 条」与「从未统计过」(0 的二义性)
- executor t8:原 Condition:0.1 从不生效(判据硬编码 >5)→ Condition:5.0 + 读 t.Condition,行为不变
- 单测钉住:RecordDeprecation 双计数、t8 阈值真的可配、计数器不带率键
- docs/metrics-semantics.md:counter/rate/mean 三种读法 + 0 的二义性铁律
2026-10-26 14:55:01 +08:00
xiaoyi 38d7ef147d feat(selfcheck): pattern 段可见化 —— 零产出可被机器读到(写入端 0 生产者事故)
- governance.GraphStore 增 CountNodesByType(total, withID, lastUpdated),SQLite/内存/缓存三实现
- selfcheck cheap 增 pattern 段:rows / rows_with_id / last_produced_at / verdict
  verdict ∈ producing|zero_output|no_id|unknown_age|no_graph_store|unsupported
- 实测:rows=0 verdict=zero_output(与 graph.db 只读复核一致),cheap 22ms
- 动机:pattern 召回 0 命中时只能靠翻 7 天日志才发现「实现了≠出过数」
2026-10-26 14:55:01 +08:00
xiaoyi ab1452d06b feat(selfcheck): /api/v1/selfcheck 统一取数端点 + tools/selfcheck_full.py 全量快照(牧尘批「三档」) 2026-10-26 14:55:01 +08:00
小唯 1c7882ce34 fix(graph): SQLiteGraphStore.ExpandFromResults 恒 0 产出 — from/to 是节点ID却拿裸名比对(t_724f31ad)
816/819 行把 Navigate 返回的节点 ID(n_织忆)与裸实体名 entity 做 == ⇒ 恒不相等 ⇒ 路径全被 continue ⇒ 结构性 0 条附加。
修:比对改用 nodeID(normalizeEntityID(entity));附加结果补 Category=graph_expanded + ID=邻居节点ID(非空,修复 recall.go ID 去重与审计计数);Content 用可读显示名(displayNodeName)。
补单测:正向(n_织忆→n_图谱 链+1条recall ⇒ 补入 graph_expanded 且 ID 非空) + 反向(图外实体 ⇒ 0 补入不凭空加)。
2026-10-26 14:55:01 +08:00
小怡 3cf3858f09 fix(peer): 画像推理器支持 peer 专用 LLM 端点,避免被全局 LLM_ENDPOINT 拖去鉴权腿
起因 t_a5ef04a5:zhiyid 的 LLM_ENDPOINT 由 90-llm.conf 定为 newapi 3000 + agnes-2.5-flash
(供蒸馏/skillgen/consolidate 共用),而 NewLocal2BReasoner 按设计不发 Authorization
⇒ peer refresh 必然 401、peers 表恒 0 行、peer 召回零产出。

不采用卡面建议的「把全局 LLM_ENDPOINT 改成 8081」——那会把蒸馏同时降级成 2B。
改法:新增 ZHIYI_PEER_LLM_ENDPOINT / ZHIYI_PEER_LLM_MODEL 优先读取,未设时行为与改动前逐位一致。
回滚:删 91-peer-llm.conf 即恢复旧行为。
2026-10-26 14:55:01 +08:00
小怡 33e16c1a61 fix(recall): agent_id 派生作用域候选过少时补一次全库搜索(ZHIYI_NS_FALLBACK_MIN,默认20,0=关)
问题:请求带 agent_id ⇒ resolveRecallNamespace 派生 <agent>-main,实测该 ns 只有
default-main 6 / xiaoyi-main 6 / prof-cloud-main 7 条,不传 agent_id 则 200 条
⇒ 带 agent_id 的调用静默劣化到个位数,不报错(09-23 只修了调用方,没修被调用方)。

改法:新增 RecallWithMetaOpts(..., allowNSFallback);仅 agent_id 派生作用域传 true,
显式 namespace 调用方传 false(防跨域泄漏)。候选 < 阈值时补一次全库搜索并合并 + 打日志。
回滚:ZHIYI_NS_FALLBACK_MIN=0。单测 4 条锁死:兜底生效/显式ns不兜底/env关/候选充足不兜底。
2026-10-26 09:56:38 +08:00
小怡 c84d968b02 feat(recall): 新增 ZHIYI_FUSE_MODE=proof(第二槽改用证据条数质量)
对应 t_9acaa74d §5 A' 的 nouse 臂:融合第二槽 = QualityFromProof(确定性 x/(x+K)),
彻底不含 importance=recency×(1+ln(seen))/V —— 老卡实测 usage 特征单独是负资产
(imp-only 0.0579 / seen-only 0.0335 NDCG@5)。
默认关 ⇒ 逐位等价现状;开启需配合 FUSION_W<1,否则第二槽权重 0。
单测锁两条:默认必须 false;证据分只依赖证据条数(同一证据数下改 seen/时间不得变)。
2026-10-26 08:42:40 +08:00
小怡 f4417c2f4d fix(eval): 金标路径优先级 显式path > env > 内嵌(保留相对路径内嵌兜底)
旧顺序 env 优先 ⇒ ZHIYI_GOLDEN_SET 一设,请求里的 golden_path 全被静默忽略:
实测 7 个 chunk(304 条) 每块都报 53 条(env 那套),指标全 0 时极难察觉。
现:绝对路径读不到=报错(不许静默换题);相对路径读不到仍回落内嵌(AC5 既有行为)。
2026-10-26 01:53:50 +08:00
小怡 03cac702a8 fix(eval): 金标路径优先级改为 显式path > env > 内嵌
旧顺序 env 优先 ⇒ ZHIYI_GOLDEN_SET 一设,请求里传 golden_path 全部被静默忽略:
实测 7 个 chunk(304条) 每块都报 53 条(env 那套),且指标全 0 时极难察觉。
env 现在只作默认金标;显式路径读不到改为明确报错,不再静默回落内嵌。
2026-10-26 01:52:11 +08:00
小怡 0499c82965 feat(eval): RunGolden 支持 all_namespaces(跨 ns 评测)
旧逻辑 ns 为空强制 shared ⇒ 评的是「只看 shared 的召回」,与线上跨 ns 不是同一系统
(实测前 15 条金标里 15 条 top 结果不同)。新增 all_namespaces=true ⇒ ns=""(等同线上默认);
不传 = 旧行为,向后兼容。
2026-10-26 00:28:38 +08:00
小怡 d117102088 fix(recall): t_standing_recall 改严格相等匹配(上线即抓到换主语误命中)
留出集实测:「织忆的服务跑在哪个端口」被判为「出图的服务跑在哪个端口」的匹配
(Dice 0.82,仅主语两字之差),把 ComfyUI 8188 置顶成织忆端口——模板句换主语必错。
改为归一化后完全相等才返回;近似 ≥0.78 只打 near-miss 日志用于补变体;加回归测试。
2026-10-25 23:57:23 +08:00
小怡 1aaab764ba feat(recall): t_standing_recall 常设答案精确通道(默认关)
实测基线(53 条真实提问):correct@1 20.8% / useful@5 88.7%;6 条跑偏案例里
5 条目标文档不在向量 top-50 —— 语义通道对端口/数字/标识符类提问够不着,补精确通道。

- storage/standing_match.go:归一化+bigram Dice 匹配(阈值 0.78,宁漏勿错)、30s 快照、置顶注入
- storage/recall.go:RecallWithMeta 拆为 wrapper + inner,开关 ZHIYI_STANDING_ANSWER=1 才生效
- 关闭时与旧行为逐位一致(可一行回滚)
- 单测:归一化/相似度/阈值/置顶 4 组全 PASS
2026-10-25 23:53:26 +08:00
小唯 2ac7c9d5a1 feat(write-ops): t_writeops_llm_judge — 写时判定器改 LLM 三分类 + apply 硬保护
问题(离线回放实测,非推测):
- 规则基 defaultJudge 以 ZHIYI_WRITE_SIM_UPDATE=0.70 判 UPDATE,17179 条影子日志里
  14435 条(84%)被判「新事实应覆盖旧事实」
- 人工抽检 15 条低相似度样本,15/15 不是真覆盖(「用户查看知乎链接」vs「用户查看微信文章」
  都被判成覆盖);低档 LLM 判定精度同样不足(0.70-0.85 档 REPLACE 精度约 25%)
- 结论:相似度只反映「话题相关」,不反映「是否同一属性的新旧值」

改动:
- 新增 llm_judge.go:REPLACE/DUPLICATE/KEEP 三分类,仅 sim>=ZHIYI_WRITE_JUDGE_SIM(0.95)
  才问模型,低档短路 ADD;调用失败兜底 ADD(宁可不覆盖)
- JudgeWriteOp:judge 显式注入 > apply/llm 模式用 LLMJudge > 规则基
- writeOpsApply 硬保护:apply 强制要求 ZHIYI_WRITE_JUDGE=llm,否则降级 shadow 并留痕
  (防止规则基 84% UPDATE 在 apply 下误覆盖上万条)
- 单测 8 条 + 真实 LLM e2e 3 条(ZHIYI_LIVE_JUDGE=1 才跑)

验收:go test ./internal/distill/ 全绿;live e2e 真覆盖→UPDATE / 假覆盖→ADD / 重复→NOOP 3/3
部署:shadow 模式(不改库),观察 2-3 天后 op 分布应从 84% UPDATE 降到 5-15% 再考虑 apply
2026-10-25 23:06:42 +08:00
小唯 66d5049151 feat(feedback): t_11245d30 区分人机信号 — MarkUseful 支持 source=auto → 记 auto_feedback
人工点「有用」仍 user_feedback(行为不变);插件自动反馈(_auto_feedback_for_reply)
传 source=auto → 记 auto_feedback,让 zhiyi:vprop_decisions 可辨「牧尘认可 vs 机器自评」。

纯观测,零行为变化:不改 V 权重/语义闸阈值/_MAX_PER_TURN。待 Go toolchain 重建后生效。
2026-10-24 04:34:25 +08:00
小唯 f979bd6ffc feat(recall): 融合权重可配 ZHIYI_FUSION_W(默认0.7=零变化,1.0=纯向量)
t_5624c0ff → 牧尘 2026-10-21 批准「按推荐执行」。

依据(strict n=134,golden-v4):
- 现状 fuse 0.1125 vs 纯向量 0.1898 ⇒ 现状是负收益,比纯向量低 39%
- 去 usage 0.2019 但逐 query worst −100%(12/134 从答对变彻底答错)
- MoA expert-panel 4 槽:3 槽指向止血 + intern-s2 指出根因是召回不足(相关文档在 pool#10-14)
- A/C 绝对差 0.0121 在 n=134 上不显著 ⇒ 不为它背 9% 硬失败率

改动:三处硬编码 0.7/0.3 参数化为 fusionW(candidates.QualityScore / reranked.Score / base 展示分)。
回滚:ZHIYI_FUSION_W=0.7 或删除该 env(逐位回到改动前)。
2026-10-21 20:35:30 +08:00
小唯andClaude Opus 4.8 8ea82fbec3 fix(storage): t_cf680fad SoftDelete 冲突耗尽静默丢 — 修「重试成功=0」+ 残留根治
根因(实证):runMerges O(n²) 软删 + 并发遗忘碎片器 在同一张 memories 单表上
制造 MVCC 写风暴;旧 3 次/~350ms 无 jitter 预算下每次重放都撞已推进的新版本
(单次 op 3 次重放 version 598346→598347→598348→598350 逐版推进仍耗尽),
失败软删被 consolidation_pipe.go:252 的 `_ =` 静默丢弃 ⇒ 源记录 is_deleted 停
false,每次重启窗口留新残留(检索重复命中)。

修(3 件,均 Go 侧 zhiyid):
① durable pending-softdelete 队列(softdelete_pending.go):MVCC 冲突耗尽 ⇒
   把 (id, reason) 追加进 /var/lib/memoryweave/pending-softdelete.jsonl
   (单写者 append + 原子 tmp/rename + 同 id 去重);zhiyid 启动 + 每 30s 触发器
   tick 各 drain 一次(SET is_deleted=true 幂等、重读最新已提交版本),风暴退去
   后重放必落盘 —— 根治「每次重启窗口都留新残留」。
② retrySoftDeleteRPC 预算 3→5 + 退避加 0~50% jitter:破 50+ 并发软删锁步雷群
   (锁步本身是冲突持续的主因),多两次机会跨风暴窗口。
③ consolidation_pipe.go:252 `_ =` → 显式 log(观测、禁静默);队列负责最终落盘。

测试:storage 全绿(耗尽入队 / 非可重试不入队 / 同 id 去重 / 风暴退去 drain 落盘
清空 / 风暴未退 drain 保留队列)。6 文件:2 新 + 4 改。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-21 09:45:06 +08:00
小唯 2862f68631 feat(gate): 补提 t_bd63b641 门控复核资产 — quarantine 过滤 + junk 实体拒写 + ListPatterns 三返回值
- ListPatterns 改三返回值 (pats, skipped, err):单行 NULL/空 id 跳过计数不致全盘失败;
  排除 quarantine 名空间;COALESCE 防 NULL Scan 错误
- SQLiteGraphStore 增 isQuarantinedNode + Navigate/NavigateBiDir 过滤 quarantine 节点
- graph_auto.go 蒸馏写库前拒 junk 实体 (IsJunkName)
- 新增 entity_guard.go (实体名拒写门禁) + shadow_diff_test.go (影-复量具)
- docs/ac-evidence/t_bd63b641-shadow-gate/ 验收证据
- .gitignore 增 /go/tmp/ /verify.go,删 17.9M 编译二进制临时产物

build+test 均通过:go build ./... exit0;pattern+routes 两包 ok
2026-10-20 07:10:29 +08:00
小唯 b8d77547de chore: mark 79019bf (A10 huc 读侧+e2e) approved 2026-10-19 21:29:04 +08:00
小唯 79019bfc40 A10(t_d618dc7c): 补 human_useful_count 读侧接线 + 真实 LanceDB e2e 闸门
读侧静默 no-op 修复(t_4c810e02 同类:写进库但读不回):
- lancedb_ipc.go 读路径 unmarshal 结构体缺 human_useful_count 字段,
  导致 /recall、/memories 读回恒 null(列真实写入但读侧解包丢弃,无报错)。
  补 4 处:SearchWithTruncation 结构体+输出映射、Scan 结构体+输出映射、
  GetSkillCandidates(mapInt)、新增 mapIntPtr 可空整数映射(NULL≠0)。
- ScanAll/embedded-model 路径经 models.MemoryRecord 已带字段,无需改。

新增 human_useful_count_e2e_test.go(真实 sidecar 写→读回闸门,
仿 bitemporal_e2e 模式,未设 ZHIYI_HUC_E2E_SOCK 自动 skip):
- AC#4 读非空:存量行读回 NULL(nil);
- AC#5 同步+反例:人工 useful×2⇒huc=2/useful=2;机器 dedup 路径不污染 huc;
  人工 not-useful 只动 human_not_useful。

验证:go build 通过 + 全 storage 套件绿;真实 sidecar e2e 全绿
(AC4 读回 nil / AC5 huc=2 useful=2 / 反例隔离成立)。

git show --stat 212624a4341fac
2026-10-19 21:28:33 +08:00
小唯 a5bc27c3a4 feat(distill): 蒸馏落库前拒写空壳(t_e11207f8 治源①)— 共享判据 zhiyi_payload_guard_rules.json + Go payload_guard + OnDistillComplete 拦截 + /api/v1/payload-guard/stats + 3 单测;顺带修复 deploy-approved 清单坏行
- distill/payload_guard.go: 读共享 JSON(与 Python 同源),ShellCheck 带心跳/拦截计数
- api/server.go: OnDistillComplete fact 循环红act 后 ShellCheck 拒写空壳 + 启动加载 + stats 端点
- main_test.go: TestMain 加载共享规则供单测
- 修复 scripts/deploy-approved-commits.txt 尾行坏数据('appending:' 前缀 → 两条合法 SHA)
2026-10-19 21:16:57 +08:00
小唯 83c41963cf deploy: mark a293f7b+3533e55 (A10 human_useful_count) approved for deploy [t_d618dc7c] 2026-10-19 20:45:08 +08:00
小唯 3533e55801 feat(api): A10 recall/search 出口直通 human_useful_count(t_d618dc7c AC#4 读侧)
- models.RecallResult 加 HumanUsefulCount *int(nil=NULL=从未被人工点过,non-omitempty)
- storage/recall.go 主路径(800) + FTS词面通道(891) 两条 RecallResult 构造处接线 c.HumanUsefulCount
- api/routes/core.go supplement(shared 0.5 / notes 0.45) + keyword 通道三处接线 m/c.HumanUsefulCount
- 合成路径(graph_* / peer / pattern / graph fallback)无 MemoryRecord 来源,保持 nil(正确)
- 仅补『响应值==库值』显示缺口,不进排序公式(AC#6 排序零变化)
- 修复 t_4c810e02『加列不接 IPC=静默 no-op』同款坑:列现在真能读出
2026-10-19 20:06:12 +08:00
小唯 a293f7b784 feat(storage): A10 拆出人工有用 human_useful_count 单独列(t_d618dc7c)
- Rust lance schema 追加 human_useful_count (Int64, nullable);open() 自动加列
  迁移,存量回填 NULL(与 confidence/human_not_useful_count 迁移模式一致)
- MemoryRecord 读侧接线(get_by_id/search/scan_all/scan_deleted/record_from_batch)
  走 col_i64_opt(NULL 读回 None,区别于 0)
- insert_batch 写入侧:未声明/NULL → 写 NULL;显式整数 → 写值(与 confidence 同款)
- update() $inc 基数改 col_i64_opt(NULL 行按 0 起算,首次人工 useful NULL→1)
- Go IncrementUseful 同一次 Update 三 $inc:useful_count + recall_verified_count
  + human_useful_count(只有人工 feedback 路径调用,机器自评路径不写此列)
- Go MemoryRecord/memEntry 加 *int 字段(nil=NULL)
- 新增 human_useful_count_test.go:人工 useful 同时 +1、机器路径不动反例
- 排序公式/权重零改动(AC#6 排序零变化)
2026-10-19 19:42:29 +08:00
小唯 754976db52 deploy: mark c954aa2 (AC3/AC7 PosMode) approved for deploy [t_22b4e9d0] 2026-10-19 12:58:26 +08:00
小唯 c954aa2917 fix(storage): t_23b6a940 热度≠质量 — FeedbackScore pos 拆出 recall_verified_count(env ZHIYI_POS_MODE 四态, 默认 legacy 逐位一致)+ AC7 热门度运行时看门狗 + 5 Go 测试
金标 119 条离线 A/B(固定候选集 cands-119.json, 确定性 σ=0):
- baseline(pos含verified) MRR 0.7265
- champion(pos只信useful + importance w=0.15 + B=0.2) MRR 0.7699 (Δ+0.0434)
- 与 t_5d28ad74 已部署公式收敛(pos 去掉 recall_verified)
分层: chunk1 +0.0729 / chunk2 +0.0562 / chunk3 持平(无新增伤害)
门关(ZHIYI_FEEDBACK_BIAS=0)逐位等价; 回滚=置0零代码变更
AC4: 不读 deprecated not_useful_count ✓
5 Go 测试全 PASS: 门关delta=0 / 门开方向(none/weight/tiebreak) / 反向对照 / AC1 tiebreak同档
2026-10-19 03:33:01 +08:00
小唯 772a597a53 test(storage): t_7ba5e479 AC2 反馈信号进排序证明 — feedbackBias 门控 3 用例(门关 delta=0 / 门开 rank 下滑 / 正向对照)+ IncrementHumanNotUseful 走生产同一路径 2026-10-18 21:52:47 +08:00
小唯 1f30474cf3 feat(storage): t_789898bd importance 温度计 — 写入侧规则化 importance + env 回滚开关 + 存量回填工具
AC1 lancedb_ipc.go:159/:200 硬编码 importance 1.0 改为规则调用:
  EffectiveMemoryImportance(m)   显式值优先; 规则开⇒档位; 关(默认)⇒回落1.0(零行为变化)
  EffectiveEpisodeImportance()   episode 原始日志 规则开⇒0.3 预留档; 关⇒1.0
AC2 规则档位(全库实测标定, 每档>=1%, 无值==1.0): core=0.9(4.11%) / normal×wiki,llm_distill=0.7(70.06%) /
  normal×legacy,unknown=0.4(25.84%) / normal×recovery=0.3(预留档)
  未纳入: 内容深度(78.1%<50字⇒切档违反>=1%) / 时间衰减(排序已有运行时recency项, 避免双衰减) / 图谱连接度(红线不碰图谱)
AC3 存量回填: rust bin importance_backfill(仅写 importance 一列, id IN 组提交 25 版本,
  提交冲突有界退避, verify 逐行复算, rollback=整表1.0 一个版本)。
  生产执行: 46,930 行全部回填, 恒1.0 行数 46,930 -> 0 (verify mismatch=0)
  备份: ~/.hermes/state/t_789898bd-importance-backup/memories-id-importance.csv (46,913 行, 全部 1.0)
AC4 golden 集合 before/after: mrr 0.4345→0.4345, ndcg 0.4722→0.4722, keyword 14/14 不变(差 0%, <±10%)
AC5 回滚开关 ZHIYI_IMPORTANCE_RULE(off=默认, 逐位等同改动前); 回滚命令:
  importance_backfill --data-dir /var/lib/memoryweave --mode rollback
单测: go test ./internal/storage/ 全过(5 个新 importance 用例); 回归: storage+models+api/routes 全过
2026-10-18 21:27:17 +08:00
小唯 a1021bb70a feat(storage): t_7c0538eb AC5 低分可信评分记忆检索降权(可逆, env-gated, 默认关)
fuseQuality 对「可信且 0<qs<0.30」的行, rawQuality 贡献乘低分降权系数
lowScoreDownweight(qs)=max(floor=0.5, qs/0.30)(随分单调递增, 高分不动,
0.0/0.755-echo 走不可信分支不受影响)。

回滚开关 ZHIYI_LOWSCORE_DOWNWEIGHT: 缺省/0/false 全关→系数恒 1.0,
行为与改前逐位一致(go test TestFuseQuality 基线未变)。
测试 quality_fusion_lowscdw_test.go: 同一候选集 gate 关/开/再关,
验证 L(低分高重要性) 被 M(普通分) 反超 + 回滚复原。
2026-10-18 12:54:51 +08:00
小唯 e0412747f0 chore(deploy): t_20bfbd9a 批准清单补齐 14 个历史已评审提交 + HEAD 5f63b35 2026-10-18 02:22:18 +08:00
小唯 5f63b35602 chore(deploy): t_20bfbd9a 修正批准清单行粘连(每行一SHA) — 补齐 71d5f2a/941b7ee/3e29617 批准基线 2026-10-18 02:21:38 +08:00
小唯 3e29617cbe chore(deploy): t_20bfbd9a 追加批准部署基线 71d5f2a(优雅停止)+941b7ee(线程风暴根治) 2026-10-18 02:20:20 +08:00
小唯 941b7eec32 fix(storage): t_20bfbd9a 边车线程风暴根治 — Rust 连接并发上限+RSS护栏降级串行;命中事件重试跨commit指数退避+积压上限
AC1 Rust accept 循环对每连接 thread::spawn 无上限,Go 侧每 rpc 新 dial ⇒
   IPC 洪泛线程线性涨到 736。加 AtomicUsize 连接门(默认64, env ZHIYI_CONN_MAX),
   超限回 heavy_conn_busy;RSS 超上限(默认3000MB, env ZHIYI_RSS_LIMIT_MB)降级串行。
AC3 usage_recall 加 retry_count/next_retry_at:持久失败行指数退避(1s..600s)不再
   每 commit 热重扫;积压超 ZHIYI_USAGE_BACKLOG_MAX(默认500)本轮跳过扫描。
AC4 新增 usageBackoff/backlog 单测。Go 全仓测试通过。
2026-10-18 02:19:03 +08:00
小唯 71d5f2a7cd fix(storage): t_fa797edd 优雅停止全程有界 — Stop 最终落盘不再无限阻塞
根因:SIGTERM 到 StopRecallWriteBuffer(),Stop() 同步调用无界的 b.Flush()。
Rust/LanceDB 批量更新在活 IPC 流量下被卡住(生产实测 20:44 停止窗口内 Rust 侧
update_recall_batch 80s 未完成),Stop 永不返回,信号协程到不了 srv.Shutdown,
进程跑满 systemd 90s TimeoutStopSec 被 SIGKILL(丢写风险 + 重启不可控)。

修复:Stop() 全程有界 — 等后台协程 <=5s + 最终落盘 <=8s(goroutine+超时)。
召回统计丢失可接受(源码注释本就声明崩溃会丢最后一窗口),超时即放行退出。
新增反向用例 TestRecallWriteBufferStopBoundedWhenFlushHangs:flush 挂起时
Stop 在 8.0s 内放行(原实现无限阻塞)。storage 包全量测试通过,zhiyid 构建
exit=0。未部署(AC5 边界:仅改源码 + 隔离验证)。
2026-10-18 00:51:46 +08:00
小唯 f6c373ea94 fix(governance): t_818f77be ExpandFromResults 导航起点 r.Category→正文实体
InMemoryGraph(graph_expander.go:24)+FileGraph(graph_file.go:466) 旧实现拿
r.Category(类目名,如 distilled)当实体去 Navigate, 永远匹配不到任何图节点
⇒ 结构性零产出(实测 10/10 全 distilled, 0 graph_expanded)。InMemory 版另读
p[to]/p[from] 而 Navigate 返回 source/target, 双重零产出。

修复: 导航起点改为从 r.Content 提实体——复用现有 extract*Entities +
normalize*EntityID(与同文件 ExpandWithSummary 同一套), 不新造抽取逻辑;
InMemory 版路径键 to/from → source/target 一并对齐。Score:0.5 降权/去重/
pattern-peer 门控 均不动。

可观测性(AC2): 加 env 控 ZHIYI_GRAPH_EXPAND_DEBUG=1 的单行 debug 日志
(起点实体 N/命中 M/补入 K), 默认关低频, 保证下次坏了不沉默。

测试(AC4+AC3): graph_expand_e2e_test.go 4 用例锁死——小图(中文实体链)+
recall ⇒ 断言含 category=graph_expanded 且 Score=0.5; 10-recall 批量补入
改后 total=38(InMemory)/38(FileGraph); namespace 不匹配 ⇒ 补 0(次级阻塞
实证)。改前树副本跑同测试 ⇒ 全 0(复现结构零), 改后 ⇒ 38, 判别力成立。

FileGraph.AddNode/AddEdge 潜伏 RWMutex 自死锁(save 在写锁内取 RLock)不属
本卡, 测试改走 JSON 文件加载绕开。
2026-10-17 23:52:51 +08:00
小唯 978f27a164 feat(api): /api/v1/recall 暴露 Rust 侧单次重读行数上限截断标志 truncated(t_3aa2a8c1)
引擎已算(Rust ConsolidateResponse.truncated,t_576714de)且边车日志已打,
但 Go IPC 客户端只打日志不上抛 ⇒ /api/v1/recall 响应无法观测「结果被截断」。

- lancedb_ipc.go: 新增 SearchWithTruncation(上抛 ipcResp.Truncated;本地兜底/零结果恒 false);
  旧签名 Search 委托之(行为不变)。
- recall.go: RecallMeta 加 Truncated;可选扩展接口 SearchTruncationReporter
  (纯 Go 后端/测试 fake 不实现 ⇒ 恒未截断);粗检索两处调用点 OR 聚合。
- core.go: Recall 路由透传(hybrid/semantic/keyword + E4.2/wiki 补充搜 OR);
  响应语义 = truncated 只在 true 时暴露(键缺失 = 未截断,等价 omitempty,注释已写明)。
- 不改:阈值 20000/ZHIYI_HEAVY_READ_MAX_ROWS、降级行为、其他响应字段。
- 测试:假 sidecar 协议级验证透传 + 路由级 4 例(截断/未截断/纯Go后端/keyword),
  逐测试摘除 SearchCacheInstance(storage init() 全局 + 共享 Redis L2 ⇒ 跨 run flaky 的根因)。

实测(独立数据副本 /tmp + 独立 socket,未部署生产):
- cap=300:top_k=3/25000 响应均含 truncated:true;边车日志
  [zhiyi-ipc] search top_k=400 CLAMPED to 300 (single-read cap 300), truncated=true
- 默认 cap:truncated 键缺失;边车 CLAMPED 0 行
- 既有字段 cache_hit/candidates_scanned/count/mode/results 不变
2026-10-17 23:48:26 +08:00
小唯 0bfa7a4bc1 fix(storage): t_bd718662 /recall topK∈{1,2} 让位截断负索引 panic — FTS 尾部位让位数封顶 min(3,F,现有条数)
根因:results 已满(len==topK)时 budget 硬编码 3,topK∈{1,2} 且 fresh≥2/≥3 时
cut=len(results)-nAppend<0 ⇒ results[:cut] 负索引(线上 trace [:-2] @ recall.go:846,
48h 15 次 / 本次复现 20/20 确定性触发)。
修法(附录 C):让位前 nAppend=min(nAppend,len(results)) ⇒ cut≥0 恒成立;
让位只做等量替换不追加扩张(全仓唯一裁剪点仍 recall.go:854,AC-D 计数=1)。
topK≥3 路径 nAppend≤3≤len(results) 封顶不生效 ⇒ 零行为变化(golden diff 为空)。
新增 TestFTS_SmallTopKTailMatrix(topK∈{1,2,3}×len(results)∈{0,1,2,topK}×len(fresh)∈{0,1,2,3,5}
=50 格,断言不 panic+len≤topK+无重复 ID+尾部 FTS 集合精确)+ TestFTS_SmallTopKGolden
(topK 3/4/5 逐字输出,修前修后 diff 空)。修前 topK1_P1_F2 格 panic [:-1] 复现。
部署另开卡(本卡边界:源码+单测+本地验证,不部署)。
2026-10-17 23:15:40 +08:00
小唯 25f68831fd fix(test): 合并两个 TestMain 为单一合并体(t_e5997ca4 落盘点护栏 + t_b7a8ed9c 打分缓存护栏)
两卡分支各自在 routes 包加了 TestMain,合入 main 后 multiple definitions of TestMain
⇒ routes 包 setup failed(全套测试拦下)。按 t_b7a8ed9c 文件顶部的合并提示,把两个
TestMain 体合进 testmain_test.go 并删除 scorecache_guard_test.go。

验收(小怡实跑):
- go test ./internal/api/routes/ → ok 50.344s(修前 setup failed)
- E2E TestCommitConflictGate_E2E → PASS 3.70s;日志 '[scorecache] 已禁用(ZHIYI_SCORE_CACHE=0)'
- 测试不再污染生产 Redis:distill-score 键数 3096 → 3096(不变)
2026-10-17 22:53:43 +08:00
小唯 42fafc495f merge: t_b7a8ed9c 测试隔离(ffc5b10) —— 打分缓存测试不再写生产 Redis,经小怡在分支上实跑验证 2026-10-17 22:49:05 +08:00
小唯 d4b8688148 merge: t_e5997ca4 gap 计数器写侧统一(45726d2) —— 经小怡终审通过(+1 收敛到编译期唯一入口) 2026-10-17 22:49:05 +08:00
小唯 bd399dd007 merge: t_576714de 单次重读设界+IPC重连(c015feb) + t_c70f385c 指标N/A护栏(44fd0e9) —— 均经小怡终审通过 2026-10-17 22:49:05 +08:00
小唯 ffc5b108c6 fix(test): t_b7a8ed9c routes E2E 被 distill 打分缓存 Redis L2 静默短路 — TestMain 关缓存 + Once 活读 env
根因(非卡内假设的 flush 触发条件重构):
- a162e27 打分记忆化 + ce38465 L2 改生产 Redis(zhiyi:distill-score:*,TTL 168h)
  后,routes 包测试(无 TestMain 关闭开关)把「测试内容哈希→结果」写进生产 Redis;
  7 天内后续 run L2 命中 → mock LLM 被短路 → 计数断言 base+1 必挂。
- 实测 TestCommitConflictGate_E2E 16.5s 固定 FAIL(got 0);日志证明 flush 正常
  触发且跑完(batch=1 → DONE),缺的只是 LLM 那次 HTTP 调用。
  生产残留键 TTL≈40h(2026-10-17 复核 143207s)即污染源,TTL 自然过期。
- 对齐门禁规矩③(加缓存须给该包 TestMain 加关闭开关):
  routes 新增 scorecache_guard_test.go TestMain 设 ZHIYI_SCORE_CACHE=0;
  distill.getScoreCache 的 Once 闭包活读 env(包级变量 init 时定型,跨包测试设不了;
  生产语义不变,启动时 env 仍在此处生效)。

验证:go test ./internal/api/routes/ -count=1(不 -skip)ok 34.9s;
go test ./internal/distill/... ./internal/api/... ./internal/selfoptimize/... 全 ok。
⚠️ t_e5997ca4 分支同包加了 testmain_test.go(ZHIYI_DASHBOARD_PATH 护栏 TestMain),
两分支合并时须合并成一个 TestMain。
2026-10-17 22:34:01 +08:00
小唯 45726d26a7 fix(selfoptimize): t_e5997ca4 gap 计数器写侧统一 — +1 只在 open→closed 迁移点 + 计数器 v2 版本化重置
根因(小怡 2026-10-17 终审实测定位):写侧仍有未收口的事件式自增点,
ClosedGaps(9) 越过 TotalGaps(2),gap_closure_rate 被 -1 断言拦下但指标永久不可解读。
  ① routes/gaps.go:44  POST /close 在 Close() 之后无条件 Dash.RecordGapClosed()
     ⇒ 同一缺口每次调用都 +1(无限计数)。且实测现行注册为前缀路由
     /api/v1/gaps/close/(无 {topic} 通配)⇒ PathValue 恒为 ""、端点恒 400,
     该自增点当时不可达,但一旦注册改通配即复活。
  ② server.go:1950  gap_scan 自动修复循环 AutoRepair 成功只计数不关缺口
     ⇒ 同一缺口每轮再 +1(无限虚增,线上 9 vs 2 的主要来源)。

修复:
  - Close/RecordHit 返回 bool(是否真发生 open→closed 迁移);+1 唯一入口
    收敛到迁移点内部;RecordGapClosed 降为包内 recordGapClosed(编译期堵死
    跨包事件式自增)。
  - gaps.go 删无条件 +1;补前缀路由 topic 解析(端点自此真正可用),
    响应如实区分 closed / already_closed。
  - server.go 修复循环改「修复成功 ⇒ 关闭缺口」(RepairHandler 同语义),
    下一轮 !g.Closed 即跳过,计数只在迁移点。
  - 历史 9 vs 2 处置选 (b) 计数器版本化重置:dashboard_schema_version=
    dashboard_schema_v2 标记入 Redis HASH + 本地 JSON(既有 90 天 TTL 键,
    不新增键);首载无标记 ⇒ ClosedGaps/TotalGaps 归零并回写标记(一次性、
    可机械复核),标记在位 ⇒ 保真。代价:丢失不可信的 gap 历史累计。
  - 口径不变:ClosedGaps/TotalGaps = 缺口个数;Metrics() 越界断言保留作
    安全网(不当修复用)。

测试:
  - selfoptimize: Close×3 / RecordHit×3 / 修复循环 3 轮 ⇒ 各只 +1、rate<=1;
    SchemaV2 一次性重置 + 标记在位保真(v1 文件 9/2 载入归零、v2 值保真)。
  - routes: POST /close ×2(现行前缀注册 + {topic} 通配注册两形态)⇒ 只 +1、
    auto_* 计数器快照不变;新增 TestMain 把 ZHIYI_DASHBOARD_PATH 指到临时目录
    (先例:selfoptimize 包同款,防测试写生产落盘点)。
  - 判别力证明(/tmp 改前树 c015feb 旁证副本,不进仓库):逐字复刻改前两写
    路径 ⇒ ClosedGaps diff=6 > TotalGaps=2(rate 3.0 越界,线上 9 vs 2 同型)
    FAIL;改后同场景 diff=2、rate=1.0。

边界:只动 gap 计数器与调用点;部署不在本卡(t_ad887e7c,待牧尘放行)。
2026-10-17 22:12:40 +08:00
小唯 c015febb49 zhiyi: 单次重读行数上限 + Go IPC 读 deadline + 每 rpc 新 dial (t_576714de)
N=2 准入只拦并发堆叠、不拦单请求自身内存:一次 top_k 大 / scan_all
仍能把单条 zhiyi-consolidate 顶到 1~2GB+ 致 swap thrash(t_eedb2d39 根因)。

改动:
- rust/main.rs: ZHIYI_HEAVY_READ_MAX_ROWS(默认 20000,可覆盖)clamp
  lancedb_search top_k + scan_all/scan_deleted;响应体加 truncated:bool
  (#[serde(default)] 向后兼容旧 Go 客户端);zhiyi_ping 暴露当前 cap。
- rust/lancedb_ops.rs: scan_all/scan_deleted 的 .limit(200_000) 参数化为 max_rows。
- go/lancedb_ipc.go: ipcReq/ipcResp 加 Truncated;rpc() 用 SetWriteDeadline(30s)/
  SetReadDeadline(120s)(原 io.ReadFull 无 deadline = 侧车挂死永久阻塞的根源);
  Search/ScanAll/ScanDeleted 截断时打日志(不静默)。
- go/ipc_reconnect_test.go: TestIPCReadDeadlineBoundsHungSidecar(纯 Go,挂死侧车
  被读 deadline 兜住 ~801ms)+ TestIPCReconnectAfterSidecarRestart(env 门控,
  真 sidecar 杀→快速失败 37µs→重启→重连成功)。

隔离副本 /tmp 实测:封顶 20k peak RSS ~1.17GB vs 不封顶 200k ~1.60GB/80s。
不部署(部署另批,需小怡终审 + 牧尘放行)。
2026-10-17 21:52:06 +08:00
小唯 44fd0e95b4 fix(metrics): t_c70f385c 消费者 N/A 护栏 + daily-check N/A 显示 + 反例测试
小怡终审 2026-10-17 21:05 必须修的 4 项之一(-1 哨兵被当低分 ⇒ 触发器恒 fire):

1. selfoptimize.go: 新增 IsNA(v) 判 N/A(metricNA<0)。
2. executor.go: TGapDetected/TQualityDrop/TVValueLow 阈值比较前显式判 N/A,
   -1 不再被当「低于阈值」每轮触发(与旧 recall_hit_rate 恒=1 同类失真、方向相反)。
   配 executor_na_test.go 反例(v=-1 断言不触发)+ 正向对照。
3. tuning.go: AnalyticsHandler 建议 builder 抽为纯函数 AnalyticsSuggestions,
   统一走 ratioFor(-1/valid=0/样本不足 ⇒ 不产生建议);配 tuning_analytics_test.go 反例。
4. 两个 daily-check.sh: gap/auto/usefulness 值为 -1 时显示 —(N/A),不再打印误导性 -1/天。

测试命令(go.mod 在 go/ 下,须 cd go):
  cd go && go test ./internal/selfoptimize/... ./internal/api/routes/
(TestCommitConflictGate_E2E 为既有环境性 LLM 蒸馏用例,非本次改动路径,单独 FAIL)

生产部署仍待小怡终审 + 牧尘放行(本卡边界)。
2026-10-17 21:22:28 +08:00
小唯 b2a016e90f fix(metrics): t_c70f385c 指标口径修复 — recall_hit_rate 退役 + gap_closure_rate 断言 + 最小样本门 N=20
- recall_hit_rate 退役:分子原为 core.go 的 len(results)>0(恒=1、无区分度),
  「返回且被使用」语义已由 recall_usefulness_rate 承担(207 样本 0.98)。
  Metrics()/distributed/metrics-gauge/executor/tuning/CLI/daily-check 全链移除该键。
- gap_closure_rate 统一为「缺口个数」口径:Close() 加 !g.Closed 守卫(与 RecordHit 一致),
  根治同一缺口重复关闭导致 ClosedGaps 虚增、越界 4.5;Metrics() 加越界断言 gapRate>1 → -1。
- 所有率类指标加最小样本门 minRateSamples=20:样本<20 ⇒ -1 + valid=0,
  杜绝 auto_resolve_rate 2 样本判满分。
- 测试:metrics_retire_regression_test.go 用生产同款计数复现改前/改后 JSON;
  selfoptimize 全套通过。
2026-10-17 20:32:34 +08:00
小唯 cee68592e3 feat(deploy): 部署夹带闸门 preflight_reviewed_commits(t_3f83df64)——构建基准 HEAD 相对 tracked 批准清单比对,未评审提交默认拒绝(--allow-unreviewed / --mark-approved 显式放行);起因:01:18 生产二进制夹带 584fe37 等未评审提交 2026-10-17 08:28:01 +08:00
小唯 c3747755b6 feat(rust): t_690cc333 重读准入信号量 N=2 — search>1000/forget_window/scan_all/scan_deleted 计数门限并发内存(3×search10000吞1.5G swap⇒OOM⇒3800写失败的放大器);无许可有界等待100ms后拒heavy_read_busy(不排队不占内存),轻op(ping/insert/update/stats)永不门控;写路径零改动;env ZHIYI_HEAVY_READ_CONCURRENCY可调;zhiyi_ping增heavy_read_in_flight观测。AtomicUsize门(本机toolchain无std::sync::Semaphore) 2026-10-17 01:09:47 +08:00
小唯 4bbf59dabe feat(rust): t_89d22fc8 风暴守卫(AtomicBool+ConsolidatingGuard)+zhiyi_ping健康探针 —— 全量consolidate独占槽防线程堆积OOM;探针判据connect≤3s+ping往返≤5s 2026-10-17 00:06:40 +08:00
小唯 0c0494dabd fix(storage): t_34ec2b1f 时间偏置×信任系数门 ZHIYI_TIME_BIAS_TRUST(默认关·逐位等价)
门关=现状(旧循环, 连乘法都不产生)。门开只降「可靠打分且低质」(0<q<0.6):
  · 不可信族(legacy/recovery/migrat) f=0 → 低质迁移/恢复残留不享受新鲜度加分(P0: q=0.5 wiki:recovery:P0 残留 rank1→9)
  · 可信源 f=q(单调, f(0.5)=0.5<f(1.0)=1.0)
  · q<=0(未打分)/q>=0.6(含0.755 echo) 保原偏置 f=1.0 —— 回归护栏: 全量不可信族清零会误降 152 金标里高质量 legacy(q=0.755/0.805)答案(实测 152=73<75, golden=13<14), 本设计将其排除
与 ZHIYI_SOURCE_TIER_BIAS 作用域不相交(tier 只加 decision, 不含于不可信族)→ 不双重计数。
A/B(隔离实例·独立数据副本·不碰生产7821): 152=76≥75, golden=14/14 不退化; AC1 低质被抬升 396→194(-51%); AC4 双口径一致。
单测: TestIsUntrustedSource / TestTimeBiasTrustFactorMonotonic / TestTimeBiasGate{Off,On,NoDoubleCount}。
2026-10-16 23:05:56 +08:00
小唯 a214cf4a0e fix(storage): t_7793a484 命中事件写路径缺口 — AppendHitEvent 冲突即终态丢(当天 42 条)⇒ 加与 Update 同规格有界重试
- lancedb_ipc.go: AppendHitEvent 原单次 lancedb_append_hit_event(Rust 内部 20 次
  commit 重试耗尽后不重发)⇒ 冲突即终态丢。现加 retryAppendHitEventRPC(同
  t_28aa278e 规格:3 次重试 + 50/100/200ms 指数退避;仅可重试 MVCC 冲突;
  非可重试立即返回)。幂等安全:hit_events 是 append-only INSERT,行 id =
  memory_id+unix微秒 唯一,MVCC commit conflict 时整个提交原子不生效(不产版本),
  重放不产生重复行(与 update col+1 重算同语义,官方 rerun-the-operation)。
- usage_track.go CheckCommittedForUse: 修复前 _ = 吞 AppendHitEvent 错误(10-16
  42 条丢失的主来源);现终态失败 ⇒ 打 [usage] hit event append FAILED 行 +
  不消费 usage_recall(consumed=0 下轮 commit 重扫重试),与 t_28aa278e
  「更新成功后才消费」同构。
- metrics.go: 新增计数器 zhiyi_hit_event_append_failed_total(/metrics 暴露,
  AC3 异源口径:日志 AppendHitEvent FAILED 行 ⇔ 计数器值)。
- IncrementUseful 无需加重试(AC1 代码依据):它走 rc.Update → retryUpdateRPC
  (t_28aa278e 已含 3 次重试);当天 2 条 'IncrementUseful failed' 实测是
  'retry exhausted after 3 retries'(风暴内 4 次尝试全冲突的终态耗尽),
  非「一次冲突即放弃」。残留:feedback 人工路径无 rescan 兜底(同 Update 现状)。
- 测试 hit_event_retry_test.go(复用序列式 fakeServer,全程临时目录+fake socket,
  绝不打开 /var/lib/memoryweave):冲突 3 次→第 4 次成功(重试行 1/3,2/3,3/3 +
  retried OK after 3 + 终态失败归零)/ 恒冲突 4 次有界 + 计数器 +1 / 无冲突恰 1
  次 rpc 0 退避(AC4 逐位等价)/ 非可重试不重试 / 修复前后数值对比(终态失败 1→0)。
  更新 TestCheckCommittedForUse_NotConsumedOnExhaustion 断言(恒冲突总 rpc 5→4:
  AHE 先行耗尽即 continue)。
- 纯 Go 改动,无 Rust/无 schema/不部署(AC5:部署另提卡经牧尘放行,prod-write-gate 会拦)。
  回滚一行:git revert <本提交>。
2026-10-16 22:46:03 +08:00
小怡 a2f355a994 feat(metanote): 影子档 ZHIYI_META_FILTER=shadow(命中只记 would-reject,不拦写)—— 激活前观察期 2026-10-16 22:44:45 +08:00
小怡 fa3490e794 chore(evidence): t_5ad3f843 交付说明(AC 逐条 before/after + 咽喉点收口 27/27 + 终审亲跑证据 + 未部署核定) 2026-10-16 22:26:31 +08:00
小唯 05100339f8 feat(metanote): t_5ad3f843 meta-note 分类门禁(Go 侧,ZHIYI_META_FILTER=1 启用,默认零行为变化)
- internal/metanote:21 条正则 Go 等价式 + 判定逐步同序镜像 Python 口径真相源
  (scripts/lib/meta_note_filter.py);\b/\w 映射显式化:\w→[[:word:]\pL\pN]、
  \b 剥离改匹配值前缀边界校验(pre/post/both 三表,未被裸替代项覆盖才校验)
- parity/差分三件套(同 memprotect 范式):
  * Go 等价式 == 变换函数(Py 源式) 逐字节锁死
  * 实时 dump Python 常量逐字节比对(漂移即 FAIL)
  * /tmp 副本异变必须让 parity FAIL(负控)
  * 14 正+11 负+14 边界+SOT 用例表 全量差分 vs 真实 Python classify
- 咽喉点接线 4 处:Commit/WriteMemory/BatchCommit(422 拒绝+skipped_meta)
  + AutoDistillTrigger(admin distill/force 经此函数一并覆盖)
- handler 级验证:门禁开 meta⇒422 先于触碰 storage(零值 API 不 panic)
- 只新增/改 go 侧与判定,未碰 7821/生产二进制/生产 unit/存量记忆,门未激活
2026-10-16 21:59:28 +08:00
小唯 00747be2db chore(evidence): t_492e3f22 teardown 记录 + 零残留判据 + 4 条 run log
跨卡通知 t_004cc147(内存告警)要求补:测试栈 27G 全下线(双孤儿 zhiyid 已由
prof-cloud SIGTERM,本侧停 sidecar + 删 /tmp/zyvfuse-t492e3f22 + 顺带清
dryrun-dir/mem-copy.lance/spike*)。终验:ps -C zhiyid 空 + du /tmp/zyvfuse-* 无残留。
2026-10-16 19:45:25 +08:00
小唯 08a649792e feat(quality): fuseQuality V值分支 env 门 ZHIYI_V_FUSE_ENABLE(t_492e3f22 次症C)
默认(unset/0)= 保持现状逐位不变;=1 = 关 V 分支(v>0 不再加权)。
AC1 默认不碰: PRE(旧二进制) ≡ DEF(新·默认) — 152 verdict 指纹/top5 顺序/probe10/golden 全同
AC2 翻转证据: DEF vs NOV 排名变化=0(v>0 融合分轴生效、cosine margin 主导无换位)
AC4 受影响条数: v>0 实测 152×5=2/760, probe10×20=2/195(预期 2-3,V 衰减漂移)
AC5 回滚复核: RBK(重启·默认) == DEF verdict 指纹 2318948a + golden 14/14
AC6 异源复算: independent_recheck(第二张口径)与主口径一致,无矛盾
含代码 + 单测(TestFuseQuality_VGateOn/Off)+ 证据包 docs/ac-evidence/t_492e3f22-v-fuse-gate/
2026-10-16 19:40:24 +08:00
小唯 584fe37c35 feat(memory): 主症A 显式记忆直写路径 /api/v1/memory/write (t_fa6c9104)
- POST /api/v1/memory/write: 同步嵌入→去重(0.90合并)/插入 memories 表→
  矛盾检测(只报告不拦)→201 含 memory_id。嵌入失败=502 不写任何东西
  (杜绝零向量);短内容400;GET 405;commit 端点零回归(W6)。
- 插件 memory_write 工具改走新端点(直写可召回),404 自动回落旧 commit 路径。
- eval_test: 金标实跑读活密钥(/var/lib/memoryweave/.api-key),修 2026-09-21
  密钥轮换后 TestEvalGolden_Live 恒 SKIP 的 401 根因;判据未动。
- 已部署 /home/muc/bin/zhiyid-new(v3);重启待操作员执行(审批门控)。

不含 t_492e3f22 未提交的 quality_fusion 改动(留在工作树)。
2026-10-16 18:55:20 +08:00
小唯 525f11f531 fix(governance): t_944344b5 返修R2 — 保护口径逐字对齐 zhiyi_protection.py(10词表+distilled门)+ 口径一致性测试
第1轮评审阻断项修复:
- D1 关键词表 7→10 逐字对齐 Python DECISION_RE(用户明确/牧尘要求/牧尘拍板/
  用户拍板/拍板/明确要求/用户偏好/牧尘决定/用户决定/硬性要求),
  删除 Go 自造词(明确指示/决策/偏好/preference/decision)
- D2 关键词分支加 category=="distilled" 门(Python 为 elif cat==distilled),
  其他 category 含关键词不保护;标签 explicit_protected→decision_content(与 Python 同名)
- D3 注释更正:删除无出处的「2026-08-24 牧尘裁定/7词/完全同规则」声明,
  改为「zhiyi_protection.py 为口径唯一真相源 + 逐字对齐 + 改动须报 default」
- D4 新增口径一致性测试(终审侧硬要求):
  · TestDecisionKeywordsParity —— 实时解析 Python DECISION_RE.pattern/DECISION_CATS/
    V_THRESHOLD 与 Go 常量逐字比对,漂移即 fail(不许手抄声明同口径)
  · TestDifferential_PythonReference —— 同一 fixture 表(A~J+K/L)喂 Go IsProtected 与
    真实 Python is_protected(纯内存),逐例断言一致
  · TestDifferential_FixtureTable —— 手写期望表(CI 安全基线)
- D6 LoadState V 值解析失败聚合记一条日志(注释与代码一致)
- server.go/admin.go 守卫注释补「distilled 且命中」限定

全表差分(t944344b5-ruleset-diff-v2.py,只读):live 28350,
Python 保护 10587 == Go 保护 10587,PYTHON-only=0 / GO-only=0(对称差=0)。
staged 二进制 r2 sha 405d32e3…(未部署,AC5 门禁);生产二进制 7d9ed410… 未动。
2026-10-16 13:03:29 +08:00
小唯 8f57bd0fae feat(governance): t_944344b5 自动遗忘(decay/auto_forget)保护守卫 — 命中保护规则的记忆禁止软删
- 新增 internal/memprotect:与 zhiyi_protection.py 同口径的 4 条保护规则
  (category∈{decision,preference,pref}/distilled决策关键词, V值≥2.0,
   freshness=verified, id∈zhiyi:protected_ids);Redis 宕机降级为纯行内规则。
- server.go decay 循环 + admin.go 自动遗忘路由:在 fragment/ShouldForget
  两条 SoftDelete 道之前拦截,命中即 skip 并打 [decay|forget] protected skip 日志。
- redis.go 增补 SMembers/SIsMember(读 protected_ids)。
- lancedb_ipc.go:IPC 候选补 Freshness 列;forgetCursorPath 支持
  ZHIYI_FORGET_CURSOR 环境变量覆盖(测试不碰生产游标);
  抽出 unmarshalMemoryRecordsTolerant 统一 ScanAll/ScanDeleted 宽容解析
  (修复 ScanDeleted 遇空时间列 time.Time 解析报错的潜在 bug)。
- 新增 memprotect 单测(8) + 端到端回归测试 forget_guard_e2e_test.go
  (复制数据目录+独立 socket,未设 PROT_TEST_SOCK 自动跳过)。

AC1/AC2 已在副本 /tmp/mw-prot-copy-20261016-122000 上验证通过;
生产二进制已备份(zhiyid-new.bak-t_944344b5-20261016-115413, sha 7d9ed410…)
并另存 staged 二进制,**未**上活库(待 default 终审,AC5)。
2026-10-16 12:31:02 +08:00
小唯 211d516e5f fix(distill): t_a0831457 P0 解耦——写时四操作门控不再劫持基础 InsertMemory(shadow/off 恒插入,门控只管标记)+ env 改名 ZHIYI_WRITE_SEMANTIC_OPS(旧名兼容一档)
根因:ApplyWriteOp 在 shadow(默认)/off 恒返回 shouldInsert=false ⇒ api/server.go 蒸馏写回路径
`if !shouldInsert { continue }` 把基础 InsertMemory 整个跳过 ⇒ 2026-10-14 起每日蒸馏事实
2053/1094/1559 → 384 → 0 全丢(shadow log 2189 条判定,2120 条该落库的被弃;episodes 原始层完整可重蒸)。
违背了门控自身注释声明的「默认 shadow ⇒ 零行为变化」硬要求(零变化=等价特性不存在,之前基础插入总是发生)。

改动(三模式语义表):
- shadow(默认) ⇒ 插入新条 ✓ + 不做任何标记 ✓ + 记 shadow log ✓
- apply        ⇒ 插入新条 ✓ + 做标记 ✓(NOOP 丢弃;apply 语义不变,回归测试守住)
- off          ⇒ 插入新条 ✓ + 不跑判定 ✓(零开销)
1) write_ops.go ApplyWriteOp:!writeOpsApply() 分支 false→true(门控不参与插入决策)
2) api/server.go 调用点:verdict!=nil 包一层;err/!shouldInsert 丢弃路径仅剩 apply+NOOP 与 apply 标记失败,均显式 warning(禁静默)
3) AC10:ZHIYI_WRITE_OPS → ZHIYI_WRITE_SEMANTIC_OPS(防「所有写操作开关」误读);旧名兼容一档+弃用日志(sync.Once 只打一次);默认值仍是 shadow
4) 回归测试 write_ops_regression_test.go(旧代码上先红已实证:shadow 4 判定/默认/off 5 项 FAIL):
   断言 shadow 下四操作均 shouldInsert=true 且 MarkSuperseded/MarkConflict 零调用(AC2 增补)
5) 旧测试 TestWriteOps_Noop 改写:其「shadow+NOOP 不插入」断言即 bug 语义本身(量具自证:该测试随修复必须改,否则回归门失效)

边界(AC6):不动 episodes 层、不删任何 memories、不改默认值(仍 shadow,改的是 shadow 的语义);
与 284584c(lancedb_ipc.go/usage_track.go)文件不重叠,合并部署=一次观察协议+一个回滚点。
未部署:按 AC3/AC9 交证据包(diff+测试红/绿+md5+回滚方案+观察协议)本体转呈牧尘拍板。
2026-10-15 22:53:03 +08:00
小唯 284584c578 fix(storage): t_28aa278e 方案① Update 冲突自动重试+指数退避 且 usage_track 更新成功后才消费
- lancedb_ipc.go: Update() 遇 LanceDB MVCC 写冲突不再静默吞错(恒返 nil),改为复用
  isRetryableLanceConflict 模式做有界指数退避重试(updateMaxRetries=3, 50/100/200ms, cap 200ms,
  最坏+350ms);耗尽/非可重试返回明确错误(含尝试次数+id)。新增 retryUpdateRPC(table,id,fields)
  (table/fields 参数化,区别于软删专用 retrySoftDeleteRPC)。保留 "Update OK/FAILED" 日志标记,
  重试成功打 "retried OK"、耗尽打 "FAILED (retry exhausted)",失败数可查。
- usage_track.go: CheckCommittedForUse 改为「更新成功后才消费 usage_recall 行」——
  Update 返回非 nil(冲突耗尽/非可重试) ⇒ consumed 保持 0,下轮 commit 重扫会重试,
  计数增量不再静默丢失(修复前无条件 consumed=1 永久丢)。
- 测试: 复用序列式 fakeServer 新增 TestUpdate_{ConflictRetry_PreservesIncrement/AlwaysConflict_ExhaustsBounded/NoConflict_ExactlyOnce_NoBackoff/NonRetryableError_NoRetry/RetryFix_BeforeVsAfterComparison};
  更新 TestConcurrentCommitConflict_CrossPath(断言修复后重试到成功、缓存/库一致) 与
  TestCheckCommittedForUse_ConsumesOnConflict→NotConsumedOnExhaustion(耗尽不消费)。
- 纯 Go 改动,无 Rust/无 schema/不部署(AC5 边界);不改 t_2be29a01 评分、不碰 t_022b116f 守卫。
2026-10-15 21:32:41 +08:00
小唯 bdc18f6177 fix(recall): t_1a30330b scan_all 读侧漏接 last_recalled_at(恒 String::new() ⇒ 误诊全库为空)+ ZHIYI_LAST_RECALLED_WRITE 冲突重试门控(默认关=现状)
根因:本体审计用 scan_all 计数 last_recalled_at,而 rust/lancedb_ops.rs 的 scan_all/search/query_memories 三处均硬编码 last_recalled_at: String::new() ⇒ 全库看似 0 非空 ⇒ 误判「写入缺失、遗忘判据退化」。实际 forget_window(遗忘循环真正读侧)读到 78.8% 非空、含当日 321 条写入 ⇒ 写入工作正常。

修复:
1. 三处读侧接线 last_recalled_at: col_str(&batch,i,"last_recalled_at") —— 根治误诊,审计/列表/测量现可见真实值。
2. env 门控 ZHIYI_LAST_RECALLED_WRITE(默认关=现状):开=对 update_recall_batch 的 Commit conflict 重试一次(重开表拿新 read_version),提高并发下 last_recalled_at 落盘率。默认关 ⇒ 生产零行为变化。
2026-10-15 21:08:39 +08:00
小唯 b017c35d37 test(storage): t_b845d2d6 复现 recall_verified_count 并发提交冲突——两条写路径(Update持锁 vs UpdateRecallBatch不持锁)在单巨片段表上重叠必冲突 + Update静默吞错致计数丢失
机制: LanceDB MVCC 乐观并发, 全部 37358 行落在单 fragment(533268),
任何单行 update 都整段重写 ⇒ 两条重叠提交必冲突(与写哪行无关)。
Go _local.mu 只串行 Update-vs-Update, 串行不了 vs UpdateRecallBatch;
sidecar(main.rs:238) 每连接一线程, 无提交串行化。
本测试用 fake socket server 镜像该并发窗口, 确定性复现:
① 并发提交 conflicts>=1  ② Update() 对冲突返回 nil(静默)  ③ 冲突后 usage_recall 仍 consumed=1 ⇒ 计数丢失
2026-10-15 21:00:27 +08:00
小唯 c12f55ef71 feat(distill): t_ffcee190 非规则卡短事实 keep 档穿透(env 门控 ZHIYI_SHORTFACT_KEEP_PASSTHROUGH,默认关=现状)
observe 档真实对话自然短事实被 <20字/实体动词 启发式误杀(真实对话子集 ~62%,t_1c420e43 量化)。
新增 env 门控(默认关=零行为变化):
- effectiveFilterLane:env 开 且 lane=observe 且 非规则卡(content 非 [规则: 前缀) → 走 LaneKeep 穿透
- 规则卡恒返回原 lane ⇒ 碎片零泄漏(规则本体在 skill)
- 不动 IsQualityFact 阈值、不动 lane 分类器
单测 6 例锁定:默认关等价 / 规则卡不穿透 / 非规则卡救回 / 疑问状态硬否定仍生效 / 规则卡碎片仍丢 / 默认关委托现状
2026-10-15 20:20:12 +08:00
小唯 e52f335998 feat(recall/fts): t_287c6d51 FTS 尾部位门槛按 query bigram 数分档(1-bigram→env 可放宽至~0.15 / ≥2-bigram 与混合恒 0.5),默认全 0.5=零行为变化 2026-10-15 19:04:31 +08:00
小唯 24d6c46065 feat(recall/fts): t_da61c470 两处 env 门控(默认=现状,零行为变化)
改点A · FTS 纯 CJK(bigram-only)尾部位门槛可配:
  - FtsBigramTailGate()/IsBigramOnlyQuery()(fts_index.go)
  - Step 3.8 尾部位门槛:bigram-only 查询走 FtsBigramTailGate()(默认 0.5=现状),
    混合/ASCII 查询恒 0.5。权重公式不动。
  - env ZHIYI_FTS_BIGRAM_TAIL_GATE≈0.15 时,纯中文专名 bigram 档分 0.16~0.256 能拿尾部位。

改点B · rerankN 下限 env 门控:
  - envInt() 助手(recall.go)
  - rerankN:=topK*2 之后加 floor(env ZHIYI_RERANK_N_FLOOR,默认 10=V2 OFF 现值)。
    A/B 设 25 救『池内有货被二次截断』(8081 组 ANN #17~21 / QE 后淇县 #13)。
    只抬下限不抬上界,V2 ON 的 150 不动。

默认值 = 现状 ⇒ env 不设时行为零变化(AC2/AC4 判据);回滚 = unset 两个 env。
零 DB 写入;只读接口 + 本地源码。
2026-10-14 19:41:38 +08:00
小唯 e42d19cfd9 fix(eval): t_dfc93ad0 AC5 golden 缺省路径内嵌兜底 + AC6 回滚时效说明
AC5:部署二进制 CWD=/home/muc,旧版 LoadGoldenSet("") 回落相对
testdata/golden-set.json ⇒ POST /api/v1/eval/golden 不传 golden_path 时
500(2026-10-14 实测复现)。修://go:embed testdata/golden-set.json 打进
二进制,缺省/读不到的路径回落内嵌基线(仍可用);env ZHIYI_GOLDEN_SET
可指定绝对路径临时替换。新增 TestLoadGoldenSet_EmbedFallback_DeployCWD
(空 CWD 下缺省/相对路径均 14 条)防回归。

AC6:fts_index.go 门控注释补「撤标志 ≠ 立即生效」——recall 路径
TTL=60s 搜索缓存(cache_hit),撤门控后 60s 内同 query 命中 ON 态缓存
(终审「撤门控仍 14/14」假象根因=两次测量隔 9s)。可证伪判据:
回滚后等 >60s(实操 75s)或重启 zhiyid 再跑 eval/golden,期望 MMR 条
keyword_pass 13/14 且 cache_hit=false。
2026-10-14 17:32:09 +08:00
xiaowei e5bd7c7576 feat(recall): t_2afae95e 修 #4 满列表尾部位扩为 3 个 ASCII 档槽位
实测:MMR 查询下 FTS top-3 = E1档#1(0.797) + MMR档#2/3(0.753),
旧版满 topK 只让 1 个尾部位 ⇒ 给 E1 档文档(内容无 MMR)⇒ 关键词
校验仍 13/14。扩为尾部最多 3 个 ASCII 档(分≥0.5)槽位:MMR 档文档
必进 top-5;bigram 噪声档(<0.32)不占槽位(只补未占满余位),
头部向量文档至多让 3 位且仅当 FTS 真有强命中。新增
TestFTS_RecallFullListTailSlots 钉住满列表场景(修前挂,修后过)。
2026-10-14 14:58:23 +08:00
xiaowei 3594456df3 fix(fts): t_2afae95e 修 #3 ASCII 档占比分母只用 ASCII 词 IDF 和
旧版分母混入 bigram IDF 和(占 Σ 大半)⇒ MMR-only=0.594 / E1-only=0.599
近乎打平,排序退化成按 id 走(实测 MMR 文档压在同分 E1 文档后面)。
改分母 = ASCII 词 IDF 和 ⇒ MMR-only=0.764 / E1-only=0.786 / 双命中=1.0,
序稳定可解释;bigram 噪声档 <0.32 不变。
2026-10-14 14:45:43 +08:00
xiaowei 3fa7850f01 fix(fts): t_2afae95e 打分改类型感知分层(ASCII 命中 ≥0.55 / 仅 bigram ≤0.32)
生产实测缺陷(二踩后定位):纯 IDF 排序被 CJK bigram 噪声反杀——
查询「MMR 多样去重」下 55-token 天气问答撞中某 df=1 bigram 拿满 IDF
分 0.976 霸占 FTS top-1(尾部保证位取 top-1),而真正含 MMR 的
8-token 目标文档(n_tokens=8)只排 #21 ⇒ golden 关键词校验仍挂 13/14。
任务明写「稀有 ASCII 词/标识符/数字优先」⇒ 分层:
  - 命中任一库内 ASCII 查询词 ⇒ 0.55 + 0.45×(命中词 IDF 占比) ∈ [0.55, 1.0]
  - 仅命中 bigram(无 ASCII 命中)⇒ 0.32×(1-1/(nb+1)),封顶 <0.32
⇒ bigram 噪声文档永远压不过 ASCII 命中文档,尾部位必选 ASCII 档。
新增 TestFTS_ASCIIHitBeatsBigramNoise 钉住该分层(修前该场景挂,修后过)。
2026-10-14 14:23:53 +08:00
xiaowei 827de4f65a fix(fts): t_2afae95e 打分改稀有词主导(0.55*稀有分量+0.45*总量分量)
实测缺陷:纯总量 TF-IDF 分被高频 CJK bigram 稀释——MMR 查询下,
无 MMR 的高分 bigram 文档(0.252)压过含 MMR 的文档(0.147,排 #13),
尾部 1 位保证位选了错文档 ⇒ 关键词校验仍挂(13/14)。
改为 0.55*(命中词中 IDF 最大者的分/maxIDF) + 0.45*(总量分/全词IDF和×2):
只命中稀有 MMR 的文档稀有分量≈1.0,无 MMR 文档=0 ⇒ 稀有词重新主导。
顺带:FtsQuery 扩 check_doc 异源复证 + topN 排序改 O(n log n)。
2026-10-14 13:52:48 +08:00
xiaowei 808b9862c9 feat(fts): t_2afae95e 新增 /api/v1/fts/query 取证端点(AC1 判据命令,观测词面 topN 命中排序) 2026-10-14 13:45:50 +08:00
xiaowei b69cc01d52 fix(fts): t_2afae95e FtsIndexStats 取值移入持锁区间(旧版 RUnlock 后读字段=数据竞争) 2026-10-14 13:22:54 +08:00
xiaowei 80d0a37116 fix(lancedb): t_2afae95e ScanAll 血缘 5 列一并 shadow 宽容(version_history 等 Rust 输出 JSON 串)
Rust scan_all 对 version_history/depends_on/source_events/supersedes/
superseded_by 输出 JSON 数组字符串,Go 模型是 [] 类型 ⇒ 直接 unmarshal
整批炸(FTS 首建二踩:parsing time 修完下一个炸 version_history)。
全部 shadow 成 string + 宽容还原(空串/[]/畸形 ⇒ nil)。测试同步扩到 8 列。
2026-10-14 13:19:26 +08:00
xiaowei 793bfefc61 fix(lancedb): t_2afae95e ScanAll 时间列宽容解析(空串/畸形不炸整批)
FTS 首建实测踩到:scan_all unmarshal: parsing time "" ...(存量行
created_at/last_recalled_at 有空串)。直接 unmarshal 到 []models.MemoryRecord
时 time.Time 严格解析把 2.6 万行全炸 ⇒ 索引建不起来。
改外层匿名 struct 用 string shadow 三个时间字段 + 宽容 parse(空/畸形⇒零值)。
新增 TestScanAllTimeTolerance 钉住该行为。
2026-10-14 13:13:58 +08:00
xiaowei db6ab564dc feat(recall): t_2afae95e FTS 词面检索通道(TF-IDF 倒排,Go 内存索引,env-gate 默认关)
根因:金标 MMR 条目的关键词只在向量 rank #126/12094 的记忆里 ⇒ 调权重救不回来,
embedding 主导的链路没有真正的词面检索通道(卡内 2026-10-14 实测定位 4 处)。

- 新 internal/storage/fts_index.go:TF-IDF 倒排(ASCII 词+CJK bigram,与
  tokenizeQueryV2 同口径;IDF=log2(N/df+1),TF=1+log(tf))。数据源=ScanAll
  (Rust IPC lancedb_scan_all 只读);索引落 Go 内存,不建 LanceDB tantivy
  索引文件/不碰 LanceDB 版本链(_versions 膨胀历史教训)。
- 生命周期:启动 3s 首建(同步)+ 6h 周期 + 写路径 FtsInvalidate 标脏后
  异步重建(代际号防双重建互踩;30min 节流防 loader 缺失时疯狂起 goroutine)。
  重建异步 ⇒ 在线召回 P95 不受建索引影响。
- recall.go Step 2.2:词面命中不进主候选池(无向量,混入会让 rerank 回退
  文本重编码路径);Step 3.8 rerank+MMR **之后**尾部保证位追加(诊断第 3 条:
  融合必须在 rerank 之后才算数;头部 topK-1 相对顺序不动 ⇒ AC4 零回归)。
- models.RecallResult 加 FtsBoosted(omitempty,展示侧可辨来源);
  QualityScore 保持 DB 原值(词面分只进排序不进展示分,对齐『响应值==库值』铁律)。
- server.go:FtsProvider 挂 ScanAll + 蒸馏落库后 FtsInvalidate +
  /api/v1/cache/stats 加 fts_index 段 + /api/v1/fts/rebuild 手工刷。
- 门控 ZHIYI_RECALL_FTS=1 或标志文件 /var/lib/memoryweave/recall-fts.on,
  默认关 ⇒ 零行为变化;回滚 = 删标志文件/置 0,一条命令。
- 单测(双向验证):稀有词取回/TF-IDF 排序/门控关零变化/尾部去重/raw 剔除/
  IDF 形状/空库边界。go test ./internal/storage ./internal/models 全绿。
2026-10-14 13:02:37 +08:00
小唯 339642b2c8 Reapply "fix(recall): 中文关键词打分 V2 + 扩候选池(env/标志文件门控,默认关)"
This reverts commit 1cdeb4a54b.
2026-10-14 10:42:01 +08:00
小唯 c2683fd6a9 Reapply "fix(recall): V2 关键词分必须进入最终排序(原被 rerank 整段丢弃)"
This reverts commit 9236d8d4b4.
2026-10-14 10:42:01 +08:00
小唯 1cdeb4a54b Revert "fix(recall): 中文关键词打分 V2 + 扩候选池(env/标志文件门控,默认关)"
This reverts commit 4565026b09.
2026-10-14 10:40:44 +08:00
小唯 9236d8d4b4 Revert "fix(recall): V2 关键词分必须进入最终排序(原被 rerank 整段丢弃)"
This reverts commit 43a9cc8751.
2026-10-14 10:40:44 +08:00
小唯 43a9cc8751 fix(recall): V2 关键词分必须进入最终排序(原被 rerank 整段丢弃)
实测:Step 3.5 的 BM25 融合作用在 candidates 上,紧接着 rerank 按 cosine 重打分
并只保留 topK*2 条 ⇒ 关键词分被整段覆盖,且相关记忆(cosine 排名 #126)在那 20 条
之外 ⇒ 无论池子放多大都无效(V2 开/关结果逐条相同,就是这么来的)。

改动(仅 V2 生效):重排候选 topK*2 → 至少 150;rerank 后按
score = 0.7*rerankScore + 0.3*kwV2 融合,再进 MMR 与截断。
2026-10-14 10:38:49 +08:00
小唯 4565026b09 fix(recall): 中文关键词打分 V2 + 扩候选池(env/标志文件门控,默认关)
实测根因(2026-10-14,golden 条目「织忆如何通过 E1 提取和 MMR 实现多样去重」):
① 候选池太窄:per-ns 向量 top-50(candCap 100),而含 MMR 的记忆向量排名 #126/12094
   ⇒ 根本进不了池,后续任何打分都救不回来。
② 旧 ComputeBM25Score 用 strings.Fields 只按空格分词、整段短语做子串计数
   ⇒ 中文查询的关键词分基本失效,且无 IDF(稀有词不加权)。
③ 融合 0.7*quality + 0.3*kw,而 IPC 路径下 quality 会饱和到 1.0(代码注释原话)
   ⇒ 候选间质量分几乎相同 ⇒ 排序实际由失效的 kw 决定 ⇒ 退化成向量原始顺序。

改动(ZHIYI_RECALL_V2=1 或标志文件 /var/lib/memoryweave/recall-v2.on 开启):
- ComputeBM25ScoreV2:查询切 ASCII 词(权重 10,廉价 IDF 代理)+ CJK 二元组(权重 1)。
- 扩池:per-ns 50→200;candCap 100→300。
默认关 ⇒ 行为零变化。单测双向验证:mmr=0.3125 vs generic=0.0312。
2026-10-14 10:34:45 +08:00
小唯 b13cdea910 chore(eval): 按 per-item ns 重建金标基线(14 条 → 唯一基线)
- 基线全部按各条 ns 的当前 top-1 重建:原 14 条里 4 条共用同一 id,现已唯一。
- 结果:kw_hit 几乎全 true(此前失败的两条「技术栈」「牧尘偏好」现均命中)
  ⇒ 证实那 2 条是 ns 假失败,不是召回缺陷。
- 刷新结论 pass=2 / warn(漂移)=11 / fail=1 —— 漂移是基线重建的正常表现。
2026-10-14 10:22:39 +08:00
小唯 88d466d156 fix(eval): 金标集 per-item namespace —— 修「ID 指标按构造恒 0」
实测根因(2026-10-14,查 LanceDB 得到):金标的 expected_id 分散在
hermes-main / domain:HUB / soulful-xiaowei / wiki / shared 多个 namespace,
而 eval 只用请求里的单一 ns(hermes)召回 ⇒ 所有 ID 类指标必然为 0。
这不是「基线漂移」(我先前两次判断都错在这里)。

同时暴露金标集本身的问题:4 条(#1/#9/#11/#12)共用同一 expected_id、
多条基线内容与 query 不匹配(如「图谱扩展机制」钉的是「待处理织忆图谱更新」)。
⇒ 框架没问题,数据是烂的。

改动:
- GoldenSetItem 加 Namespace 字段;RunGolden 用 item.Namespace(空则回落到请求 ns)。
- 金标集 14/14 条补 namespace=hermes-main(内容实际所在),基线随后用
  golden-check.py --refresh 按当前 top-1 重建。
- 同口径修 Python 量具 golden-check.py(recall 支持 namespace 参数)。
2026-10-14 10:21:42 +08:00
小唯 faf71cbe99 fix(tuning): 调参接线消费方 + 阻尼 + 持久化(治「旋钮没接到任何东西」)
实测三真相(2026-10-14):
① 无消费者 —— grep Tuner. 全仓只有 3 处路由注册,没有任何代码读
   Diversity/GapThreshold/DecayRate/DistillInterval ⇒ 调参改了数字、行为零变化。
② 不幂等(棘轮)—— 同一判据每调一次推一步:连跑两次 decay 0.015→0.018→0.0216。
③ 不持久化 —— 包级内存变量,重启即回默认。

修法:
- 接线:storage.SetDefaultMMRDiversity / selfoptimize.SetGapThreshold(消费者持值、调参器推送,
  避免 storage←routes 反向依赖)。初值对齐消费方真实默认(Diversity 0.5→0.3)⇒ 当天行为不变。
- 阻尼:同一参数 6h 冷却。
- 持久化:/var/lib/memoryweave/tuning-state.json(env ZHIYI_TUNING_STATE 可覆盖),启动 load+接线。
- 删规则3(废弃率→提高衰减率):方向危险 —— NewForgetter 注释记载
  「0.015→0.03 曾致误删(36天库龄下 0-recall 长记忆全被清)」。
- 删规则5(蒸馏损失→缩短蒸馏间隔):无正确消费方 —— engine.go 的 batchTimeout 是
  「队列冻结兜底 ticker」(安全网),不是蒸馏节奏。

测试 4 个:哨兵/少样本拦截、冷却、推送、持久化(含「健康数据仍能调」反向验证)。
2026-10-14 09:50:09 +08:00
小唯 113e6e8274 fix(tuning): AutoTune 加指标可用性护栏 —— 无数据(-1)/退化/样本<30 一律不调参
事故面(实测现网):
- gap_closure_rate = -1(无数据哨兵)⇒ 规则4 'gapRate < 0.5' 恒真 ⇒ 每轮把 GapThreshold 往下砍
- recall_usefulness_rate 只有 9 个样本 ⇒ 一次偶然好评就改 Diversity
即:AutoTune 会**按垃圾指标改生产运行参数**,且从不记'为什么没调'。

修法:用 selfoptimize 早已定义好的约定(<_base>_rate / _valid / _samples,
分母退化时比率返回 -1)判定可用性;三类不可用一律跳过,绝不改参数。
并在 status/run 暴露 last_skipped,让外部能区分'健康'与'数据不可用'。

注:命名不统一(值键 avg_distill_loss 无 _rate 后缀),用 TrimSuffix 推 base ——
我第一版按 '<key>_samples' 拼键,护栏静默失效,被新增的双向验证测试当场逮住。
2026-10-14 09:41:36 +08:00
小唯 de0ddf4b8b chore(deploy): bi-temporal e2e 闸门接进部署流程(堵『写了但永不开火』的洞)
背景:t_763e8701 交付了 bitemporal_e2e_test.go,但该测试默认 t.Skip
(需 ZHIYI_BITEMPORAL_SOCK 指向运行中的 sidecar)。没人设环境变量 ⇒
这道『机器闸门』永不开火 ⇒ 等于不存在(机制铁律:写完无人调用 = 零效力)。

改动:do_verify 增加 verify_e2e_bitemporal() ——
用本次已安装的 sidecar 开临时 socket + 临时 data-dir(不碰生产库),
跑 Go e2e(真实 LanceDB 写→读回),失败即 die。

起因事故:t_3ada332b 单测全 PASS 但生产 no-op —— 三列在 LanceDB 边界
被静默丢弃(Go payload 缺 3 键 + Rust schema 缺 3 列),
validityExpired 永远拿到空串 ⇒ 降权永不触发。本闸门即该事故的守门人。

验证(双向):
- 通过路径:deploy.sh --verify-only 全绿,含『✔ bi-temporal e2e 闸门通过』
- 失败路径:把测试指向不存在的 socket ⇒ go test 返回 FAIL(非零)⇒ 函数 rc≠0 即 die
2026-10-14 05:05:24 +08:00
小唯 d0558e1fd5 fix(zhiyi): t_763e8701 返修 bi-temporal 三列未落 LanceDB — 补齐 Go payload+读回 & Rust schema+builder+读侧 5 层
根因:t_3ada332b 只改了 models 结构体 + server.go 算值,LanceDB 边界三层缺失
(Go lancedb_ipc.go 写 payload + 读回解包、Rust arrow_schema + struct + builder + col_str)
⇒ valid_to 永不持久化 ⇒ validityExpired("") 恒 false ⇒ 失效降权生产 no-op。

本次(照 t_3e98b65a 给 superseded_by 的 5 层模式逐一对齐):
1. Go lancedb_ipc.go 写侧 payload 补 valid_from/valid_to/provenance 三键
2. Go 读侧补三处:Search 解包struct+输出映射 / Scan 解包struct+输出映射 / GetSkillCandidates mapStr 路径
3. Rust lancedb_ops.rs struct 字段(#[serde(default)],存量行不报错)
4. Rust arrow_schema Field::new 三列(nullable,存量由 open() 迁移补列)
5. Rust 写 builder col vec + push + StringArray + 5 处读侧 col_str

新增 e2e 回归闸门 bitemporal_e2e_test.go:经真实 LanceDB sidecar 写一条 valid_to 已过期记忆
→ 读回断言三列非空且等于写入值(ZHIYI_BITEMPORAL_SOCK 门控,未设跳过)。
单测(AC1/AC2/AC3/AC4)+e2e 全 PASS;全量 go test 仅既有 flaky TestCommitConflictGate_E2E(clean baseline 同败)。
2026-10-14 05:01:27 +08:00
小唯 12f808d0e1 feat(zhiyi): t_3ada332b P3 bi-temporal 有效期 — memories 表加 valid_from/valid_to/provenance(默认空=永久有效) + 写时识别时间敏感事实(distill/bitemporal.go DetectValidTo 只认强时间词+带触发上下文显式日期, 误报止损) + 写路径填 valid_to/provenance + 召回降权(recall.go validityExpired, env 门控 ZHIYI_BITEMPORAL=0 默认关零行为变化, non-lossy 只剔候选不删库); 单测 storage 3(AC1/AC3/AC4)+recall 2(AC2)+distill 4 PASS; 全量 go test ./... 仅预存 flaky TestCommitConflictGate_E2E(clean baseline 同败,归因 t_fcadee04) 2026-10-14 04:11:52 +08:00
小唯 44ccb69ca5 chore(deploy): deploy.sh 加构建前洁净检查 + ignore 探针残留
起因(2026-10-14 当晚三次同族事故):
① f8ffcde 把另一任务在途的 server.go 布线一起提交 ⇒ HEAD 编译不过;
② deploy.sh 原先直接从**工作树**构建 ⇒ 任一 worker 在途改动会被顺手编进**生产二进制**,
   且部署后『运行二进制校验和 == 本次构建』仍然通过 ——
   校验和只能证明『跑的是本次构建』,不能证明『本次构建来自已提交代码』。这是验证链的洞。

改动:
- preflight_clean_tree(): git status --porcelain(含未跟踪)非空则拒绝构建;
  显式 --allow-dirty 可覆盖(风险自负,须在卡上记原因)。
  含未跟踪是刻意的:internal/peer 那类事故正是『已提交代码引用未跟踪文件』。
- .gitignore: /go/*-probe(清 17MB 残留 rulefirst-probe)

验证:闸门在 build 前 die(rc=1),生产未动(两服务仍 active、二进制时间戳不变);
--verify-only 不受影响。
2026-10-14 03:44:39 +08:00
小唯 95cc81db4b feat(zhiyi): P3 Peer Representation (t_f8fc97e6) — Honcho四层推理画像 explicit→deductive→inductive→abductive; 新 internal/peer 包(CollectFacts分类+Reasoner接口化:生产8081本地2B,测试Fake零网络)+peers表(graph.db同库,不碰state.db)+API POST /peer/{id}/refresh+GET explain+POST /recall/peer+主召回注入(env门控ZHIYI_PEER_RECALL默认0零行为变化)+commit异步钩子(非阻塞); 单测6+8+3 PASS; 幂等non-lossy合并 2026-10-14 03:39:49 +08:00
小唯 f8ffcde0f4 feat(distill): t_3e98b65a 写时四操作(ADD/UPDATE/NOOP/CONFLICT-MARK)+写前哈希去重——Mem0血统
影子默认(ZHIYI_WRITE_OPS=shadow,零行为变化;=apply才真改库):
- 新 distill/write_ops.go: 归一化+MD5哈希去重(ContentHash)、规则基四操作判定
  (defaultJudge可注入JudgeFunc/ContradictionFunc,生产可接governance矛盾检测)、
  shadow日志JSONL(/var/lib/memoryweave/write-ops-shadow.log)
- 四操作: ADD新建/UPDATE旧条标superseded_by+新条active/NOOP丢弃记log/
  CONFLICT-MARK双方标conflict交人工(永不自动删)
- server.go OnDistillComplete 写路径挂四操作判定(影子只记不改库)
- schema: models+sqlite.go+lancedb_ipc.go+rust 加 superseded_by 反向指针列(additive)
- 顺手: engine.go Weights/echoScoreVectors 补 MemOS 血统出处注释(借鉴溯源全清单§2.1)
- 报告生成器 scripts/tools/zhiyi-writeops-report.py(影子48h后出分布+误判抽样)

AC: 四操作各1单测+哈希去重+影子零变更全PASS; go build全过;
Rust release build 0; 除既有flaky TestCommitConflictGate_E2E(干净HEAD同败)外go test全绿
2026-10-14 03:18:13 +08:00
小唯 0afff5734e feat(zhiyi): L3 skill 自动草稿 (t_fcadee04) — skills表(graph.db同库,不碰state.db)+草稿落盘attachments(<pattern>/skill-draft.md带[AUTO-DRAFT],绝不进skills/)+POST /patterns/{id}/draft+promote(draft→active人工确认); 生成器接口化(生产复用8081本地2B,测试FakeGenerator零网络/CGO); 显式API不挂cron默认零行为变化; 单测11+5用例PASS; 幂等:重复draft覆盖内容,active不覆盖 2026-10-14 02:35:50 +08:00
小唯 ef4f379736 refactor(storage): t_5028c14d 抽出召回融合纯函数 + 回归测试锁死「频率/时间衰减参与排序」 — Search()内联算法逐位等价抽到 quality_fusion.go(computeRecencyFactor/computeImportance/fuseQuality), 行为零变化不改数值; 新增单测锁定频率↑/越近→融合分↑; go build 0; 除既有flaky TestCommitConflictGate_E2E(干净HEAD同败)外全绿 2026-10-14 02:05:00 +08:00
小唯 aab877a13c fix(distill): t_c06ad4d0 解污染——蒸馏回调改走独立计数 distill_success_count,不再灌 UsefulCount/recall_usefulness_rate 分子; 拆账不丢数(字段+RecordDistillSuccess+Redis/本地持久化+Metrics键); 回归测试10次蒸馏⇒UsefulCount仍0、无反馈样本rate仍metricNA 2026-10-14 02:01:36 +08:00
小唯 1cfb17ac5e chore(git): 停止跟踪 13 个 *.bak.* 改动前快照 + .gitignore 加规则防复发
这些 .bak.<topic>-<ts> 是各卡改动前的备份快照,被多张卡(proofchain/timedecay/standing)
陆续 commit 进版本库,累计约 4800 行纯垃圾,且会随历史永久保留。
git 本身就是版本历史,备份快照不属于版本库。

做法:git rm --cached(磁盘文件保留,不动内容)+ .gitignore 加 *.bak.* 。
本 commit 不触碰任何源码。
2026-10-14 01:57:18 +08:00
小唯 8364ff1b36 feat(standing-answers): t_e9a4d18f P2 常设答案(mental models)——standing_answers表(SQLite+Rust schema)+读写存储+API直读(/api/v1/standing-answers)+注册表(3问)+生成脚本zhiyi-standing-answers.py(复用本地2B); 单测4/4 PASS; 默认不注入行为零变化 2026-10-14 01:47:58 +08:00
小唯 57bdf7fa03 feat(zhiyi): ②L2 模式检测+召回注入 (t_d32e5f8a) — 复活 L1→L2 pattern 蒸馏: 新 internal/pattern 包(工具组合频率≥3 + 路径 cosine≥0.85 聚类,设计§4.1)→ upsert graph_nodes(type='pattern',ns=daemon-distill)(与 85 存量同表不开新表·modernc sqlite 短命开/关·不碰 state.db); API POST /patterns/detect + GET /patterns + zhiyi-cli pattern detect/list(cron 可单发); 召回注入 env 门控 ZHIYI_PATTERN_RECALL 默认关⇒零行为变化; 单测 12 用例(detect5+store4+注入3+route4 含空库幂等)PASS; 零新依赖不碰 state.db 2026-10-14 01:44:39 +08:00
小唯 d2348eda43 feat(distill): t_2515307a 确定性判据强化——召回侧规则过滤/降权(env门控ZHIYI_RULE_FIRST默认关); RecallRuleVerdict复用quality.go+admission_lane规则+URL/路径强实体豁免; routes/core.go Recall按verdict过滤drop/降权demote; 单测16用例PASS; 影子探针rulefirst-probe/scan取证26drop+4demote; 零新依赖不碰state.db 2026-10-14 01:28:10 +08:00
小唯 467d9b4ac1 fix(metrics): 分母退化返回 N/A(-1) 而非 1 + 每项比率自报 samples/positive/negative
牧尘批准「分母退化就返回 N/A 而非 1」。逐项落地:
- 新增 metricNA(-1) 哨兵:真实比率必落 [0,1],-1 不可能被误读成任何一档成绩
- 6 项比率/均值统一走 rateOrNA():分母<=0 ⇒ -1(旧实现返回 0,0 意为「0%」同样是没资格下的结论)
- 每项配 <name>_valid(1=可解读) / <name>_samples(分母) / usefulness 另配 positive/negative
  ⇒ 让「负样本饥饿」可被机器读出,无需魔法阈值
- distributed.go Prometheus 渲染同步:HELP 注明 -1 语义 + 输出 *_valid/*_samples,
  并警告看板须先按 *_valid 过滤,否则 -1 会拉低均值

只增键、不改键名 ⇒ 全部既有消费方(SyncFromDashboard / 各 JSON 端点)不受影响。
单测 5/5 新增 PASS;既有 2 例断言旧语义(零状态=0)已按本意更新为对齐零状态值。
go build ./... 通过;go vet 干净。

⚠️ 尚未解决(另案):recall_usefulness_rate 的真病灶是 server.go:384 在蒸馏完成回调里
调 RecordUseful() ⇒ 分子数的是「蒸馏成功次数」、分母是「召回负反馈次数」,两者不同源。
本 commit 只让它可被看见,未改该调用点(server.go 当前有其他 worker 在改,避免撞车)。
2026-10-14 01:25:45 +08:00
小唯 ef64da6df2 feat(zhiyi): ⑤使用追踪 (t_5aa4dcac) — 召回→后续内容携带→判定被用上, env门控ZHIYI_USAGE_TRACK默认关⇒零行为变化; 落hit_event(pos/downstream_use)+累加recall_verified_count供⑥反馈; /usage/report+/usage/detect; 单测6用例+真实corpus演示5样本; 零新依赖不碰state.db 2026-10-14 01:14:37 +08:00
小唯 ae39e1ce8a feat(evidence-chain): t_e9a4d18f P1 证据链+proof_count——SourceEvents(多证据列)+ProofCount派生+QualityFromProof(饱和x/(x+K),ZHIYI_PROOF_FALLBACK门控兜底)+Supersedes矛盾保留列; 单测5/5 PASS; Go+Rust build通过; 默认关行为零变化 2026-10-14 00:52:29 +08:00
小唯 a1c9fc1631 feat(distill): t_554b8122 对抗审计插槽——env门控ZHIYI_ADVERSARIAL_AUDIT(默认关⇒零行为变化)+按抽样比例送异源模型(NewAPI deepseek-v4-flash,非本地2B)审计,结论只写影子日志不改丢弃/准入; 单测6/6 PASS+liveprobe真模型3样本; 零新依赖不碰state.db 2026-10-14 00:28:18 +08:00
小唯 f37f5e744e feat(storage): t_e9a4d18f P0 时间维检索——ZHIYI_TIME_DECAY(env门控加性recency偏置,默认0)+stale标记(ZHIYI_STALE_AFTER_HOURS,默认0); 单测4/4 PASS; env=0行为零变化 2026-10-13 23:43:55 +08:00
小唯 0bdcae2b91 feat(storage): t_8f1f6e5e 反馈回路接通——FeedbackScore()(env门控加性偏置,默认0)+单测5/5; ep_*从VValueProvider排除(只改读侧); ZHIYI_FEEDBACK_BIAS=0行为零变化 2026-10-13 22:19:48 +08:00
小唯 1b6920e8e1 feat(storage): t_be3e958a 召回排序加来源分层偏置——决策类(wiki:decision)在 MMR 前加性加分,流水(llm_distill/episode)不动;env ZHIYI_SOURCE_TIER_BIAS 控制(0=关)+单测 2026-10-13 07:26:59 +08:00
小唯 a377a7f667 fix(distill): t_91af03e9 补停用词层——noiseEntity 精确匹配整名的结构性泛词表(任务/过程/状态/流程/词汇/工具/工具动词/判据/结论/结果/方案/内容/信息/原因/问题/步骤/阶段/记录/清单/备注/说明)+探针残留(甲/乙/丙/探针/PROBE前缀);先过停用词表再走现有正则;长实体名(≥4字)不因本表被拦,复合形态(任务状态/状态机)照常放行 2026-10-13 06:00:12 +08:00
小唯 e282a41bab fix(distill): t_62f35e62 补收口三——①noiseEntity 正则拆两并列分支,旧关键词(健康检查/部署流程/测试结果/代码修改/grep)恢复单独可拦,泛词(看板/任务/kanban)仍只组合形态 ②emitResult 早退条件放宽为 facts与entities都空才return,facts==0时实体仍进图(修复领域实体静默丢失) 2026-10-13 04:13:05 +08:00
小唯 0959af388b fix(distill): t_286d1310 写库闸门 lane 感知——server.go OnDistillComplete 改用 FilterQualityFactsWithLane(复用 engine 同函数),LaneKeep 时决策类 <20 字碎片不再被第三次丢弃(落库判定由 ClassifyAdmission 重算 lane)+ 新增写库判据单测 2026-10-13 03:26:06 +08:00
小唯 6d724dc937 ga-二收口 t_348cefc1: ①停写图 DERIVED_FROM 边(血缘由 memories.derived_from 字段承载, 图上无人读, 消除 ep_/f_ 目标节点恒增) ②noiseEntity 补看板卡ID t_[0-9a-f]{6,} + 任务/看板流程词组合形态(不单拦泛词) — 探针乙 4/4 泄漏形态全拦 2026-10-13 02:49:43 +08:00
204 changed files with 59557 additions and 706 deletions
+11
View File
@@ -38,3 +38,14 @@ go/zhiyid
# 临时工件(cron list dump,随时可 `hermes cron list` 重现,不入库)
cron_raw.txt
# 改动前备份快照不进 git(git 本身就是版本历史)
*.bak.*
# 探针/临时构建产物(2026-10-14 t_3e98b65a 调查时发现 17MB 残留未清)
/go/*-probe
/go/rulefirst-probe
# 临时构建/验证工件(t_5f822020 清理:quantify/test_one 二进制 + verify.go 探针)
/go/tmp/
/verify.go
+1 -2
View File
@@ -197,7 +197,6 @@ def stats(stat_type, json_flag):
if "metrics" in data:
m = data["metrics"]
click.echo(f"\n📊 自优化指标:")
click.echo(f" 召回命中率: {m.get('recall_hit_rate', '?'):.1%}")
click.echo(f" 召回有用率: {m.get('recall_usefulness_rate', '?'):.1%}")
click.echo(f" 记忆总数: {m.get('total_memories', '?')}")
@@ -429,7 +428,7 @@ def _repl_with_skin(c: ZhiYiClient, skin):
["记忆数", str(s.get("total_memories", "?"))],
["图谱节点", str(g.get("node_count", "?"))],
["图谱边", str(g.get("edge_count", "?"))],
["召回命中率", f"{m.get('recall_hit_rate', '?'):.1%}"],
["召回有用率", f"{m.get('recall_usefulness_rate', '?'):.1%}"],
],
)
elif cmd == "graph" and args and args[0] == "navigate":
@@ -0,0 +1,13 @@
[152] /home/muc/.hermes/kanban/workspaces/t_492e3f22/ac-evidence/t492-DEF-152-20261016-184003.json total=152 PASS=74 id_hits=7 fp=2318948a910f6d70de6be063216c78089c4315bd p95=2630ms errs=0
[golden] /home/muc/.hermes/kanban/workspaces/t_492e3f22/ac-evidence/t492-DEF-golden-20261016-184003.json pass=14/14 fail=0
[probe10] 牧尘工作所在的县是哪个: n=20 v>0=0 -> []
[probe10] 淇县: n=20 v>0=1 -> ['mem_1792088552819293505']
[probe10] 招商项目台账: n=20 v>0=0 -> []
[probe10] 生物制造产业专班: n=20 v>0=0 -> []
[probe10] 小怡的工作笔记在哪: n=15 v>0=0 -> []
[probe10] 昨天做了什么: n=20 v>0=0 -> []
[probe10] 飞书怎么发语音: n=20 v>0=0 -> []
[probe10] 家服怎么连: n=20 v>0=0 -> []
[probe10] 织忆怎么部署: n=20 v>0=1 -> ['mem_1791740271337572997']
[probe10] 记忆保护名单: n=20 v>0=0 -> []
DONE DEF at 20261016-184003
@@ -0,0 +1,13 @@
[152] /home/muc/.hermes/kanban/workspaces/t_492e3f22/ac-evidence/t492-NOV-152-20261016-184835.json total=152 PASS=74 id_hits=7 fp=2318948a910f6d70de6be063216c78089c4315bd p95=812ms errs=0
[golden] /home/muc/.hermes/kanban/workspaces/t_492e3f22/ac-evidence/t492-NOV-golden-20261016-184835.json pass=14/14 fail=0
[probe10] 牧尘工作所在的县是哪个: n=20 v>0=0 -> []
[probe10] 淇县: n=20 v>0=1 -> ['mem_1792088552819293505']
[probe10] 招商项目台账: n=20 v>0=0 -> []
[probe10] 生物制造产业专班: n=20 v>0=0 -> []
[probe10] 小怡的工作笔记在哪: n=15 v>0=0 -> []
[probe10] 昨天做了什么: n=20 v>0=0 -> []
[probe10] 飞书怎么发语音: n=20 v>0=0 -> []
[probe10] 家服怎么连: n=20 v>0=0 -> []
[probe10] 织忆怎么部署: n=20 v>0=1 -> ['mem_1791740271337572997']
[probe10] 记忆保护名单: n=20 v>0=0 -> []
DONE NOV at 20261016-184835
@@ -0,0 +1,13 @@
[152] /home/muc/.hermes/kanban/workspaces/t_492e3f22/ac-evidence/t492-PRE-152-20261016-183449.json total=152 PASS=74 id_hits=7 fp=2318948a910f6d70de6be063216c78089c4315bd p95=809ms errs=0
[golden] /home/muc/.hermes/kanban/workspaces/t_492e3f22/ac-evidence/t492-PRE-golden-20261016-183449.json pass=14/14 fail=0
[probe10] 牧尘工作所在的县是哪个: n=20 v>0=0 -> []
[probe10] 淇县: n=20 v>0=1 -> ['mem_1792088552819293505']
[probe10] 招商项目台账: n=20 v>0=0 -> []
[probe10] 生物制造产业专班: n=20 v>0=0 -> []
[probe10] 小怡的工作笔记在哪: n=15 v>0=0 -> []
[probe10] 昨天做了什么: n=20 v>0=0 -> []
[probe10] 飞书怎么发语音: n=20 v>0=0 -> []
[probe10] 家服怎么连: n=20 v>0=0 -> []
[probe10] 织忆怎么部署: n=20 v>0=1 -> ['mem_1791740271337572997']
[probe10] 记忆保护名单: n=20 v>0=0 -> []
DONE PRE at 20261016-183449
@@ -0,0 +1,13 @@
[152] /home/muc/.hermes/kanban/workspaces/t_492e3f22/ac-evidence/t492-RBK-152-20261016-190913.json total=152 PASS=74 id_hits=7 fp=2318948a910f6d70de6be063216c78089c4315bd p95=931ms errs=0
[golden] /home/muc/.hermes/kanban/workspaces/t_492e3f22/ac-evidence/t492-RBK-golden-20261016-190913.json pass=14/14 fail=0
[probe10] 牧尘工作所在的县是哪个: n=20 v>0=0 -> []
[probe10] 淇县: n=20 v>0=1 -> ['mem_1792088552819293505']
[probe10] 招商项目台账: n=20 v>0=0 -> []
[probe10] 生物制造产业专班: n=20 v>0=0 -> []
[probe10] 小怡的工作笔记在哪: n=15 v>0=0 -> []
[probe10] 昨天做了什么: n=20 v>0=0 -> []
[probe10] 飞书怎么发语音: n=20 v>0=0 -> []
[probe10] 家服怎么连: n=20 v>0=0 -> []
[probe10] 织忆怎么部署: n=20 v>0=1 -> ['mem_1791740271337572997']
[probe10] 记忆保护名单: n=20 v>0=0 -> []
DONE RBK at 20261016-190913
@@ -0,0 +1,87 @@
# t_492e3f22 · fuseQuality V 值分支 env 门(ZHIYI_V_FUSE_ENABLE)— A/B 证据包
次症 C:给 Go 端 fuseQuality 的 V 值分支加 env 门。**默认行为逐位保持现状**;`ZHIYI_V_FUSE_ENABLE=1`
关闭 V 分支(v>0 时不再用 0.5v+0.5imp / 0.3v+0.3imp+0.4raw 加权,退化为 untrusted→min(imp,1)、
trusted→min(0.4imp+0.6raw,1))。本包 = 代码 + 测试 + 双口径 A/B 证据。
## 结论(2026-10-16 实测)
| AC | 判据 | 结果 |
|---|---|---|
| AC1 默认不碰 | PRE(旧二进制) ≡ DEF(新二进制默认):152 集 verdict 指纹、top5 顺序、probe10 top20、golden | ✅ 全同(指纹 2318948a…;golden 14/14) |
| AC2 翻转证据 | DEF vs NOV(env=1):verdict 指纹同;probe10 10 条 affected=0;两条受影响 query top50 顺序逐位全同(fp 47e228e8 / 9dd57897 各两边一致);v>0 条目 rank 零移动 | ✅(排名变化 = 0,见「解读」) |
| AC4 受影响条数 | v>0 条数独立复算(4 个 tag 集合完全一致):152×top5 = 2/760 (0.26%);probe10×top20 = 2/195 (1.03%) | ✅ 实测 2 条,任务表预期 2–3(≈1.5%,V 按 0.01/天衰减) |
| AC5 回滚复核 | RBK(新二进制重启·默认) == DEF 指纹(verdict 层 2318948a + probe10 全同 + golden 14/14) | ✅(仅 2 条 v=0 Gitea top1 互漂,跨跑 usage_track 漂移,见「已知噪声」) |
| AC6 异源复算 | 第二张口径(independent_recheck.py,sha256 顺序指纹 + 逐条 v>0 集合复算)与主口径一致,无矛盾 | ✅ PASS |
## 改动文件
- `go/internal/storage/quality_fusion.go` — `vFuseUseV = os.Getenv("ZHIYI_V_FUSE_ENABLE") != "1"`(进程级,同 ZHIYI_PROOF_FALLBACK 模式),V 分支条件 `if vFuseUseV && vValue > 0`
- `go/internal/storage/quality_fusion_test.go` — `TestFuseQuality_VGateOn`(门开:v>0 条 fused 0.85 vs v=0 条 0.725,排序保持)+ `TestFuseQuality_VGateOff`(门关:两者同值)
- 单测:`cd go && go test ./internal/storage/ -run 'TestFuseQuality' -v` → 3/3 PASS(2026-10-16 19:35 复跑)
## 实例配置(隔离:独立数据副本,不碰主库)
- 数据:`/tmp/zyvfuse-t492e3f22/data`(/var/lib/memoryweave 全量副本,symbolic link 已解)
- sidecar:`zhiyi-consolidate --mode socket --socket /tmp/zyvfuse-t492e3f22/cons.sock`
- 4 实例:PRE :7891(旧二进制) / DEF :7892(新·默认) / NOV :7893(新·env=1,/proc environ 实证) / RBK :7892 重启(新·默认)
- 评测集:152 条(zhiyi-loop-runner 池)+ golden 14 + probe10(淇县/织忆怎么部署/记忆保护名单 等卡表同源)
## 受影响 3 样本表(AC2,卡表口径)
| query | v>0(任务表→实测) | v>0 条目 | DEF→NOV 排名变化 |
|---|---|---|---|
| 淇县 | 0 → 1 | mem_1792088552819293505 (v=1.0, rank1) | 0(top50 逐位全同) |
| 织忆怎么部署 | 2 → 1 | mem_1791740271337572997 (v=1.0) | 0(top50 逐位全同) |
| 记忆保护名单 | 1 → 0 | (本次 top20 内无 v>0) | — |
**解读(为什么排名变化=0)**:recall 排序 base = 0.7×cosine + 0.3×fusedQuality。V 门控只改 v>0 条的
fused 分(untrusted 时 0.5v+0.5imp → min(imp,1)),Δbase 上限 0.3×Δfused≈0.15;本数据分布下
cosine 主导的条间 margin > Δbase ⇒ 无换位。门控在**融合分轴**生效(单测证明公式差),
**排序轴**在本数据规模上实测零扰动 —— 这正是「默认不碰、关也不破」的安全证据。
v>0 条数随时间漂(V 衰减 0.01/天 + seen-count 变化):任务表 3 → 实测 2。
## 已知噪声(与门控无关,如实记录)
- 152 集中 2 条 Gitea query 的 top1 在跨跑间互换(mem_1792088552817004309 ↔ mem_1792088552816998158,
两侧 v=0、raw=0):DEF 跑 / NOV 跑 / RBK 跑各自写 usage_track(seen-count +1、last_recalled_at 刷新),
imp 微漂 ⇒ 同分对 tie 换位。verdict/指纹层不受影响(4 tag verdict 指纹全同 2318948a)。
- 152 集 P95:PRE=809ms / DEF=2630ms(冷启动+首轮 warmup)/ NOV≈951 / RBK=931 —— AC 判据为 ±20% 口径
以稳态(RBK/NOV)为准,DEF 高值系冷启动,非门控效应。
## 复算命令
```bash
# 单测
cd /home/muc/src/memoryweave/go && go test ./internal/storage/ -run 'TestFuseQuality' -v
# 异源复算(本目录 raw/*.json)
python3 <kanban-workspace>/harness/independent_recheck.py # 输出 independent_recheck_out.json
python3 <kanban-workspace>/harness/diffrun.py DEF NOV # 主口径 diff
```
## 部署提示(本卡不含生产动作)
- 生产 zhiyid(systemd, :7821)与 zhiyi-consolidate 不动;本卡只验证行为等价 + 门控生效。
- 真要关 V 分支:`Environment=ZHIYI_V_FUSE_ENABLE=1` 加进 deploy/zhiyid.service + 重启(需另行提卡审批)。
- 回滚 = 删该行/置 0,重启即恢复现状(RBK 证据)。
## teardown + 零残留判据(19:44 补,跨卡通知 t_004cc147 内存告警处置要求)
测试栈(27G:data/ 副本 + graph.db 447M + 双 zhiyid 守护 + sidecar)已全部下线:
- 双孤儿 zhiyid(PID 347191 :7893 / 367265 :7892)已由 prof-cloud SIGTERM(19:15 内存告警处置)
- 本侧:sidecar(cons.sock,data 副本)停掉;`/tmp/zyvfuse-t492e3f22` 整目录删除
(evidence 先全量落盘 `ac-evidence/` 与本目录 raw/,再删)
- 顺带清:`/tmp/dryrun-dir`(2.6G) `/tmp/mem-copy.lance`(604M) `spike*`(441M×2+小文件)
—— 删前确认:lsof 无占用、无看板卡引用、mtime 10-11/10-12
终验输出(19:44):
```
ps -C zhiyid -o pid,args → 仅表头,无进程(exit 1)
du -sh /tmp/zyvfuse-* → No such file or directory
du -sh /tmp/dryrun-dir /tmp/mem-copy.lance /tmp/spike* → 全部 No such file
free -h → available 5.3Gi(告警前 <1G)
```
**判据补录(本卡验收新增)**:`ps -C zhiyid -o pid,args` 为空(测试栈守护零残留)+
`du -sh /tmp/zyvfuse-*` 无残留 —— 已通过。
@@ -0,0 +1,230 @@
{
"a": "DEF",
"b": "NOV",
"files": {
"a": {
"152": [
"at",
"tag",
"base",
"total",
"pass",
"fail",
"metrics",
"latency_ms",
"fail_mode",
"recall_errors",
"fingerprint",
"namespace",
"top_k",
"results"
],
"probe10": [
"at",
"tag",
"base",
"top_k",
"rows"
],
"golden": [
"at",
"tag",
"base",
"keyword_pass",
"keyword_fail",
"keyword_total",
"recall_at_5",
"mrr",
"items"
]
},
"b": [
"152",
"probe10",
"golden"
]
},
"152": {
"fp_a": "2318948a910f6d70de6be063216c78089c4315bd",
"fp_b": "2318948a910f6d70de6be063216c78089c4315bd",
"fp_same": true,
"pass_a": 74,
"pass_b": 74,
"id_hits_a": 7,
"id_hits_b": 7,
"p95_a_ms": 2630,
"p95_b_ms": 812,
"changed_queries": 1,
"changed_detail": {
"局域网里访问 Gitea 家服连哪个 IP": {
"kind": "probe",
"changed": [
"top5"
],
"a_top5": [
"mem_1792088552817004309",
"mem_1789136383983279469",
"ep_1786521524260755589",
"mem_1789150776299341327",
"mem_1789737118121867083"
],
"b_top5": [
"mem_1792088552816998158",
"mem_1789136383983279469",
"ep_1786521524260755589",
"mem_1789150776299341327",
"mem_1789737118121867083"
]
}
},
"v0_rank_shifts": []
},
"probe10": {
"rows": [
{
"query": "牧尘工作所在的县是哪个",
"n_a": 20,
"n_b": 20,
"set_added": [],
"set_removed": [],
"order_diff_positions": 0,
"v_pos_a": 0,
"v_pos_b": 0,
"v_pos_ids_a": [],
"v_pos_ids_b": [],
"v_rank_moved": []
},
{
"query": "淇县",
"n_a": 20,
"n_b": 20,
"set_added": [],
"set_removed": [],
"order_diff_positions": 0,
"v_pos_a": 1,
"v_pos_b": 1,
"v_pos_ids_a": [
"mem_1792088552819293505"
],
"v_pos_ids_b": [
"mem_1792088552819293505"
],
"v_rank_moved": []
},
{
"query": "招商项目台账",
"n_a": 20,
"n_b": 20,
"set_added": [],
"set_removed": [],
"order_diff_positions": 0,
"v_pos_a": 0,
"v_pos_b": 0,
"v_pos_ids_a": [],
"v_pos_ids_b": [],
"v_rank_moved": []
},
{
"query": "生物制造产业专班",
"n_a": 20,
"n_b": 20,
"set_added": [],
"set_removed": [],
"order_diff_positions": 0,
"v_pos_a": 0,
"v_pos_b": 0,
"v_pos_ids_a": [],
"v_pos_ids_b": [],
"v_rank_moved": []
},
{
"query": "小怡的工作笔记在哪",
"n_a": 15,
"n_b": 15,
"set_added": [],
"set_removed": [],
"order_diff_positions": 0,
"v_pos_a": 0,
"v_pos_b": 0,
"v_pos_ids_a": [],
"v_pos_ids_b": [],
"v_rank_moved": []
},
{
"query": "昨天做了什么",
"n_a": 20,
"n_b": 20,
"set_added": [],
"set_removed": [],
"order_diff_positions": 0,
"v_pos_a": 0,
"v_pos_b": 0,
"v_pos_ids_a": [],
"v_pos_ids_b": [],
"v_rank_moved": []
},
{
"query": "飞书怎么发语音",
"n_a": 20,
"n_b": 20,
"set_added": [],
"set_removed": [],
"order_diff_positions": 0,
"v_pos_a": 0,
"v_pos_b": 0,
"v_pos_ids_a": [],
"v_pos_ids_b": [],
"v_rank_moved": []
},
{
"query": "家服怎么连",
"n_a": 20,
"n_b": 20,
"set_added": [],
"set_removed": [],
"order_diff_positions": 0,
"v_pos_a": 0,
"v_pos_b": 0,
"v_pos_ids_a": [],
"v_pos_ids_b": [],
"v_rank_moved": []
},
{
"query": "织忆怎么部署",
"n_a": 20,
"n_b": 20,
"set_added": [],
"set_removed": [],
"order_diff_positions": 0,
"v_pos_a": 1,
"v_pos_b": 1,
"v_pos_ids_a": [
"mem_1791740271337572997"
],
"v_pos_ids_b": [
"mem_1791740271337572997"
],
"v_rank_moved": []
},
{
"query": "记忆保护名单",
"n_a": 20,
"n_b": 20,
"set_added": [],
"set_removed": [],
"order_diff_positions": 0,
"v_pos_a": 0,
"v_pos_b": 0,
"v_pos_ids_a": [],
"v_pos_ids_b": [],
"v_rank_moved": []
}
],
"affected_queries": [],
"n_affected_queries": 0
},
"golden": {
"a": "14/14",
"b": "14/14"
}
}
@@ -0,0 +1,250 @@
{
"a": "DEF",
"b": "RBK",
"files": {
"a": {
"152": [
"at",
"tag",
"base",
"total",
"pass",
"fail",
"metrics",
"latency_ms",
"fail_mode",
"recall_errors",
"fingerprint",
"namespace",
"top_k",
"results"
],
"probe10": [
"at",
"tag",
"base",
"top_k",
"rows"
],
"golden": [
"at",
"tag",
"base",
"keyword_pass",
"keyword_fail",
"keyword_total",
"recall_at_5",
"mrr",
"items"
]
},
"b": [
"152",
"probe10",
"golden"
]
},
"152": {
"fp_a": "2318948a910f6d70de6be063216c78089c4315bd",
"fp_b": "2318948a910f6d70de6be063216c78089c4315bd",
"fp_same": true,
"pass_a": 74,
"pass_b": 74,
"id_hits_a": 7,
"id_hits_b": 7,
"p95_a_ms": 2630,
"p95_b_ms": 931,
"changed_queries": 2,
"changed_detail": {
"家庭服务器(跑 Gitea)在局域网里的地址是哪个": {
"kind": "probe",
"changed": [
"top5"
],
"a_top5": [
"mem_1792088552816998158",
"mem_1789136383983279469",
"ep_1785695216419714474",
"ep_1786336919812827426",
"ep_1786452727331645773"
],
"b_top5": [
"mem_1792088552817004309",
"mem_1789136383983279469",
"ep_1785695216419714474",
"ep_1786336919812827426",
"ep_1786452727331645773"
]
},
"局域网里访问 Gitea 家服连哪个 IP": {
"kind": "probe",
"changed": [
"top5"
],
"a_top5": [
"mem_1792088552817004309",
"mem_1789136383983279469",
"ep_1786521524260755589",
"mem_1789150776299341327",
"mem_1789737118121867083"
],
"b_top5": [
"mem_1792088552816998158",
"mem_1789136383983279469",
"ep_1786521524260755589",
"mem_1789150776299341327",
"mem_1789737118121867083"
]
}
},
"v0_rank_shifts": []
},
"probe10": {
"rows": [
{
"query": "牧尘工作所在的县是哪个",
"n_a": 20,
"n_b": 20,
"set_added": [],
"set_removed": [],
"order_diff_positions": 0,
"v_pos_a": 0,
"v_pos_b": 0,
"v_pos_ids_a": [],
"v_pos_ids_b": [],
"v_rank_moved": []
},
{
"query": "淇县",
"n_a": 20,
"n_b": 20,
"set_added": [],
"set_removed": [],
"order_diff_positions": 0,
"v_pos_a": 1,
"v_pos_b": 1,
"v_pos_ids_a": [
"mem_1792088552819293505"
],
"v_pos_ids_b": [
"mem_1792088552819293505"
],
"v_rank_moved": []
},
{
"query": "招商项目台账",
"n_a": 20,
"n_b": 20,
"set_added": [],
"set_removed": [],
"order_diff_positions": 0,
"v_pos_a": 0,
"v_pos_b": 0,
"v_pos_ids_a": [],
"v_pos_ids_b": [],
"v_rank_moved": []
},
{
"query": "生物制造产业专班",
"n_a": 20,
"n_b": 20,
"set_added": [],
"set_removed": [],
"order_diff_positions": 0,
"v_pos_a": 0,
"v_pos_b": 0,
"v_pos_ids_a": [],
"v_pos_ids_b": [],
"v_rank_moved": []
},
{
"query": "小怡的工作笔记在哪",
"n_a": 15,
"n_b": 15,
"set_added": [],
"set_removed": [],
"order_diff_positions": 0,
"v_pos_a": 0,
"v_pos_b": 0,
"v_pos_ids_a": [],
"v_pos_ids_b": [],
"v_rank_moved": []
},
{
"query": "昨天做了什么",
"n_a": 20,
"n_b": 20,
"set_added": [],
"set_removed": [],
"order_diff_positions": 0,
"v_pos_a": 0,
"v_pos_b": 0,
"v_pos_ids_a": [],
"v_pos_ids_b": [],
"v_rank_moved": []
},
{
"query": "飞书怎么发语音",
"n_a": 20,
"n_b": 20,
"set_added": [],
"set_removed": [],
"order_diff_positions": 0,
"v_pos_a": 0,
"v_pos_b": 0,
"v_pos_ids_a": [],
"v_pos_ids_b": [],
"v_rank_moved": []
},
{
"query": "家服怎么连",
"n_a": 20,
"n_b": 20,
"set_added": [],
"set_removed": [],
"order_diff_positions": 0,
"v_pos_a": 0,
"v_pos_b": 0,
"v_pos_ids_a": [],
"v_pos_ids_b": [],
"v_rank_moved": []
},
{
"query": "织忆怎么部署",
"n_a": 20,
"n_b": 20,
"set_added": [],
"set_removed": [],
"order_diff_positions": 0,
"v_pos_a": 1,
"v_pos_b": 1,
"v_pos_ids_a": [
"mem_1791740271337572997"
],
"v_pos_ids_b": [
"mem_1791740271337572997"
],
"v_rank_moved": []
},
{
"query": "记忆保护名单",
"n_a": 20,
"n_b": 20,
"set_added": [],
"set_removed": [],
"order_diff_positions": 0,
"v_pos_a": 0,
"v_pos_b": 0,
"v_pos_ids_a": [],
"v_pos_ids_b": [],
"v_rank_moved": []
}
],
"affected_queries": [],
"n_affected_queries": 0
},
"golden": {
"a": "14/14",
"b": "14/14"
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,82 @@
{
"at": "20261016-184343",
"tag": "DEF",
"base": "http://127.0.0.1:7892",
"keyword_pass": 14,
"keyword_fail": 0,
"keyword_total": 14,
"recall_at_5": 0.42857142857142855,
"mrr": 0.2380952380952381,
"items": [
{
"query": "织忆是什么",
"keyword_ok": true,
"id_recall": 0
},
{
"query": "织忆的图谱扩展机制是什么",
"keyword_ok": true,
"id_recall": 1
},
{
"query": "织忆如何通过 E1 提取和 MMR 实现多样去重",
"keyword_ok": true,
"id_recall": 0
},
{
"query": "牧尘的偏好是什么",
"keyword_ok": true,
"id_recall": 1
},
{
"query": "牧尘喜欢什么样的工作方式",
"keyword_ok": true,
"id_recall": 0
},
{
"query": "如何根据牧尘的偏好调整记忆检索策略",
"keyword_ok": true,
"id_recall": 1
},
{
"query": "当前项目的技术栈是什么",
"keyword_ok": true,
"id_recall": 1
},
{
"query": "织忆项目有哪些核心组件",
"keyword_ok": true,
"id_recall": 0
},
{
"query": "织忆和其他记忆系统相比有什么架构优势",
"keyword_ok": true,
"id_recall": 1
},
{
"query": "如何使用 recall 接口",
"keyword_ok": true,
"id_recall": 0
},
{
"query": "织忆有哪些管理工具",
"keyword_ok": true,
"id_recall": 0
},
{
"query": "如何通过 API 扩展织忆功能",
"keyword_ok": true,
"id_recall": 0
},
{
"query": "本地 llama 服务 8080 端口",
"keyword_ok": true,
"id_recall": 0
},
{
"query": "session-to-zhiyi.py 脚本名",
"keyword_ok": true,
"id_recall": 1
}
]
}
@@ -0,0 +1,932 @@
{
"at": "20261016-184350",
"tag": "DEF",
"base": "http://127.0.0.1:7892",
"top_k": 20,
"rows": [
{
"query": "牧尘工作所在的县是哪个",
"latency_ms": 689,
"top_ids": [
"mem_1792088552818864597",
"mem_1792144084503554069",
"mem_1792144084726385461",
"mem_1790349546889496890",
"mem_1787450890745924038",
"mem_1790448247125520424",
"mem_1789063252280657787",
"mem_1792088552817383356",
"ep_1786293372952742428",
"mem_1792144084989342595",
"mem_1789815923678472264",
"mem_1788006341389931001",
"mem_1790425119425241515",
"mem_1791556228356038757",
"mem_1789230281180128695",
"mem_1792088552824873585",
"mem_1792088552817930263",
"mem_1790424260127845812",
"mem_1789737493969591957",
"mem_1787926246493058856"
],
"v_values": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"quality_scores": [
0.5,
0.59,
0.59,
0.755,
0.255,
0.755,
0.755,
0.5,
0,
0.59,
0.83,
0.755,
0.755,
0,
0.755,
0.5,
0.5,
0.753,
0.755,
0.775
],
"raw_quality_scores": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"v_pos": 0,
"v_pos_ids": []
},
{
"query": "淇县",
"latency_ms": 717,
"top_ids": [
"mem_1792144084726385461",
"mem_1792088552818354175",
"mem_1792088552818868548",
"mem_1792088552818381759",
"mem_1792144084503554069",
"mem_1790938090098284542",
"mem_1790938089297531705",
"mem_1790603669933523968",
"mem_1792088552818672993",
"mem_1790425189400296970",
"mem_1791564884666490485",
"mem_1790848976254029727",
"mem_1792088552819307750",
"mem_1790657384251944657",
"mem_1789328667925309581",
"mem_1792116219795602288",
"mem_1790425213354467843",
"mem_1791395173327211388",
"mem_1792141689103478828",
"mem_1792088552819293505"
],
"v_values": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1
],
"quality_scores": [
0.59,
0.5,
0.5,
0.5,
0.59,
0.83,
0.83,
0,
0.5,
0.755,
0,
0.755,
0.5,
0.755,
0.83,
0.63,
0.755,
0.675,
0.525,
0.99
],
"raw_quality_scores": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"v_pos": 1,
"v_pos_ids": [
"mem_1792088552819293505"
]
},
{
"query": "招商项目台账",
"latency_ms": 751,
"top_ids": [
"mem_1790657711315786810",
"mem_1790425023223201421",
"mem_1792088552819227083",
"mem_1792088552817389514",
"mem_1791194371133842796",
"mem_1790565541250175254",
"mem_1791387362920806120",
"mem_1791899910885939521",
"mem_1789694252600441660",
"mem_1791665519266094329",
"mem_1790309303666562764",
"mem_1790581054400784704",
"mem_1792088552818016699",
"mem_1792088552818037737",
"mem_1790590528061314469",
"mem_1790187043969918230",
"mem_1790448247323881504",
"mem_1790329285912241710",
"mem_1792088552817184125",
"mem_1789327284892773149"
],
"v_values": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"quality_scores": [
0.755,
0.853,
0.5,
0.5,
0.755,
0.755,
0.765,
0.59,
0.755,
0,
0.755,
0.755,
0.5,
0.5,
0.755,
0.755,
0.755,
0.755,
0.5,
0.755
],
"raw_quality_scores": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"v_pos": 0,
"v_pos_ids": []
},
{
"query": "生物制造产业专班",
"latency_ms": 722,
"top_ids": [
"mem_1792144084989342595",
"mem_1792107514588524675",
"mem_1791567307185364992",
"mem_1790051867711758989",
"mem_1789983973135821157",
"mem_1792088552816455607",
"mem_1791380468697720355",
"mem_1792088552817215072",
"mem_1790658098063956127",
"mem_1792088552825118896",
"mem_1790656401441522455",
"mem_1789755524758238574",
"mem_1789741519238314161",
"mem_1789736468534024216",
"mem_1789731934167097953",
"mem_1791916787313401423",
"mem_1787336595731500272",
"mem_1791794756620086535",
"mem_1791811143462384371",
"mem_1791735819718797684"
],
"v_values": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"quality_scores": [
0.59,
0.63,
0,
0.755,
0.755,
0.5,
0.78,
0.5,
0.755,
0.5,
0.755,
0.755,
0.755,
0.755,
0.798,
0.395,
0.695,
0.63,
0.315,
0.6
],
"raw_quality_scores": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"v_pos": 0,
"v_pos_ids": []
},
{
"query": "小怡的工作笔记在哪",
"latency_ms": 614,
"top_ids": [
"mem_1791365211038188118",
"mem_1792088552817459951",
"mem_1789155186005621317",
"mem_1790441160106001927",
"mem_1791736002933120377",
"mem_1792118701572499829",
"mem_1792088552818645685",
"mem_1791915848530338805",
"mem_1791383333319512288",
"mem_1792088552818535755",
"mem_1790187044046223493",
"mem_1792088552824837822",
"mem_1792088552817461960",
"mem_1791423103974079882",
"mem_1792088552825338800"
],
"v_values": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"quality_scores": [
0.8,
0.5,
0.755,
0.755,
0.39,
0.63,
0.5,
0.5,
0.8,
0.5,
0.755,
0.5,
0.5,
0.6,
0.5
],
"raw_quality_scores": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"v_pos": 0,
"v_pos_ids": []
},
{
"query": "昨天做了什么",
"latency_ms": 692,
"top_ids": [
"mem_1788341686813816985",
"mem_1790425119425241515",
"mem_1791633893565216844",
"mem_1790455525438011532",
"mem_1792083942223793654",
"mem_1792088770119950733",
"mem_1790805789157124891",
"mem_1792107513668237224",
"mem_1789431180512315186",
"mem_1790425238816494139",
"mem_1790424256264891954",
"mem_1787926246433496550",
"mem_1790425017614420145",
"mem_1790581068974704280",
"mem_1789327284892773149",
"mem_1790226520252986178",
"mem_1790269130144600786",
"mem_1791819411153164256",
"mem_1789827897771148953",
"mem_1788166192802637740"
],
"v_values": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"quality_scores": [
0.755,
0.755,
0,
0.755,
0.395,
0.63,
0.755,
0.63,
0.515,
0.755,
0.755,
0.775,
0.755,
0.755,
0.755,
0,
0.755,
0.395,
0.755,
0.755
],
"raw_quality_scores": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"v_pos": 0,
"v_pos_ids": []
},
{
"query": "飞书怎么发语音",
"latency_ms": 783,
"top_ids": [
"mem_1790345721671326922",
"mem_1790350480105660984",
"mem_1790425022831993728",
"mem_1791118583544396904",
"mem_1791533226241026355",
"mem_1790455570122981543",
"mem_1791445601992763027",
"mem_1790496848751501955",
"mem_1789273123503223085",
"mem_1791666641349257940",
"mem_1791666631693960928",
"mem_1792088552825510526",
"mem_1791666639652621391",
"mem_1790265788637717626",
"mem_1790428068416795396",
"mem_1791118583617248844",
"mem_1791666711653684211",
"mem_1789557079608816710",
"mem_1789327693618007907",
"mem_1789273123339483168"
],
"v_values": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"quality_scores": [
0.803,
0.755,
0.755,
0.755,
0,
0.803,
0,
0.83,
0.755,
0,
0.789,
0.5,
0,
0.755,
0.755,
0.755,
0,
0.805,
0.755,
0.755
],
"raw_quality_scores": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"v_pos": 0,
"v_pos_ids": []
},
{
"query": "家服怎么连",
"latency_ms": 700,
"top_ids": [
"mem_1791556229473565240",
"mem_1792084245285474959",
"mem_1792088552818289574",
"mem_1790180705032672204",
"mem_1792100307763093595",
"mem_1791665516356975992",
"mem_1792084245095017393",
"mem_1792082720405068478",
"mem_1791440639711180469",
"mem_1792114886051333715",
"mem_1792088552825127031",
"mem_1791642436962837771",
"mem_1792088552819438066",
"mem_1792088552816808997",
"mem_1790187091203506519",
"mem_1792126203805297968",
"mem_1792088552819539001",
"mem_1791904832104972267",
"mem_1792088552818662517",
"mem_1792088552824945417"
],
"v_values": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"quality_scores": [
0,
0.63,
0.5,
0.755,
0.63,
0.8,
0.63,
0.63,
0,
0.63,
0.5,
0.65,
0.5,
0.5,
0.755,
0.395,
0.5,
0.43,
0.5,
0.5
],
"raw_quality_scores": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"v_pos": 0,
"v_pos_ids": []
},
{
"query": "织忆怎么部署",
"latency_ms": 724,
"top_ids": [
"mem_1788623666023332134",
"mem_1789793765940732350",
"mem_1791479003228249623",
"mem_1789149704491770048",
"mem_1789154058288011112",
"mem_1791818814047111008",
"mem_1792144557208382006",
"mem_1791402157932171759",
"mem_1791899478077693848",
"mem_1789411888019473186",
"mem_1792088552825130331",
"mem_1792088552817959556",
"mem_1791912042293867384",
"mem_1788657176712786530",
"mem_1790424244350350337",
"mem_1791740271337572997",
"mem_1792088552818065436",
"mem_1789132107404617336",
"mem_1788340352324421455",
"mem_1791431677583759030"
],
"v_values": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0
],
"quality_scores": [
0.911,
0.755,
0,
0.99,
0.695,
0.63,
0.565,
0.99,
0.644,
0.865,
0.5,
0.5,
0.59,
0.755,
0.99,
0,
0.5,
0.755,
0.755,
0.675
],
"raw_quality_scores": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"v_pos": 1,
"v_pos_ids": [
"mem_1791740271337572997"
]
},
{
"query": "记忆保护名单",
"latency_ms": 708,
"top_ids": [
"mem_1790424365997011289",
"mem_1792126499467223243",
"mem_1788602693814224919",
"mem_1791725846574107784",
"mem_1789222925018439889",
"mem_1791666995981185275",
"mem_1792088552818471375",
"mem_1791686097363885894",
"mem_1789554588799449124",
"mem_1791420422726554280",
"mem_1791686088996261165",
"mem_1789760781378060592",
"mem_1790427668905832166",
"mem_1791111607517908194",
"mem_1791818212646773755",
"mem_1790051973384869549",
"mem_1790564623527240562",
"mem_1789992010025213786",
"mem_1792088552819583406",
"mem_1791685772880116219"
],
"v_values": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"quality_scores": [
0.99,
0.5,
0.285,
0,
0.755,
0,
0.5,
0,
0.755,
0.914629,
0,
0.755,
0.755,
0.755,
0.63,
0.755,
0.755,
0.99,
0.5,
0
],
"raw_quality_scores": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"v_pos": 0,
"v_pos_ids": []
}
]
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,82 @@
{
"at": "20261016-185023",
"tag": "NOV",
"base": "http://127.0.0.1:7893",
"keyword_pass": 14,
"keyword_fail": 0,
"keyword_total": 14,
"recall_at_5": 0.42857142857142855,
"mrr": 0.2380952380952381,
"items": [
{
"query": "织忆是什么",
"keyword_ok": true,
"id_recall": 0
},
{
"query": "织忆的图谱扩展机制是什么",
"keyword_ok": true,
"id_recall": 1
},
{
"query": "织忆如何通过 E1 提取和 MMR 实现多样去重",
"keyword_ok": true,
"id_recall": 0
},
{
"query": "牧尘的偏好是什么",
"keyword_ok": true,
"id_recall": 1
},
{
"query": "牧尘喜欢什么样的工作方式",
"keyword_ok": true,
"id_recall": 0
},
{
"query": "如何根据牧尘的偏好调整记忆检索策略",
"keyword_ok": true,
"id_recall": 1
},
{
"query": "当前项目的技术栈是什么",
"keyword_ok": true,
"id_recall": 1
},
{
"query": "织忆项目有哪些核心组件",
"keyword_ok": true,
"id_recall": 0
},
{
"query": "织忆和其他记忆系统相比有什么架构优势",
"keyword_ok": true,
"id_recall": 1
},
{
"query": "如何使用 recall 接口",
"keyword_ok": true,
"id_recall": 0
},
{
"query": "织忆有哪些管理工具",
"keyword_ok": true,
"id_recall": 0
},
{
"query": "如何通过 API 扩展织忆功能",
"keyword_ok": true,
"id_recall": 0
},
{
"query": "本地 llama 服务 8080 端口",
"keyword_ok": true,
"id_recall": 0
},
{
"query": "session-to-zhiyi.py 脚本名",
"keyword_ok": true,
"id_recall": 1
}
]
}
@@ -0,0 +1,932 @@
{
"at": "20261016-185030",
"tag": "NOV",
"base": "http://127.0.0.1:7893",
"top_k": 20,
"rows": [
{
"query": "牧尘工作所在的县是哪个",
"latency_ms": 828,
"top_ids": [
"mem_1792088552818864597",
"mem_1792144084503554069",
"mem_1792144084726385461",
"mem_1790349546889496890",
"mem_1787450890745924038",
"mem_1790448247125520424",
"mem_1789063252280657787",
"mem_1792088552817383356",
"ep_1786293372952742428",
"mem_1792144084989342595",
"mem_1789815923678472264",
"mem_1788006341389931001",
"mem_1790425119425241515",
"mem_1791556228356038757",
"mem_1789230281180128695",
"mem_1792088552824873585",
"mem_1792088552817930263",
"mem_1790424260127845812",
"mem_1789737493969591957",
"mem_1787926246493058856"
],
"v_values": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"quality_scores": [
0.5,
0.59,
0.59,
0.755,
0.255,
0.755,
0.755,
0.5,
0,
0.59,
0.83,
0.755,
0.755,
0,
0.755,
0.5,
0.5,
0.753,
0.755,
0.775
],
"raw_quality_scores": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"v_pos": 0,
"v_pos_ids": []
},
{
"query": "淇县",
"latency_ms": 748,
"top_ids": [
"mem_1792144084726385461",
"mem_1792088552818354175",
"mem_1792088552818868548",
"mem_1792088552818381759",
"mem_1792144084503554069",
"mem_1790938090098284542",
"mem_1790938089297531705",
"mem_1790603669933523968",
"mem_1792088552818672993",
"mem_1790425189400296970",
"mem_1791564884666490485",
"mem_1790848976254029727",
"mem_1792088552819307750",
"mem_1790657384251944657",
"mem_1789328667925309581",
"mem_1792116219795602288",
"mem_1790425213354467843",
"mem_1791395173327211388",
"mem_1792141689103478828",
"mem_1792088552819293505"
],
"v_values": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1
],
"quality_scores": [
0.59,
0.5,
0.5,
0.5,
0.59,
0.83,
0.83,
0,
0.5,
0.755,
0,
0.755,
0.5,
0.755,
0.83,
0.63,
0.755,
0.675,
0.525,
0.99
],
"raw_quality_scores": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"v_pos": 1,
"v_pos_ids": [
"mem_1792088552819293505"
]
},
{
"query": "招商项目台账",
"latency_ms": 692,
"top_ids": [
"mem_1790657711315786810",
"mem_1790425023223201421",
"mem_1792088552819227083",
"mem_1792088552817389514",
"mem_1791194371133842796",
"mem_1790565541250175254",
"mem_1791387362920806120",
"mem_1791899910885939521",
"mem_1789694252600441660",
"mem_1791665519266094329",
"mem_1790309303666562764",
"mem_1790581054400784704",
"mem_1792088552818016699",
"mem_1792088552818037737",
"mem_1790590528061314469",
"mem_1790187043969918230",
"mem_1790448247323881504",
"mem_1790329285912241710",
"mem_1792088552817184125",
"mem_1789327284892773149"
],
"v_values": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"quality_scores": [
0.755,
0.853,
0.5,
0.5,
0.755,
0.755,
0.765,
0.59,
0.755,
0,
0.755,
0.755,
0.5,
0.5,
0.755,
0.755,
0.755,
0.755,
0.5,
0.755
],
"raw_quality_scores": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"v_pos": 0,
"v_pos_ids": []
},
{
"query": "生物制造产业专班",
"latency_ms": 708,
"top_ids": [
"mem_1792144084989342595",
"mem_1792107514588524675",
"mem_1791567307185364992",
"mem_1790051867711758989",
"mem_1789983973135821157",
"mem_1792088552816455607",
"mem_1791380468697720355",
"mem_1792088552817215072",
"mem_1790658098063956127",
"mem_1792088552825118896",
"mem_1790656401441522455",
"mem_1789755524758238574",
"mem_1789741519238314161",
"mem_1789736468534024216",
"mem_1789731934167097953",
"mem_1791916787313401423",
"mem_1787336595731500272",
"mem_1791794756620086535",
"mem_1791811143462384371",
"mem_1791735819718797684"
],
"v_values": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"quality_scores": [
0.59,
0.63,
0,
0.755,
0.755,
0.5,
0.78,
0.5,
0.755,
0.5,
0.755,
0.755,
0.755,
0.755,
0.798,
0.395,
0.695,
0.63,
0.315,
0.6
],
"raw_quality_scores": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"v_pos": 0,
"v_pos_ids": []
},
{
"query": "小怡的工作笔记在哪",
"latency_ms": 736,
"top_ids": [
"mem_1791365211038188118",
"mem_1792088552817459951",
"mem_1789155186005621317",
"mem_1790441160106001927",
"mem_1791736002933120377",
"mem_1792118701572499829",
"mem_1792088552818645685",
"mem_1791915848530338805",
"mem_1791383333319512288",
"mem_1792088552818535755",
"mem_1790187044046223493",
"mem_1792088552824837822",
"mem_1792088552817461960",
"mem_1791423103974079882",
"mem_1792088552825338800"
],
"v_values": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"quality_scores": [
0.8,
0.5,
0.755,
0.755,
0.39,
0.63,
0.5,
0.5,
0.8,
0.5,
0.755,
0.5,
0.5,
0.6,
0.5
],
"raw_quality_scores": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"v_pos": 0,
"v_pos_ids": []
},
{
"query": "昨天做了什么",
"latency_ms": 791,
"top_ids": [
"mem_1788341686813816985",
"mem_1790425119425241515",
"mem_1791633893565216844",
"mem_1790455525438011532",
"mem_1792083942223793654",
"mem_1792088770119950733",
"mem_1790805789157124891",
"mem_1792107513668237224",
"mem_1789431180512315186",
"mem_1790425238816494139",
"mem_1790424256264891954",
"mem_1787926246433496550",
"mem_1790425017614420145",
"mem_1790581068974704280",
"mem_1789327284892773149",
"mem_1790226520252986178",
"mem_1790269130144600786",
"mem_1791819411153164256",
"mem_1789827897771148953",
"mem_1788166192802637740"
],
"v_values": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"quality_scores": [
0.755,
0.755,
0,
0.755,
0.395,
0.63,
0.755,
0.63,
0.515,
0.755,
0.755,
0.775,
0.755,
0.755,
0.755,
0,
0.755,
0.395,
0.755,
0.755
],
"raw_quality_scores": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"v_pos": 0,
"v_pos_ids": []
},
{
"query": "飞书怎么发语音",
"latency_ms": 723,
"top_ids": [
"mem_1790345721671326922",
"mem_1790350480105660984",
"mem_1790425022831993728",
"mem_1791118583544396904",
"mem_1791533226241026355",
"mem_1790455570122981543",
"mem_1791445601992763027",
"mem_1790496848751501955",
"mem_1789273123503223085",
"mem_1791666641349257940",
"mem_1791666631693960928",
"mem_1792088552825510526",
"mem_1791666639652621391",
"mem_1790265788637717626",
"mem_1790428068416795396",
"mem_1791118583617248844",
"mem_1791666711653684211",
"mem_1789557079608816710",
"mem_1789327693618007907",
"mem_1789273123339483168"
],
"v_values": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"quality_scores": [
0.803,
0.755,
0.755,
0.755,
0,
0.803,
0,
0.83,
0.755,
0,
0.789,
0.5,
0,
0.755,
0.755,
0.755,
0,
0.805,
0.755,
0.755
],
"raw_quality_scores": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"v_pos": 0,
"v_pos_ids": []
},
{
"query": "家服怎么连",
"latency_ms": 722,
"top_ids": [
"mem_1791556229473565240",
"mem_1792084245285474959",
"mem_1792088552818289574",
"mem_1790180705032672204",
"mem_1792100307763093595",
"mem_1791665516356975992",
"mem_1792084245095017393",
"mem_1792082720405068478",
"mem_1791440639711180469",
"mem_1792114886051333715",
"mem_1792088552825127031",
"mem_1791642436962837771",
"mem_1792088552819438066",
"mem_1792088552816808997",
"mem_1790187091203506519",
"mem_1792126203805297968",
"mem_1792088552819539001",
"mem_1791904832104972267",
"mem_1792088552818662517",
"mem_1792088552824945417"
],
"v_values": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"quality_scores": [
0,
0.63,
0.5,
0.755,
0.63,
0.8,
0.63,
0.63,
0,
0.63,
0.5,
0.65,
0.5,
0.5,
0.755,
0.395,
0.5,
0.43,
0.5,
0.5
],
"raw_quality_scores": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"v_pos": 0,
"v_pos_ids": []
},
{
"query": "织忆怎么部署",
"latency_ms": 682,
"top_ids": [
"mem_1788623666023332134",
"mem_1789793765940732350",
"mem_1791479003228249623",
"mem_1789149704491770048",
"mem_1789154058288011112",
"mem_1791818814047111008",
"mem_1792144557208382006",
"mem_1791402157932171759",
"mem_1791899478077693848",
"mem_1789411888019473186",
"mem_1792088552825130331",
"mem_1792088552817959556",
"mem_1791912042293867384",
"mem_1788657176712786530",
"mem_1790424244350350337",
"mem_1791740271337572997",
"mem_1792088552818065436",
"mem_1789132107404617336",
"mem_1788340352324421455",
"mem_1791431677583759030"
],
"v_values": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0
],
"quality_scores": [
0.911,
0.755,
0,
0.99,
0.695,
0.63,
0.565,
0.99,
0.644,
0.865,
0.5,
0.5,
0.59,
0.755,
0.99,
0,
0.5,
0.755,
0.755,
0.675
],
"raw_quality_scores": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"v_pos": 1,
"v_pos_ids": [
"mem_1791740271337572997"
]
},
{
"query": "记忆保护名单",
"latency_ms": 664,
"top_ids": [
"mem_1790424365997011289",
"mem_1792126499467223243",
"mem_1788602693814224919",
"mem_1791725846574107784",
"mem_1789222925018439889",
"mem_1791666995981185275",
"mem_1792088552818471375",
"mem_1791686097363885894",
"mem_1789554588799449124",
"mem_1791420422726554280",
"mem_1791686088996261165",
"mem_1789760781378060592",
"mem_1790427668905832166",
"mem_1791111607517908194",
"mem_1791818212646773755",
"mem_1790051973384869549",
"mem_1790564623527240562",
"mem_1789992010025213786",
"mem_1792088552819583406",
"mem_1791685772880116219"
],
"v_values": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"quality_scores": [
0.99,
0.5,
0.285,
0,
0.755,
0,
0.5,
0,
0.755,
0.914629,
0,
0.755,
0.755,
0.755,
0.63,
0.755,
0.755,
0.99,
0.5,
0
],
"raw_quality_scores": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"v_pos": 0,
"v_pos_ids": []
}
]
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,82 @@
{
"at": "20261016-183633",
"tag": "PRE",
"base": "http://127.0.0.1:7891",
"keyword_pass": 14,
"keyword_fail": 0,
"keyword_total": 14,
"recall_at_5": 0.42857142857142855,
"mrr": 0.2380952380952381,
"items": [
{
"query": "织忆是什么",
"keyword_ok": true,
"id_recall": 0
},
{
"query": "织忆的图谱扩展机制是什么",
"keyword_ok": true,
"id_recall": 1
},
{
"query": "织忆如何通过 E1 提取和 MMR 实现多样去重",
"keyword_ok": true,
"id_recall": 0
},
{
"query": "牧尘的偏好是什么",
"keyword_ok": true,
"id_recall": 1
},
{
"query": "牧尘喜欢什么样的工作方式",
"keyword_ok": true,
"id_recall": 0
},
{
"query": "如何根据牧尘的偏好调整记忆检索策略",
"keyword_ok": true,
"id_recall": 1
},
{
"query": "当前项目的技术栈是什么",
"keyword_ok": true,
"id_recall": 1
},
{
"query": "织忆项目有哪些核心组件",
"keyword_ok": true,
"id_recall": 0
},
{
"query": "织忆和其他记忆系统相比有什么架构优势",
"keyword_ok": true,
"id_recall": 1
},
{
"query": "如何使用 recall 接口",
"keyword_ok": true,
"id_recall": 0
},
{
"query": "织忆有哪些管理工具",
"keyword_ok": true,
"id_recall": 0
},
{
"query": "如何通过 API 扩展织忆功能",
"keyword_ok": true,
"id_recall": 0
},
{
"query": "本地 llama 服务 8080 端口",
"keyword_ok": true,
"id_recall": 0
},
{
"query": "session-to-zhiyi.py 脚本名",
"keyword_ok": true,
"id_recall": 1
}
]
}
@@ -0,0 +1,932 @@
{
"at": "20261016-183639",
"tag": "PRE",
"base": "http://127.0.0.1:7891",
"top_k": 20,
"rows": [
{
"query": "牧尘工作所在的县是哪个",
"latency_ms": 670,
"top_ids": [
"mem_1792088552818864597",
"mem_1792144084503554069",
"mem_1792144084726385461",
"mem_1790349546889496890",
"mem_1787450890745924038",
"mem_1790448247125520424",
"mem_1789063252280657787",
"mem_1792088552817383356",
"ep_1786293372952742428",
"mem_1792144084989342595",
"mem_1789815923678472264",
"mem_1788006341389931001",
"mem_1790425119425241515",
"mem_1791556228356038757",
"mem_1789230281180128695",
"mem_1792088552824873585",
"mem_1792088552817930263",
"mem_1790424260127845812",
"mem_1789737493969591957",
"mem_1787926246493058856"
],
"v_values": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"quality_scores": [
0.5,
0.59,
0.59,
0.755,
0.255,
0.755,
0.755,
0.5,
0,
0.59,
0.83,
0.755,
0.755,
0,
0.755,
0.5,
0.5,
0.753,
0.755,
0.775
],
"raw_quality_scores": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"v_pos": 0,
"v_pos_ids": []
},
{
"query": "淇县",
"latency_ms": 699,
"top_ids": [
"mem_1792144084726385461",
"mem_1792088552818354175",
"mem_1792088552818868548",
"mem_1792088552818381759",
"mem_1792144084503554069",
"mem_1790938090098284542",
"mem_1790938089297531705",
"mem_1790603669933523968",
"mem_1792088552818672993",
"mem_1790425189400296970",
"mem_1791564884666490485",
"mem_1790848976254029727",
"mem_1792088552819307750",
"mem_1790657384251944657",
"mem_1789328667925309581",
"mem_1792116219795602288",
"mem_1790425213354467843",
"mem_1791395173327211388",
"mem_1792141689103478828",
"mem_1792088552819293505"
],
"v_values": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1
],
"quality_scores": [
0.59,
0.5,
0.5,
0.5,
0.59,
0.83,
0.83,
0,
0.5,
0.755,
0,
0.755,
0.5,
0.755,
0.83,
0.63,
0.755,
0.675,
0.525,
0.99
],
"raw_quality_scores": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"v_pos": 1,
"v_pos_ids": [
"mem_1792088552819293505"
]
},
{
"query": "招商项目台账",
"latency_ms": 682,
"top_ids": [
"mem_1790657711315786810",
"mem_1790425023223201421",
"mem_1792088552819227083",
"mem_1792088552817389514",
"mem_1791194371133842796",
"mem_1790565541250175254",
"mem_1791387362920806120",
"mem_1791899910885939521",
"mem_1789694252600441660",
"mem_1791665519266094329",
"mem_1790309303666562764",
"mem_1790581054400784704",
"mem_1792088552818016699",
"mem_1792088552818037737",
"mem_1790590528061314469",
"mem_1790187043969918230",
"mem_1790448247323881504",
"mem_1790329285912241710",
"mem_1792088552817184125",
"mem_1789327284892773149"
],
"v_values": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"quality_scores": [
0.755,
0.853,
0.5,
0.5,
0.755,
0.755,
0.765,
0.59,
0.755,
0,
0.755,
0.755,
0.5,
0.5,
0.755,
0.755,
0.755,
0.755,
0.5,
0.755
],
"raw_quality_scores": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"v_pos": 0,
"v_pos_ids": []
},
{
"query": "生物制造产业专班",
"latency_ms": 675,
"top_ids": [
"mem_1792144084989342595",
"mem_1792107514588524675",
"mem_1791567307185364992",
"mem_1790051867711758989",
"mem_1789983973135821157",
"mem_1792088552816455607",
"mem_1791380468697720355",
"mem_1792088552817215072",
"mem_1790658098063956127",
"mem_1792088552825118896",
"mem_1790656401441522455",
"mem_1789755524758238574",
"mem_1789741519238314161",
"mem_1789736468534024216",
"mem_1789731934167097953",
"mem_1791916787313401423",
"mem_1787336595731500272",
"mem_1791794756620086535",
"mem_1791811143462384371",
"mem_1791735819718797684"
],
"v_values": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"quality_scores": [
0.59,
0.63,
0,
0.755,
0.755,
0.5,
0.78,
0.5,
0.755,
0.5,
0.755,
0.755,
0.755,
0.755,
0.798,
0.395,
0.695,
0.63,
0.315,
0.6
],
"raw_quality_scores": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"v_pos": 0,
"v_pos_ids": []
},
{
"query": "小怡的工作笔记在哪",
"latency_ms": 583,
"top_ids": [
"mem_1791365211038188118",
"mem_1792088552817459951",
"mem_1789155186005621317",
"mem_1790441160106001927",
"mem_1791736002933120377",
"mem_1792118701572499829",
"mem_1792088552818645685",
"mem_1791915848530338805",
"mem_1791383333319512288",
"mem_1792088552818535755",
"mem_1790187044046223493",
"mem_1792088552824837822",
"mem_1792088552817461960",
"mem_1791423103974079882",
"mem_1792088552825338800"
],
"v_values": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"quality_scores": [
0.8,
0.5,
0.755,
0.755,
0.39,
0.63,
0.5,
0.5,
0.8,
0.5,
0.755,
0.5,
0.5,
0.6,
0.5
],
"raw_quality_scores": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"v_pos": 0,
"v_pos_ids": []
},
{
"query": "昨天做了什么",
"latency_ms": 737,
"top_ids": [
"mem_1788341686813816985",
"mem_1790425119425241515",
"mem_1791633893565216844",
"mem_1790455525438011532",
"mem_1792083942223793654",
"mem_1792088770119950733",
"mem_1790805789157124891",
"mem_1792107513668237224",
"mem_1789431180512315186",
"mem_1790425238816494139",
"mem_1790424256264891954",
"mem_1787926246433496550",
"mem_1790425017614420145",
"mem_1790581068974704280",
"mem_1789327284892773149",
"mem_1790226520252986178",
"mem_1790269130144600786",
"mem_1791819411153164256",
"mem_1789827897771148953",
"mem_1788166192802637740"
],
"v_values": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"quality_scores": [
0.755,
0.755,
0,
0.755,
0.395,
0.63,
0.755,
0.63,
0.515,
0.755,
0.755,
0.775,
0.755,
0.755,
0.755,
0,
0.755,
0.395,
0.755,
0.755
],
"raw_quality_scores": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"v_pos": 0,
"v_pos_ids": []
},
{
"query": "飞书怎么发语音",
"latency_ms": 710,
"top_ids": [
"mem_1790345721671326922",
"mem_1790350480105660984",
"mem_1790425022831993728",
"mem_1791118583544396904",
"mem_1791533226241026355",
"mem_1790455570122981543",
"mem_1791445601992763027",
"mem_1790496848751501955",
"mem_1789273123503223085",
"mem_1791666641349257940",
"mem_1791666631693960928",
"mem_1792088552825510526",
"mem_1791666639652621391",
"mem_1790265788637717626",
"mem_1790428068416795396",
"mem_1791118583617248844",
"mem_1791666711653684211",
"mem_1789557079608816710",
"mem_1789327693618007907",
"mem_1789273123339483168"
],
"v_values": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"quality_scores": [
0.803,
0.755,
0.755,
0.755,
0,
0.803,
0,
0.83,
0.755,
0,
0.789,
0.5,
0,
0.755,
0.755,
0.755,
0,
0.805,
0.755,
0.755
],
"raw_quality_scores": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"v_pos": 0,
"v_pos_ids": []
},
{
"query": "家服怎么连",
"latency_ms": 718,
"top_ids": [
"mem_1791556229473565240",
"mem_1792084245285474959",
"mem_1792088552818289574",
"mem_1790180705032672204",
"mem_1792100307763093595",
"mem_1791665516356975992",
"mem_1792084245095017393",
"mem_1792082720405068478",
"mem_1791440639711180469",
"mem_1792114886051333715",
"mem_1792088552825127031",
"mem_1791642436962837771",
"mem_1792088552819438066",
"mem_1792088552816808997",
"mem_1790187091203506519",
"mem_1792126203805297968",
"mem_1792088552819539001",
"mem_1791904832104972267",
"mem_1792088552818662517",
"mem_1792088552824945417"
],
"v_values": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"quality_scores": [
0,
0.63,
0.5,
0.755,
0.63,
0.8,
0.63,
0.63,
0,
0.63,
0.5,
0.65,
0.5,
0.5,
0.755,
0.395,
0.5,
0.43,
0.5,
0.5
],
"raw_quality_scores": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"v_pos": 0,
"v_pos_ids": []
},
{
"query": "织忆怎么部署",
"latency_ms": 732,
"top_ids": [
"mem_1788623666023332134",
"mem_1789793765940732350",
"mem_1791479003228249623",
"mem_1789149704491770048",
"mem_1789154058288011112",
"mem_1791818814047111008",
"mem_1792144557208382006",
"mem_1791402157932171759",
"mem_1791899478077693848",
"mem_1789411888019473186",
"mem_1792088552825130331",
"mem_1792088552817959556",
"mem_1791912042293867384",
"mem_1788657176712786530",
"mem_1790424244350350337",
"mem_1791740271337572997",
"mem_1792088552818065436",
"mem_1789132107404617336",
"mem_1788340352324421455",
"mem_1791431677583759030"
],
"v_values": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0
],
"quality_scores": [
0.911,
0.755,
0,
0.99,
0.695,
0.63,
0.565,
0.99,
0.644,
0.865,
0.5,
0.5,
0.59,
0.755,
0.99,
0,
0.5,
0.755,
0.755,
0.675
],
"raw_quality_scores": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"v_pos": 1,
"v_pos_ids": [
"mem_1791740271337572997"
]
},
{
"query": "记忆保护名单",
"latency_ms": 694,
"top_ids": [
"mem_1790424365997011289",
"mem_1792126499467223243",
"mem_1788602693814224919",
"mem_1791725846574107784",
"mem_1789222925018439889",
"mem_1791666995981185275",
"mem_1792088552818471375",
"mem_1791686097363885894",
"mem_1789554588799449124",
"mem_1791420422726554280",
"mem_1791686088996261165",
"mem_1789760781378060592",
"mem_1790427668905832166",
"mem_1791111607517908194",
"mem_1791818212646773755",
"mem_1790051973384869549",
"mem_1790564623527240562",
"mem_1789992010025213786",
"mem_1792088552819583406",
"mem_1791685772880116219"
],
"v_values": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"quality_scores": [
0.99,
0.5,
0.285,
0,
0.755,
0,
0.5,
0,
0.755,
0.914629,
0,
0.755,
0.755,
0.755,
0.63,
0.755,
0.755,
0.99,
0.5,
0
],
"raw_quality_scores": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"v_pos": 0,
"v_pos_ids": []
}
]
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,82 @@
{
"at": "20261016-191108",
"tag": "RBK",
"base": "http://127.0.0.1:7892",
"keyword_pass": 14,
"keyword_fail": 0,
"keyword_total": 14,
"recall_at_5": 0.42857142857142855,
"mrr": 0.2380952380952381,
"items": [
{
"query": "织忆是什么",
"keyword_ok": true,
"id_recall": 0
},
{
"query": "织忆的图谱扩展机制是什么",
"keyword_ok": true,
"id_recall": 1
},
{
"query": "织忆如何通过 E1 提取和 MMR 实现多样去重",
"keyword_ok": true,
"id_recall": 0
},
{
"query": "牧尘的偏好是什么",
"keyword_ok": true,
"id_recall": 1
},
{
"query": "牧尘喜欢什么样的工作方式",
"keyword_ok": true,
"id_recall": 0
},
{
"query": "如何根据牧尘的偏好调整记忆检索策略",
"keyword_ok": true,
"id_recall": 1
},
{
"query": "当前项目的技术栈是什么",
"keyword_ok": true,
"id_recall": 1
},
{
"query": "织忆项目有哪些核心组件",
"keyword_ok": true,
"id_recall": 0
},
{
"query": "织忆和其他记忆系统相比有什么架构优势",
"keyword_ok": true,
"id_recall": 1
},
{
"query": "如何使用 recall 接口",
"keyword_ok": true,
"id_recall": 0
},
{
"query": "织忆有哪些管理工具",
"keyword_ok": true,
"id_recall": 0
},
{
"query": "如何通过 API 扩展织忆功能",
"keyword_ok": true,
"id_recall": 0
},
{
"query": "本地 llama 服务 8080 端口",
"keyword_ok": true,
"id_recall": 0
},
{
"query": "session-to-zhiyi.py 脚本名",
"keyword_ok": true,
"id_recall": 1
}
]
}
@@ -0,0 +1,932 @@
{
"at": "20261016-191116",
"tag": "RBK",
"base": "http://127.0.0.1:7892",
"top_k": 20,
"rows": [
{
"query": "牧尘工作所在的县是哪个",
"latency_ms": 836,
"top_ids": [
"mem_1792088552818864597",
"mem_1792144084503554069",
"mem_1792144084726385461",
"mem_1790349546889496890",
"mem_1787450890745924038",
"mem_1790448247125520424",
"mem_1789063252280657787",
"mem_1792088552817383356",
"ep_1786293372952742428",
"mem_1792144084989342595",
"mem_1789815923678472264",
"mem_1788006341389931001",
"mem_1790425119425241515",
"mem_1791556228356038757",
"mem_1789230281180128695",
"mem_1792088552824873585",
"mem_1792088552817930263",
"mem_1790424260127845812",
"mem_1789737493969591957",
"mem_1787926246493058856"
],
"v_values": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"quality_scores": [
0.5,
0.59,
0.59,
0.755,
0.255,
0.755,
0.755,
0.5,
0,
0.59,
0.83,
0.755,
0.755,
0,
0.755,
0.5,
0.5,
0.753,
0.755,
0.775
],
"raw_quality_scores": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"v_pos": 0,
"v_pos_ids": []
},
{
"query": "淇县",
"latency_ms": 789,
"top_ids": [
"mem_1792144084726385461",
"mem_1792088552818354175",
"mem_1792088552818868548",
"mem_1792088552818381759",
"mem_1792144084503554069",
"mem_1790938090098284542",
"mem_1790938089297531705",
"mem_1790603669933523968",
"mem_1792088552818672993",
"mem_1790425189400296970",
"mem_1791564884666490485",
"mem_1790848976254029727",
"mem_1792088552819307750",
"mem_1790657384251944657",
"mem_1789328667925309581",
"mem_1792116219795602288",
"mem_1790425213354467843",
"mem_1791395173327211388",
"mem_1792141689103478828",
"mem_1792088552819293505"
],
"v_values": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1
],
"quality_scores": [
0.59,
0.5,
0.5,
0.5,
0.59,
0.83,
0.83,
0,
0.5,
0.755,
0,
0.755,
0.5,
0.755,
0.83,
0.63,
0.755,
0.675,
0.525,
0.99
],
"raw_quality_scores": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"v_pos": 1,
"v_pos_ids": [
"mem_1792088552819293505"
]
},
{
"query": "招商项目台账",
"latency_ms": 730,
"top_ids": [
"mem_1790657711315786810",
"mem_1790425023223201421",
"mem_1792088552819227083",
"mem_1792088552817389514",
"mem_1791194371133842796",
"mem_1790565541250175254",
"mem_1791387362920806120",
"mem_1791899910885939521",
"mem_1789694252600441660",
"mem_1791665519266094329",
"mem_1790309303666562764",
"mem_1790581054400784704",
"mem_1792088552818016699",
"mem_1792088552818037737",
"mem_1790590528061314469",
"mem_1790187043969918230",
"mem_1790448247323881504",
"mem_1790329285912241710",
"mem_1792088552817184125",
"mem_1789327284892773149"
],
"v_values": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"quality_scores": [
0.755,
0.853,
0.5,
0.5,
0.755,
0.755,
0.765,
0.59,
0.755,
0,
0.755,
0.755,
0.5,
0.5,
0.755,
0.755,
0.755,
0.755,
0.5,
0.755
],
"raw_quality_scores": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"v_pos": 0,
"v_pos_ids": []
},
{
"query": "生物制造产业专班",
"latency_ms": 753,
"top_ids": [
"mem_1792144084989342595",
"mem_1792107514588524675",
"mem_1791567307185364992",
"mem_1790051867711758989",
"mem_1789983973135821157",
"mem_1792088552816455607",
"mem_1791380468697720355",
"mem_1792088552817215072",
"mem_1790658098063956127",
"mem_1792088552825118896",
"mem_1790656401441522455",
"mem_1789755524758238574",
"mem_1789741519238314161",
"mem_1789736468534024216",
"mem_1789731934167097953",
"mem_1791916787313401423",
"mem_1787336595731500272",
"mem_1791794756620086535",
"mem_1791811143462384371",
"mem_1791735819718797684"
],
"v_values": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"quality_scores": [
0.59,
0.63,
0,
0.755,
0.755,
0.5,
0.78,
0.5,
0.755,
0.5,
0.755,
0.755,
0.755,
0.755,
0.798,
0.395,
0.695,
0.63,
0.315,
0.6
],
"raw_quality_scores": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"v_pos": 0,
"v_pos_ids": []
},
{
"query": "小怡的工作笔记在哪",
"latency_ms": 653,
"top_ids": [
"mem_1791365211038188118",
"mem_1792088552817459951",
"mem_1789155186005621317",
"mem_1790441160106001927",
"mem_1791736002933120377",
"mem_1792118701572499829",
"mem_1792088552818645685",
"mem_1791915848530338805",
"mem_1791383333319512288",
"mem_1792088552818535755",
"mem_1790187044046223493",
"mem_1792088552824837822",
"mem_1792088552817461960",
"mem_1791423103974079882",
"mem_1792088552825338800"
],
"v_values": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"quality_scores": [
0.8,
0.5,
0.755,
0.755,
0.39,
0.63,
0.5,
0.5,
0.8,
0.5,
0.755,
0.5,
0.5,
0.6,
0.5
],
"raw_quality_scores": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"v_pos": 0,
"v_pos_ids": []
},
{
"query": "昨天做了什么",
"latency_ms": 739,
"top_ids": [
"mem_1788341686813816985",
"mem_1790425119425241515",
"mem_1791633893565216844",
"mem_1790455525438011532",
"mem_1792083942223793654",
"mem_1792088770119950733",
"mem_1790805789157124891",
"mem_1792107513668237224",
"mem_1789431180512315186",
"mem_1790425238816494139",
"mem_1790424256264891954",
"mem_1787926246433496550",
"mem_1790425017614420145",
"mem_1790581068974704280",
"mem_1789327284892773149",
"mem_1790226520252986178",
"mem_1790269130144600786",
"mem_1791819411153164256",
"mem_1789827897771148953",
"mem_1788166192802637740"
],
"v_values": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"quality_scores": [
0.755,
0.755,
0,
0.755,
0.395,
0.63,
0.755,
0.63,
0.515,
0.755,
0.755,
0.775,
0.755,
0.755,
0.755,
0,
0.755,
0.395,
0.755,
0.755
],
"raw_quality_scores": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"v_pos": 0,
"v_pos_ids": []
},
{
"query": "飞书怎么发语音",
"latency_ms": 810,
"top_ids": [
"mem_1790345721671326922",
"mem_1790350480105660984",
"mem_1790425022831993728",
"mem_1791118583544396904",
"mem_1791533226241026355",
"mem_1790455570122981543",
"mem_1791445601992763027",
"mem_1790496848751501955",
"mem_1789273123503223085",
"mem_1791666641349257940",
"mem_1791666631693960928",
"mem_1792088552825510526",
"mem_1791666639652621391",
"mem_1790265788637717626",
"mem_1790428068416795396",
"mem_1791118583617248844",
"mem_1791666711653684211",
"mem_1789557079608816710",
"mem_1789327693618007907",
"mem_1789273123339483168"
],
"v_values": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"quality_scores": [
0.803,
0.755,
0.755,
0.755,
0,
0.803,
0,
0.83,
0.755,
0,
0.789,
0.5,
0,
0.755,
0.755,
0.755,
0,
0.805,
0.755,
0.755
],
"raw_quality_scores": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"v_pos": 0,
"v_pos_ids": []
},
{
"query": "家服怎么连",
"latency_ms": 787,
"top_ids": [
"mem_1791556229473565240",
"mem_1792084245285474959",
"mem_1792088552818289574",
"mem_1790180705032672204",
"mem_1792100307763093595",
"mem_1791665516356975992",
"mem_1792084245095017393",
"mem_1792082720405068478",
"mem_1791440639711180469",
"mem_1792114886051333715",
"mem_1792088552825127031",
"mem_1791642436962837771",
"mem_1792088552819438066",
"mem_1792088552816808997",
"mem_1790187091203506519",
"mem_1792126203805297968",
"mem_1792088552819539001",
"mem_1791904832104972267",
"mem_1792088552818662517",
"mem_1792088552824945417"
],
"v_values": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"quality_scores": [
0,
0.63,
0.5,
0.755,
0.63,
0.8,
0.63,
0.63,
0,
0.63,
0.5,
0.65,
0.5,
0.5,
0.755,
0.395,
0.5,
0.43,
0.5,
0.5
],
"raw_quality_scores": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"v_pos": 0,
"v_pos_ids": []
},
{
"query": "织忆怎么部署",
"latency_ms": 936,
"top_ids": [
"mem_1788623666023332134",
"mem_1789793765940732350",
"mem_1791479003228249623",
"mem_1789149704491770048",
"mem_1789154058288011112",
"mem_1791818814047111008",
"mem_1792144557208382006",
"mem_1791402157932171759",
"mem_1791899478077693848",
"mem_1789411888019473186",
"mem_1792088552825130331",
"mem_1792088552817959556",
"mem_1791912042293867384",
"mem_1788657176712786530",
"mem_1790424244350350337",
"mem_1791740271337572997",
"mem_1792088552818065436",
"mem_1789132107404617336",
"mem_1788340352324421455",
"mem_1791431677583759030"
],
"v_values": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0
],
"quality_scores": [
0.911,
0.755,
0,
0.99,
0.695,
0.63,
0.565,
0.99,
0.644,
0.865,
0.5,
0.5,
0.59,
0.755,
0.99,
0,
0.5,
0.755,
0.755,
0.675
],
"raw_quality_scores": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"v_pos": 1,
"v_pos_ids": [
"mem_1791740271337572997"
]
},
{
"query": "记忆保护名单",
"latency_ms": 968,
"top_ids": [
"mem_1790424365997011289",
"mem_1792126499467223243",
"mem_1788602693814224919",
"mem_1791725846574107784",
"mem_1789222925018439889",
"mem_1791666995981185275",
"mem_1792088552818471375",
"mem_1791686097363885894",
"mem_1789554588799449124",
"mem_1791420422726554280",
"mem_1791686088996261165",
"mem_1789760781378060592",
"mem_1790427668905832166",
"mem_1791111607517908194",
"mem_1791818212646773755",
"mem_1790051973384869549",
"mem_1790564623527240562",
"mem_1789992010025213786",
"mem_1792088552819583406",
"mem_1791685772880116219"
],
"v_values": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"quality_scores": [
0.99,
0.5,
0.285,
0,
0.755,
0,
0.5,
0,
0.755,
0.914629,
0,
0.755,
0.755,
0.755,
0.63,
0.755,
0.755,
0.99,
0.5,
0
],
"raw_quality_scores": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"v_pos": 0,
"v_pos_ids": []
}
]
}
@@ -0,0 +1,116 @@
# 交付说明 · t_5ad3f843【织忆·主症B 返修】元笔记分类器 + 咽喉点收口
- **卡号**:`t_5ad3f843` | **执行者**:prof-dev(2 次 run 均 `rc=1` 崩于"写交付说明"步骤)
- **终审收口**:小怡(default)—— 逐条**亲跑**验证,不采信 worker 自报
- **日期**:2026-10-16 | **commit**:`e85c9db`(代码)+ 本文件(证据)
- **结论**:AC1–AC8 **全绿**;**生产未部署**(源码级交付)⇒ **可申请激活**(需另提卡 + 牧尘放行)
---
## ① 判据脚本原始输出(AC1 · exit 0)
```
$ python3 ~/.hermes/scripts/verify-meta-filter.py
正例 14 条 / 负例 11 条;不符 0 条
[PASS] 分类器达标
$ echo $?
0
```
> 基线(返修前):exit=1,**10 条不符(7 漏拦 + 3 误拦)**。
## ② 逐条 before / after(AC7 要求)
### 病根一 · 漏拦(7 条):判据只认"写下来的那几种写法"
| 案例(生产原文) | before | after | 命中规则 |
|---|---|---|---|
| `实测 淇县 ANN 731→15、鹤壁 6536→18(都进池50)` | **放行 ✗** | 拦 ✓ | `pool_in+rank_num+num_arrow+pool` |
| `实测 淇县 ANN 731→15(都进池50)` | **放行 ✗** | 拦 ✓ | `pool_in+rank_num+num_arrow+pool` |
| `短问法 MISS 复现:牧尘工作所在的县是哪个` | 放行 ✗ | 拦 ✓ | `repro` |
| `淇县库里 21 条、鹤壁 10 条、河南 15 条语料可用。` | 放行 ✗ | 拦 ✓ | `data_inv` |
| `实测(只读,未改任何库/代码):目标「用户在招商局工作」在 query…` | 放行 ✗ | 拦 ✓ | `data_inv+pool+rank` |
| `memory_write 落 episodes 表 + vector 全 0 —— 写入…` | 放行 ✗ | 拦 ✓ | `write_bug` |
| `织忆 golden 14/14 不退化,recall@5 = 0.4286` | 放行 ✗ | 拦 ✓ | `eval_metric` |
**新增规则原文**(形态匹配,非加字面词 —— 满足 AC3):
```
_RANK_NUM = \bANN\s*\d+\s*→\s*\d+
_NUM_ARROW = \d+\s*→\s*\d+
_POOL_IN = 进\s*池\s*\d+
_REPRO = (MISS\s*复现|复现[::((]|复现\s*(步骤|方法|过程|流程|记录)\s*[::((])
_DATA_INV = (语料盘点:N 条…可用)
_EVAL_METRIC= (golden \d+/\d+|recall@\d+)
_DEBUG_MARK = (\bDEBUG\b|探针|自报|脚本输出)
_WRITE_BUG = (写入类故障记录)
```
### 病根二 · 误拦(3 条):把"关于系统的事实"当调查记录拦了
| 案例(事实句,金标同族题面) | before | after | 理由 |
|---|---|---|---|
| `织忆召回接口 POST /api/v1/recall 接受 top_k 参数。` | **拦 ✗** | 放 ✓ | `_FACT_DECL` 事实声明形豁免 |
| `召回缓存 recall-cache 每 6 小时刷新一次。` | **拦 ✗** | 放 ✓ | `_FACT_DECL`(周期形) |
| `织忆主服务进程名是 zhiyid-new,合并服务是 zhiyi-consolidate。` | **拦 ✗** | 放 ✓ | `_FACT_DECL`(进程名形) |
**AC4 区分判据**:`svc_core` 由 STRONG 降级为 **cue 计数**;通用功能词(召回|蒸馏)各计 1 个 cue,需叠加才判;**事实声明形**(`_FACT_DECL`:接受X参数/监听N端口/每N小时…一次/进程名是/地址是/模型是/字体/行距)⇒ **判事实放行**;带 实测/复现/诊断/命中/命名空间/限流/退避/碎片化/FALLBACK 等概念词的调查记录**不受豁免** ⇒ 既放行 3 条误拦,又**不漏原有 16 条自检正例**(两套用例同时绿)。
### 负例 11 条全放行(`[放✓]`)
牧尘在河南省鹤壁市淇县工作。· 淇县招商项目台账(09-27阅读版).md 已发布。· 织忆服务的健康检查接口监听 7821 端口。· 织忆召回接口 POST /api/v1/recall 接受 top_k 参数。· 召回缓存 recall-cache 每 6 小时刷新一次。· 织忆主服务进程名是 zhiyid-new,合并服务是 zhiyi-consolidate · 公文排版正文用仿宋_GB2312 字体,行距 28 磅。· 家庭服务器(跑 Gitea)在局域网里的地址是 192.168.123.11。· 本地 2B 模型服务监听 8080 端口。· 牧尘负责生物制造产业专班 + 卫都街道安全管理。· 小怡日常主链模型是 deepseek-v4-flash(官方直连)。
## ③ AC8 咽喉点收口(本卡新增,最关键)
**问题**:B 卡只接了 3 条管线,而污染样本 **5/6 来自未覆盖路径**(27 个写者只有 3 个被覆盖)。
**修法**:接在**唯一共享入口**上 —— 所有 27 个写者都走 `POST /api/v1/commit`:
```go
// go/internal/api/routes/core.go:108(/commit 处理器,AutoDistillTrigger 之前)
if skip, reason := metanote.ShouldSkip(req.Content); skip {
log.Printf("[metanote] commit rejected (gate ON, meta-note): ...")
respondError(w, 422, "meta-note rejected: "+reason)
return // ← 真拒写:不落 episode、不进蒸馏
}
// core.go:322(write_memory 处理器)同样接线
```
⇒ 覆盖 **27/27 写者**(对比修前 3/27)。
**Python↔Go 同源锁死**:`internal/metanote`(patterns.go / classify.go)与 `~/.hermes/scripts/lib/meta_note_filter.py` 口径同源,由 parity + 差分测试锁死:
- `TestParity_PythonLive`(Live parity,0.03s)
- `TestParity_MutatedCopyFails` —— **变异必 FAIL**(负控,0.03s)
- `TestDifferential_PythonReference` / `TestDifferential_ExpectedSpotChecks`
## ④ 小怡终审亲跑证据(非自报)
| 项 | 命令 | 结果 |
|---|---|---|
| 构建 | `go build ./...` | **exit 0** |
| 测试 | `go test ./internal/metanote/` | **ok 0.130s**(8 项含 parity/mutation) |
| 判据 | `verify-meta-filter.py` | **exit 0**(正14/负11/不符0) |
| 判据未被改(AC2) | `verify-meta-filter.py` mtime **20:08:22** 早于 worker 首启 **20:09:36** | ✓ 正/负例逐字未动 |
| 门默认关(AC6) | gateway env / zhiyid env | **各 0 处** `ZHIYI_META_FILTER` |
| **未部署** | 生产二进制 mtime `19:02:50` **早于** 提交 `21:59:28`;md5 `95286f63789c7ecd` 未变;`strings` 内 `metanote` **0 处** | ✓ 源码级 |
| 零行为变化 | `classify.go:91 ShouldSkip` → `if !GateEnabled() { return false, "" }` | ✓ 门关即不参与 |
## ⑤ 改动文件清单
| 文件 | 说明 |
|---|---|
| `go/internal/metanote/patterns.go` | 新增(规则表,Python 口径同源) |
| `go/internal/metanote/classify.go` | 新增(分类 + `GateEnabled`/`ShouldSkip`) |
| `go/internal/metanote/metanote_parity_test.go` | 新增(parity) |
| `go/internal/metanote/metanote_live_test.go` | 新增(Live parity + 变异负控) |
| `go/internal/metanote/metanote_diff_test.go` | 新增(差分对照) |
| `go/internal/api/routes/core.go` | 咽喉点接线(:108 / :322) |
| `~/.hermes/scripts/lib/meta_note_filter.py` | 分类器 193 → 326 行(仓库外,`.hermes` 侧) |
## ⑥ 激活状态
**可申请激活**(分类器绿 + 咽喉点覆盖 27/27 + parity/变异测试在 + 默认关 + 未部署)。
**但激活不在本卡范围**:需 ① 另提卡 ② 重建二进制并部署 ③ 牧尘放行(`prod-write-gate.py` 会拦,别绕)。
## ⑦ 遗留(诚实记录)
1. **两次 run 均 `rc=1` 崩于"写交付说明"步骤**,日志**无任何错误行**(裸退);代码工作在两轮内已完成并提交 ⇒ 未放行第三次盲跑。**真因未定**(疑与同 profile 并发 worker / 会话结束路径有关,待查)。
2. **AC8.1 覆盖表**:本文件 ③ 已给出结构性论证(单一入口 ⇒ 27/27),worker 原计划的逐写者分布表未落盘(随交付说明一起丢)。
@@ -0,0 +1,147 @@
# 【织忆·门控复核】PATTERN/PEER 影子 diff 量化 — 对照表与原始证据
卡: t_bd63b641 · 执行人: prof-dev · 2026-10-17 21:0x CST
方法: 不碰生产 env/二进制/服务(AC 硬边界)。开态用「独立离线评测」——
真实 PatternProvider/PeerAPI + 生产 graph.db(只读) + 实时 bge embedder(:8000),
等价于活服务开态注入路径;关态取活服务(:7821, PID 812251)实测。
────────────────────────────────────────────────────────────
## 结论
关态 vs 开态 diff = **空(当前态)**,且开态即使修 bug 也**无净收益/伤主路径**。
两个通道本卡判定 **「不开闸」**,如实回卡。理由:
- PEER: 无 peers 表(0 行),开态物理零注入,无产出。
- PATTERN: 当前 NULL-id bug 使 `ListPatterns` 恒报错 → 零注入;**即使修好 bug**,
85 行全是「图聚类摘要节点」(无 data/tools 内容,只有聚类键名),对金标余弦
0.675–0.715(≥0.6 阈值)会注入 ≤3 条**无召回价值的聚类键噪声/金标**,外加每
query ~85 次 embed(冷 ~1.4s)拉高 P95 + 稀释 top-5 ⇒ 伤主路径、无净收益。
- 原始阻塞理由(上游未落地)小怡已复核失效(t_3e98b65a/t_fcadee04 done),
但「上游 done ≠ 该开」—— 实测证明开了也净负收益(比图谱路「接线已证、收益未证」
更明确: PATTERN 有 NULL-id 读库 bug + 数据源本身不是行为 pattern)。
────────────────────────────────────────────────────────────
## 一、门控现状(关态坐实,双源独立)
- systemd unit 权威配置: `systemctl --user cat zhiyid | grep ZHIYI_` → 无
ZHIYI_PATTERN_RECALL / 无 ZHIYI_PEER_RECALL
- 运行进程 env: `/proc/812251/environ` → 无此二键(与 prof-cloud 异源复核一致)
- 结论: 两通道 env 均「未设/≠1」⇒ server.go 启动期 `if os.Getenv(...)=="1"` 不成立
⇒ `storage.PatternRecallProvider` / `storage.PeerRecallProvider` 均未注册 ⇒ 关态
## 二、影子 diff(关态 vs 开态,逐条对照)
固定 query 集: 14 金标(golden-set.json)+ 69 条真实 content-recall(usage_recall 表)
### PATTERN 通道(ZHIYI_PATTERN_RECALL)
开态注入结果: 当前 **0 条 / 14 金标**(`shadow-on-pattern-inject.json` = 空 `{}`)
根因(不是「没命中」而是「读不出来」):
- 生产 graph_nodes(type='pattern') 共 85 行,**id 列 85/85 全为 NULL**
(`sqlite3: sum(id IS NULL)=85`)
- `PatternProvider.Match` → `GraphPatternStore.ListPatterns` 读 `SELECT id,...`
为 string,遇 NULL 恒报 `sql: Scan error: converting NULL to string`
(影子测试实测抓到原文: `PATTERN_LISTERROR="sql: Scan error on column index 0,
name \"id\": converting NULL to string is unsupported"`,`PATTERNS_LOADED=0`)
- ⇒ `Match` 直接 return nil ⇒ **当前开态无任何 pattern 注入**
深层(修 bug 后会怎样 —— 已量化,证明「修了也不该开」):
- 这 85 行 properties **0 行有 `data` 键、0 行有 `tools` 键**(只有 20 行有
`description`),全是「图聚类摘要节点」(如 `hermes-main/entity 共 3867 节点…`),
**不是 L2 行为 pattern**。`Match` 里 `p.Data` 恒空 → 回退 `p.Name`(聚类键,
如 `topic:memory` / `pattern:default-main:entity`)。
- 余弦探针(绕过 NULL-id 直接 embed 85 个 pattern 名 + 金标,bge-m3:8000 实测):
5 条金标 maxcos = 0.6752 / 0.6891 / 0.6975 / 0.7038 / 0.7148,**全 ≥ 0.6 阈值
⇒ 若修好 NULL-id,开态会对每条金标注入 ≤3 条聚类键条目**(`offset-cosine-probe.txt`)。
- 注入内容 = 裸聚类键名(`topic:memory`、`pattern:learner-main:concept`),
**对「织忆是什么」这类 query 无召回价值,只会稀释 top-5**。
- 额外成本: 每 query 需 embed 85 个 pattern 名(冷调用 ~85×16.5ms≈1.4s,
探针 430 embed/7.12s 实测)⇒ **P95 主路径显著上升**(OFF p95=3177ms → 开态
叠加 ~1.4s)。
- 判读: 0.6 阈值对 bge-m3 余弦分布偏低(无关文本基线 0.67+),开态注入的多是
**假阳性噪声**。
- ⇒ 修 bug 后开态 = 「伤主路径 + 无净收益」,仍 **不开**。
### PEER 通道(ZHIYI_PEER_RECALL)
开态注入结果: **0 条 / 14 金标**
根因: 生产 graph.db **无 peers 表**(`SELECT count(*) FROM sqlite_master WHERE
name='peers'` = 0)。`PeerAPI.GetRecallResults` → `Store.List(200)` → 0 行 →
return nil(peer.go:305-308 源码)。⇒ **开态也无任何 peer 注入**。
### 对照表
| 维度 | 关态(活服务) | 开态(影子/离线) | diff |
|---|---|---|---|
| PATTERN 注入条数 | 0(未注册) | 当前 0(NULL-id bug);修 bug 后 ≤3 聚类键/金标 | **0(当前)/ 噪声(修后)** |
| PEER 注入条数 | 0(未注册) | 0(无 peers 表) | **0** |
| 召回结果 top5 id | 见 off-recall-14golden-live.json | 当前与关态逐条相同(无注入项可加) | **空(当前)** |
| /eval/golden keyword_pass | 14/14 | =关态(当前注入 0 不动);修后注入聚类键不改关键词判据 | **0(当前)** |
| /eval/golden recall@5 | 0.643 | =关态 | **0(当前)** |
| 开态额外成本 | — | 修 bug 后每 query ~85 embed(冷 ~1.4s)+ top-5 稀释 | 伤主路径 |
| 新增条目位置 | — | 追加在结果尾部(recall.go:901-932 append 到 deduped 之后) | — |
## 三、延迟 P50/P95(阈值从实测读,非拍脑袋)
关态(活服务 :7821):
- 69 真实 query(本卡 AC 要求的「≥50 query」集): **p50=1383.7ms, p95=3177.2ms**
(`off-69-queries-lat-ms.json`,一致单次 run 采集)
- 14 金标: 空载热态 p50=949.9/p95=2288.5ms;我在并发 69-query 压测后重跑
p50=3936.9/p95=5255.5ms(`off-recall-14golden-live.json` = 并发态)——
**绝对值随负载/缓存/8081 LLM 队列代理波动**(非本卡判据,不追服务性能)。
开态:
- **当前态(NULL-id bug 在)**: 注入 0 条,代码块 `if patternRecallEnabled() &&
Provider!=nil` 即便走进去也只 `for _, pr := range nil` ⇒ **开态延迟 = 关态延迟
(逐条相同,无新增 embed)**。
- **修 bug 后态**: 每 query 额外 embed 85 个 pattern 名(探针实测 430 embed/7.12s
≈16.5ms/embed,冷调用 ~1.4s),追加在结果尾部 ⇒ **P95 主路径上叠加 ~1.4s 冷成本
(冷 ≈ 关态 p95 翻倍量级)** ⇒ 伤主路径(超 AC 判据「P95 涨幅>20%」)。
判读: 延迟上「开 vs 关」的**决策相关量 = 修 bug 后每次召回 +~1.4s 冷成本**;
当前态(bug 在)开=关=0 差异。绝对基线随负载波动,不作为开闸判据。
## 四、AC#2: 开门是否需重启才注册 Provider?
**答案: 是,必须重启。**
- 源码证据: `go/internal/api/server.go:189-202` ——
`if os.Getenv("ZHIYI_PATTERN_RECALL")=="1" { storage.PatternRecallProvider =
patternProvider.Match }` / `if os.Getenv("ZHIYI_PEER_RECALL")=="1"
{ storage.PeerRecallProvider = peerAPI.GetRecallResults }`,均在 `NewServer()`
启动期执行;`storage.PatternRecallProvider` 是包级变量,仅在这两处赋值。
改运行中 env(不重启)无法注册 Provider。
- 实测证据: PID 812251 运行二进制 mtime=2026-10-17 01:18,其 /proc environ 无
此二键,当前 Provider 未注册(关态)。要开态只能: 在 systemd unit 加
`Environment=ZHIYI_*_RECALL=1` + `daemon-reload` + `restart zhiyid`。
- 开闸成本 = 一次重启(低)。但本卡结论「不开」基于净收益为负(见二),
重启成本本身不是拦路项——拦路的是「开了也伤主路径 + 无净收益」。
## 五、判据(按卡 AC)
- 当前态 diff 为空(PATTERN NULL-id bug → 0 注入;PEER 无表 → 0 注入)
⇒ 「开态无产出」判据满足 ⇒ **不开闸**。
- 修 bug 后态: 注入聚类键噪声 + P95 主路径涨幅 >20% ⇒ 「有产出但伤主路径」判据
也满足 ⇒ **仍不开**。两条判据各自独立支撑「不开」。
- 主路径未退化(关态 /eval/golden kw 14/14、recall@5=0.643;开态当前态=关态)。
────────────────────────────────────────────────────────────
## 原始 JSON(改前/改后,禁只贴结论)
- 关态活服务 14 金标逐条: `off-recall-14golden-live.json`
- 关态活服务 /eval/golden 汇总: `off-eval-golden-raw.json`
- 关态 69 真实 query 延迟: `off-69-queries-lat-ms.json`
- 开态影子 pattern 注入(当前态 = 空 `{}`): `shadow-on-pattern-inject.json`
- 开态影子 peer 表存在性: 0(`SHADOW_PEER_TABLE_EXISTS=0`,见 shadow_diff_test.go)
- 修 bug 后余弦探针(85 聚类键 vs 金标 maxcos): `offset-cosine-probe.txt`
+ 复现 `TestShadowPattern_CosineIfIDFixed`(`PATTERN_ROWS_LOADABLE_BYPASS_ID=85`)
复跑命令:
# 关态金标召回(活服务)
cd <ws> && python3 eval_off.py
# 关态 69 真实 query 延迟
cd <ws> && python3 latency_off.py # 注: stdout 混进度行,JSON 在尾部
# 开态影子注入 + peer 表 + 修 bug 后余弦探针(只读 graph.db + :8000 embedder,不改生产)
cd /home/muc/src/memoryweave/go && go test ./internal/pattern/ \
-run 'TestShadowPattern_Diff|TestShadowPeer_NoTable|TestShadowPattern_CosineIfIDFixed' \
-v -count=1
## 给审计脚本的「已明确决策」标记(消音用)
本卡对 PATTERN/PEER 门控的终态 = **已明确决策: 暂不开闸(证据: 开态零产出,见上)**。
若审计脚本下次再报「门控到期/上游 done 但门关」,应据此识别为「已决策不开」而非
「没人复核」。建议: 门控台账加 `decision` 字段(values: open/no-open/unknown),
本卡结论落 `no-open` + 证据指针,脚本对 no-open 态静默。
────────────────────────────────────────────────────────────
## 未决(需人拍板,本卡不越界执行)
1. PATTERN NULL-id 读库 bug: `GraphPatternStore.ListPatterns` 扫 NULL id 报错。
85 存量 pattern 行全是聚类摘要节点且 id=NULL——**这套数据本身可能就不该作为
L2 行为 pattern 召回源**。属「修数据还是修代码」的决策,交 hub/ops,不在本卡。
2. 开闸动作: 小怡终审 + 牧尘放行(本卡只出证据,AC 明令不开门)。
@@ -0,0 +1 @@
[-1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, 825.0, 952.6, 1123.0, 1376.9, 1383.7, 1544.4, 1583.8, 1592.2, 1635.1, 1680.3, 1685.1, 1691.0, 1726.0, 1754.9, 1794.7, 1851.8, 1874.3, 1878.2, 1901.0, 1909.9, 1924.8, 1937.8, 1954.0, 2037.1, 2053.7, 2058.2, 2154.6, 2174.1, 2287.7, 2355.2, 2718.2, 2789.6, 2791.9, 2832.9, 3109.5, 3222.3, 3507.6, 3534.9, 3906.4]
@@ -0,0 +1 @@
{"id":"20261017-205654","model":"golden-set","recall_at_5":0.6428571428571429,"precision_at_5":0.12857142857142856,"mean_reciprocal_rank":0.47619047619047616,"ndcg":0.5238095238095238,"query_details":[{"query":"织忆是什么","expected_ids":["mem_1791666702950691634"],"must_include_keywords":["织忆"],"hits":null,"misses":["mem_1791666702950691634"],"recall_at_5":0,"precision_at_5":0,"keyword_ok":true},{"query":"织忆的图谱扩展机制是什么","expected_ids":["mem_1791899478673190284"],"must_include_keywords":["图谱"],"hits":["mem_1791899478673190284"],"misses":null,"recall_at_5":1,"precision_at_5":0.2,"keyword_ok":true},{"query":"织忆如何通过 E1 提取和 MMR 实现多样去重","expected_ids":["mem_1791666890486125605"],"must_include_keywords":["MMR"],"hits":null,"misses":["mem_1791666890486125605"],"recall_at_5":0,"precision_at_5":0,"keyword_ok":true},{"query":"牧尘的偏好是什么","expected_ids":["mem_1790448247125520424"],"must_include_keywords":["牧尘"],"hits":["mem_1790448247125520424"],"misses":null,"recall_at_5":1,"precision_at_5":0.2,"keyword_ok":true},{"query":"牧尘喜欢什么样的工作方式","expected_ids":["mem_1790448247125520424"],"must_include_keywords":["牧尘"],"hits":["mem_1790448247125520424"],"misses":null,"recall_at_5":1,"precision_at_5":0.2,"keyword_ok":true},{"query":"如何根据牧尘的偏好调整记忆检索策略","expected_ids":["mem_1791747597032219499"],"must_include_keywords":["记忆"],"hits":["mem_1791747597032219499"],"misses":null,"recall_at_5":1,"precision_at_5":0.2,"keyword_ok":true},{"query":"当前项目的技术栈是什么","expected_ids":["mem_1790455676397796671"],"must_include_keywords":["技术栈"],"hits":["mem_1790455676397796671"],"misses":null,"recall_at_5":1,"precision_at_5":0.2,"keyword_ok":true},{"query":"织忆项目有哪些核心组件","expected_ids":["mem_1791906354849281414"],"must_include_keywords":["织忆"],"hits":null,"misses":["mem_1791906354849281414"],"recall_at_5":0,"precision_at_5":0,"keyword_ok":true},{"query":"织忆和其他记忆系统相比有什么架构优势","expected_ids":["mem_1791666702950691634"],"must_include_keywords":["织忆"],"hits":["mem_1791666702950691634"],"misses":null,"recall_at_5":1,"precision_at_5":0.2,"keyword_ok":true},{"query":"如何使用 recall 接口","expected_ids":["mem_1791796178328736793"],"must_include_keywords":["recall"],"hits":null,"misses":["mem_1791796178328736793"],"recall_at_5":0,"precision_at_5":0,"keyword_ok":true},{"query":"织忆有哪些管理工具","expected_ids":["mem_1791666702950691634"],"must_include_keywords":["织忆"],"hits":null,"misses":["mem_1791666702950691634"],"recall_at_5":0,"precision_at_5":0,"keyword_ok":true},{"query":"如何通过 API 扩展织忆功能","expected_ids":["mem_1791666188168772049"],"must_include_keywords":["织忆"],"hits":["mem_1791666188168772049"],"misses":null,"recall_at_5":1,"precision_at_5":0.2,"keyword_ok":true},{"query":"本地 llama 服务 8080 端口","expected_ids":["mem_1791423503359721152"],"must_include_keywords":["8080"],"hits":["mem_1791423503359721152"],"misses":null,"recall_at_5":1,"precision_at_5":0.2,"keyword_ok":true},{"query":"session-to-zhiyi.py 脚本名","expected_ids":["mem_1791688470278719760"],"must_include_keywords":["session-to-zhiyi"],"hits":["mem_1791688470278719760"],"misses":null,"recall_at_5":1,"precision_at_5":0.2,"keyword_ok":true}],"queries":14,"ran_at":"2026-10-17T20:56:54.195909811+08:00","keyword_pass":14,"keyword_fail":0,"keyword_total":14}
@@ -0,0 +1,319 @@
{
"state": "OFF",
"results": [
{
"i": 0,
"q": "织忆是什么",
"count": 5,
"top5_ids": [
"mem_1791899478673190284",
"mem_1792154287491185572",
"mem_1791912043093092663",
"mem_1792154287177190000",
"mem_1792154288142808071"
],
"top5_cats": [
"distilled",
"distilled",
"distilled",
"distilled",
"distilled"
],
"kw_ok": true,
"lat_ms": 3936.9
},
{
"i": 1,
"q": "织忆的图谱扩展机制是什么",
"count": 5,
"top5_ids": [
"mem_1791899478673190284",
"mem_1791725847707296273",
"mem_1792154287491185572",
"mem_1791770032575826407",
"mem_1792088552819764989"
],
"top5_cats": [
"distilled",
"distilled",
"distilled",
"distilled",
"distilled"
],
"kw_ok": true,
"lat_ms": 4225.2
},
{
"i": 2,
"q": "织忆如何通过 E1 提取和 MMR 实现多样去重",
"count": 5,
"top5_ids": [
"mem_1792088552818081545",
"mem_1791666890486125605",
"mem_1791520496931190142",
"mem_1791422985447662675",
"mem_1791498226924653975"
],
"top5_cats": [
"distilled",
"distilled",
"distilled",
"distilled",
"distilled"
],
"kw_ok": true,
"lat_ms": 3643.6
},
{
"i": 3,
"q": "牧尘的偏好是什么",
"count": 5,
"top5_ids": [
"mem_1791556229473565240",
"mem_1791376118421773238",
"mem_1790448247125520424",
"mem_1792088552818232071",
"mem_1792086639900350664"
],
"top5_cats": [
"distilled",
"distilled",
"distilled",
"distilled",
"distilled"
],
"kw_ok": true,
"lat_ms": 3367.5
},
{
"i": 4,
"q": "牧尘喜欢什么样的工作方式",
"count": 5,
"top5_ids": [
"mem_1790448247125520424",
"mem_1792086639900350664",
"mem_1791482493617981202",
"mem_1789063252280657787",
"mem_1792086640133724836"
],
"top5_cats": [
"distilled",
"distilled",
"distilled",
"distilled",
"distilled"
],
"kw_ok": true,
"lat_ms": 3511.3
},
{
"i": 5,
"q": "如何根据牧尘的偏好调整记忆检索策略",
"count": 5,
"top5_ids": [
"mem_1792091916993248722",
"mem_1791747597032219499",
"mem_1792088552824810016",
"mem_1792153987878359340",
"mem_1790137162828579293"
],
"top5_cats": [
"distilled",
"distilled",
"distilled",
"distilled",
"distilled"
],
"kw_ok": true,
"lat_ms": 3544.6
},
{
"i": 6,
"q": "当前项目的技术栈是什么",
"count": 5,
"top5_ids": [
"mem_1792088552817810427",
"mem_1790455676397796671",
"mem_1791665641471849057",
"mem_1790317738849661043",
"mem_1790425023223201421"
],
"top5_cats": [
"distilled",
"distilled",
"distilled",
"distilled",
"distilled"
],
"kw_ok": true,
"lat_ms": 4874.5
},
{
"i": 7,
"q": "织忆项目有哪些核心组件",
"count": 5,
"top5_ids": [
"mem_1792154287491185572",
"mem_1792154287177190000",
"mem_1791770032575826407",
"mem_1792154288142808071",
"mem_1791666702950691634"
],
"top5_cats": [
"distilled",
"distilled",
"distilled",
"distilled",
"distilled"
],
"kw_ok": true,
"lat_ms": 3811.5
},
{
"i": 8,
"q": "织忆和其他记忆系统相比有什么架构优势",
"count": 5,
"top5_ids": [
"mem_1791899478077693848",
"mem_1791666702950691634",
"mem_1792088552819764989",
"mem_1791770032575826407",
"mem_1792154287491185572"
],
"top5_cats": [
"distilled",
"distilled",
"distilled",
"distilled",
"distilled"
],
"kw_ok": true,
"lat_ms": 3254.5
},
{
"i": 9,
"q": "如何使用 recall 接口",
"count": 5,
"top5_ids": [
"mem_1791482321288100863",
"mem_1792153988391999660",
"ep_1785686577666177306",
"ep_1786452727331645773",
"mem_1788717420755520289"
],
"top5_cats": [
"distilled",
"distilled",
"distilled",
"distilled",
"distilled"
],
"kw_ok": true,
"lat_ms": 4110.9
},
{
"i": 10,
"q": "织忆有哪些管理工具",
"count": 5,
"top5_ids": [
"mem_1792154287491185572",
"mem_1791666855166846631",
"mem_1792144557208382006",
"mem_1792116217448228684",
"mem_1792154288142808071"
],
"top5_cats": [
"distilled",
"distilled",
"distilled",
"distilled",
"distilled"
],
"kw_ok": true,
"lat_ms": 5963.1
},
{
"i": 11,
"q": "如何通过 API 扩展织忆功能",
"count": 5,
"top5_ids": [
"mem_1791666188168772049",
"mem_1791818814047111008",
"ep_1785695216419714474",
"ep_1785702789193047009",
"ep_1786336919812827426"
],
"top5_cats": [
"distilled",
"distilled",
"distilled",
"distilled",
"distilled"
],
"kw_ok": true,
"lat_ms": 4222.9
},
{
"i": 12,
"q": "本地 llama 服务 8080 端口",
"count": 5,
"top5_ids": [
"mem_1791423503359721152",
"mem_1791440673318711992",
"mem_1788594533802527918",
"mem_1788620335517621905",
"mem_1788700944516696452"
],
"top5_cats": [
"distilled",
"distilled",
"distilled",
"distilled",
"distilled"
],
"kw_ok": false,
"lat_ms": 3803.5
},
{
"i": 13,
"q": "session-to-zhiyi.py 脚本名",
"count": 5,
"top5_ids": [
"mem_1791688470278719760",
"mem_1789152643785440562",
"mem_1790154887033667954",
"mem_1790001291060572107",
"mem_1789896720973833628"
],
"top5_cats": [
"distilled",
"distilled",
"distilled",
"distilled",
"distilled"
],
"kw_ok": true,
"lat_ms": 4078.1
}
],
"kw_pass": 13,
"kw_total": 14,
"p50_ms": 3936.9,
"p95_ms": 5255.5,
"all_lat_ms": [
3254.5,
3367.5,
3511.3,
3544.6,
3643.6,
3803.5,
3811.5,
3936.9,
4078.1,
4110.9,
4222.9,
4225.2,
4874.5,
5963.1
]
}
@@ -0,0 +1,6 @@
shadow_diff_test.go:200: PATTERN_ROWS_LOADABLE_BYPASS_ID=85
shadow_diff_test.go:224: COSINE_PROBE "织忆是什么" maxcos=0.7038 hit=true (thr=0.6) best=topic:memory
shadow_diff_test.go:224: COSINE_PROBE "牧尘的偏好是什么" maxcos=0.6975 hit=true (thr=0.6) best=pattern:default-main:entity
shadow_diff_test.go:224: COSINE_PROBE "如何使用 recall 接口" maxcos=0.6891 hit=true (thr=0.6) best=topic:replying
shadow_diff_test.go:224: COSINE_PROBE "本地 llama 服务 8080 端口" maxcos=0.6752 hit=true (thr=0.6) best=pattern:test-llm:entity
shadow_diff_test.go:224: COSINE_PROBE "织忆项目有哪些核心组件" maxcos=0.7148 hit=true (thr=0.6) best=pattern:learner-main:concept
@@ -0,0 +1,229 @@
package pattern
import (
"database/sql"
"encoding/json"
"os"
"testing"
_ "modernc.org/sqlite"
"github.com/xiaoxue/memoryweave/internal/models"
"github.com/xiaoxue/memoryweave/internal/storage"
)
// ── t_bd63b641 影-复:PATTERN/PEER 开门量具(不碰生产;只读 graph.db + 实时 embedder)──
// 量化「开 ZHIYI_PATTERN_RECALL / ZHIYI_PEER_RECALL」对 14 金标会注入什么。
// Provider 装配在 server.go 启动期由 env 决定 ⇒ 不重启无法在活服务上测开态;
// 故本测试直接构造真实 Provider + 生产 graph.db(只读)+ 实时 bge embedder,
// 跑出「开态会注入的条目」,与关态(=主召回原样)对比。
//
// 本测试只读、不落盘、不改库(AC:不碰生产二进制/不重启)。
type logRecord struct {
ID string
Category string
Content string
}
// runShadowWithDiag 返回:注入结果 + 诊断(pattern 装载数 / query 是否编码成功 / 各 query 最高余弦)。
// ⚠️ 关键发现:生产 graph_nodes(type='pattern') 的 id 全为 NULL(85/85),ListPatterns 读 id 为 string
// ⇒ 恒报 sql: Scan error (converting NULL to string),Match 直接 return nil。
// 故 runShadow 不把 ListPatterns 错误当失败 —— 它本身就是「开态也无法注入」的根因证据。
func runShadowWithDiag() (map[string][]logRecord, int, string, map[string]float64) {
endpoint := os.Getenv("VLLM_ENDPOINT")
if endpoint == "" {
endpoint = "http://localhost:8000/v1/embeddings"
}
emb := storage.NewEmbedder(endpoint)
store := NewGraphPatternStore("/var/lib/memoryweave/graph.db")
pp := NewPatternProvider(store, NewEmbedderProvider(emb))
// 装载 pattern 数(防「store 空 → 注入恒 0」的假阴性);记录 ListPatterns 错误原文
pats, _, lerr := store.ListPatterns(1000)
loaded := 0
listErr := ""
if lerr != nil {
listErr = lerr.Error() // 根因:id 全 NULL → Scan error
} else {
loaded = len(pats)
}
golden := []string{
"织忆是什么", "织忆的图谱扩展机制是什么",
"织忆如何通过 E1 提取和 MMR 实现多样去重", "牧尘的偏好是什么",
"牧尘喜欢什么样的工作方式", "如何根据牧尘的偏好调整记忆检索策略",
"当前项目的技术栈是什么", "织忆项目有哪些核心组件",
"织忆和其他记忆系统相比有什么架构优势", "如何使用 recall 接口",
"织忆有哪些管理工具", "如何通过 API 扩展织忆功能",
"本地 llama 服务 8080 端口", "session-to-zhiyi.py 脚本名",
}
out := make(map[string][]logRecord)
maxCos := make(map[string]float64)
// 预编码 14 query(若编码失败会记 0,便于区分「没命中」vs「编码挂了」)
for _, q := range golden {
qv, eerr := emb.EncodeSingle(q)
if eerr != nil || len(qv) == 0 {
maxCos[q] = -1 // 编码失败标记
continue
}
// 逐个 pattern 算余弦,取最高(复现 Provider.Match 内部逻辑)
best := -1.0
for _, p := range pats {
rep := p.Data
if rep == "" {
rep = p.Name
}
text := rep
for _, tl := range p.Tools {
if tl != "" {
text += " " + tl
}
}
pv, perr := emb.EncodeSingle(text)
if perr != nil || len(pv) == 0 {
continue
}
s := cosineF32(qv, pv)
if s > best {
best = s
}
}
maxCos[q] = best
}
// 真注入(走 Provider.Match,同 server 路径)
for _, g := range golden {
pat := pp.Match(g, "hermes-main", 3) // server.go:902 topK=3
for _, r := range pat {
out[g] = append(out[g], logRecord{ID: r.ID, Category: r.Category, Content: r.Content})
}
}
return out, loaded, listErr, maxCos
}
// TestShadowPattern_Diff —— 开态 pattern 注入量化(只读生产库)
func TestShadowPattern_Diff(t *testing.T) {
out, loaded, listErr, maxCos := runShadowWithDiag()
t.Logf("PATTERNS_LOADED=%d (防假阴性:store 有货才会注入)", loaded)
if listErr != "" {
t.Logf("PATTERN_LISTERROR=%q — 根因:pattern 行 id 全 NULL ⇒ Provider.Match 恒 return nil", listErr)
}
queries := []string{
"织忆是什么", "织忆的图谱扩展机制是什么",
"织忆如何通过 E1 提取和 MMR 实现多样去重", "牧尘的偏好是什么",
"牧尘喜欢什么样的工作方式", "如何根据牧尘的偏好调整记忆检索策略",
"当前项目的技术栈是什么", "织忆项目有哪些核心组件",
"织忆和其他记忆系统相比有什么架构优势", "如何使用 recall 接口",
"织忆有哪些管理工具", "如何通过 API 扩展织忆功能",
"本地 llama 服务 8080 端口", "session-to-zhiyi.py 脚本名",
}
totalInjected := 0
for _, q := range queries {
mc := maxCos[q]
if mc < 0 {
t.Logf("[EMB-FAIL] %q → 编码失败(不计命中)", q)
}
if n := len(out[q]); n > 0 {
totalInjected += n
t.Logf("[INJECT] %q → %d pattern (maxcos=%.3f)", q, n, mc)
for _, r := range out[q] {
t.Logf(" id=%s cat=%s content=%s", r.ID, r.Category, trunc(r.Content, 60))
}
} else {
t.Logf("[NO-INJECT] %q → 0 pattern (maxcos=%.3f, 阈值=0.6)", q, mc)
}
}
b, _ := json.MarshalIndent(out, "", " ")
t.Logf("SHADOW_PATTERN_TOTAL_INJECTED=%d", totalInjected)
os.WriteFile("/tmp/shadow-pattern-inject.json", b, 0644)
t.Logf("raw json → /tmp/shadow-pattern-inject.json (%d bytes)", len(b))
}
func trunc(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}
// ── peer 侧:生产 graph.db 无 peers 表 ⇒ 开态无数据可注入 ──
func TestShadowPeer_NoTable(t *testing.T) {
db, err := peerOpenReadOnly("/var/lib/memoryweave/graph.db")
if err != nil {
t.Fatalf("open graph.db: %v", err)
}
defer db.Close()
var cnt int
err = db.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='peers'").Scan(&cnt)
if err != nil {
t.Fatalf("query peers table existence: %v", err)
}
t.Logf("SHADOW_PEER_TABLE_EXISTS=%d (0 ⇒ 开态 peer 无数据可注入)", cnt)
}
// peerOpenReadOnly 只读开 sqlite(WAL 兼容),避免 peer.Store 的写路径。
func peerOpenReadOnly(path string) (*sql.DB, error) {
return sql.Open("sqlite", "file:"+path+"?mode=ro&_pragma=busy_timeout(5000)")
}
// ── 深度根因:即使修好 NULL-id,85 行 pattern 无 data/tools 键(只有 description),
// Match 只能 embed 命名键(如 "pattern:hermes-main:entity")⇒ 对金标余弦 < 0.6 阈值。
// 本测试量化「修好 NULL-id 后开态到底能注入几类金标」—— 证明开态收益仍≈0。
func TestShadowPattern_CosineIfIDFixed(t *testing.T) {
endpoint := os.Getenv("VLLM_ENDPOINT")
if endpoint == "" {
endpoint = "http://localhost:8000/v1/embeddings"
}
emb := storage.NewEmbedder(endpoint)
// 绕过 NULL-id 的 ListPatterns:只读 name + properties(非 NULL 列)
db, err := peerOpenReadOnly("/var/lib/memoryweave/graph.db")
if err != nil {
t.Fatalf("open: %v", err)
}
defer db.Close()
rows, _ := db.Query(`SELECT name, properties FROM graph_nodes WHERE type='pattern'`)
type patText struct{ name, text string }
var pats []patText
for rows.Next() {
var name, props string
if serr := rows.Scan(&name, &props); serr != nil {
continue
}
// 复现 Match 的 text 构造:rep=data(无→空) 回退 name;无 tools
text := name
pats = append(pats, patText{name, text})
}
rows.Close()
t.Logf("PATTERN_ROWS_LOADABLE_BYPASS_ID=%d", len(pats))
if len(pats) == 0 {
t.Skip("无 pattern 行可载(NULL-id 未修前本就 0 注入,已在 TestShadowPattern_Diff 覆盖)")
}
golden := []string{"织忆是什么", "牧尘的偏好是什么", "如何使用 recall 接口",
"本地 llama 服务 8080 端口", "织忆项目有哪些核心组件"}
for _, q := range golden {
qv, qerr := emb.EncodeSingle(q)
if qerr != nil || len(qv) == 0 {
t.Logf("[EMBFAIL] %q → %v", q, qerr)
continue
}
best, bestName := -1.0, ""
for _, p := range pats {
pv, perr := emb.EncodeSingle(p.text)
if perr != nil || len(pv) == 0 {
continue
}
s := cosineF32(qv, pv)
if s > best {
best, bestName = s, p.name
}
}
hit := best >= 0.6
t.Logf("COSINE_PROBE %q maxcos=%.4f hit=%v (thr=0.6) best=%s", q, best, hit, bestName)
}
}
// 防 unused 误报
var _ = models.RecallResult{}
+30
View File
@@ -0,0 +1,30 @@
# 织忆指标语义:counter / rate / mean(`/api/v1/metrics`)
> 2026-10-26 建立。起因:有效性审计把 `deprecated_per_day`(计数器)当成率指标,
> 报出「0 样本却判 valid=None ⇒ 缺最小样本门」。计数器没有分母,「样本门」对它无定义
> —— 属**探针模型错配**,不是指标缺陷。本文件是给下游(审计脚本 / 看板 / 人)的唯一口径。
## 三类指标,三种读法
| 类型 | 命名特征 | 自带字段 | 怎么用 | 例子 |
|---|---|---|---|---|
| **counter 计数器** | `*_per_day` / `*_total` / `*_count` | **无** `_samples` / `_valid` | 直接读绝对值,跨时间比大小;不要算比率、不要报「样本不足」 | `deprecated_per_day`、`deprecated_total`、`distill_success_count` |
| **rate 比率** | `*_rate` | `_samples`(分母)+ `_valid`(1=可判 / 0=不可判 / -1=N/A) | `_valid != 1` 时**不得**当结论;`_samples` 小于门槛时只报「待积累」 | `auto_resolve_rate`、`recall_usefulness_rate` |
| **mean 均值** | `avg_*` | `_samples` + `_valid` | 同上:无分母就不下判断 | `avg_distill_loss` |
**铁律**:`0` 永远有两种含义 —— 「今天确实没有」和「根本没在统计」。
- 计数器必须配一个累计值来区分:`deprecated_per_day=0` + `deprecated_total=0` ⇒ 从未统计过;
`per_day=0` + `total=137` ⇒ 统计活着,只是今天没发生。
- 率指标必须配 `_valid`/`_samples`,分母退化时返回 `metricNA(-1)`,不给 1(给 1 会被读成满分)。
## 触发器阈值也用同一套语义
`internal/selfoptimize/executor.go` 的 `t8`(`TDecayCritical` → `archive_or_boost`):
- 判据读的是**计数器** `deprecated_per_day`(条/天),阈值由 `t8.Condition` 承载,**默认 5 条/天**。
- 2026-10-26 修正:原先 `Condition: 0.1` 但判据函数里硬编码 `> 5`,配置写了从不生效。
现改为 `Condition: 5.0` + 判据读 `t.Condition`(行为不变,但配置真的能被改)。
- 若要改成「率」语义,需要先有分母(当日活跃写入量),否则别动 —— 那会变成另一个探针错配。
## 已知遗留
- 审计脚本侧对计数器发「样本不足」告警的白名单修复,归 `t_44c07eca`(脚本侧统一处理,避免多处各改一遍)。
+174
View File
@@ -0,0 +1,174 @@
// 织忆·确定性判据强化 影子探针(t_2515307a)—— 只记录,不施加
//
// 职责:从线上 zhiyid 拉真实召回候选(top_k=50),对每条内容跑
// distill.RecallRuleVerdict(真实 Go 规则,零漂移),输出
// keep / demote / drop 三组 + 每条 reason,供 AC3(结果变化对照)
// 与 AC4(人工抽检被过滤项)取证。不改生产行为、不写库。
//
// 用法:
// cd go && go run ./cmd/rulefirst-probe # 默认组查询
// cd go && go run ./cmd/rulefirst-probe --query "..." # 单条指定
// cd go && go run ./cmd/rulefirst-probe --out /tmp/rf.json # 落盘供 AC4 抽检
package main
import (
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"os"
"strings"
"github.com/xiaoxue/memoryweave/internal/distill"
"github.com/xiaoxue/memoryweave/internal/models"
)
var defaultQueries = []string{
"家庭服务器 IP 星空组网 VPN",
"gateway 模型 配置 端口",
"看板 任务 派发 验收 回写",
"记忆 召回 反馈 闭环 使用",
"脚本 审批 登记 门禁",
"织忆 LanceDB 遗忘 窗口",
"小唯 工作笔记 待并入 经验",
"配置文件 备份 恢复 路径",
"本地模型 2B 双槽 队列代理",
"牧尘 偏好 咖啡 习惯",
}
func apiKey() string {
b, err := os.ReadFile("/var/lib/memoryweave/.api-key")
if err == nil {
for _, line := range strings.Split(string(b), "\n") {
s := strings.TrimSpace(line)
if s != "" && !strings.HasPrefix(s, "#") && !strings.Contains(s, "legacy") {
return strings.Fields(s)[0]
}
}
}
return os.Getenv("ZHIYI_API_KEY")
}
func recall(query string, topK int) ([]models.RecallResult, error) {
body, _ := json.Marshal(map[string]any{"query": query, "top_k": topK, "namespace": ""})
req, err := http.NewRequest("POST", "http://127.0.0.1:7821/api/v1/recall", strings.NewReader(string(body)))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-API-Key", apiKey())
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
var d struct {
Results []models.RecallResult `json:"results"`
}
if err := json.Unmarshal(raw, &d); err != nil {
return nil, fmt.Errorf("unmarshal: %w", err)
}
return d.Results, nil
}
func main() {
var q, out string
flag.StringVar(&q, "query", "", "指定单条 query(默认跑内置 10 条)")
flag.StringVar(&out, "out", "", "落盘 JSON 路径(供 AC4 抽检)")
flag.Parse()
queries := defaultQueries
if q != "" {
queries = []string{q}
}
type item struct {
Query string `json:"query"`
ID string `json:"id"`
Content string `json:"content"`
Verdict string `json:"verdict"`
Reason string `json:"reason"`
}
var all []item
var keep, demote, drop []item
byQ := map[string][3][]item{}
for _, query := range queries {
res, err := recall(query, 50)
if err != nil {
fmt.Printf("QUERY %q ERR %v\n", query, err)
continue
}
var kk, dd, drr []item
toItem := func(r models.RecallResult, v distill.RecallVerdict, why string) item {
return item{Query: query, ID: r.ID, Content: r.Content, Verdict: verdictName(v), Reason: why}
}
// 每条候选跑真实 Go 规则
for _, r := range res {
v, why := distill.RecallRuleVerdict(r.Content)
it := toItem(r, v, why)
all = append(all, it)
switch v {
case distill.RecallKeep:
kk = append(kk, it)
case distill.RecallDemote:
dd = append(dd, it)
case distill.RecallDrop:
drr = append(drr, it)
}
}
keep = append(keep, kk...)
demote = append(demote, dd...)
drop = append(drop, drr...)
byQ[query] = [3][]item{kk, dd, drr}
fmt.Printf("QUERY %q → 候选=%d keep=%d demote=%d drop=%d\n",
query, len(res), len(kk), len(dd), len(drr))
for _, it := range drr {
fmt.Printf(" [DROP] %-30s %s | %s\n", it.ID, it.Reason, truncate(it.Content, 42))
}
for _, it := range dd {
fmt.Printf(" [DEMOTE] %-30s %s | %s\n", it.ID, it.Reason, truncate(it.Content, 42))
}
}
fmt.Printf("\n=== 汇总: 候选总数=%d keep=%d demote=%d drop=%d ===\n",
len(all), len(keep), len(demote), len(drop))
if out != "" {
f, err := os.Create(out)
if err == nil {
enc := json.NewEncoder(f)
enc.SetIndent("", " ")
_ = enc.Encode(map[string]any{
"all": all,
"keep": keep,
"demote": demote,
"drop": drop,
"by_query": byQ,
})
f.Close()
fmt.Printf("已落盘 %s\n", out)
}
}
}
func verdictName(v distill.RecallVerdict) string {
switch v {
case distill.RecallKeep:
return "keep"
case distill.RecallDemote:
return "demote"
default:
return "drop"
}
}
func truncate(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n]) + "…"
}
+109
View File
@@ -0,0 +1,109 @@
// 织忆·确定性判据扫描(t_2515307a 取证)——按 namespace 分批扫全表,输出 drop/demote 明细供 AC4
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"github.com/xiaoxue/memoryweave/internal/distill"
)
func apikey() string {
b, _ := os.ReadFile("/var/lib/memoryweave/.api-key")
for _, l := range strings.Split(string(b), "\n") {
s := strings.TrimSpace(l)
if s != "" && !strings.HasPrefix(s, "#") && !strings.Contains(s, "legacy") {
return strings.Fields(s)[0]
}
}
return ""
}
func list(ns string, limit int) ([]struct {
ID string `json:"id"`
Content string `json:"content"`
}, error) {
u := fmt.Sprintf("http://127.0.0.1:7821/api/v1/memories?limit=%d", limit)
if ns != "" {
u += "&namespace=" + ns
}
req, _ := http.NewRequest("GET", u, nil)
req.Header.Set("X-API-Key", apikey())
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<26))
var d struct {
Memories []struct {
ID string `json:"id"`
Content string `json:"content"`
} `json:"memories"`
}
if err := json.Unmarshal(raw, &d); err != nil {
return nil, err
}
return d.Memories, nil
}
func main() {
namespaces := []string{
"", "hermes-main", "shared", "wiki-curator-main", "wiki", "soulful-xiaowei",
"a06-main", "domain:dev", "default-main", "hermes", "wiki:decision",
}
seen := map[string]bool{}
var all []struct {
ID string `json:"id"`
Content string `json:"content"`
}
for _, ns := range namespaces {
ms, err := list(ns, 1000)
if err != nil {
continue
}
for _, m := range ms {
if !seen[m.ID] {
seen[m.ID] = true
all = append(all, m)
}
}
}
type rec struct {
ID string
Content string
Why string
}
var drops, demotes []rec
for _, m := range all {
v, why := distill.RecallRuleVerdict(m.Content)
if v == distill.RecallDrop {
drops = append(drops, rec{m.ID, m.Content, why})
} else if v == distill.RecallDemote {
demotes = append(demotes, rec{m.ID, m.Content, why})
}
}
fmt.Printf("unique_total=%d drops=%d demotes=%d\n\n", len(all), len(drops), len(demotes))
fmt.Println("===== DROPS (进不了 top-N) =====")
for i, d := range drops {
rc := []rune(d.Content)
c := d.Content
if len(rc) > 55 {
c = string(rc[:55]) + "…"
}
fmt.Printf("%2d. [%s] %s\n", i+1, d.Why, c)
}
fmt.Println("\n===== DEMOTES (降权) =====")
for i, d := range demotes {
rc := []rune(d.Content)
c := d.Content
if len(rc) > 55 {
c = string(rc[:55]) + "…"
}
fmt.Printf("%2d. [%s] %s\n", i+1, d.Why, c)
}
}
+83
View File
@@ -10,6 +10,7 @@ import (
"net/http"
"os"
"sort"
"strconv"
"strings"
"unicode/utf8"
)
@@ -92,6 +93,8 @@ func main() {
runErr = runStats(apiKeyVal)
case "entity":
runErr = runEntity(args, apiKeyVal)
case "pattern":
runErr = runPattern(args, apiKeyVal)
case "help", "--help", "-h":
flag.Usage()
os.Exit(0)
@@ -130,6 +133,7 @@ func usage() {
recall <query> 语义搜索,返回 top-10 结果
stats 显示系统统计(记忆数、蒸馏状态等)
entity <name> 查询实体详情(出现次数、关联记忆)
pattern <detect|list> L2 模式检测/列出(detect=手动触发检测,list=列出已检测)
示例:
zhiyi tree
@@ -461,6 +465,85 @@ func runStats(apiKey string) error {
return nil
}
// runPattern L2 模式检测 CLI 子命令(t_d32e5f8a,cron 单发)。
// 子命令:
// zhiyi pattern detect [--limit N] 触发检测(扫 L1 蒸馏记忆 → 工具组合/路径聚类 → 写 graph_nodes)
// zhiyi pattern list [N] 列出已检测的 pattern(默认 20)
func runPattern(args []string, apiKey string) error {
// 子命令(默认 detect)
sub := "detect"
rest := []string{}
if len(args) > 0 {
// 允许 "detect" / "list" 显式指定,或把第一个参数当 limit(list N)
switch args[0] {
case "detect", "list":
sub = args[0]
rest = args[1:]
default:
// 数字参数 = list 的 limit
if n, err := strconv.Atoi(args[0]); err == nil && n > 0 {
sub = "list"
rest = []string{args[0]}
} else {
rest = args
}
}
}
if sub == "list" {
limit := "20"
if len(rest) > 0 {
limit = rest[0]
}
var resp struct {
Patterns []struct {
ID string `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Confidence float64 `json:"confidence"`
Tools []string `json:"tools"`
} `json:"patterns"`
Count int `json:"count"`
}
if err := apiGet("/api/v1/patterns?limit="+limit, &resp, apiKey); err != nil {
return err
}
fmt.Printf("📐 已检测模式 [%s] (%d)\n", *ns, resp.Count)
fmt.Println(strings.Repeat("─", 50))
for i, p := range resp.Patterns {
if i >= 30 {
fmt.Printf(" … 还有 %d 个\n", len(resp.Patterns)-30)
break
}
tools := ""
if len(p.Tools) > 0 {
tools = strings.Join(p.Tools, ",")
}
fmt.Printf(" [%s] %s conf=%.2f %s\n", p.Type, p.Name, p.Confidence, tools)
}
if resp.Count == 0 {
fmt.Println(" (暂无已检测模式)")
}
return nil
}
// detect
var resp struct {
Scanned int `json:"scanned"`
Created int `json:"created"`
Updated int `json:"updated"`
Detected int `json:"detected"`
}
if err := apiPost("/api/v1/patterns/detect", map[string]interface{}{
"namespace": *ns,
}, &resp, apiKey); err != nil {
return err
}
fmt.Printf("🔬 L2 模式检测 [%s] 扫描 %d 条 → 命中 %d(新建 %d / 更新 %d)\n",
*ns, resp.Scanned, resp.Detected, resp.Created, resp.Updated)
return nil
}
func runEntity(args []string, apiKey string) error {
if len(args) == 0 {
return fmt.Errorf("用法: zhiyi entity <name>")
+16
View File
@@ -14,6 +14,7 @@ import (
"time"
"github.com/xiaoxue/memoryweave/internal/governance"
"github.com/xiaoxue/memoryweave/internal/memprotect"
"github.com/xiaoxue/memoryweave/internal/storage"
)
@@ -67,6 +68,10 @@ func (aa *AdminAPI) Forget(w http.ResponseWriter, r *http.Request) {
}
forgotten := 0
// 2026-10-16 (t_944344b5) 遗忘保护守卫:本批加载一次保护状态(Redis vprop + protected_ids),
// 命中任一保护规则的候选永不自动软删(fragment 与 ShouldForget 两条道都拦截)。
protState := memprotect.LoadState()
protected := 0
for _, mem := range results {
lastAccess := parseTimeStr(mem["last_recalled_at"])
// GA-P0-2 (t_c6d41ac6):衰减/遗忘只认 recall_verified_count(正反馈才是 V 值)。
@@ -83,6 +88,15 @@ func (aa *AdminAPI) Forget(w http.ResponseWriter, r *http.Request) {
if len([]rune(content)) > 200 {
continue
}
// 🔒 2026-10-16 (t_944344b5) 遗忘保护守卫: 命中任一保护规则的候选
// (category∈{decision,preference,pref} / V≥2.0 / freshness=verified /
// protected_ids 名单 / distilled 且内容命中 10 个决策·偏好关键词) 永不自动软删。
// 拦截 point 放在 fragment + ShouldForget 两条 SoftDelete 道之前。
if isProt, protRule := memprotect.IsProtected(mem, protState); isProt {
protected++
log.Printf("[forget] protected skip id=%q rule=%s (遗忘保护守卫命中, 跳过自动遗忘)", strVal(mem["id"]), protRule)
continue
}
// 2026-09-07 方案A 碎片快速道(与 server.go decay trigger 一致):
// <30 字且 >20 天未访问 → 直接遗忘(碎片 recall_count 可能虚高, 绕过保命)
if len([]rune(content)) < 30 && time.Since(lastAccess).Hours()/24 > 20 {
@@ -117,6 +131,8 @@ func (aa *AdminAPI) Forget(w http.ResponseWriter, r *http.Request) {
}
respond(w, 200, map[string]interface{}{
"status": "ok", "scanned": len(results), "forgotten": forgotten,
// 2026-10-16 (t_944344b5):protected = 被遗忘保护守卫拦截、未软删的候选数
"protected": protected,
})
}
+10
View File
@@ -2,10 +2,12 @@
package routes
import (
"log"
"time"
"github.com/xiaoxue/memoryweave/internal/distill"
"github.com/xiaoxue/memoryweave/internal/governance"
"github.com/xiaoxue/memoryweave/internal/metanote"
"github.com/xiaoxue/memoryweave/internal/selfoptimize"
)
@@ -27,6 +29,14 @@ type CommitMeta struct {
// 2026-10-08 (t_7e3577c5):新增 embedModel/scriptVer 血缘字段透传
// 2026-10-08 (Gap#2):改为 CommitMeta 结构体,新增 confidence 透传
func AutoDistillTrigger(episodeID string, content string, category string, namespace string, agentID string, meta CommitMeta) {
// 2026-10-16 t_5ad3f843:meta-note 门禁(env ZHIYI_META_FILTER=1 启用;默认关闭=零行为变化)。
// 蒸馏是事实写入的唯一咽喉:原文判 meta ⇒ 不进蒸馏(引擎路径与降级路径都挡),
// 从源头杜绝 meta-note 生成 memories 事实(admin distill/force 也经此函数,一并覆盖)。
if skip, reason := metanote.ShouldSkip(content); skip {
log.Printf("[metanote] auto-distill skipped: ep=%s agent=%s reason=%s content=%.60s", episodeID, agentID, reason, content)
return
}
// 1. 硬规则过滤
if !distill.HardRulesPass(content) {
return
+6 -1
View File
@@ -249,7 +249,12 @@ func (cp *ConsolidationPipeline) mergeSimilar() (int, error) {
// 保留较新的
idI := strVal(mems[i]["id"])
idJ := strVal(mems[j]["id"])
_ = cp.ldb.SoftDelete(idJ, fmt.Sprintf("merged_into_%s", idI))
// t_cf680fad:不再 `_ =` 静默吞错。MVCC 冲突耗尽时 SoftDelete 内部会
// 把 (idJ, reason) 落 durable pending 队列(softdelete_pending.go),
// 启动/每 tick drain 重放必落盘;这里显式记日志做观测(禁静默)。
if err := cp.ldb.SoftDelete(idJ, fmt.Sprintf("merged_into_%s", idI)); err != nil {
log.Printf("[consolidation] merge-softdelete FAILED: src=%s → target=%s err=%v (pending-queue 将重放)", idJ, idI, err)
}
merged++
}
}
@@ -0,0 +1,163 @@
// 织忆 MemoryWeave — 整合链路健康跟踪(t_10757bd5)
//
// 背景(2026-10-24 小怡体检实测):
// - t_distill(15min) 与 t_consolidation 同时触发 consolPipe,
// Rust 侧互斥整合槽被占用时 Go 立即降级 Go 启发式 ⇒ consolidate_busy ~55 次/日静默堆积
// - /health 只看 sidecar TCP 连通性 ⇒ 整合链路持续失败也假绿
//
// 本文件提供:
// 1. ConsolidationTracker — 记录连续失败次数 + 最近一次成功时间(供 /health 读)
// 2. busyWaitRetry — Rust sidecar 报 busy 时等待 3s×3 次再降级(整合全程 ~126s)
// 3. 引用 metrics 包新增的 5 个指标(AC5:失败路径带计数)
package routes
import (
"strings"
"sync"
"time"
"github.com/xiaoxue/memoryweave/internal/consolidate"
"github.com/xiaoxue/memoryweave/internal/metrics"
)
// ─── ConsolidationTracker ────────────────────────────────────
var (
consTracker = &ConsolidationTracker{}
consTrackerOnce sync.Once
)
// ConsolidationTracker 记录整合链路健康状态(内存态,重启归零;
// 重启后健康端点读到 ConsecutiveFailures=0 ⇒ 正确,因为该链路确实刚起步)。
type ConsolidationTracker struct {
mu sync.Mutex
LastSuccess time.Time
LastAttempt time.Time
startup time.Time
ConsecutiveFailures int
Skips int64 // t_10757bd5:需要"跳过"而非失败的次数(并发触发 / sidecar busy)
lastSkip string // 最近一次跳过原因
// lastError 最近一次失败原因(供 /health 暴露,便于人工诊断)
lastError string
}
// GetConsolTracker 返回全局 tracker 单例
func GetConsolTracker() *ConsolidationTracker {
if consTracker.startup.IsZero() {
consTracker.mu.Lock()
if consTracker.startup.IsZero() {
consTracker.startup = time.Now()
}
consTracker.mu.Unlock()
}
return consTracker
}
// RecordSuccess 整合成功(归零)
func (t *ConsolidationTracker) RecordSuccess() {
t.mu.Lock()
defer t.mu.Unlock()
t.LastAttempt = time.Now()
t.LastSuccess = time.Now()
t.ConsecutiveFailures = 0
t.lastError = ""
metrics.ConsolLastSuccessTime.SetToCurrentTime()
metrics.ConsolConsecutiveFailures.Set(0)
}
// RecordSkip 本次触发被**跳过**(不是失败)。
//
// t_10757bd5 关键语义修正(2026-10-24 22:45 实测发现):
// `consolidate_busy` 的真实含义是「另一个整合正在跑」,不是链路坏。
// 把它记成失败会让 /health 报 degraded —— 明明整合正在正常工作,却又是一次假红。
// 只有真正的错误(sidecar 崩、DB 错、超时)才算 failure。
func (t *ConsolidationTracker) RecordSkip(reason string) {
t.mu.Lock()
defer t.mu.Unlock()
t.LastAttempt = time.Now()
t.Skips++
t.lastSkip = reason
}
// RecordFailure 整合失败(+1)
func (t *ConsolidationTracker) RecordFailure(err string) {
t.mu.Lock()
defer t.mu.Unlock()
t.LastAttempt = time.Now()
t.ConsecutiveFailures++
t.lastError = err
metrics.ConsolConsecutiveFailures.Set(float64(t.ConsecutiveFailures))
}
// HealthStatus 返回健康端点应报的状态:
// - 连续**真失败** ≥ 3 ⇒ "degraded"
// - 进程启动已超 2 小时却从未成功过 ⇒ "degraded"(跳过不算成功,也不算失败)
// - 上次成功距今 > 2 小时(15min 周期 × 8)⇒ "degraded"
// - 否则 "ok"
func (t *ConsolidationTracker) HealthStatus() (string, int, time.Duration, string) {
t.mu.Lock()
defer t.mu.Unlock()
fails := t.ConsecutiveFailures
var sinceSuccess time.Duration
if t.LastSuccess.IsZero() {
sinceSuccess = 0 // 从未成功
} else {
sinceSuccess = time.Since(t.LastSuccess)
}
status := "ok"
if fails >= 3 {
status = "degraded"
}
if t.LastSuccess.IsZero() {
if !t.startup.IsZero() && time.Since(t.startup) > 2*time.Hour {
status = "degraded"
}
} else if sinceSuccess > 2*time.Hour {
status = "degraded"
}
return status, fails, sinceSuccess, t.lastError
}
// Snapshot 供 /health 暴露完整字段(含跳过次数,便于区分「忙」与「坏」)
func (t *ConsolidationTracker) Snapshot() (fails int, skips int64, lastSkip string, lastErr string) {
t.mu.Lock()
defer t.mu.Unlock()
return t.ConsecutiveFailures, t.Skips, t.lastSkip, t.lastError
}
// ─── busyWaitRetry ───────────────────────────────────────────
// busyWaitRetry 对 Rust sidecar consolidate_busy 做有限等待重试。
//
// 原理:Rust 整合全程约 126s(DBSCAN + 衰减校准),触发器每 15min 一次,
// 两个触发器同秒到达时第二个撞 busy。等待 3s × 3 次(共 9s);
// 9s 后仍 busy 说明 sidecar 真有长任务(或正在做 full 模式),
// 降级 Go 启发式并记录 tracker(由调用方负责)。
//
// 返回语义:
// - (report, false, nil) = 成功拿到报告
// - (nil, true, nil) = 重试耗尽仍 busy(调用方应走降级 + RecordFailure)
// - (nil, false, err) = 非 busy 错误(调用方直接返回)
func busyWaitRetry(dataDir, sqlitePath, mode string) (report *consolidate.Result, stillBusy bool, err error) {
const (
maxRetries = 3
waitDur = 3 * time.Second
)
for i := 0; i <= maxRetries; i++ {
if i > 0 {
metrics.ConsolidateBusyWaitTotal.Inc()
time.Sleep(waitDur)
}
report, err = consolidate.Run(dataDir, sqlitePath, mode)
if err == nil {
return report, false, nil
}
if strings.Contains(err.Error(), "consolidate_busy") {
continue // 仍 busy,重试
}
// 非 busy 错误直接返回
return nil, false, err
}
// 重试耗尽仍 busy
return nil, true, nil
}
+119
View File
@@ -0,0 +1,119 @@
// 织忆 MemoryWeave — 召回响应契约辅助(P0-3,2026-10-26)
//
// 背景(CS249r Vol III Ch6 检索契约):响应只有 cache_hit/candidates_scanned/count/mode/graph_expanded,
// 空结果分不清「不存在」与「扫了一半就超时」——这是 Air Canada 类静默幻觉的结构性入口。
//
// 本文件提供最小可用契约三件:
// ① status/partial_reason:complete 的空结果才允许解读为「不存在」;
// ② scope{namespace,snapshot,...}:说明这次答案基于哪个数据视图(本服务无索引版本号,
// 故 snapshot = 观测时点 + 缓存状态,缓存命中代表 60s 内旧快照 —— 语义诚实,不假装有版本号);
// ③ continuation:不透明翻页令牌(query 指纹 + ns 绑定,防止跨 query 串页)。
package routes
import (
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"strconv"
"time"
"github.com/xiaoxue/memoryweave/internal/models"
"github.com/xiaoxue/memoryweave/internal/storage"
)
// maxContinuationOffset 硬上限:防止把 topK 放大到爆内存/延迟。
const maxContinuationOffset = 500
type pageToken struct {
V int `json:"v"`
Q string `json:"q"` // query 指纹(sha256 前 8 hex)
NS string `json:"ns"` // 作用域绑定
Off int `json:"o"` // 下一页起点
Lim int `json:"l"` // 页大小
// Prev 已返回过的 ID(去重锚点)。活索引下排序会随 fetchLimit 变化而轻微漂移,
// 只靠 offset 会出现跨页重复;token 携带已返回 ID 才能机械保证「不重」。
Prev []string `json:"p,omitempty"`
}
// maxPrevIDs 令牌里最多携带的去重锚点数(3~5 页内足够;超长则丢最旧,令牌不会无限膨胀)。
const maxPrevIDs = 100
// queryFingerprint 8 hex 的 query 指纹(足够防串页,不泄露正文)。
func queryFingerprint(q string) string {
sum := sha256.Sum256([]byte(q))
return hex.EncodeToString(sum[:4])
}
// encodeContinuation 生成下一页令牌;没有下一页时返回空串。
func encodeContinuation(query, namespace string, offset, limit int, prev []string) string {
if limit <= 0 {
return ""
}
if len(prev) > maxPrevIDs {
prev = prev[len(prev)-maxPrevIDs:]
}
tok := pageToken{V: 1, Q: queryFingerprint(query), NS: namespace, Off: offset, Lim: limit, Prev: prev}
b, err := json.Marshal(tok)
if err != nil {
return ""
}
return base64.RawURLEncoding.EncodeToString(b)
}
// decodeContinuation 解析令牌。错误信息面向调用方(可据此修请求)。
func decodeContinuation(raw string) (pageToken, error) {
var tok pageToken
b, err := base64.RawURLEncoding.DecodeString(raw)
if err != nil {
return tok, errors.New("continuation 不是合法 base64url 令牌")
}
if err := json.Unmarshal(b, &tok); err != nil {
return tok, errors.New("continuation 内容不是合法 JSON")
}
if tok.V != 1 {
return tok, errors.New("continuation 版本不支持:" + strconv.Itoa(tok.V))
}
if tok.Off < 0 || tok.Lim <= 0 {
return tok, errors.New("continuation 字段非法(offset/limit)")
}
if tok.Off > maxContinuationOffset {
return tok, errors.New("continuation offset 超出上限 " + strconv.Itoa(maxContinuationOffset))
}
return tok, nil
}
// buildScope 组装本次召回的数据视图声明。
func buildScope(namespace string, topK int, mode string, cacheHit, truncated bool, started time.Time) map[string]interface{} {
snap := "t" + strconv.FormatInt(started.UnixMilli(), 10)
if cacheHit {
snap += "-cache" // 缓存命中:读的是 60s 内旧快照,不是本次实时扫描
} else {
snap += "-scan"
}
if truncated {
snap += "-truncated"
}
return map[string]interface{}{
"namespace": namespace,
"snapshot": snap,
"cache_hit": cacheHit,
"truncated": truncated,
"top_k": topK,
"mode": mode,
}
}
// traceItems 把最终返回集映射成 trace 的 item 归因(rank 从 1 起)。
func traceItems(results []models.RecallResult) []storage.TraceItem {
items := make([]storage.TraceItem, 0, len(results))
for i, r := range results {
ch := r.Channel
if ch == "" {
ch = "vector" // 老调用方/旧缓存条目无 channel ⇒ 主通道默认值
}
items = append(items, storage.TraceItem{Rank: i + 1, ID: r.ID, Channel: ch, Score: r.Score})
}
return items
}
+116
View File
@@ -0,0 +1,116 @@
package routes
import (
"encoding/json"
"testing"
"time"
"github.com/xiaoxue/memoryweave/internal/models"
)
func TestContinuationRoundTrip(t *testing.T) {
tok := encodeContinuation("织忆怎么召回", "hermes-main", 10, 5, []string{"mem_1", "mem_2"})
if tok == "" {
t.Fatal("有下一页时令牌不应为空")
}
got, err := decodeContinuation(tok)
if err != nil {
t.Fatalf("解码失败:%v", err)
}
if got.Off != 10 || got.Lim != 5 {
t.Fatalf("令牌字段错误:%+v", got)
}
if got.Q != queryFingerprint("织忆怎么召回") {
t.Fatalf("query 指纹不一致")
}
if got.NS != "hermes-main" {
t.Fatalf("namespace 不一致:%s", got.NS)
}
if len(got.Prev) != 2 || got.Prev[0] != "mem_1" {
t.Fatalf("去重锚点未随令牌传递:%v", got.Prev)
}
}
func TestContinuationRejectsBadToken(t *testing.T) {
if _, err := decodeContinuation("not-base64!!"); err == nil {
t.Fatal("非法 base64 应报错")
}
if _, err := decodeContinuation("eyJ2Ijo5fQ"); err == nil { // {"v":9}
t.Fatal("未知版本应报错")
}
tok := encodeContinuation("q", "ns", 9999, 5, nil)
if _, err := decodeContinuation(tok); err == nil {
t.Fatal("offset 超上限应报错")
}
}
func TestQueryFingerprintStable(t *testing.T) {
a := queryFingerprint("同样的问句")
b := queryFingerprint("同样的问句")
c := queryFingerprint("不同的问句")
if a != b {
t.Fatal("同 query 指纹应稳定")
}
if a == c {
t.Fatal("不同 query 指纹应不同")
}
if len(a) != 8 {
t.Fatalf("指纹应为 8 hex,实际 %d", len(a))
}
}
func TestBuildScope(t *testing.T) {
started := time.UnixMilli(1700000000000)
sc := buildScope("hermes-main", 5, "hybrid", false, false, started)
for _, k := range []string{"namespace", "snapshot", "cache_hit", "truncated", "top_k", "mode"} {
if _, ok := sc[k]; !ok {
t.Fatalf("scope 缺字段 %s", k)
}
}
snap := sc["snapshot"].(string)
if snap != "t1700000000000-scan" {
t.Fatalf("实时扫描 snapshot 形态错误:%s", snap)
}
cached := buildScope("ns", 5, "hybrid", true, true, started)["snapshot"].(string)
if cached != "t1700000000000-cache-truncated" {
t.Fatalf("缓存+截断 snapshot 形态错误:%s", cached)
}
}
func TestTraceItems(t *testing.T) {
rs := []models.RecallResult{
{ID: "a", Channel: "vector"},
{ID: "b"}, // 老调用方无 channel ⇒ 默认 vector
{ID: "c", Channel: "graph"},
}
items := traceItems(rs)
if len(items) != 3 || items[0].Rank != 1 || items[2].Rank != 3 {
t.Fatalf("rank 编号错误:%+v", items)
}
if items[1].Channel != "vector" {
t.Fatalf("缺省 channel 应回落 vector,实际 %s", items[1].Channel)
}
}
// TestRecallResultContractFields 契约字段必须真的出现在 JSON 里(不是「加了结构体没接线」)。
func TestRecallResultContractFields(t *testing.T) {
r := models.RecallResult{
ID: "mem_1", Channel: "fts", Stale: true, Version: 3,
ValidFrom: "2026-10-26T10:00:00+08:00", ValidTo: "2026-10-26T11:00:00+08:00",
Supersedes: []string{"mem_0"}, SupersededBy: []string{"mem_2"}, Superseded: true,
}
b, err := json.Marshal(r)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var m map[string]interface{}
if err := json.Unmarshal(b, &m); err != nil {
t.Fatalf("unmarshal: %v", err)
}
for _, k := range []string{"channel", "stale", "generation", "valid_from", "valid_to",
"supersedes", "superseded_by", "superseded"} {
if _, ok := m[k]; !ok {
t.Fatalf("响应缺字段 %s(JSON=%s)", k, string(b))
}
}
}
+610 -68
View File
@@ -2,17 +2,22 @@
package routes
import (
"context"
"encoding/json"
"fmt"
"log"
"math"
"net/http"
"os"
"sort"
"strconv"
"strings"
"time"
"unicode"
"github.com/xiaoxue/memoryweave/internal/distill"
"github.com/xiaoxue/memoryweave/internal/governance"
"github.com/xiaoxue/memoryweave/internal/metanote"
"github.com/xiaoxue/memoryweave/internal/metrics"
"github.com/xiaoxue/memoryweave/internal/models"
"github.com/xiaoxue/memoryweave/internal/selfoptimize"
@@ -98,6 +103,20 @@ func (a *API) Commit(w http.ResponseWriter, r *http.Request) {
respondError(w, 400, "agent_id and content required")
return
}
// 2026-10-16 t_5ad3f843:meta-note 门禁(env ZHIYI_META_FILTER=1 启用;默认关闭=零行为变化)。
// 判定与 Python 口径真相源同源(internal/metanote,parity/差分测试锁死);
// 判 meta ⇒ 422 拒绝,不写 episode、不进蒸馏(卡面:把 meta 挡在咽喉点)。
if skip, reason := metanote.ShouldSkip(req.Content); skip {
// t_10757bd5 AC5:metanote 拒绝 ⇒ 计数器 +1(与日志 "[metanote] commit rejected" 行对齐)
metrics.MetaNoteRejectTotal.Inc()
log.Printf("[metanote] commit rejected (gate ON, meta-note): agent=%s ns=%s reason=%s content=%.60s",
req.AgentID, req.Namespace, reason, req.Content)
respondError(w, 422, "meta-note rejected: "+reason)
return
}
// 影子档(ZHIYI_META_FILTER=shadow):命中只记「would-reject」,照常放行。
// 默认 off ⇒ 立即返回,零开销、零行为变化。
metanote.ObserveShadow(req.Content)
if req.Namespace == "" {
req.Namespace = classifyNamespace(req.Category, req.AgentID, req.Content)
}
@@ -155,6 +174,14 @@ func (a *API) Commit(w http.ResponseWriter, r *http.Request) {
return
}
// ⑤使用追踪(t_5aa4dcac):commit 写侧检测——新写入的内容是否携带了
// 最近被召回记忆的独特 token;命中 ⇒ 判定「被用上」(downstream_use)并累加
// recall_verified_count(env 门控 ZHIYI_USAGE_TRACK,默认关 ⇒ 零行为变化)。
// 异步执行:不阻塞 commit 主路径、不改响应、不拒写入。
if storage.UsageTrackingEnabled() {
go storage.CheckCommittedForUse(a.LanceDB, req.AgentID, req.Namespace, req.Content)
}
// 2. 编码
vector, err := a.Embedder.EncodeSingle(req.Content)
if err != nil {
@@ -260,6 +287,170 @@ func (a *API) Commit(w http.ResponseWriter, r *http.Request) {
respond(w, 201, respData)
}
// POST /api/v1/memory/write — 显式写入 memories 表(主症A,t_fa6c9104,2026-10-16)
//
// 背景:/api/v1/commit 把原文落 episodes 表、等异步 LLM 蒸馏(质量门丢弃率 ~68%)。
// 「显式保存重要事实」场景(Hermes 插件 memory_write)走 commit ⇒ 写进去的东西
// 自己召回不回来:episodes 表(recall 查 memories 表)+ 零向量 + 靠蒸馏流水线
// 升级成 mem_*(大概率静默丢失)。
//
// 本端点:同步嵌入 → 去重/合并 → 矛盾只报告不拦 → 直写 memories 表 ⇒ 写完立刻可召回。
// 硬约束遵守:
// - 纯新增路由:commit / recall / 蒸馏 路径零改动(默认零行为变化)
// - 嵌入失败 ⇒ 502 不写(写零向量 = 主症A 病根)
// - category 缺省 distilled:不在 recall Step 2.5 rawCategories 过滤集里
// - 不走写时四操作门控(ZHIYI_WRITE_SEMANTIC_OPS):显式写入 = 调用方意图,
// 去重 3a/3b + 矛盾报告已足够;门控只约束 LLM 蒸馏事实
func (a *API) WriteMemory(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
respondError(w, 405, "POST only")
return
}
var req struct {
Content string `json:"content"`
Category string `json:"category"`
AgentID string `json:"agent_id"`
Namespace string `json:"namespace"`
Source string `json:"source"`
EmbeddingModel string `json:"embedding_model"`
ScriptVersion string `json:"script_version"`
Confidence *float64 `json:"confidence"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, 400, "invalid body")
return
}
if len([]rune(req.Content)) < 8 {
respondError(w, 400, "content too short (<8 chars)")
return
}
// 2026-10-16 t_5ad3f843:meta-note 门禁(同 Commit;默认关闭=零行为变化)。
if skip, reason := metanote.ShouldSkip(req.Content); skip {
log.Printf("[metanote] write_memory rejected: agent=%s reason=%s content=%.60s", req.AgentID, reason, req.Content)
respondError(w, 422, "meta-note rejected: "+reason)
return
}
// 影子档(同 Commit):命中只记「would-reject」,照常放行;默认 off 零开销。
metanote.ObserveShadow(req.Content)
if req.AgentID == "" {
req.AgentID = "hermes-a06"
}
if req.Category == "" {
req.Category = "distilled"
}
if req.Namespace == "" {
req.Namespace = classifyNamespace(req.Category, req.AgentID, req.Content)
}
src := req.Source
if src == "" {
src = "explicit_write"
}
embModel := req.EmbeddingModel
if embModel == "" {
embModel = "bge-m3"
}
scriptVer := req.ScriptVersion
if scriptVer == "" {
scriptVer = "hermes-zhiyi"
}
// 1. 同步嵌入:失败 ⇒ 502,**不写零向量记录**(零向量 = 主症A 病根)
vector, err := a.Embedder.EncodeSingle(req.Content)
if err != nil {
respondErrorSchema(w, 502, "embed_failed", "embed failed: "+err.Error(), 5)
return
}
// 2. 去重:exact/near-dup ⇒ 并入既有记忆(与 commit 3a/3b 同语义)
similar, _ := a.LanceDB.Search("memories", vector, 5, req.Namespace)
for _, existing := range similar {
if existing.Content == req.Content {
a.mergeMemory(existing, req.Content, req.Category, req.AgentID)
respond(w, 200, map[string]interface{}{
"status": "merged", "memory_id": existing.ID, "reason": "exact_dup",
"table": "memories",
})
return
}
if len(existing.Content) > 0 && len(req.Content) > 0 {
ev, eErr := a.Embedder.EncodeSingle(existing.Content)
if eErr == nil && cosineSimilarity(vector, ev) >= 0.98 {
a.mergeMemory(existing, req.Content, req.Category, req.AgentID)
respond(w, 200, map[string]interface{}{
"status": "merged", "memory_id": existing.ID, "reason": "near_dup",
"table": "memories",
})
return
}
}
}
// 3. 矛盾检测(规则型,廉价):**只报告不拦** —— 显式写入是调用方意图,
// 旧新并存由写时四操作门控/人工 feedback 收口(与 commit 的 conflict 报告同形)
var conflictSources []string
for _, existing := range similar {
if existing.Content != req.Content {
conflictSources = append(conflictSources, existing.Content)
}
}
conflicts := a.ConflictDetector.DetectContradiction(req.Content, conflictSources)
// 4. 直写 memories 表(跳过 LLM 蒸馏 + 质量门 ⇒ 无 68% 静默丢弃)
now := time.Now()
confidenceSource := ""
if req.Confidence != nil {
confidenceSource = "authoritative"
}
mem := models.MemoryRecord{
ID: fmt.Sprintf("mem_%d", now.UnixNano()),
AgentID: req.AgentID,
Namespace: req.Namespace,
Content: req.Content,
Category: req.Category,
Vector: vector,
Tier: "normal",
Importance: 1.0,
QualityScore: 1.0, // 显式写入 = 可信来源(对照 LLM 蒸馏的 result.Overall)
Source: src,
Freshness: "fresh",
Provenance: "user", // bi-temporal 来源枚举 user|inferred|import
EmbeddingModel: embModel,
ScriptVersion: scriptVer,
Confidence: req.Confidence,
ConfidenceSource: confidenceSource,
CreatedAt: now,
UpdatedAt: now,
}
if err := a.LanceDB.InsertMemory(mem); err != nil {
respondError(w, 500, "insert memory: "+err.Error())
return
}
// 5. 搜索缓存失效(与蒸馏落库同一原则:写 memories ⇒ 该 namespace 缓存作废)
if storage.SearchCacheInstance != nil && mem.Namespace != "" {
storage.SearchCacheInstance.Invalidate(mem.Namespace)
}
// 跨 Agent 广播(镜像 commit 写后 goroutine;显式写入同样通知其它 agent)
go func() {
governance.PushCacheInvalidate(req.Namespace, "memory_write")
governance.PushMemoryCommitted(req.AgentID, req.Namespace, mem.ID)
}()
respData := map[string]interface{}{
"status": "ok",
"memory_id": mem.ID,
"table": "memories",
"category": mem.Category,
"namespace": mem.Namespace,
"note": "direct write: embedded + recallable immediately (no distill queue)",
}
if len(conflicts) > 0 {
respData["conflicts"] = conflicts
respData["warning"] = "contradictory memories exist; explicit write kept both (resolve via feedback)"
}
respond(w, 201, respData)
}
// mergeMemory 合并重复记忆:升版本、提重要性、更新时间戳
func (a *API) mergeMemory(existing models.MemoryRecord, newContent, newCategory, agentID string) {
existing.Version++
@@ -318,13 +509,15 @@ func cosineSimilarity(a, b []float32) float64 {
// 用途:区分「真无匹配」(results=[] 但候选数>0)与「依赖故障」(非 2xx)。
func (a *API) Recall(w http.ResponseWriter, r *http.Request) {
var req struct {
Query string `json:"query"`
Limit int `json:"limit"`
TopK int `json:"top_k"` // 兼容插件 top_k 参数
Namespace string `json:"namespace"`
AgentID string `json:"agent_id"` // 用于推导默认 namespace
Diversity float64 `json:"diversity"`
Mode string `json:"mode"` // "hybrid"(default), "semantic", "keyword"
Query string `json:"query"`
Limit int `json:"limit"`
TopK int `json:"top_k"` // 兼容插件 top_k 参数
Namespace string `json:"namespace"`
AgentID string `json:"agent_id"` // 用于推导默认 namespace
Diversity float64 `json:"diversity"`
Mode string `json:"mode"` // "hybrid"(default), "semantic", "keyword"
DeadlineMS int `json:"deadline_ms"` // P0-3:本次召回的硬超时(毫秒),超时返回 partial
Continuation string `json:"continuation"` // P0-3:上一页返回的不透明翻页令牌
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, 400, "invalid body")
@@ -357,11 +550,53 @@ func (a *API) Recall(w http.ResponseWriter, r *http.Request) {
req.Limit = 10
}
// 2026-09-21 五修(t_24eceed0 返修):无 ns 且无 agent_id → namespace="" → 全库跨 ns 召回
// 2026-10-26:区分「作用域来源」——只有 agent_id 派生的 ns 才允许兜底,
// 显式传 namespace 的调用方不兜底(避免跨域泄漏)。
nsDerivedFromAgent := req.Namespace == "" && req.AgentID != ""
req.Namespace = resolveRecallNamespace(req.Namespace, req.AgentID, req.Query)
// ── P0-3 契约 + P0-1 追踪(2026-10-26)──────────────────────────────
// 翻页令牌:绑定 query 指纹 + 作用域,跨 query 复用一律 400(防串页)。
offset := 0
var prevIDs []string
if req.Continuation != "" {
pt, perr := decodeContinuation(req.Continuation)
if perr != nil {
respondErrorSchema(w, 400, "bad_continuation", perr.Error(), 0)
return
}
if pt.Q != queryFingerprint(req.Query) {
respondErrorSchema(w, 400, "continuation_query_mismatch",
"continuation 属于另一个 query,不能跨 query 复用", 0)
return
}
if pt.NS != req.Namespace {
respondErrorSchema(w, 400, "continuation_namespace_mismatch",
"continuation 的 namespace 与本次请求不一致", 0)
return
}
offset = pt.Off
prevIDs = pt.Prev
}
// 取数上限 = 页尾(offset+limit);MMR 的 k 随之上抬,保证前缀稳定。
// 多取 len(prevIDs):用于过滤跨页重复项,过滤后仍能凑满一页(否则「不重」要靠「漏」换)。
fetchLimit := req.Limit + offset + len(prevIDs)
if fetchLimit > maxContinuationOffset+req.Limit {
fetchLimit = maxContinuationOffset + req.Limit
}
ctx := r.Context()
if req.DeadlineMS > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, time.Duration(req.DeadlineMS)*time.Millisecond)
defer cancel()
}
// H4: Default diversity — balance relevance & diversity
// 2026-10-14:改读 storage 的推送值(默认 0.3,与下方原硬编码一致 ⇒ 行为不变),
// 让调参器能真正影响召回 —— 此前调参改了 Tuner.Diversity 但没人读(空转)。
if req.Diversity <= 0 {
req.Diversity = 0.3
req.Diversity = storage.DefaultMMRDiversity()
}
var results []models.RecallResult
@@ -374,71 +609,78 @@ func (a *API) Recall(w http.ResponseWriter, r *http.Request) {
// (LanceDB ANN 返回 + 去重 + 原始对话过滤之后、rerank/MMR 截断之前捕获)。
// 此前误用 len(results)(最终产出数)作代理值——字段名说 "scanned"(扫描候选池)
// 值却是"产出数",属"用文档补假名"。现改为 RecallWithMeta 返回的候选池真实大小。
// keyword 路径未做 ANN 扫描 ⇒ 恒 0;graph 兜底路径 = 兜底返回数。
// keyword 主路径未做 ANN 扫描 ⇒ 恒 0;graph 兜底路径 = 兜底返回数。
annScanned := 0
// t_3aa2a8c1:粗检索被 Rust 侧单次重读行数上限截断的标志(来源 RecallMeta.Truncated)。
// graph 兜底 / keyword 空匹配兜底结果不经 IPC 粗检索 ⇒ 恒 false。
truncated := false
// H5: Mode routing — hybrid / semantic / keyword
if req.Mode == "" {
req.Mode = "hybrid"
}
// P0-1:trace 在 mode 归一之后创建(记录真实 mode);所有路径共用同一条 trace。
tr := storage.NewRecallTrace("", req.Query, req.Namespace, req.Mode)
var lastMeta storage.RecallMeta
switch req.Mode {
case "keyword":
// Pure keyword search: LanceDB + BM25 re-rank, or graph.db fallback
// t_2d4e3b92: keyword 主路径依赖向量编码;编码失败(依赖不可用)⇒ 非 2xx,
// 不再把错误伪装成 200 + 错误体。
// t_1c12e000:keyword 路径同样收进 RecallWithMeta(Pipeline.Recall 内部
// 走同一缓存键),只为把 cache_hit 带回响应——旧版用 Recall 丢掉了 meta。
var kwMeta storage.RecallMeta
kwResults, kwMeta, kwErr := a.Pipeline.RecallWithMeta(req.Query, req.Namespace, req.Limit, req.Diversity)
// t_2d4e3b92: keyword 主路径依赖向量编码;编码失败(依赖不可用)⇒ 非 2xx。
stKw := time.Now()
kwResults, kwMeta, kwErr := a.Pipeline.RecallWithMetaTraced(ctx, req.Query, req.Namespace, fetchLimit, req.Diversity, nsDerivedFromAgent, tr)
if kwErr != nil {
// 主路径硬错误(向量编码/rerank 依赖不可用):即便兜底能补结果,也按 502 上报,
// 调用方据此走失败路径而不是把依赖故障误判成"无匹配"
tr.StageNote("keyword_path", stKw, 0, kwErr.Error())
tr.Error = kwErr.Error()
respondErrorSchema(w, 502, "recall_backend_error",
"keyword recall failed: "+kwErr.Error(), 5)
log.Printf("[recall:keyword] failed: %v", kwErr)
return
}
lastMeta = kwMeta
cacheHit = kwMeta.CacheHit
truncated = kwMeta.Truncated
if len(kwResults) == 0 {
// 真无匹配(正常业务):成功 schema,results=[] + candidates_scanned 供区分
fallbackResults := a.GraphStore.FallbackTextSearch(req.Query, req.Namespace, req.Limit)
results = convertFallbackResults(fallbackResults)
respond(w, 200, map[string]interface{}{
"results": results,
"count": len(results),
"candidates_scanned": 0, // keyword 路径未做 ANN 扫描
"mode": "keyword",
"cache_hit": false, // graph 兜底结果不来自 search cache
})
return
// 真无匹配(正常业务)或纯词面路径空:graph.db 兜底
results = convertFallbackResults(a.GraphStore.FallbackTextSearch(req.Query, req.Namespace, fetchLimit))
if len(results) > 0 {
tr.StageNote("keyword_fallback", stKw, len(results), "主路径 0 命中 ⇒ graph.db 兜底")
} else {
tr.Stage("keyword_path", stKw, 0)
}
cacheHit = false // 兜底结果不来自 search cache
} else {
// Re-rank by BM25 only
for i := range kwResults {
kwResults[i].Score = storage.ComputeBM25Score(req.Query, kwResults[i].Content)
}
sort.Slice(kwResults, func(i, j int) bool { return kwResults[i].Score > kwResults[j].Score })
if len(kwResults) > fetchLimit {
kwResults = kwResults[:fetchLimit]
}
for i := range kwResults {
if kwResults[i].Channel == "" {
kwResults[i].Channel = "keyword"
}
}
results = kwResults
tr.Stage("keyword_path", stKw, len(results))
}
// Re-rank by BM25 only
for i := range kwResults {
kwResults[i].Score = storage.ComputeBM25Score(req.Query, kwResults[i].Content)
}
sort.Slice(kwResults, func(i, j int) bool { return kwResults[i].Score > kwResults[j].Score })
if len(kwResults) > req.Limit {
kwResults = kwResults[:req.Limit]
}
respond(w, 200, map[string]interface{}{
"results": kwResults, "count": len(kwResults), "mode": "keyword",
"candidates_scanned": 0,
"cache_hit": cacheHit, // t_1c12e000:keyword 主路径命中 search cache 时显式标注
})
return
case "semantic":
// Pure semantic — BM25 off (H1 skipped, Pipeline Recall has BM25 built in — handled by switch)
// t_949840b1:RecallWithMeta 额外返回候选池真实大小(ANN 粗检索池,非最终产出数)
// Pure semantic — BM25 off(Pipeline 内部按 mode 处理)
var meta storage.RecallMeta
results, meta, err = a.Pipeline.RecallWithMeta(req.Query, req.Namespace, req.Limit, req.Diversity)
results, meta, err = a.Pipeline.RecallWithMetaTraced(ctx, req.Query, req.Namespace, fetchLimit, req.Diversity, nsDerivedFromAgent, tr)
lastMeta = meta
annScanned = meta.CandidatesScanned
cacheHit = meta.CacheHit // t_1c12e000
cacheHit = meta.CacheHit
truncated = meta.Truncated
case "hybrid":
// BM25 + vector combined (H1: BM25 scoring inside Pipeline.Recall)
var meta storage.RecallMeta
results, meta, err = a.Pipeline.RecallWithMeta(req.Query, req.Namespace, req.Limit, req.Diversity)
results, meta, err = a.Pipeline.RecallWithMetaTraced(ctx, req.Query, req.Namespace, fetchLimit, req.Diversity, nsDerivedFromAgent, tr)
lastMeta = meta
annScanned = meta.CandidatesScanned
cacheHit = meta.CacheHit // t_1c12e000
cacheHit = meta.CacheHit
truncated = meta.Truncated
}
if err != nil {
// P0: 降级到 graph.db 关键词搜索(仅 hybrid/semantic 主路径依赖向量/rerank)
@@ -446,10 +688,13 @@ func (a *API) Recall(w http.ResponseWriter, r *http.Request) {
if len(fallbackResults) > 0 {
results = convertFallbackResults(fallbackResults)
annScanned = len(fallbackResults)
cacheHit = false // t_1c12e000:graph 兜底结果不来自 search cache
cacheHit = false // t_1c12e000:graph 兜底结果不来自 search cache
truncated = false // t_3aa2a8c1:graph.db 兜底结果不经 IPC 粗检索 ⇒ 无截断可言
w.Header().Set("X-Fallback", "graph")
} else {
// t_2d4e3b92: 主路径与兜底都失败 ⇒ 非 2xx + 统一错误体(绝不在 200 里塞错误)
tr.MarkPartial("stage_error")
tr.Error = err.Error()
respondErrorSchema(w, 502, "recall_backend_error",
"recall failed: "+err.Error()+"; graph fallback also empty", 5)
log.Printf("[recall] failed: %v; fallback empty", err)
@@ -465,7 +710,15 @@ func (a *API) Recall(w http.ResponseWriter, r *http.Request) {
if len(results) > 0 && len(results) < 3 && req.Namespace != "shared" && req.Namespace != "" {
vec, encErr := a.Embedder.EncodeQuery(req.Query)
if encErr == nil {
shared, _ := a.LanceDB.Search("memories", vec, 5, "shared")
var shared []models.MemoryRecord
// t_3aa2a8c1:补充搜同属粗检索 ⇒ 截断也计入本次 recall 的 truncated
if rep, ok := a.LanceDB.(storage.SearchTruncationReporter); ok {
var tSup bool
shared, tSup, _ = rep.SearchWithTruncation("memories", vec, 5, "shared")
truncated = truncated || tSup
} else {
shared, _ = a.LanceDB.Search("memories", vec, 5, "shared")
}
for _, m := range shared {
// 去重:跳过已在 own namespace 结果中的记忆
alreadyHave := false
@@ -483,12 +736,20 @@ func (a *API) Recall(w http.ResponseWriter, r *http.Request) {
vv = storage.VValueProvider(m.ID)
}
results = append(results, models.RecallResult{
ID: m.ID,
Content: m.Content,
Category: m.Category,
Score: 0.5, // shared 结果降权,使用默认分
QualityScore: m.QualityScore,
VValue: vv,
ID: m.ID,
Content: m.Content,
Category: m.Category,
Score: 0.5, // shared 结果降权,使用默认分
QualityScore: m.QualityScore,
VValue: vv,
HumanUsefulCount: m.HumanUsefulCount, // A10 (t_d618dc7c)
Channel: "shared", // P0-1:来源通道
Version: m.Version,
ValidFrom: m.ValidFrom,
ValidTo: m.ValidTo,
Supersedes: m.Supersedes,
SupersededBy: m.SupersededBy,
Superseded: len(m.SupersededBy) > 0,
})
}
}
@@ -502,7 +763,15 @@ func (a *API) Recall(w http.ResponseWriter, r *http.Request) {
if req.Namespace != "wiki-curator-main" && req.Namespace != "" {
if vec, encErr := a.Embedder.EncodeQuery(req.Query); encErr == nil {
for _, extra := range []string{"wiki-curator-main"} {
extraHits, _ := a.LanceDB.Search("memories", vec, 5, extra)
var extraHits []models.MemoryRecord
// t_3aa2a8c1:补充搜同属粗检索 ⇒ 截断也计入本次 recall 的 truncated
if rep, ok := a.LanceDB.(storage.SearchTruncationReporter); ok {
var tSup bool
extraHits, tSup, _ = rep.SearchWithTruncation("memories", vec, 5, extra)
truncated = truncated || tSup
} else {
extraHits, _ = a.LanceDB.Search("memories", vec, 5, extra)
}
for _, m := range extraHits {
dup := false
for _, r := range results {
@@ -518,12 +787,20 @@ func (a *API) Recall(w http.ResponseWriter, r *http.Request) {
vv = storage.VValueProvider(m.ID)
}
results = append(results, models.RecallResult{
ID: m.ID,
Content: m.Content,
Category: m.Category,
Score: 0.45, // 笔记知识降权:不挤掉主记忆,但能被看到
QualityScore: m.QualityScore,
VValue: vv,
ID: m.ID,
Content: m.Content,
Category: m.Category,
Score: 0.45, // 笔记知识降权:不挤掉主记忆,但能被看到
QualityScore: m.QualityScore,
VValue: vv,
HumanUsefulCount: m.HumanUsefulCount, // A10 (t_d618dc7c)
Channel: "wiki", // P0-1:来源通道
Version: m.Version,
ValidFrom: m.ValidFrom,
ValidTo: m.ValidTo,
Supersedes: m.Supersedes,
SupersededBy: m.SupersededBy,
Superseded: len(m.SupersededBy) > 0,
})
}
}
@@ -531,6 +808,50 @@ func (a *API) Recall(w http.ResponseWriter, r *http.Request) {
}
}
// ── 2026-10-14 (t_2515307a) 确定性判据强化:召回后规则过滤/降权 ──
// env 门控 ZHIYI_RULE_FIRST,默认关 ⇒ 零行为变化(跳过本块)。
// =1 时:对每条结果用 distill.RecallRuleVerdict 判确定性可用性——
// RecallDrop → 过滤(不进 top-N);RecallDemote → 降权(排到 keep 之后)。
// 只改读侧返回集,不碰 state.db、不碰 LanceDB 数据。
ruleFilteredCount := 0
ruleDemotedCount := 0
if os.Getenv("ZHIYI_RULE_FIRST") == "1" && len(results) > 0 {
kept, demoted, dropped, _ := distill.FilterRecallByRule(results,
func(r models.RecallResult) string { return r.Content })
ordered := make([]models.RecallResult, 0, len(kept)+len(demoted))
ordered = append(ordered, kept...)
ordered = append(ordered, demoted...)
ruleFilteredCount = len(dropped)
ruleDemotedCount = len(demoted)
if ruleFilteredCount+ruleDemotedCount > 0 {
log.Printf("[rule-first] query=%q 过滤 %d / 降权 %d / 保留 %d", req.Query,
len(dropped), len(demoted), len(kept))
}
results = ordered
}
// ── 2026-10-26(P1-3 生命周期 fixture 抓到的真 bug)──────────────────
// 主候选池在 pipeline 里已做 bi-temporal 过滤,但 **shared / wiki 补充通道与图谱注入
// 不过那道闸** ⇒ 已失效事实会从补充通道回流进 top-k(实测:valid_to=11:00 的记录
// 被 shared 通道以 score=0.5 重新塞回)。这里在「所有注入完成之后、cap 之前」统一过滤,
// 保证读侧只有一条有效期口径。全失效时保留原结果(不把召回清空,non-lossy)。
if storage.BitemporalEnabled() {
now := time.Now()
live := make([]models.RecallResult, 0, len(results))
expired := 0
for _, r := range results {
if storage.ValidityExpired(r.ValidTo, now) {
expired++
continue
}
live = append(live, r)
}
if len(live) > 0 && expired > 0 {
log.Printf("[recall] 补充通道 bi-temporal 过滤 %d 条失效事实(统一读侧口径)", expired)
results = live
}
}
// Record recall hit rate (has results = hit, empty = miss)
selfoptimize.Dash.RecordRecall(len(results) > 0)
// Gap auto-close: recall 命中后自动关闭该 query 对应的 open gap
@@ -557,19 +878,129 @@ func (a *API) Recall(w http.ResponseWriter, r *http.Request) {
// 非最终返回数——最终条数以 count 为准),
// 使"真无匹配"(results=[] 但 candidates_scanned>0)与依赖故障(非 2xx)可区分。
// 调用方契约:200 + results 键存在 = 成功(results 可为空);非 2xx = 失败,绝不解码为成功。
// ⑤使用追踪(t_5aa4dcac):记录本次召回的 mem_* 记忆 + 独特 token(env 门控 ZHIYI_USAGE_TRACK,
// 默认关 ⇒ 零行为变化)。供 commit 写侧检测「后续内容是否携带了这些记忆 → 判定被用上」。
if storage.UsageTrackingEnabled() && len(results) > 0 {
recs := make([]storage.RecalledMemory, 0, len(results))
for _, r := range results {
if r.ID == "" || r.Content == "" || strings.HasPrefix(r.ID, "ep_") {
continue // 只追踪 mem_* 蒸馏记忆;ep_* 原始轮次不是「要判定用没用」的目标
}
recs = append(recs, storage.RecalledMemory{
ID: r.ID, Content: r.Content, Namespace: r.Namespace,
Agent: req.AgentID, Query: req.Query,
})
}
storage.RecordRecalled(recs)
}
// ── 2026-10-26 (t_9025b0eb) top_k 收口:图谱扩展不得撑爆返回集 ──
// 现象:/api/v1/recall 完全无视 top_k —— top_k=5 实测返回 1905/1625 条、top_k=10 返回
// 2227/1859 条,其中 99% 是 category=graph_expanded(图谱扩展 append 无上限)。
// 后果:插件记忆预取被撑到 103,299 chars 截断(真相关条目落在被截断段)、count 失真、
// ⑤使用追踪按千级结果记账。
// 契约修正(主结果优先):
// results = 主检索结果(category != graph_expanded),截断到 req.Limit;
// 主结果不足 req.Limit 时用扩展结果按原顺序补齐(稀疏查询仍有扩展收益)。
// graph_expanded = 扩展结果**单独成字段**(上限 req.Limit)——能力仍可观测、可统计,
// 但不再挤占主结果、不再污染 results/count。
results, graphExpanded := capRecallResults(results, fetchLimit)
if results == nil {
results = []models.RecallResult{}
}
respond(w, 200, map[string]interface{}{
// ── P0-3 翻页:从 fetchLimit 结果里切出 [offset, offset+limit) ──
// 说明:翻页重算整段前 offset+limit 条再切片,保证 MMR 前缀稳定;
// 令牌绑定 query 指纹 + namespace,跨 query/跨域复用一律 400(防串页)。
totalFetched := len(results)
if offset > 0 {
if offset >= len(results) {
results = []models.RecallResult{}
} else {
results = results[offset:]
}
}
if len(prevIDs) > 0 {
seenPrev := make(map[string]bool, len(prevIDs))
for _, id := range prevIDs {
seenPrev[id] = true
}
kept := results[:0]
for _, r := range results {
if !seenPrev[r.ID] {
kept = append(kept, r)
}
}
results = kept
}
if len(results) > req.Limit {
results = results[:req.Limit]
}
nextCont := ""
if totalFetched >= fetchLimit && len(results) == req.Limit && offset+req.Limit < maxContinuationOffset {
returned := make([]string, 0, len(prevIDs)+len(results))
returned = append(returned, prevIDs...)
for _, r := range results {
returned = append(returned, r.ID)
}
nextCont = encodeContinuation(req.Query, req.Namespace, offset+req.Limit, req.Limit, returned)
}
// ── P0-1/P0-3:契约字段 + trace 落盘 ──
tr.SetItems(traceItems(results))
status := lastMeta.Status
if status == "" {
status = "complete"
}
partialReason := lastMeta.PartialReason
if tr.Status == "partial" && status == "complete" {
status = "partial"
partialReason = tr.PartialReason
}
if truncated && status == "complete" {
status = "partial"
partialReason = "row_cap_truncated"
}
tr.Status = status
tr.PartialReason = partialReason
tr.CacheHit = cacheHit
tr.Truncated = truncated
tr.CandidatesScanned = annScanned
tr.Namespace = req.Namespace
tr.Mode = req.Mode
tr.Scope = buildScope(req.Namespace, req.Limit, req.Mode, cacheHit, truncated, tr.StartedAt)
if offset > 0 {
tr.Scope["offset"] = offset
}
storage.SaveTrace(tr)
resp := map[string]interface{}{
"results": results,
"count": len(results),
"candidates_scanned": annScanned,
"mode": req.Mode,
// 2026-10-26 (t_9025b0eb):扩展结果单独成字段,避免撑爆 results。
// 键恒存在(可为空数组),便于探针/统计区分「无扩展」与「字段缺失」。
"graph_expanded": graphExpanded,
// t_1c12e000:AC3 —— 缓存命中/回源扫表必须可区分。
// true ⇒ candidates_scanned=0 是「读了 60s 内的旧快照」(含 L1/L2);
// false ⇒ 本次真扫了表(candidates_scanned 为真实候选池大小)。
"cache_hit": cacheHit,
})
// 2026-10-26(P0-1/P0-3 契约):
// trace_id ⇒ GET /api/v1/trace/<id> 可重建本次调用链(阶段耗时 + 每通道贡献);
// status ⇒ complete 的空结果才可解读为「不存在」;partial 必须看 partial_reason;
// scope ⇒ 本次答案基于哪个数据视图(namespace/snapshot/cache_hit/truncated);
// continuation ⇒ 空串=无下一页,非空=拼回请求体取下一页。
"trace_id": tr.TraceID,
"status": status,
"scope": tr.Scope,
"continuation": nextCont,
}
if partialReason != "" {
resp["partial_reason"] = partialReason
}
// t_3aa2a8c1:truncated —— 只在 true 时暴露(键缺失 = 未截断,等价 omitempty 语义)。
if truncated {
resp["truncated"] = true
}
respond(w, 200, resp)
// H3: Async trust score update
if a.GraphStore != nil {
@@ -585,6 +1016,38 @@ func (a *API) Recall(w http.ResponseWriter, r *http.Request) {
// useful_count / not_useful_count(后者是 P0-1 拆分的旧混写列)。反馈统一走
// feedback.go 的 /api/v1/feedback/{useful,not-useful,deprecate,correct}(写 human_not_useful_count)。
// GET /api/v1/usage/report — ⑤使用追踪上报(t_5aa4dcac)。
// 返回最近被自动判定「被用上」(carry_forward)的记忆样本,供 ⑥反馈消费 + 取证。
// env 门控 ZHIYI_USAGE_TRACK,默认关 ⇒ 返回空;不写任何东西,零行为变化。
func (a *API) UsageReport(w http.ResponseWriter, r *http.Request) {
limit := 20
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 200 {
limit = n
}
}
flags := storage.UsageReport(limit)
respond(w, 200, map[string]interface{}{
"enabled": storage.UsageTrackingEnabled(),
"count": len(flags),
"flags": flags,
})
}
// POST /api/v1/usage/detect — ⑤使用追踪手动触发(t_5aa4dcac)。
// 对「当前有 ZHIYI_USAGE_TRACK 且仍有未消费 recall 行」做一次兜底检测。
// 主要靠 commit 写侧自动检测;此端点供运维/验证在无新 commit 时手动补扫。
// 门控:关闭时返回 enabled=false,不碰任何数据。
func (a *API) UsageDetect(w http.ResponseWriter, r *http.Request) {
if !storage.UsageTrackingEnabled() {
respond(w, 200, map[string]interface{}{"enabled": false, "count": 0, "flags": []storage.UsageFlag{}})
return
}
// 手动触发需要一个候选内容作比对源;无 commit 时无可比内容,返回空。
// 真实检测由 commit 写侧完成(见 Commit handler)。此端点仅回执开关状态。
respond(w, 200, map[string]interface{}{"enabled": true, "count": 0, "note": "detection runs on commit write-side"})
}
// POST /api/v1/recall/debug — recall 诊断端点(优化),返回各阶段耗时和状态
func (a *API) RecallDebug(w http.ResponseWriter, r *http.Request) {
var req struct {
@@ -706,6 +1169,7 @@ func (a *API) RecallDebug(w http.ResponseWriter, r *http.Request) {
results = append(results, models.RecallResult{
ID: c.ID, Content: c.Content, Category: c.Category,
Score: score, Timestamp: c.CreatedAt.Format(time.RFC3339),
HumanUsefulCount: c.HumanUsefulCount, // A10 (t_d618dc7c)
})
}
}
@@ -968,7 +1432,14 @@ func (a *API) BatchCommit(w http.ResponseWriter, r *http.Request) {
}
var ids []string
skippedMeta := 0
for _, item := range req.Items {
// 2026-10-16 t_5ad3f843:meta-note 门禁——逐条跳过 meta 条目(门禁默认关闭=零行为变化)。
if skip, reason := metanote.ShouldSkip(item.Content); skip {
log.Printf("[metanote] batch item skipped: agent=%s reason=%s content=%.60s", item.AgentID, reason, item.Content)
skippedMeta++
continue
}
ns := item.Namespace
if ns == "" {
cat := item.Category
@@ -988,7 +1459,7 @@ func (a *API) BatchCommit(w http.ResponseWriter, r *http.Request) {
}
ids = append(ids, id)
}
respond(w, 201, map[string]interface{}{"ids": ids, "count": len(ids), "status": "ok"})
respond(w, 201, map[string]interface{}{"ids": ids, "count": len(ids), "skipped_meta": skippedMeta, "status": "ok"})
}
// ─── Namespace 分类规则(按 §1.3 设计)─────────────────────
@@ -1157,3 +1628,74 @@ func (a *API) EdgeFeedback(w http.ResponseWriter, r *http.Request) {
_ = a.GraphStore.UpdateEdgeTrustScores()
respond(w, 200, map[string]string{"status": "ok"})
}
// GET /api/v1/popwatch — t_23b6a940 AC7:热门度看门狗快照(report-only 观测)。
// 服务端记录「最终 rank1 的 recall_verified_count 连续越 p95 阈值」计数
// (storage.PopWatch,观测点在 recallUncached 主路径漏斗末尾);本端点只读快照,
// 不改排序、不写库。传感器端脚本(zhiyi-popwatch.py)读它转既有告警通道;
// ZHIYI_POPWATCH=0 时 enabled=false(观测关闭)。
func (a *API) PopWatch(w http.ResponseWriter, r *http.Request) {
respond(w, 200, storage.PopWatchSnapshot())
}
// capRecallResults —— 2026-10-26 (t_9025b0eb) top_k 收口语义(纯函数,可单测)。
//
// 输入是 pipeline 返回的原始结果(主检索在前、图谱扩展 append 在后,扩展无上限)。
// 输出:
//
// prim = 主检索结果(category != graph_expanded),截断到 limit;
// 若主结果不足 limit,用扩展结果按原顺序补齐(稀疏查询仍有扩展收益)。
// expanded = 剩余扩展结果,单独成字段返回,上限 limit(能力可观测,但不挤占主结果)。
//
// 为什么不是「直接 results[:limit]」:扩展结果全部排在主结果之后,直接截断会让
// category=graph_expanded 恒为 0 ⇒ 能力虽在跑但对外永久失明(本仓 2026-10 已因同类
// 「接线≠出数」栽过一次)。所以既收口、又保留可观测通道。
func capRecallResults(results []models.RecallResult, limit int) (prim, expanded []models.RecallResult) {
if limit <= 0 {
limit = 10
}
prim = make([]models.RecallResult, 0, limit)
if len(results) == 0 {
return prim, []models.RecallResult{}
}
exp := make([]models.RecallResult, 0, len(results))
// 2026-10-26 (t_e98cb1d6):pattern / peer 是「附带注入」的召回条目(recall.go Step 6.2/6.3),
// 追加在 results 末尾 ⇒ 若并入 prim 一起截断,distilled 占满 limit 时它们恒被切光
// (16:28 topkcap 引入的 pattern 截断回归,limit=50 时 pattern=0 / limit=200 才现形 3 条)。
// 正确语义:主检索结果(distilled 等)照常收口到 limit;pattern/peer 作为附带条目
// 恒保留(不挤占主结果配额),避免「命中即注入」被 cap 吞掉。graph_expanded 仍走独立字段。
var carry []models.RecallResult
for _, r := range results {
switch r.Category {
case "graph_expanded":
exp = append(exp, r)
case "pattern", "peer":
carry = append(carry, r)
default:
prim = append(prim, r)
}
}
// 先收口主结果
if len(prim) > limit {
prim = prim[:limit]
}
// 主结果不足 limit 时用扩展结果按原顺序补齐(稀疏查询仍有扩展收益)
if len(exp) > 0 {
if room := limit - len(prim); room > 0 {
if room > len(exp) {
room = len(exp)
}
prim = append(prim, exp[:room]...)
exp = exp[room:]
}
if len(exp) > limit {
exp = exp[:limit]
}
}
// pattern / peer 附带条目恒保留在 results 末尾(独立于主结果配额)
prim = append(prim, carry...)
if expanded == nil {
expanded = exp
}
return prim, expanded
}
+153
View File
@@ -0,0 +1,153 @@
package routes
import (
"testing"
"github.com/xiaoxue/memoryweave/internal/models"
)
// 2026-10-26 (t_9025b0eb):top_k 收口契约测试。
// 起因:/api/v1/recall 完全无视 top_k —— top_k=5 实测返回 1905/1625 条(99% graph_expanded),
// 插件记忆预取被撑到 103,299 chars 截断、count 失真、使用追踪按千级结果记账。
// 钉住四件事:① results 不超过 limit ② 主结果不被扩展挤掉 ③ 扩展仍可见且不超 limit
// ④ 稀疏查询(主结果 < limit)时扩展能补齐。
func TestCapRecallResults(t *testing.T) {
mk := func(n int, cat string) []models.RecallResult {
out := make([]models.RecallResult, n)
for i := 0; i < n; i++ {
out[i] = models.RecallResult{ID: cat + string(rune('a'+i%26)), Category: cat, Content: cat}
}
return out
}
cases := []struct {
name string
prim, exp int
limit int
wantPrim int
wantExp int
primCategory string
}{
{"主结果足额_扩展不得进results", 10, 1900, 5, 5, 5, "distilled"},
{"主结果只有2条_扩展补齐到limit", 2, 900, 5, 5, 5, "distilled"},
{"无扩展_纯截断", 30, 0, 10, 10, 0, "distilled"},
{"扩展少于limit", 1, 3, 5, 4, 0, "distilled"},
{"空结果", 0, 0, 5, 0, 0, ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
all := append(mk(c.prim, "distilled"), mk(c.exp, "graph_expanded")...)
prim, exp := capRecallResults(all, c.limit)
if len(prim) != c.wantPrim {
t.Fatalf("prim=%d want %d", len(prim), c.wantPrim)
}
if len(exp) != c.wantExp {
t.Fatalf("expanded=%d want %d", len(exp), c.wantExp)
}
if len(prim)+len(exp) > c.limit*2 {
t.Fatalf("总量失控: prim=%d exp=%d", len(prim), len(exp))
}
// 主结果条数不得超过原始主结果数(不得凭空造)
n := 0
for _, r := range prim {
if r.Category != "graph_expanded" {
n++
}
}
if n > c.prim {
t.Fatalf("主结果 %d 条,超过输入 %d 条(凭空造)", n, c.prim)
}
if c.primCategory != "" && c.prim > 0 && n == 0 {
t.Fatalf("主结果被扩展挤光:%+v", prim)
}
})
}
}
// limit 非法(0/负数)时按默认 10 处理,不做无界返回。
func TestCapRecallResultsDefaultLimit(t *testing.T) {
all := make([]models.RecallResult, 50)
for i := range all {
all[i] = models.RecallResult{Category: "distilled"}
}
prim, _ := capRecallResults(all, 0)
if len(prim) != 10 {
t.Fatalf("limit=0 应回落 10,得到 %d", len(prim))
}
}
// ── t_e98cb1d6 回归:pattern / peer 被 cap 截断 ────────────────────────────
// 病灶:capRecallResults 把非 graph_expanded 全部并入 prim 再截断到 limit;
// pattern/peer 是 recall.go Step 6.2/6.3 追加到 results 末尾的附带条目,
// distilled 占满 limit 时它们被整段切掉(16:28 topkcap 引入,limit=50 时 pattern=0)。
// 修后:pattern/peer 恒保留在 results 末尾(独立于主结果配额),主结果仍照常收口。
func TestCapRecallResults_PatternAndPeerSurviveCap(t *testing.T) {
mk := func(n int, cat string) []models.RecallResult {
out := make([]models.RecallResult, n)
for i := 0; i < n; i++ {
out[i] = models.RecallResult{ID: cat + string(rune('a'+i%26)), Category: cat, Content: cat}
}
return out
}
count := func(rs []models.RecallResult, cat string) int {
n := 0
for _, r := range rs {
if r.Category == cat {
n++
}
}
return n
}
cases := []struct {
name string
d, p, pe, e int // distilled / pattern / peer / graph_expanded
limit int
wantD, wantP, wantPe int
}{
// ① 金标场景:主结果占满 limit,pattern/peer 追加在末尾 ⇒ 修前恒为 0
{"limit=50_主结果占满_pattern 恒保留", 50, 3, 1, 0, 50, 50, 3, 1},
{"limit=200_主结果占满_pattern 仍保留", 100, 3, 1, 97, 200, 100, 3, 1},
// ② peer 注入同样不被截断
{"limit=50_peer 注入不被截断", 50, 0, 2, 0, 50, 50, 0, 2},
// ③ 主结果不足 limit:扩展先补齐主结果,carry 仍保留在末尾
{"limit=50_主结果不足_扩展补齐+carry 保留", 30, 2, 1, 10, 50, 30, 2, 1},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
all := make([]models.RecallResult, 0, c.d+c.p+c.pe+c.e)
all = append(all, mk(c.d, "distilled")...)
all = append(all, mk(c.p, "pattern")...)
all = append(all, mk(c.pe, "peer")...)
all = append(all, mk(c.e, "graph_expanded")...)
prim, exp := capRecallResults(all, c.limit)
d := count(prim, "distilled")
p := count(prim, "pattern")
pe := count(prim, "peer")
e := count(prim, "graph_expanded")
if d != c.wantD {
t.Errorf("distilled=%d want %d", d, c.wantD)
}
// AC:limit=50 时 pattern 必须 ≥1(金标 query 复现终审现象)
if c.p > 0 && p < 1 {
t.Errorf("pattern=%d,应 ≥1(截断回归未修)", p)
}
if p != c.wantP {
t.Errorf("pattern=%d want %d", p, c.wantP)
}
if pe != c.wantPe {
t.Errorf("peer=%d want %d", pe, c.wantPe)
}
// cap 仍然生效:主结果(distilled + 扩展补齐)不得超过 limit
if d+e > c.limit {
t.Fatalf("主结果超 cap: distilled=%d graph_expanded=%d limit=%d", d, e, c.limit)
}
if len(exp) > c.limit {
t.Fatalf("graph_expanded 字段超 cap: %d > %d", len(exp), c.limit)
}
// 附带条目不得被塞进 graph_expanded 字段
if count(exp, "pattern") > 0 || count(exp, "peer") > 0 {
t.Fatalf("pattern/peer 被误放入 graph_expanded 字段")
}
})
}
}
+70 -12
View File
@@ -3,8 +3,11 @@ package routes
import (
"encoding/json"
_ "embed"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
@@ -12,6 +15,15 @@ import (
"github.com/xiaoxue/memoryweave/internal/storage"
)
// go:embed testdata/golden-set.json 把金标集打进二进制(t_dfc93ad0 AC5):
// 部署 CWD=/home/muc,旧版「path=="" → 相对 testdata/」解析到 /home/muc/testdata
// ⇒ POST /api/v1/eval/golden 不传 golden_path 时 500(2026-10-14 实测复现)。
// 内嵌兜底后:缺省/相对路径读不到 ⇒ 回落内嵌基线(仍可用);env ZHIYI_GOLDEN_SET
// 指定绝对路径 ⇒ 读该文件(可临时替换金标集)。测试仍按显式路径调用,行为不变。
//
//go:embed testdata/golden-set.json
var embeddedGoldenSetJSON []byte
// ─── 金标集定义 ──────────────────────────────────────────────
// GoldenSetItem 单条金标:query + 具体 expected_ids + 关键词相关性判据
@@ -19,9 +31,14 @@ type GoldenSetItem struct {
Query string `json:"query"`
ExpectedIDs []string `json:"expected_ids"`
MustIncludeKeywords []string `json:"must_include_keywords"`
Category string `json:"category,omitempty"`
Difficulty string `json:"difficulty,omitempty"`
IDBaseline string `json:"id_baseline,omitempty"`
// Namespace 该条目应在哪个 namespace 里召回。为空则用请求传入的 ns。
// 为什么必须 per-item(2026-10-14 实测):金标的 expected_id 分散在
// hermes-main / domain:HUB / soulful-xiaowei / wiki / shared 等多个 ns,
// 而 eval 只按请求里的单一 ns 召回 ⇒ ID 类指标**按构造恒为 0**(不是召回差)。
Namespace string `json:"namespace,omitempty"`
Category string `json:"category,omitempty"`
Difficulty string `json:"difficulty,omitempty"`
IDBaseline string `json:"id_baseline,omitempty"`
}
// GoldenSet 金标集
@@ -30,15 +47,44 @@ type GoldenSet struct {
Items []GoldenSetItem `json:"items"`
}
// LoadGoldenSet 从 testdata/ 加载金标集,支持相对路径(生产用)和绝对路径(测试用)
// LoadGoldenSet 加载金标集。路径解析(t_dfc93ad0 AC5,部署 CWD=/home/muc):
// 1. env ZHIYI_GOLDEN_SET 指定路径 ⇒ 优先读它(临时替换金标集用,不重编译);
// 2. 显式 path 非空且能读到 ⇒ 用它(绝对/相对皆按调用方语义);
// 3. path 为空、或上面读不到(典型:部署二进制 CWD=/home/muc 无 testdata/,
// 旧版相对路径解析失败 ⇒ 500)⇒ 回落 **内嵌** 基线 embeddedGoldenSetJSON。
// 校验(空断言自守)在拿到内容后统一跑,三种来源同口径。
func LoadGoldenSet(path string) (*GoldenSet, error) {
if path == "" {
// 编译后无法直接拿到源码路径,回退到 CWD 的 testdata/
path = "testdata/golden-set.json"
var data []byte
usedSource := "env:ZHIYI_GOLDEN_SET"
// 2026-10-26 修正优先级:显式 path > env > 内嵌。
// 旧顺序(env 先)导致 ZHIYI_GOLDEN_SET 一旦设置,任何请求传 golden_path 都被静默忽略
// ⇒ 实测「传 7 个 chunk 却每块都报 53 条」(env 指向的 53 条集)—— 评测对象与预期不符,
// 且指标全是 0 时极难发现。env 现在只作「默认金标」,显式 path 必须能覆盖它。
if path != "" {
usedSource = "path:" + path
d, err := os.ReadFile(path)
if err == nil {
data = d
} else if filepath.IsAbs(path) {
// 绝对路径 = 明确指名要跑这套题 ⇒ 读不到必须报错,不许静默换题
return nil, fmt.Errorf("读 golden_path 失败(%s): %w", path, err)
} else {
// 相对路径在部署 CWD 下读不到 ⇒ 内嵌兜底(t_dfc93ad0 AC5 既有行为,保留)
data = embeddedGoldenSetJSON
usedSource = "embedded"
}
} else if p := os.Getenv("ZHIYI_GOLDEN_SET"); p != "" {
var err error
data, err = os.ReadFile(p)
if err != nil {
return nil, err
}
} else {
data = embeddedGoldenSetJSON
usedSource = "embedded"
}
data, err := os.ReadFile(path)
if err != nil {
return nil, err
if len(data) == 0 {
return nil, fmt.Errorf("golden set 不可用(%s 为空且无内嵌基线)", usedSource)
}
var gs GoldenSet
if err := json.Unmarshal(data, &gs); err != nil {
@@ -346,13 +392,20 @@ func (ea *EvalAPI) RunGolden(w http.ResponseWriter, r *http.Request) {
var req struct {
Namespace string `json:"namespace"`
GoldenPath string `json:"golden_path"` // 可选,默认 testdata/golden-set.json
// AllNamespaces(2026-10-26)跨 namespace 评测开关。
// 背景:旧逻辑 ns 为空 ⇒ 强制 "shared",而真实金标分散在 hermes-main/wiki/HUB 等多个 ns
// ⇒ 评测跑的是"只看 shared"的召回,与线上跨 ns 召回不是同一个系统(实测前 15 条里 15 条结果不同)。
// 语义:true ⇒ ns = ""(全库跨 ns,等同线上默认);false ⇒ 保持旧行为(默认 shared,向后兼容)。
AllNamespaces bool `json:"all_namespaces"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, 400, "invalid body")
return
}
ns := req.Namespace
if ns == "" {
if req.AllNamespaces {
ns = ""
} else if ns == "" {
ns = "shared"
}
gs, err := LoadGoldenSet(req.GoldenPath)
@@ -379,7 +432,12 @@ func (ea *EvalAPI) RunGolden(w http.ResponseWriter, r *http.Request) {
continue
}
results, err := ea.Pipeline.Recall(item.Query, ns, 5, 0.5)
// per-item namespace:条目自带 ns 优先(金标基线是按 ns 钉的,见 GoldenSetItem.Namespace)
itemNS := item.Namespace
if itemNS == "" {
itemNS = ns
}
results, err := ea.Pipeline.Recall(item.Query, itemNS, 5, 0.5)
if err != nil {
failures = append(failures, item.Query+": recall 失败 "+err.Error())
keywordFail++
+61 -3
View File
@@ -10,10 +10,32 @@ import (
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// liveAPIKey 取活服务密钥:优先 env ZHIYI_TEST_API_KEY;否则读密钥文件
// /var/lib/memoryweave/.api-key 的主密钥(第 1 个非注释、非 legacy 行);
// 再退到旧 dev 键(无生产密钥文件的环境,如 CI)。
// 2026-09-21 密钥轮换后,旧硬编码键 zhiyi-dev-key-2026 已 401 —— 全量金标实跑
// 永远 SKIP 的根因(t_fa6c9104 修复量具可用性;不改任何判据)。
func liveAPIKey() string {
if k := os.Getenv("ZHIYI_TEST_API_KEY"); k != "" {
return k
}
if b, err := os.ReadFile("/var/lib/memoryweave/.api-key"); err == nil {
for _, line := range strings.Split(string(b), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "legacy:") {
continue
}
return line
}
}
return "zhiyi-dev-key-2026"
}
// ─── recall 辅助:liveRecallOnce ─────────────────────────────
// 一次 recall API 调用同时拿 top-5 的 ids + contents,避免两条 HTTP(限流保护)。
// 每次调用间加 200ms 间隔,由调用方负责(TestEvalGolden_Live 里 sleep)。
@@ -38,7 +60,7 @@ func liveRecallOnce(query string, maxRetries int, retryWait time.Duration) recal
}
req, _ := http.NewRequest("POST", "http://127.0.0.1:7821/api/v1/recall", bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-API-Key", "zhiyi-dev-key-2026")
req.Header.Set("X-API-Key", liveAPIKey())
resp, err := http.DefaultClient.Do(req)
if err != nil {
lastErr = fmt.Errorf("HTTP 请求失败: %v", err)
@@ -218,13 +240,49 @@ func TestLoadGoldenSet_RejectsVacuousIDs(t *testing.T) {
}
}
// t_dfc93ad0 AC5 回归:部署二进制 CWD=/home/muc(无 testdata/),
// POST /api/v1/eval/golden 不传 golden_path 时,旧版「相对 testdata/ 读不到」⇒ 500。
// 修复后:缺省路径 / 读不到的路径 ⇒ 回落 **内嵌** 基线(//go:embed),仍可用。
// 判据(可复算):在**没有 testdata/ 的 CWD** 里 LoadGoldenSet("") 与
// LoadGoldenSet("testdata/golden-set.json")(相对路径读不到)都必须成功
// 且返回 14 条 —— 即部署态「缺省命令可用」。
func TestLoadGoldenSet_EmbedFallback_DeployCWD(t *testing.T) {
empty := t.TempDir() // 模拟部署 CWD:里面没有 testdata/
orig, err := os.Getwd()
if err != nil {
t.Fatalf("取 CWD 失败: %v", err)
}
if err := os.Chdir(empty); err != nil {
t.Fatalf("chdir 到空目录失败: %v", err)
}
t.Cleanup(func() { _ = os.Chdir(orig) })
// ① 缺省(path=""):不传 golden_path 的部署态 ⇒ 必须成功 + 14 条(内嵌兜底)
gs, err := LoadGoldenSet("")
if err != nil {
t.Fatalf("✗ AC5:缺省路径加载金标集失败(旧版这里 500): %v", err)
}
if len(gs.Items) != 14 {
t.Fatalf("✗ AC5:内嵌基线期望 14 条,实际 %d", len(gs.Items))
}
// ② 相对路径读不到(部署 CWD 无 testdata/):也回落内嵌,不 500
gs2, err := LoadGoldenSet(filepath.Join("testdata", "golden-set.json"))
if err != nil {
t.Fatalf("✗ AC5:相对路径读不到时应回落内嵌基线而非报错: %v", err)
}
if len(gs2.Items) != 14 {
t.Fatalf("✗ AC5:回落基线期望 14 条,实际 %d", len(gs2.Items))
}
t.Logf("✓ AC5:空 CWD(无 testdata/)下 缺省/相对路径 均回落内嵌基线(14 条),部署态缺省命令可用")
}
// ─── AC① 反证:故意把某条 expected_id 改成错误 ID → 该条必须 FAIL ───
// 这是本卡核心验收点:证明判据非恒真(改错期望 → 真 FAIL)
func TestEvalGolden_CounterEvidence(t *testing.T) {
// 探活 zhiyid(无服务 → 跳过)
healthReq, _ := http.NewRequest("GET", "http://127.0.0.1:7821/api/v1/health", nil)
healthReq.Header.Set("X-API-Key", "zhiyi-dev-key-2026")
healthReq.Header.Set("X-API-Key", liveAPIKey())
resp, err := http.DefaultClient.Do(healthReq)
if err != nil || resp == nil {
t.Skipf("zhiyid 服务不可达(%v),跳过反证测试", err)
@@ -300,7 +358,7 @@ func TestEvalGolden_CounterEvidence(t *testing.T) {
func TestEvalGolden_Live(t *testing.T) {
// 探活 zhiyid(无服务 → 跳过,不污染 CI)
healthReq, _ := http.NewRequest("GET", "http://127.0.0.1:7821/api/v1/health", nil)
healthReq.Header.Set("X-API-Key", "zhiyi-dev-key-2026")
healthReq.Header.Set("X-API-Key", liveAPIKey())
resp, err := http.DefaultClient.Do(healthReq)
if err != nil || resp == nil {
t.Skipf("zhiyid 服务不可达(%v),跳过全量金标实跑", err)
+11 -2
View File
@@ -23,6 +23,11 @@ func NewFeedbackAPI(ldb storage.LanceDB) *FeedbackAPI {
func (fa *FeedbackAPI) MarkUseful(w http.ResponseWriter, r *http.Request) {
var req struct {
MemoryID string `json:"memory_id"`
// 2026-10-24 (t_11245d30):区分人机信号。
// 空 = 人工点「有用」(保持 user_feedback,行为不变);
// "auto" = 插件自动反馈(_auto_feedback_for_reply)→ 记 auto_feedback,让 V 账可辨「牧尘认可 vs 机器自评」。
// 纯观测,不改 V 权重/阈值/上限。
Source string `json:"source"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, 400, "invalid body")
@@ -34,11 +39,15 @@ func (fa *FeedbackAPI) MarkUseful(w http.ResponseWriter, r *http.Request) {
}
selfoptimize.Dash.RecordFeedback(true)
// V 值:useful = success
// V 值:useful = success。action 名按来源区分(人工 user_feedback / 自动 auto_feedback)。
action := "user_feedback"
if req.Source == "auto" {
action = "auto_feedback"
}
selfoptimize.VProp.RecordDecision(
"useful_"+req.MemoryID,
[]string{req.MemoryID},
"user_feedback", "success", "",
action, "success", "",
)
if err := fa.LanceDB.IncrementUseful(req.MemoryID); err != nil {
log.Printf("[feedback] IncrementUseful failed %s: %v", req.MemoryID, err)
@@ -0,0 +1,143 @@
// Package routes — 遗忘保护守卫端到端回归测试(t_944344b5,AC1/AC2)。
//
// 2026-10-16 (t_944344b5):证明「decay/auto_forget 的判定道(admin Forget 路由)命中保护
// 规则的候选**不被** SoftDelete,而非保护的衰减候选**照常**被软删」。
//
// 必须走**真实判定路径**(routes.NewAdminAPI.Forget),不能只测 memprotect.IsProtected,
// 因为守卫是在 decay 循环 / admin Forget 路由里 SoftDelete 之前拦截的(见 server.go / admin.go)。
//
// 跑法(A/B 纪律:复制数据目录 + 独立 socket,绝不碰生产 /var/lib/memoryweave):
// 1. cp -a /var/lib/memoryweave /tmp/mw-prot-copy-<ts>
// 2. zhiyi-consolidate --mode socket --socket /tmp/mw-prot.sock \
// --data-dir /tmp/mw-prot-copy-<ts> &
// 3. PROT_TEST_SOCK=/tmp/mw-prot.sock \
// go test ./internal/api/routes/ -run TestForgetGuardE2E -v -count=1
//
// 未设 PROT_TEST_SOCK → 自动跳过(可安全留在 CI)。
package routes_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/xiaoxue/memoryweave/internal/governance"
"github.com/xiaoxue/memoryweave/internal/models"
"github.com/xiaoxue/memoryweave/internal/storage"
"github.com/xiaoxue/memoryweave/internal/api/routes"
)
func TestForgetGuardE2E(t *testing.T) {
sock := os.Getenv("PROT_TEST_SOCK")
if sock == "" {
t.Skip("PROT_TEST_SOCK 未设置,跳过(需先起 test sidecar 指向复制的数据目录)")
}
// 让遗忘窗口游标指到临时文件(缺失 → 空游标 → 从最小 id 起轮转),
// 保证我们构造的 `mem_0...` 行(id 最小)落在首个 2000 窗口内。
// 不碰生产 /var/lib/memoryweave/forget-cursor.json。
cursorDir := t.TempDir()
cursorFile := cursorDir + "/test-forget-cursor.json"
t.Setenv("ZHIYI_FORGET_CURSOR", cursorFile)
// embedder 传 nil:记录自带 1024 维 Vector,走不到编码分支(同 conf_e2e_test.go 做法)。
rc := storage.NewRustLanceDBClient(sock, nil)
f := governance.NewForgetter()
aa := routes.NewAdminAPI(rc, f, nil) // GraphStore 传 nil:extractTopEntityDegree 空安全
// ── 构造 4 条测试记忆(id 都以 mem_0 开头 → 字典序最小 → 必在首窗口)──
vec := make([]float32, 1024)
for i := range vec {
vec[i] = 0.001
}
now := time.Now().UTC()
mk := func(id, category, content, freshness string, daysAgo int) models.MemoryRecord {
lr := now.Add(-time.Duration(daysAgo) * 24 * time.Hour)
return models.MemoryRecord{
ID: id, AgentID: "hermes-a06", Namespace: "prot-test",
Content: content, Category: category, Vector: vec,
Tier: "normal", Importance: 0.5, QualityScore: 0.5,
Version: 1, Freshness: freshness,
Source: "wiki:/tmp/forget-guard-e2e.md",
EmbeddingModel: "bge-m3", ScriptVersion: "forget-guard-e2e",
CreatedAt: lr, UpdatedAt: lr, LastRecalledAt: lr,
RecallVerifiedCount: 0,
}
}
// P1:规则3 freshness=verified 保护。content <30 字 + >20 天 ⇒ 本应走碎片快速道被删,
// 但 freshness=verified ⇒ 守卫拦截 ⇒ 存活。
p1 := mk("mem_0aaaa_p1", "distilled", "verified fact, kept", "verified", 30)
// P2:规则1 category=decision 保护。
p2 := mk("mem_0aaaa_p2", "decision", "short decision note", "fresh", 30)
// P3:规则4 content 关键词「牧尘要求」保护。
p3 := mk("mem_0aaaa_p3", "distilled", "牧尘要求:部署前必须先备份", "fresh", 30)
// N1:不命中任何规则(distilled / fresh / 无关键词 / 低 V / 不在名单)⇒ 应被碎片快速道软删。
n1 := mk("mem_0aaaa_n1", "distilled", "hi", "fresh", 45)
for _, m := range []models.MemoryRecord{p1, p2, p3, n1} {
if err := rc.InsertMemory(m); err != nil {
t.Fatalf("InsertMemory(%s) 失败: %v", m.ID, err)
}
}
// ── 跑真实判定路径:admin Forget 路由(= decay/auto_forget 决策 + 守卫 + SoftDelete)──
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/forget", nil)
w := httptest.NewRecorder()
aa.Forget(w, req)
if w.Code != http.StatusOK {
t.Fatalf("admin forget expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
t.Logf("admin forget 响应: %v", resp)
// ── 断言 ──
// 读路径语义(rust lancedb_ops.rs):
// scan_all → 仅 is_deleted=false(存活集,无 5000 上限)
// scan_deleted → 仅 is_deleted=true(已删集,带 delete_reason)
// 故 P1/P2/P3 应出现在 ScanAll 里;N1 应不在 ScanAll、而在 ScanDeleted 里。
all, err := rc.ScanAll()
if err != nil {
t.Fatalf("ScanAll 失败: %v", err)
}
liveSet := map[string]bool{}
for _, m := range all {
liveSet[m.ID] = true // scan_all 只回 is_deleted=false
}
for _, id := range []string{p1.ID, p2.ID, p3.ID} {
if !liveSet[id] {
t.Errorf("❌ 受保护记忆 %s 应存活(ScanAll 命中),实际不在 —— 守卫失效", id)
}
}
if liveSet[n1.ID] {
t.Errorf("❌ 非保护衰减记忆 %s 应被软删(不在 ScanAll),实际仍存活 —— 回归异常", n1.ID)
}
deleted, derr := rc.ScanDeleted()
if derr != nil {
t.Fatalf("ScanDeleted 失败: %v", derr)
}
deletedSet, n1Reason := map[string]bool{}, ""
for _, m := range deleted {
deletedSet[m.ID] = true
if m.ID == n1.ID {
n1Reason = m.DeleteReason
}
}
if !deletedSet[n1.ID] {
t.Errorf("❌ 非保护衰减记忆 %s 应出现在 ScanDeleted(is_deleted=true),实际不在", n1.ID)
}
if t.Failed() {
return
}
// 受保护 3 条存活 + 非保护 1 条被删,打印结论
t.Logf("✅ AC1 3 条命中保护规则(freshness=verified / category=decision / 关键词)全部存活于 ScanAll")
t.Logf("✅ AC2 非保护衰减碎片 %s 照常软删(在 ScanDeleted)", n1.ID)
// 取证:N1 的 delete_reason(auto_forget*/fragment 经 NormalizeDeleteReason → retention)
t.Logf("取证: %s delete_reason=%q", n1.ID, n1Reason)
}
+19 -4
View File
@@ -4,6 +4,7 @@ package routes
import (
"encoding/json"
"net/http"
"strings"
"github.com/xiaoxue/memoryweave/internal/selfoptimize"
)
@@ -32,17 +33,31 @@ func (ga *GapAPI) List(w http.ResponseWriter, r *http.Request) {
})
}
// POST /api/v1/gaps/{topic}/close
// POST /api/v1/gaps/close/{topic}(现行前缀注册 /api/v1/gaps/close/;亦兼容 {topic} 通配注册)
func (ga *GapAPI) Close(w http.ResponseWriter, r *http.Request) {
topic := r.PathValue("topic")
if topic == "" {
// 现行注册是前缀路由(模式无 {topic} 通配 ⇒ PathValue 恒为 "",端点此前恒 400):
// 回落剥前缀取 topic。若日后改注册为 /api/v1/gaps/{topic}/close,PathValue 直接命中。
const prefix = "/api/v1/gaps/close/"
if strings.HasPrefix(r.URL.Path, prefix) {
topic = r.URL.Path[len(prefix):]
}
}
if topic == "" {
respondError(w, 400, "topic required")
return
}
ga.Detector.Close(topic)
selfoptimize.Dash.RecordGapClosed()
respond(w, 200, map[string]string{"status": "closed", "topic": topic})
// t_e5997ca4:计数口径 = 「缺口个数」。+1 只发生在 GapDetector 的 open→closed 迁移点
// (Close 内部);旧的无条件 selfoptimize.Dash.RecordGapClosed() 已删除 —— 那是同一
// 缺口可被无限计数、ClosedGaps(9) 越过 TotalGaps(2) 的写侧根因 #1。
switch ga.Detector.Close(topic) {
case true:
respond(w, 200, map[string]string{"status": "closed", "topic": topic})
default:
respond(w, 200, map[string]string{"status": "already_closed", "topic": topic})
}
}
// POST /api/v1/gaps/detect — 手动触发缺口检测(miss 计数)
+112
View File
@@ -0,0 +1,112 @@
// 织忆 MemoryWeave — gap 关闭端点写侧回归(t_e5997ca4 卡 AC2)
//
// POST /close 打两回(httptest)⇒ gap 计数器只 +1;auto 等其余计数器不受影响。
package routes
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/xiaoxue/memoryweave/internal/selfoptimize"
)
// makeGapTopic 3 次 miss 命中默认阈值 3 ⇒ 生成缺口(TotalGaps +1)。
func makeGapTopic(t *testing.T, gd *selfoptimize.GapDetector, topic string) {
t.Helper()
var gap *selfoptimize.Gap
for i := 0; i < 3; i++ {
if gap = gd.RecordMiss(topic); gap != nil {
break
}
}
if gap == nil {
t.Fatalf("expected gap for %q after 3 misses", topic)
}
}
func postGapClose(t *testing.T, url string) (int, map[string]string) {
t.Helper()
resp, err := http.Post(url, "application/json", nil)
if err != nil {
t.Fatalf("POST %s: %v", url, err)
}
defer resp.Body.Close()
var body map[string]string
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("POST %s: 非 JSON 响应 %q: %v", url, b, err)
}
return resp.StatusCode, body
}
// 卡 AC2:现行注册形态(前缀路由,topic 在 /api/v1/gaps/close/ 之后)打两回 ⇒ 只 +1。
func TestGapAPI_CloseTwice_CounterOnlyOnce(t *testing.T) {
gd := selfoptimize.NewGapDetector(nil, nil)
makeGapTopic(t, gd, "close-twice-topic")
baseClosed := selfoptimize.Dash.ClosedGapsCount()
baseTotal := selfoptimize.Dash.TotalGapsCount()
baseMetrics := selfoptimize.Dash.Metrics() // auto_* 快照(应不受影响)
api := NewGapAPI(gd)
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/gaps/close/", api.Close) // 与 server.go:1205 现行注册同形
ts := httptest.NewServer(mux)
defer ts.Close()
code1, body1 := postGapClose(t, ts.URL+"/api/v1/gaps/close/close-twice-topic")
if code1 != 200 || body1["status"] != "closed" {
t.Fatalf("第 1 次 POST: got %d %v, want 200 closed", code1, body1)
}
code2, body2 := postGapClose(t, ts.URL+"/api/v1/gaps/close/close-twice-topic")
if code2 != 200 || body2["status"] != "already_closed" {
t.Fatalf("第 2 次 POST: got %d %v, want 200 already_closed(不得再动计数)", code2, body2)
}
if got := selfoptimize.Dash.ClosedGapsCount() - baseClosed; got != 1 {
t.Fatalf("2× POST close 后 ClosedGaps 增量: got %d, want 1(改前为 +3 ⇒ 虚增)", got)
}
if got := selfoptimize.Dash.TotalGapsCount(); got != baseTotal {
t.Errorf("TotalGaps 不应被 close 端点移动: got %d want %d", got, baseTotal)
}
after := selfoptimize.Dash.Metrics()
if after["gap_closure_rate"] > 1 {
t.Errorf("gap_closure_rate = %f 越界", after["gap_closure_rate"])
}
// auto(冲突)计数器与 gap 关闭事件无关 ⇒ 快照不变(「auto 不适用」)
for _, k := range []string{"auto_resolve_rate", "auto_resolve_samples", "auto_resolve_valid"} {
if after[k] != baseMetrics[k] {
t.Errorf("auto 计数器被 gap 关闭污染: %s got %f want %f", k, after[k], baseMetrics[k])
}
}
}
// {topic} 通配注册形态(若日后把 server.go 注册改成 /api/v1/gaps/{topic}/close):
// PathValue 直接命中,计数语义不变(+1 只在首次)。
func TestGapAPI_CloseWildcardRegistration(t *testing.T) {
gd := selfoptimize.NewGapDetector(nil, nil)
makeGapTopic(t, gd, "wildcard-topic")
baseClosed := selfoptimize.Dash.ClosedGapsCount()
api := NewGapAPI(gd)
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/gaps/{topic}/close", api.Close)
ts := httptest.NewServer(mux)
defer ts.Close()
code, body := postGapClose(t, ts.URL+"/api/v1/gaps/wildcard-topic/close")
if code != 200 || body["status"] != "closed" {
t.Fatalf("通配注册第 1 次 POST: got %d %v, want 200 closed", code, body)
}
if _, body2 := postGapClose(t, ts.URL+"/api/v1/gaps/wildcard-topic/close"); body2["status"] != "already_closed" {
t.Errorf("通配注册第 2 次 POST: got %v, want already_closed", body2)
}
if got := selfoptimize.Dash.ClosedGapsCount() - baseClosed; got != 1 {
t.Fatalf("通配注册 2× POST 后 ClosedGaps 增量: got %d, want 1", got)
}
}
+222
View File
@@ -0,0 +1,222 @@
// 织忆 MemoryWeave — /api/v1/memory/write 显式直写 memories 表(主症A,t_fa6c9104,2026-10-16)
//
// 锁定行为:
//
// W1 写入落 memories 表:InsertMemory 被调用,id 前缀 mem_,tier=normal,quality=1.0,
// 且向量非全 0(嵌入服务故障时不写零向量记录)
// W2 category 缺省 distilled(recall Step 2.5 rawCategories 不过滤它)
// W3 嵌入失败 ⇒ 502 且不写(零向量 = 主症A 病根,禁静默写)
// W4 精确重复 ⇒ merged(不新增行)
// W5 短内容 <8 字 ⇒ 400;GET ⇒ 405
// W6 零回归:commit 路径仍只写 episodes、不写 memories(AC5)
package routes
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"testing"
"github.com/xiaoxue/memoryweave/internal/distill"
"github.com/xiaoxue/memoryweave/internal/governance"
"github.com/xiaoxue/memoryweave/internal/models"
"github.com/xiaoxue/memoryweave/internal/storage"
)
// recLance 记录型 LanceDB mock:断言 InsertMemory/Update 的实际调用与载荷。
// 复用 zz_commit_conflict_gate_test.go 的 mockGraph / newMockEmbedServer / newMockLLM / commitWith(同包)。
type recLance struct {
storage.LanceDB // 未实现的方法落到接口兜底(panic 即测试失败,不放任)
mu sync.Mutex
seeds []models.MemoryRecord // 预置「库里已有」的记忆(Search 返回)
inserted []models.MemoryRecord // 实际 InsertMemory 调用(W1/W3/W4 断言)
updates []string // Update(table,id,...) 的 id 序列(merge 断言)
failIns bool // 模拟 IPC 落库失败(500 断言)
}
func (r *recLance) Search(table string, vector []float32, topK int, ns string) ([]models.MemoryRecord, error) {
if table != "memories" {
return nil, nil
}
r.mu.Lock()
defer r.mu.Unlock()
out := make([]models.MemoryRecord, 0, len(r.seeds))
out = append(out, r.seeds...)
if topK > 0 && len(out) > topK {
out = out[:topK]
}
return out, nil
}
func (r *recLance) InsertMemory(m models.MemoryRecord) error {
r.mu.Lock()
defer r.mu.Unlock()
if r.failIns {
return errors.New("ipc down (simulated)")
}
r.inserted = append(r.inserted, m)
r.seeds = append(r.seeds, m) // 后续 Search 能看见(近重复语义)
return nil
}
func (r *recLance) Update(table, id string, fields map[string]any) error {
r.mu.Lock()
defer r.mu.Unlock()
r.updates = append(r.updates, id)
return nil
}
func (r *recLance) InsertEpisode(agentID, namespace, content, category string) (string, error) {
r.mu.Lock()
defer r.mu.Unlock()
return "ep_rec_" + strconv.Itoa(len(r.inserted)), nil
}
func newRecAPI(t *testing.T, seeds ...models.MemoryRecord) (*API, *recLance) {
t.Helper()
emb := newMockEmbedServer()
t.Cleanup(emb.Close)
ldb := &recLance{seeds: seeds}
return NewAPI(ldb, storage.NewEmbedder(emb.URL), nil, governance.NewConflictDetector(), mockGraph{}), ldb
}
func writeMemRequest(t *testing.T, api *API, body map[string]any) (int, map[string]interface{}) {
t.Helper()
b, _ := json.Marshal(body)
req := httptest.NewRequest("POST", "/api/v1/memory/write", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
api.WriteMemory(w, req)
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
return w.Code, resp
}
// W1+W2:写入落 memories 表,mem_ 前缀,category 缺省 distilled,向量非全 0
func TestWriteMemory_InsertLandsInMemories(t *testing.T) {
api, ldb := newRecAPI(t)
code, resp := writeMemRequest(t, api, map[string]any{
"content": "牧尘的工作所在省份是河南省",
"agent_id": "hermes-a06",
})
if code != 201 {
t.Fatalf("expected 201, got %d: %s", code, resp)
}
if resp["status"] != "ok" || resp["table"] != "memories" {
t.Errorf("expected status=ok table=memories, got %v", resp)
}
if !strings.HasPrefix(resp["memory_id"].(string), "mem_") {
t.Errorf("memory_id 须 mem_ 前缀(落 memories 表),got %v", resp["memory_id"])
}
if resp["category"] != "distilled" {
t.Errorf("category 缺省须 distilled(Step 2.5 不过滤),got %v", resp["category"])
}
if len(ldb.inserted) != 1 {
t.Fatalf("expected exactly 1 InsertMemory, got %d", len(ldb.inserted))
}
m := ldb.inserted[0]
if m.Category != "distilled" || m.Tier != "normal" || m.QualityScore != 1.0 {
t.Errorf("row 字段: category=%s tier=%s quality=%v(期望 distilled/normal/1.0)", m.Category, m.Tier, m.QualityScore)
}
if m.Namespace != "hermes-main" {
t.Errorf("namespace 推导: got %s 期望 hermes-main(hermes-a06 ⇒ deriveNamespace)", m.Namespace)
}
if len(m.Vector) != 1024 {
t.Fatalf("vector 维度须 1024,got %d", len(m.Vector))
}
// W1 核心判据:vector 非全 0
nonzero := 0
for _, f := range m.Vector {
if f != 0 {
nonzero++
}
}
if nonzero == 0 {
t.Errorf("向量全 0 —— 主症A 病根复现(禁写零向量记录)")
}
}
// W3:嵌入失败 ⇒ 502 且不写库(fail-close,不静默写零向量)
func TestWriteMemory_EmbedFail_502_NoWrite(t *testing.T) {
failEmb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "embed backend down", http.StatusInternalServerError)
}))
defer failEmb.Close()
ldb := &recLance{}
api := NewAPI(ldb, storage.NewEmbedder(failEmb.URL), nil, governance.NewConflictDetector(), mockGraph{})
code, resp := writeMemRequest(t, api, map[string]any{"content": "牧尘的工作所在省份是河南省"})
if code != 502 {
t.Fatalf("embed 失败须 502,got %d: %v", code, resp)
}
if len(ldb.inserted) != 0 {
t.Errorf("嵌入失败不得写库(零向量 = 主症A),inserted=%d", len(ldb.inserted))
}
}
// W4:精确重复 ⇒ merged,不新增行
func TestWriteMemory_ExactDupMerges(t *testing.T) {
seed := models.MemoryRecord{ID: "mem_seed", Content: "家用服务器地址是 192.168.123.11", Category: "distilled"}
api, ldb := newRecAPI(t, seed)
code, resp := writeMemRequest(t, api, map[string]any{"content": seed.Content})
if code != 200 || resp["status"] != "merged" {
t.Fatalf("expected 200 merged, got %d %v", code, resp)
}
if len(ldb.inserted) != 0 {
t.Errorf("精确重复不得新增行,inserted=%d", len(ldb.inserted))
}
if len(ldb.updates) == 0 || ldb.updates[0] != "mem_seed" {
t.Errorf("merge 须 Update 既有行 mem_seed,updates=%v", ldb.updates)
}
}
// W5:短内容 400;GET 405
func TestWriteMemory_Validation(t *testing.T) {
api, _ := newRecAPI(t)
if c, _ := writeMemRequest(t, api, map[string]any{"content": "太短了"}); c != 400 {
t.Errorf("短内容(<8字)须 400, got %d", c)
}
req := httptest.NewRequest("GET", "/api/v1/memory/write", nil)
w := httptest.NewRecorder()
api.WriteMemory(w, req)
if w.Code != 405 {
t.Errorf("GET 须 405, got %d", w.Code)
}
}
// 落库失败(IPC down)⇒ 500 不吞错
func TestWriteMemory_InsertFail_500(t *testing.T) {
api, ldb := newRecAPI(t)
ldb.failIns = true
code, _ := writeMemRequest(t, api, map[string]any{"content": "测试落库失败场景内容足够长"})
if code != 500 {
t.Fatalf("InsertMemory 失败须 500(不吞错),got %d", code)
}
}
// W6 零回归:commit 路径仍「只写 episodes、不写 memories」(等蒸馏)
func TestCommit_StillEpisodesOnly_ZeroRegression(t *testing.T) {
api, ldb := newRecAPI(t)
llm := newMockLLM()
defer llm.server.Close()
oldRef := DistillEngineRef
DistillEngineRef = distill.NewEngine(llm.server.URL, "mock-model", "")
defer func() { DistillEngineRef = oldRef }()
// 复用 commitWith(同包 zz 测试):普通对话提交
resp := commitWith(t, api, "今天天气不错,我们去楼下散步", false)
if resp["status"] != "ok" {
t.Fatalf("commit 基线须 status=ok,got %v", resp)
}
if resp["memory_id"] != "" {
t.Errorf("commit 不在写入时创建 memory(AC5 零回归),got %v", resp["memory_id"])
}
if len(ldb.inserted) != 0 {
t.Errorf("commit 不得直写 memories 表(零回归),inserted=%d", len(ldb.inserted))
}
}
@@ -0,0 +1,55 @@
package routes
// t_5ad3f843 AC8:handler 级门禁验证。
// 门禁开 ⇒ meta-note 在**触碰 storage 之前**被 422 拒绝:
// 零值 API{}(LanceDB 接口为 nil)足证——若门禁按设计在写路径前生效,
// 422 先行返回、不会 nil 解引用 panic;若门禁失效走到 InsertEpisode ⇒ panic。
import (
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/xiaoxue/memoryweave/internal/metanote"
)
func TestCommitGate_MetaRejectedBeforeStorage(t *testing.T) {
old := os.Getenv("ZHIYI_META_FILTER")
os.Setenv("ZHIYI_META_FILTER", "1")
defer os.Setenv("ZHIYI_META_FILTER", old)
a := &API{} // 零值:LanceDB 为 nil —— 422 先于写路径 ⇒ 不 panic
body := `{"agent_id":"hermes-a06","namespace":"hermes-main","content":"实测 淇县 ANN 731→15(都进池50)","category":"system_fact"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/commit", strings.NewReader(body))
w := httptest.NewRecorder()
a.Commit(w, req)
if w.Code != 422 {
t.Fatalf("commit(meta, 门禁开) 期望 422,实际 %d body=%s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "meta-note") {
t.Errorf("拒绝体应说明 meta-note 原因: %s", w.Body.String())
}
// 门禁关 ⇒ 同一内容 ShouldSkip 必须 false(AC6 零行为变化)
os.Setenv("ZHIYI_META_FILTER", "")
if skip, _ := metanote.ShouldSkip("实测 淇县 ANN 731→15(都进池50)"); skip {
t.Fatal("门禁关闭时 ShouldSkip 必须为 false(零行为变化)")
}
}
func TestWriteMemoryGate_MetaRejectedBeforeStorage(t *testing.T) {
old := os.Getenv("ZHIYI_META_FILTER")
os.Setenv("ZHIYI_META_FILTER", "1")
defer os.Setenv("ZHIYI_META_FILTER", old)
a := &API{}
body := `{"agent_id":"hermes-a06","content":"织忆 golden 14/14 不退化,recall@5 = 0.4286"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/memory/write", strings.NewReader(body))
w := httptest.NewRecorder()
a.WriteMemory(w, req)
if w.Code != 422 {
t.Fatalf("write_memory(meta, 门禁开) 期望 422,实际 %d body=%s", w.Code, w.Body.String())
}
}
+211
View File
@@ -0,0 +1,211 @@
// 织忆 MemoryWeave — L2 模式 API(t_d32e5f8a)
//
// 设计 §4.1 端点:
// POST /api/v1/patterns/detect 手动触发模式检测(扫 L1 蒸馏记忆 → 工具组合频率 + 路径聚类
// → 写 graph_nodes type='pattern')。短命单发:modernc sqlite
// 开/关不占长连接、不碰 state.db(规避当年 daemon main_loop
// 退役根因)—— 可挂 cron 单发。
// GET /api/v1/patterns 列出检测到的模式(按 source_tasks 长度降序)。
package routes
import (
"database/sql"
"encoding/json"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"github.com/xiaoxue/memoryweave/internal/models"
"github.com/xiaoxue/memoryweave/internal/pattern"
"github.com/xiaoxue/memoryweave/internal/skillgen"
"github.com/xiaoxue/memoryweave/internal/storage"
)
// PatternAPI L2 模式端点 + L3 skill 草稿端点。
type PatternAPI struct {
Store *pattern.GraphPatternStore
Provider *pattern.PatternProvider
LDB func() storage.LanceDB // 扫 L1 记忆(distilled)
Cfg pattern.Config
// L3 skill 草稿(t_fcadee04):草稿生成器 + skills 表存储 + 落盘根目录。
// 由 server.go 装配;不装配时 Draft/Promote 返回 503/404(显式 API,不挂 cron)。
SkillStore *skillgen.Store
Generator skillgen.Generator
AttachmentsDir string
}
// NewPatternAPI 构造 L2 模式端点。
func NewPatternAPI(store *pattern.GraphPatternStore, provider *pattern.PatternProvider, ldb func() storage.LanceDB) *PatternAPI {
cfg := pattern.DefaultConfig()
// cron 单发跑大批量时可放宽扫描上限
if v := os.Getenv("ZHIYI_PATTERN_MAX_RECORDS"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
cfg.MaxRecords = n
}
}
return &PatternAPI{Store: store, Provider: provider, LDB: ldb, Cfg: cfg}
}
// GET /api/v1/patterns — 列出所有检测到的模式(AC1:空库返回 count=0 不报错)。
func (pa *PatternAPI) List(w http.ResponseWriter, r *http.Request) {
limit := pa.Cfg.TopKPatternList
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
limit = n
}
}
pats, _, err := pa.Store.ListPatterns(limit)
if err != nil {
respondError(w, 500, "list patterns: "+err.Error())
return
}
respond(w, 200, map[string]interface{}{
"patterns": pats,
"count": len(pats),
})
}
// POST /api/v1/patterns/detect — 手动触发模式检测。
// body 可选 {"limit": N, "namespace": "hermes-main"}。
// 流程:LanceDB Scan(limit, ns) → pattern.DetectFromRecords → UpsertPatterns(graph_nodes)。
// 空库/无命中 → created/updated=0,不报错(AC1)。
func (pa *PatternAPI) Detect(w http.ResponseWriter, r *http.Request) {
var req struct {
Limit int `json:"limit"`
Namespace string `json:"namespace"`
}
if r.Body != nil {
_ = json.NewDecoder(r.Body).Decode(&req) // 空 body 也合法(用默认)
}
if req.Limit <= 0 {
req.Limit = pa.Cfg.MaxRecords
}
ldb := pa.LDB()
if ldb == nil {
respondError(w, 503, "lancedb unavailable")
return
}
records, err := ldb.Scan(req.Limit, req.Namespace)
if err != nil {
respondError(w, 500, "scan memories: "+err.Error())
return
}
// 只拿蒸馏产物(L1 蒸馏记忆)做检测——原始对话/episode 不是「已验证」的解决路径
// (与召回管线 2.5 步同源判据:raw/episodes 类不参与模式沉淀)。
distilled := make([]models.MemoryRecord, 0, len(records))
for _, rec := range records {
if rec.Category == "distilled" || rec.Category == "" {
distilled = append(distilled, rec)
}
}
pats := pattern.DetectFromRecords(distilled, pa.Cfg)
created, updated, uerr := pa.Store.UpsertPatterns(pats)
if uerr != nil {
respondError(w, 500, "upsert patterns: "+uerr.Error())
return
}
respond(w, 200, map[string]interface{}{
"scanned": len(records),
"created": created,
"updated": updated,
"detected": len(pats),
"patterns": pats,
})
}
// ── L3 skill 草稿(t_fcadee04,设计 §4.2 SkillGenerator)────────────────────
// patternSubPath 解析 /api/v1/patterns/{id}/{action} 子路径。
// 返回 (url-unescape 后的 id, action)。格式不符 → ("", action=="")。
func patternSubPath(path string) (id, action string) {
const prefix = "/api/v1/patterns/"
if !strings.HasPrefix(path, prefix) {
return "", ""
}
rest := strings.TrimPrefix(path, prefix) // <id>/draft 或 <id>/promote
parts := strings.Split(rest, "/")
if len(parts) != 2 || parts[0] == "" {
return "", ""
}
dec, err := url.PathUnescape(parts[0])
if err != nil {
return "", ""
}
return dec, parts[1]
}
// Sub 分发 /api/v1/patterns/{id}/draft 与 /api/v1/patterns/{id}/promote。
// 装配在 server.go 的 /api/v1/patterns/ 前缀路由;List(/patterns) 与 Detect(/patterns/detect)
// 因更长的精确路由优先,不受影响。
func (pa *PatternAPI) Sub(w http.ResponseWriter, r *http.Request) {
id, action := patternSubPath(r.URL.Path)
if id == "" {
respondError(w, 404, "pattern id required")
return
}
switch action {
case "draft":
pa.Draft(w, r, id)
case "promote":
pa.Promote(w, r, id)
default:
respondError(w, 404, "unknown action: "+action)
}
}
// POST /api/v1/patterns/{id}/draft — 生成 L3 skill 草稿(conf≥0.85 才生成)。
// 流程:读 pattern → 校验 conf → DraftSkill(写 skills 表 draft + 落盘 attachments)。
// 草稿绝不进 skills/ 技能库(红线)。
func (pa *PatternAPI) Draft(w http.ResponseWriter, r *http.Request, id string) {
if pa.SkillStore == nil || pa.Generator == nil {
respondError(w, 503, "skillgen 未装配(SkillStore/Generator 为空)")
return
}
pat, err := pa.Store.GetPattern(id)
if err != nil {
if err == sql.ErrNoRows {
respondError(w, 404, "pattern not found: "+id)
} else {
respondError(w, 500, "get pattern: "+err.Error())
}
return
}
attRoot := pa.AttachmentsDir
if attRoot == "" {
attRoot = skillgen.DefaultAttachmentsRoot()
}
sk, path, derr := skillgen.DraftSkill(pa.SkillStore, pa.Generator, pat, attRoot)
if derr != nil {
respondError(w, 400, "draft skill: "+derr.Error())
return
}
respond(w, 200, map[string]interface{}{
"skill": sk,
"file": path,
})
}
// POST /api/v1/patterns/{id}/promote — 人工确认后 draft→active。
func (pa *PatternAPI) Promote(w http.ResponseWriter, r *http.Request, id string) {
if pa.SkillStore == nil {
respondError(w, 503, "skillgen 未装配(SkillStore 为空)")
return
}
if err := pa.SkillStore.Promote(id); err != nil {
if err == sql.ErrNoRows {
respondError(w, 404, "no skill draft for pattern: "+id)
} else {
respondError(w, 500, "promote skill: "+err.Error())
}
return
}
sk, gerr := pa.SkillStore.Get(id)
if gerr != nil {
respondError(w, 500, "read skill after promote: "+gerr.Error())
return
}
respond(w, 200, map[string]interface{}{"skill": sk})
}
@@ -0,0 +1,145 @@
// 织忆 MemoryWeave — L3 skill 草稿 API 路由测试(t_fcadee04)
//
// 覆盖端点:POST /api/v1/patterns/{id}/draft + POST /api/v1/patterns/{id}/promote
// 共享同一临时 graph.db(pattern 在 graph_nodes、skill 在 skills 表同库),零网络/零 CGO。
package routes
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/xiaoxue/memoryweave/internal/pattern"
"github.com/xiaoxue/memoryweave/internal/skillgen"
"github.com/xiaoxue/memoryweave/internal/storage"
)
// newSkillgenTestAPI 构造共享同一 temp graph.db 的 PatternAPI(含 L3 装配)。
func newSkillgenTestAPI(t *testing.T) (*PatternAPI, string) {
t.Helper()
dbDir := t.TempDir()
graphPath := filepath.Join(dbDir, "graph.db")
pstore := pattern.NewGraphPatternStore(graphPath)
t.Cleanup(pstore.Close)
sstore := skillgen.NewStore(graphPath)
t.Cleanup(sstore.Close)
attRoot := filepath.Join(t.TempDir(), "attachments")
api := NewPatternAPI(pstore, nil, func() storage.LanceDB { return nil })
api.SkillStore = sstore
api.Generator = &skillgen.FakeGenerator{Out: "## 适用场景\n重复处理"}
api.AttachmentsDir = attRoot
// 种一个 conf=0.9 的 pattern 进 graph_nodes
hi := pattern.Pattern{
ID: "pat_tool_combo:curl+git+jq", Type: "tool_combo",
Name: "tool_combo:curl+git+jq", Data: "curl+git+jq",
Confidence: 0.9, Tools: []string{"curl", "git", "jq"},
SourceTasks: []string{"m_a", "m_b", "m_c"},
}
if _, _, err := pstore.UpsertPatterns([]pattern.Pattern{hi}); err != nil {
t.Fatalf("seed pattern: %v", err)
}
return api, attRoot
}
// TestSkillgen_DraftRoute —— AC1 API 层:draft 端点 → 200 + skills 表 1 行 draft + 附件文件。
func TestSkillgen_DraftRoute(t *testing.T) {
api, attRoot := newSkillgenTestAPI(t)
id := "pat_tool_combo:curl+git+jq"
rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/api/v1/patterns/"+id+"/draft", nil)
api.Sub(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("draft 期望 200,实际 %d body=%s", rec.Code, rec.Body.String())
}
// skills 表 1 行 draft
if n, _ := api.SkillStore.Count("draft"); n != 1 {
t.Fatalf("AC1 判据:draft 行数应 1,实际 %d", n)
}
sk, err := api.SkillStore.Get(id)
if err != nil || sk.Status != "draft" {
t.Fatalf("skill 应存在且 draft,实际 %v %+v", err, sk)
}
// 附件文件存在 + 含 [AUTO-DRAFT]
path := filepath.Join(attRoot, skillgen.DraftSubDir("tool_combo:curl+git+jq"), "skill-draft.md")
data, rerr := os.ReadFile(path)
if rerr != nil {
t.Fatalf("附件文件不存在 %s: %v", path, rerr)
}
if !strings.Contains(string(data), skillgen.AUTO_DRAFT_MARKER) {
t.Fatalf("附件应含 [AUTO-DRAFT] 标记")
}
t.Logf("route draft 落盘: %s", path)
}
// TestSkillgen_PromoteRoute —— AC3 API 层:promote 端点 → 200 + status active。
func TestSkillgen_PromoteRoute(t *testing.T) {
api, _ := newSkillgenTestAPI(t)
id := "pat_tool_combo:curl+git+jq"
// 先 draft
dr := httptest.NewRecorder()
api.Sub(dr, httptest.NewRequest("POST", "/api/v1/patterns/"+id+"/draft", nil))
if dr.Code != http.StatusOK {
t.Fatalf("前置 draft 应 200,实际 %d", dr.Code)
}
// promote
pr := httptest.NewRecorder()
api.Sub(pr, httptest.NewRequest("POST", "/api/v1/patterns/"+id+"/promote", nil))
if pr.Code != http.StatusOK {
t.Fatalf("promote 期望 200,实际 %d body=%s", pr.Code, pr.Body.String())
}
var resp struct {
Skill struct {
Status string `json:"status"`
} `json:"skill"`
}
if err := json.Unmarshal(pr.Body.Bytes(), &resp); err != nil {
t.Fatalf("promote 解析: %v", err)
}
if resp.Skill.Status != "active" {
t.Fatalf("AC3 判据:promote 后 status 应 active,实际 %s", resp.Skill.Status)
}
// 库复核(AC3 判据:sqlite 查)
if n, _ := api.SkillStore.Count("active"); n != 1 {
t.Fatalf("AC3 判据:active 行数应 1,实际 %d", n)
}
}
// TestSkillgen_DraftRoute_NotFound —— 不存在的 pattern → 404。
func TestSkillgen_DraftRoute_NotFound(t *testing.T) {
api, _ := newSkillgenTestAPI(t)
rec := httptest.NewRecorder()
api.Sub(rec, httptest.NewRequest("POST", "/api/v1/patterns/nope/draft", nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("不存在 pattern 的 draft 应 404,实际 %d", rec.Code)
}
}
// TestSkillgen_PromoteRoute_NoDraft —— 无草稿即 promote → 404。
func TestSkillgen_PromoteRoute_NoDraft(t *testing.T) {
api, _ := newSkillgenTestAPI(t)
rec := httptest.NewRecorder()
api.Sub(rec, httptest.NewRequest("POST", "/api/v1/patterns/pat_tool_combo:curl+git+jq/promote", nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("无草稿 promote 应 404,实际 %d body=%s", rec.Code, rec.Body.String())
}
}
// TestSkillgen_SubPath —— 子路径解析单元(含 URL 编码 id)。
func TestSkillgen_SubPath(t *testing.T) {
id, action := patternSubPath("/api/v1/patterns/pat_tool_combo:curl+git+jq/promote")
if id != "pat_tool_combo:curl+git+jq" || action != "promote" {
t.Fatalf("解析失败 id=%q action=%q", id, action)
}
if _, a := patternSubPath("/api/v1/patterns"); a != "" {
t.Fatalf("无子路径应 action 空")
}
if _, a := patternSubPath("/api/v1/patterns/detect"); a != "" {
t.Fatalf("/patterns/detect 是精确路由,不应走 Sub")
}
}
+132
View File
@@ -0,0 +1,132 @@
package routes
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/xiaoxue/memoryweave/internal/models"
"github.com/xiaoxue/memoryweave/internal/pattern"
"github.com/xiaoxue/memoryweave/internal/storage"
)
// 织忆 — L2 模式 API 路由测试(t_d32e5f8a)
// AC1: 空库 detect/list → 不报错(count=0)
// AC2: 3 条同工具组合记忆 → detect 后 list 能查到 tool_combo pattern
// newPatternTestAPI 构造一个用临时 graph.db + 假 LanceDB 后端的 PatternAPI(无网络、无 CGO)。
// 假后端只实现 PatternAPI 实际调用的 Scan(其余方法嵌 nil 接口,测试路径不会触达)。
func newPatternTestAPI(t *testing.T, mems []models.MemoryRecord) *PatternAPI {
t.Helper()
store := pattern.NewGraphPatternStore(t.TempDir() + "/graph.db")
t.Cleanup(store.Close)
mlc := &patternScanLance{rows: mems}
pemb := pattern.NewEmbedderProvider(&fakePatternEmbedder{})
return NewPatternAPI(store, pattern.NewPatternProvider(store, pemb), func() storage.LanceDB { return mlc })
}
// patternScanLance 覆写 Scan(PatternAPI.Detect 唯一用到的 LanceDB 方法);
// 其余方法嵌 nil 接口 —— 本测试路径不会调用,触达即 panic(暴露误用)。
type patternScanLance struct {
storage.LanceDB
rows []models.MemoryRecord
}
func (f *patternScanLance) Scan(limit int, namespaceFilter string) ([]models.MemoryRecord, error) {
if limit > 0 && len(f.rows) > limit {
return f.rows[:limit], nil
}
return f.rows, nil
}
// fakePatternEmbedder 确定性假向量(pattern.Provider 匹配测试用;注入测试走 storage 侧不走这里)。
type fakePatternEmbedder struct{}
func (fakePatternEmbedder) EncodeSingle(text string) ([]float32, error) {
v := make([]float32, 8)
for i := 0; i < 8; i++ {
v[i] = 0.1
}
return v, nil
}
// TestPatterns_ListEmpty —— AC1:空库 list → 200 + count=0(不报错)。
func TestPatterns_ListEmpty(t *testing.T) {
api := newPatternTestAPI(t, nil)
rec := httptest.NewRecorder()
api.List(rec, httptest.NewRequest("GET", "/api/v1/patterns", nil))
if rec.Code != http.StatusOK {
t.Fatalf("空库 list 期望 200,实际 %d body=%s", rec.Code, rec.Body.String())
}
var resp map[string]interface{}
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
if int(resp["count"].(float64)) != 0 {
t.Fatalf("空库 count 期望 0,实际 %v", resp["count"])
}
}
// TestPatterns_DetectEmpty —— AC1:空库 detect → 200 + scanned=0/created=0(不报错)。
func TestPatterns_DetectEmpty(t *testing.T) {
api := newPatternTestAPI(t, nil)
rec := httptest.NewRecorder()
api.Detect(rec, httptest.NewRequest("POST", "/api/v1/patterns/detect", nil))
if rec.Code != http.StatusOK {
t.Fatalf("空库 detect 期望 200,实际 %d body=%s", rec.Code, rec.Body.String())
}
var resp map[string]interface{}
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
if int(resp["scanned"].(float64)) != 0 || int(resp["created"].(float64)) != 0 {
t.Fatalf("空库 detect 期望 0/0,实际 %v/%v", resp["scanned"], resp["created"])
}
}
// TestPatterns_DetectToolCombo —— AC2:3 条同工具组合蒸馏记忆 → detect 产出 1 个 tool_combo,
// 且 list 能查到(sqlite graph_nodes 判据)。
func TestPatterns_DetectToolCombo(t *testing.T) {
mems := make([]models.MemoryRecord, 0, 3)
for i, id := range []string{"pc_a", "pc_b", "pc_c"} {
mems = append(mems, models.MemoryRecord{
ID: id,
Content: "用 jq git curl 处理第" + string(rune('一'+i)) + "项",
Category: "distilled",
})
}
api := newPatternTestAPI(t, mems)
rec := httptest.NewRecorder()
api.Detect(rec, httptest.NewRequest("POST", "/api/v1/patterns/detect", nil))
if rec.Code != http.StatusOK {
t.Fatalf("detect 期望 200,实际 %d body=%s", rec.Code, rec.Body.String())
}
var dresp map[string]interface{}
_ = json.Unmarshal(rec.Body.Bytes(), &dresp)
if int(dresp["detected"].(float64)) < 1 {
t.Fatalf("3 条同组合应检出 ≥1 个 pattern,实际 detected=%v", dresp["detected"])
}
// 用 list 再核一遍 sqlite 判据:确有 tool_combo 行
lrec := httptest.NewRecorder()
api.List(lrec, httptest.NewRequest("GET", "/api/v1/patterns", nil))
var lresp struct {
Count int `json:"count"`
Patterns []struct {
Type string `json:"type"`
Name string `json:"name"`
} `json:"patterns"`
}
if err := json.Unmarshal(lrec.Body.Bytes(), &lresp); err != nil {
t.Fatalf("list 解析: %v (body=%s)", err, lrec.Body.String())
}
if lresp.Count < 1 {
t.Fatalf("AC2 判据:detect 后 list count 应 ≥1,实际 %d (detected=%v)", lresp.Count, dresp["detected"])
}
var foundToolCombo bool
for _, p := range lresp.Patterns {
if p.Type == "tool_combo" {
foundToolCombo = true
}
}
if !foundToolCombo {
t.Fatalf("AC2 判据:list 应含 tool_combo pattern,实际 %d 条均非 tool_combo", lresp.Count)
}
}
+335
View File
@@ -0,0 +1,335 @@
// 织忆 MemoryWeave — P3 Peer Representation API(t_f8fc97e6,设计 §6.1)
//
// 端点:
//
// POST /api/v1/peer/{id}/refresh 手动刷新某实体的四层推理画像(显式触发)
// GET /api/v1/peer/{id}/explain 解释为何这样理解该实体(可审计:画像 + 来源记忆)
// POST /api/v1/recall/peer?peer_id=… 召回某 peer 的推理画像(env 门控 ZHIYI_PEER_RECALL)
//
// 设计约束(与 L2 pattern / L3 skillgen 同源):
//
// · refresh/explain 是显式 API,不挂 cron ⇒ 默认零行为变化。
// · 召回注入走独立端点 + env 门控 ZHIYI_PEER_RECALL,默认 0 ⇒ 主召回管线逐条不变(AC2)。
// · peers 表在 graph.db(短命 modernc),不碰 state.db。
package routes
import (
"database/sql"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"github.com/xiaoxue/memoryweave/internal/governance"
"github.com/xiaoxue/memoryweave/internal/models"
"github.com/xiaoxue/memoryweave/internal/peer"
"github.com/xiaoxue/memoryweave/internal/storage"
)
// PeerAPI P3 peer 表示端点。
type PeerAPI struct {
Store *peer.Store
Reasoner peer.Reasoner
LDB func() storage.LanceDB // 扫 L1 记忆(explicit 层来源)
GraphStore governance.GraphStore // 图谱上下文(推理输入)
}
// NewPeerAPI 构造。
func NewPeerAPI(store *peer.Store, reasoner peer.Reasoner, ldb func() storage.LanceDB, g governance.GraphStore) *PeerAPI {
return &PeerAPI{Store: store, Reasoner: reasoner, LDB: ldb, GraphStore: g}
}
// peerSubPath 解析 /api/v1/peer/{id}/{action} 子路径。
func peerSubPath(path string) (id, action string) {
const prefix = "/api/v1/peer/"
if !strings.HasPrefix(path, prefix) {
return "", ""
}
rest := strings.TrimPrefix(path, prefix) // <id>/refresh 或 <id>/explain
parts := strings.Split(rest, "/")
if len(parts) != 2 || parts[0] == "" {
return "", ""
}
dec, err := url.PathUnescape(parts[0])
if err != nil {
return "", ""
}
return dec, parts[1]
}
// Sub 分发 /api/v1/peer/{id}/refresh 与 /api/v1/peer/{id}/explain。
func (pa *PeerAPI) Sub(w http.ResponseWriter, r *http.Request) {
id, action := peerSubPath(r.URL.Path)
if id == "" {
respondError(w, 404, "peer id required")
return
}
switch action {
case "refresh":
if r.Method != http.MethodPost {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
pa.Refresh(w, r, id)
case "explain":
if r.Method != http.MethodGet {
http.Error(w, "GET only", http.StatusMethodNotAllowed)
return
}
pa.Explain(w, r, id)
default:
respondError(w, 404, "unknown action: "+action)
}
}
// RefreshAsync 异步刷新某实体的四层画像(推理不阻塞主流程,AC4)。
// 返回一个 done channel,调用方可不等待(非阻塞);推理在后台 goroutine 完成。
// 任何错误只记日志,不影响调用方。
func (pa *PeerAPI) RefreshAsync(id, namespace string) chan error {
done := make(chan error, 1)
if pa == nil || pa.Store == nil || pa.Reasoner == nil {
done <- fmt.Errorf("peer 未装配")
return done
}
go func() {
done <- pa.refresh(id, namespace, 2000)
}()
return done
}
// RefreshFromContent 提交新记忆后异步刷新内容里出现的已知实体(设计「新记忆进入 → 不阻塞主流程
// → 更新该 peer 表示」)。匹配:content 含某 peer_id 子串即触发。全部 goroutine 内执行,立即返回。
// 由 server.go 在 commit 处理器里以 ZHIYI_PEER_RECALL=1 门控调用;默认关 ⇒ 不触发。
func (pa *PeerAPI) RefreshFromContent(content, namespace string) {
if pa == nil || pa.Store == nil || content == "" {
return
}
peers, err := pa.Store.List(200)
if err != nil || len(peers) == 0 {
return
}
for _, p := range peers {
if p.PeerID != "" && strings.Contains(content, p.PeerID) {
go pa.refresh(p.PeerID, namespace, 2000)
}
}
}
// refresh 实际刷新编排(同步执行体;由 RefreshAsync / RefreshFromContent 包 goroutine)。
func (pa *PeerAPI) refresh(id, namespace string, limit int) error {
explicit, _, scanErr := pa.collectExplicit(id, namespace, limit)
if scanErr != nil {
return scanErr
}
graphCtx := pa.collectGraphContext(id)
if _, err := peer.BuildPeer(pa.Store, pa.Reasoner, id, explicit.facts, explicit.prefs, explicit.skills, graphCtx); err != nil {
return err
}
return nil
}
// Refresh POST /api/v1/peer/{id}/refresh — 扫 L1 记忆 + 图谱上下文 → 四层推理 → 写 peers 表。
// body 可选 {"namespace": "...", "limit": N}(namespace 空 = 全库)。
func (pa *PeerAPI) Refresh(w http.ResponseWriter, r *http.Request, id string) {
if pa.Store == nil || pa.Reasoner == nil {
respondError(w, 503, "peer 未装配(Store/Reasoner 为空)")
return
}
var req struct {
Namespace string `json:"namespace"`
Limit int `json:"limit"`
}
if r.Body != nil {
_ = json.NewDecoder(r.Body).Decode(&req)
}
if req.Limit <= 0 {
req.Limit = 2000
}
// 1. 扫 L1 记忆(LanceDB),提取 explicit 层
explicit, matched, scanErr := pa.collectExplicit(id, req.Namespace, req.Limit)
if scanErr != nil {
respondError(w, 500, "scan memories: "+scanErr.Error())
return
}
// 2. 图谱上下文(实体 1 跳邻居名)
graphCtx := pa.collectGraphContext(id)
// 3. 四层推理 + 写库
p, perr := peer.BuildPeer(pa.Store, pa.Reasoner, id, explicit.facts, explicit.prefs, explicit.skills, graphCtx)
if perr != nil {
respondError(w, 400, "build peer: "+perr.Error())
return
}
respond(w, 200, map[string]interface{}{
"peer": p,
"matched": matched,
"scanned": req.Limit,
"graph_ctx": graphCtx,
"has_reason": len(p.InferredTraits) > 0 || len(p.InferredGoals) > 0 || p.HypothesizedContext != "",
})
}
// Explain GET /api/v1/peer/{id}/explain — 可审计解释:画像 + 来源记忆。
func (pa *PeerAPI) Explain(w http.ResponseWriter, r *http.Request, id string) {
if pa.Store == nil {
respondError(w, 503, "peer 未装配(Store 为空)")
return
}
p, err := pa.Store.Get(id)
if err != nil {
if err == sql.ErrNoRows {
respondError(w, 404, "peer not found: "+id+"(先 POST /peer/{id}/refresh 生成画像)")
} else {
respondError(w, 500, "get peer: "+err.Error())
}
return
}
// 来源记忆:扫 L1 里含该实体的记忆(仅读,不改库)
src := []string{}
if pa.LDB != nil {
if ldb := pa.LDB(); ldb != nil {
if recs, serr := ldb.Scan(2000, ""); serr == nil {
for _, m := range recs {
if strings.Contains(m.Content, id) {
src = append(src, m.Content)
}
}
}
}
}
respond(w, 200, map[string]interface{}{"peer": p, "source_memories": src})
}
// RecallPeer POST /api/v1/recall/peer?peer_id=… — 召回某 peer 的四层推理画像。
// env 门控 ZHIYI_PEER_RECALL:默认 0 → 404(零变化);=1 → 返回画像 JSON(AC3 判据)。
func (pa *PeerAPI) RecallPeer(w http.ResponseWriter, r *http.Request) {
if !peerRecallEnabled() {
respondError(w, 404, "peer recall 默认关(ZHIYI_PEER_RECALL 未设/≠1)")
return
}
pid := r.URL.Query().Get("peer_id")
if pid == "" {
respondError(w, 400, "peer_id required")
return
}
if pa.Store == nil {
respondError(w, 503, "peer 未装配(Store 为空)")
return
}
p, err := pa.Store.Get(pid)
if err != nil {
if err == sql.ErrNoRows {
respondError(w, 404, "peer not found: "+pid)
} else {
respondError(w, 500, "get peer: "+err.Error())
}
return
}
respond(w, 200, map[string]interface{}{
"peer_id": p.PeerID,
"portrait": map[string]interface{}{
"explicit": map[string]interface{}{"facts": p.Facts, "preferences": p.Preferences, "skills": p.Skills},
"deductive": map[string]interface{}{"traits": p.InferredTraits, "goals": p.InferredGoals},
"inductive": map[string]interface{}{"patterns": p.Patterns},
"abductive": map[string]interface{}{"hypothesized_context": p.HypothesizedContext},
},
"updated_at": p.UpdatedAt,
})
}
// explicitResult explicit 层收集结果。
type explicitResult struct {
facts, prefs, skills []string
}
// collectExplicit 扫 L1 记忆并提取 explicit 层。namespace 空 = 全库。
func (pa *PeerAPI) collectExplicit(id, namespace string, limit int) (explicitResult, int, error) {
var res explicitResult
if pa.LDB == nil {
return res, 0, nil
}
ldb := pa.LDB()
if ldb == nil {
return res, 0, nil
}
recs, err := ldb.Scan(limit, namespace)
if err != nil {
return res, 0, err
}
facts, prefs, skills, matched := peer.CollectFacts(recs, id)
return explicitResult{facts: facts, prefs: prefs, skills: skills}, matched, nil
}
// collectGraphContext 取实体图谱 1 跳邻居名作为推理上下文(读图,只读不改)。
func (pa *PeerAPI) collectGraphContext(id string) []string {
if pa.GraphStore == nil {
return nil
}
paths, err := pa.GraphStore.Navigate(id, 1, "", nil)
if err != nil || len(paths) == 0 {
return nil
}
seen := map[string]bool{}
var out []string
for _, p := range paths {
for _, k := range []string{"to", "target"} {
if v, ok := p[k].(string); ok && v != "" && !seen[v] && v != id {
seen[v] = true
out = append(out, v)
}
}
}
if len(out) > 10 {
out = out[:10]
}
return out
}
// peerRecallEnabled 每次调用读 env(默认关,零行为变化)。
func peerRecallEnabled() bool {
switch strings.ToLower(os.Getenv("ZHIYI_PEER_RECALL")) {
case "1", "true", "on":
return true
}
return false
}
// GetRecallResults 供 storage.PeerRecallProvider 装配(server.go 在 ZHIYI_PEER_RECALL=1
// 时注册)。query 命中某实体 → 返回该 peer 的四层画像(Category="peer")。
// 命中判定:query 里含 peer_id 子串(大小写不敏感);取最多 topK 个。无命中 → 空。
func (pa *PeerAPI) GetRecallResults(query, namespace string, topK int) []models.RecallResult {
if pa == nil || pa.Store == nil || topK <= 0 {
return nil
}
peers, err := pa.Store.List(200)
if err != nil || len(peers) == 0 {
return nil
}
q := strings.ToLower(strings.TrimSpace(query))
var out []models.RecallResult
for _, p := range peers {
if p.PeerID != "" && q != "" && strings.Contains(q, strings.ToLower(p.PeerID)) {
content := "peer 画像: " + p.PeerID +
" | 事实: " + strings.Join(p.Facts, "; ") +
" | 偏好: " + strings.Join(p.Preferences, "; ") +
" | 技能: " + strings.Join(p.Skills, "; ") +
" | 演绎特质: " + strings.Join(p.InferredTraits, "; ") +
" | 演绎目标: " + strings.Join(p.InferredGoals, "; ") +
" | 归纳模式: " + strings.Join(p.Patterns, "; ") +
" | 最佳解释: " + p.HypothesizedContext
out = append(out, models.RecallResult{
ID: "peer:" + p.PeerID,
Content: content,
Category: "peer",
Score: 1.0,
Namespace: namespace,
Source: "peer_representation",
})
if len(out) >= topK {
break
}
}
}
return out
}
+255
View File
@@ -0,0 +1,255 @@
// 织忆 MemoryWeave — P3 Peer Representation API 路由测试(t_f8fc97e6)
//
// 覆盖端点:POST /api/v1/peer/{id}/refresh + GET /api/v1/peer/{id}/explain
// - POST /api/v1/recall/peer(env 门控)+ storage.PeerRecallProvider 注入。
//
// 共享同一临时 graph.db(peers 表),零网络 / CGO / 真模型。
package routes
import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"github.com/xiaoxue/memoryweave/internal/models"
"github.com/xiaoxue/memoryweave/internal/peer"
"github.com/xiaoxue/memoryweave/internal/storage"
)
// peerScanLance 覆写 Scan(PeerAPI.Refresh/Explain 唯一用到的 LanceDB 方法);
// 其余方法嵌 nil 接口 —— 本测试路径不会调用,触达即 panic(暴露误用)。
type peerScanLance struct {
storage.LanceDB
rows []models.MemoryRecord
}
func (f *peerScanLance) Scan(limit int, namespaceFilter string) ([]models.MemoryRecord, error) {
if limit > 0 && len(f.rows) > limit {
return f.rows[:limit], nil
}
return f.rows, nil
}
// newPeerTestAPI 构造共享同一 temp graph.db 的 PeerAPI。
// GraphStore 传 nil(collectGraphContext 处理 nil ⇒ 无图上下文,不炸)。
func newPeerTestAPI(t *testing.T, mems []models.MemoryRecord) *PeerAPI {
t.Helper()
dir := t.TempDir()
store := peer.NewStore(filepath.Join(dir, "graph.db"))
t.Cleanup(store.Close)
mlc := &peerScanLance{rows: mems}
return NewPeerAPI(store, &peer.FakeReasoner{}, func() storage.LanceDB { return mlc }, nil)
}
// fiveMems 造 5 条关于「牧尘」的 L1 记忆(AC1 输入构造)。
func fiveMems() []models.MemoryRecord {
return []models.MemoryRecord{
{Content: "牧尘选择 Docker 部署"},
{Content: "牧尘偏好 SysV 风格"},
{Content: "牧尘擅长 Python"},
{Content: "牧尘厌恶 systemd"},
{Content: "牧尘使用 jq 处理配置"},
}
}
// TestPeer_RefreshRoute —— AC1 API 层:5 条记忆 → refresh → peers 表 1 行四层非空。
func TestPeer_RefreshRoute(t *testing.T) {
api := newPeerTestAPI(t, fiveMems())
rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/api/v1/peer/%E7%89%A7%E5%B0%98/refresh", nil) // 牧尘
api.Sub(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("refresh 期望 200,实际 %d body=%s", rec.Code, rec.Body.String())
}
var resp struct {
Peer peer.Peer `json:"peer"`
Matched int `json:"matched"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("refresh 解析: %v", err)
}
if resp.Matched != 5 {
t.Errorf("AC1:matched=%d 期望 5", resp.Matched)
}
if resp.Peer.PeerID != "牧尘" {
t.Errorf("peer_id=%q 期望 牧尘", resp.Peer.PeerID)
}
// 四层非空(AC1 判据)
if len(resp.Peer.InferredTraits) == 0 {
t.Error("inferred_traits 空")
}
if len(resp.Peer.InferredGoals) == 0 {
t.Error("inferred_goals 空")
}
if len(resp.Peer.Patterns) == 0 {
t.Error("patterns 空")
}
if resp.Peer.HypothesizedContext == "" {
t.Error("hypothesized_context 空")
}
// peers 表行数(AC1:sqlite 查)
if n, _ := api.Store.Count(); n != 1 {
t.Errorf("AC1:peers 行数应 1,实际 %d", n)
}
}
// TestPeer_ExplainRoute —— GET explain → 200 + 画像 + 来源记忆。
func TestPeer_ExplainRoute(t *testing.T) {
api := newPeerTestAPI(t, fiveMems())
// 先 refresh 种画像
rec := httptest.NewRecorder()
api.Sub(rec, httptest.NewRequest("POST", "/api/v1/peer/%E7%89%A7%E5%B0%98/refresh", nil))
if rec.Code != http.StatusOK {
t.Fatalf("前置 refresh 应 200,实际 %d", rec.Code)
}
// explain
er := httptest.NewRecorder()
api.Sub(er, httptest.NewRequest("GET", "/api/v1/peer/%E7%89%A7%E5%B0%98/explain", nil))
if er.Code != http.StatusOK {
t.Fatalf("explain 期望 200,实际 %d body=%s", er.Code, er.Body.String())
}
var resp struct {
Peer peer.Peer `json:"peer"`
SourceMemories []string `json:"source_memories"`
}
if err := json.Unmarshal(er.Body.Bytes(), &resp); err != nil {
t.Fatalf("explain 解析: %v", err)
}
if len(resp.SourceMemories) != 5 {
t.Errorf("source_memories=%d 期望 5(可审计:画像有来源)", len(resp.SourceMemories))
}
}
// TestPeer_Explain_NotFound —— 未 refresh 直接 explain → 404。
func TestPeer_Explain_NotFound(t *testing.T) {
api := newPeerTestAPI(t, fiveMems())
rec := httptest.NewRecorder()
api.Sub(rec, httptest.NewRequest("GET", "/api/v1/peer/%E7%89%A7%E5%B0%98/explain", nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("无画像 explain 应 404,实际 %d", rec.Code)
}
}
// TestPeer_RecallPeer_DefaultOff —— AC3:ZHIYI_PEER_RECALL=0(默认)→ recall/peer 404 零变化。
func TestPeer_RecallPeer_DefaultOff(t *testing.T) {
t.Setenv("ZHIYI_PEER_RECALL", "0")
api := newPeerTestAPI(t, fiveMems())
rec := httptest.NewRecorder()
api.RecallPeer(rec, httptest.NewRequest("POST", "/api/v1/recall/peer?peer_id=牧尘", nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("默认关 recall/peer 应 404,实际 %d body=%s", rec.Code, rec.Body.String())
}
}
// TestPeer_RecallPeer_Enabled —— AC3:ZHIYI_PEER_RECALL=1 → 返回四层画像 JSON。
func TestPeer_RecallPeer_Enabled(t *testing.T) {
t.Setenv("ZHIYI_PEER_RECALL", "1")
api := newPeerTestAPI(t, fiveMems())
// 先 refresh 种画像
rrec := httptest.NewRecorder()
api.Sub(rrec, httptest.NewRequest("POST", "/api/v1/peer/%E7%89%A7%E5%B0%98/refresh", nil))
if rrec.Code != http.StatusOK {
t.Fatalf("前置 refresh 应 200,实际 %d", rrec.Code)
}
// recall/peer
rec := httptest.NewRecorder()
api.RecallPeer(rec, httptest.NewRequest("POST", "/api/v1/recall/peer?peer_id=牧尘", nil))
if rec.Code != http.StatusOK {
t.Fatalf("recall/peer 期望 200,实际 %d body=%s", rec.Code, rec.Body.String())
}
var resp struct {
Portrait map[string]interface{} `json:"portrait"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("recall/peer 解析: %v", err)
}
// 四层各有内容(AC3 判据)
for _, layer := range []string{"explicit", "deductive", "inductive", "abductive"} {
v, ok := resp.Portrait[layer]
if !ok {
t.Errorf("portrait 缺 %s 层: %v", layer, resp.Portrait)
continue
}
s, _ := json.Marshal(v)
if string(s) == "{}" || strings.Contains(string(s), "[]\"") {
t.Errorf("portrait.%s 层空: %s", layer, s)
}
}
t.Logf("AC3 recall/peer 画像: %s", rec.Body.String())
}
// TestPeer_GetRecallResults —— storage.PeerRecallProvider 注入:query 含实体 → 返回画像条目。
func TestPeer_GetRecallResults(t *testing.T) {
api := newPeerTestAPI(t, fiveMems())
// 种画像
api.Sub(httptest.NewRecorder(),
httptest.NewRequest("POST", "/api/v1/peer/%E7%89%A7%E5%B0%98/refresh", nil))
res := api.GetRecallResults("牧尘怎么部署", "", 1)
if len(res) != 1 {
t.Fatalf("GetRecallResults 应返回 1 条,实际 %d", len(res))
}
if res[0].Category != "peer" || res[0].ID != "peer:牧尘" {
t.Errorf("返回条目 Category=%q ID=%q 期望 peer / peer:牧尘", res[0].Category, res[0].ID)
}
if !strings.Contains(res[0].Content, "演绎特质") || !strings.Contains(res[0].Content, "最佳解释") {
t.Errorf("画像内容缺四层: %s", res[0].Content)
}
// 无命中 → 空
if got := api.GetRecallResults("如何优化 Redis", "", 1); len(got) != 0 {
t.Errorf("不相关 query 应空,实际 %d", len(got))
}
}
// TestPeer_SubPath —— 子路径解析单元。
func TestPeer_SubPath(t *testing.T) {
id, action := peerSubPath("/api/v1/peer/%E7%89%A7%E5%B0%98/refresh")
if id != "牧尘" || action != "refresh" {
t.Fatalf("解析失败 id=%q action=%q", id, action)
}
if _, a := peerSubPath("/api/v1/peer"); a != "" {
t.Fatalf("无子路径应 action 空")
}
}
// blockingReasoner 会阻塞的推理器(测非阻塞:RefreshAsync 不等它完成)。
type blockingReasoner struct{ release chan struct{} }
func (b *blockingReasoner) Reason(facts, prefs, skills, graphCtx []string) (*peer.ReasoningOutput, error) {
<-b.release
return &peer.ReasoningOutput{InferredTraits: []string{"T"}}, nil
}
// TestPeer_RefreshAsync_NonBlocking —— AC4:推理不阻塞主流程。
// RefreshAsync 立即返回(后台 goroutine 推理),即使推理器被阻塞也绝不等它。
func TestPeer_RefreshAsync_NonBlocking(t *testing.T) {
dir := t.TempDir()
store := peer.NewStore(filepath.Join(dir, "graph.db"))
t.Cleanup(store.Close)
br := &blockingReasoner{release: make(chan struct{})}
mlc := &peerScanLance{rows: fiveMems()}
api := NewPeerAPI(store, br, func() storage.LanceDB { return mlc }, nil)
done := api.RefreshAsync("牧尘", "")
select {
case <-done:
t.Fatal("RefreshAsync 应在推理器仍阻塞时立即返回(非阻塞),却等到了结果")
case <-time.After(200 * time.Millisecond):
// 非阻塞 ✓:200ms 内未等推理完成
}
// 释放推理器,后台完成
close(br.release)
select {
case <-done:
// 后台完成
case <-time.After(2 * time.Second):
t.Fatal("释放后后台推理未完成")
}
// 画像已落库
if n, _ := store.Count(); n != 1 {
t.Errorf("非阻塞刷新后 peers 行数应 1,实际 %d", n)
}
}
@@ -0,0 +1,67 @@
package routes
// 2026-10-26(P1-1):supersede / 版本字段必须真的出现在 /recall 响应里。
// 用同包 fakeLanceDB 构造一条「已被取代 + 有效期已过 + 版本 2」的记录:
// - 这是「写侧有、读侧看不见」的复发点(t_4c810e02「加列不接 IPC=静默 no-op」同款坑);
// - 判据只看 JSON 响应键值,不看结构体注释。
import (
"testing"
"github.com/xiaoxue/memoryweave/internal/models"
)
func TestRecall_SupersedeAndValidityFieldsExposed(t *testing.T) {
db := &fakeLanceDB{searchResults: []models.MemoryRecord{
{
ID: "mem_old_fact",
Content: "织忆默认 stale 阈值是 720 小时(旧事实)",
Category: "distilled",
Version: 2,
ValidFrom: "2026-10-26T10:00:00+08:00",
ValidTo: "2026-10-26T11:00:00+08:00",
SupersededBy: []string{"mem_new_fact"},
},
{ID: "mem_fresh", Content: "织忆默认 stale 阈值是 720 小时(现行事实)", Category: "distilled"},
}}
code, m, body := recallBody(t, db, `{"query":"stale 阈值多少小时","limit":5,"namespace":"hermes-main"}`)
if code != 200 {
t.Fatalf("expected 200, got %d; body=%s", code, body)
}
results, _ := m["results"].([]interface{})
if len(results) == 0 {
t.Fatalf("无结果,无法验证字段:%s", body)
}
var found map[string]interface{}
for _, raw := range results {
r, _ := raw.(map[string]interface{})
if r["id"] == "mem_old_fact" {
found = r
break
}
}
if found == nil {
t.Fatalf("目标记录未被召回(fake DB 应原样返回),body=%s", body)
}
if found["superseded_by"] == nil {
t.Fatalf("superseded_by 未暴露给读侧:%v", found)
}
if found["superseded"] != true {
t.Fatalf("superseded 便捷布尔应为 true:%v", found["superseded"])
}
if found["generation"] != float64(2) {
t.Fatalf("generation 应为 2:%v", found["generation"])
}
if found["valid_to"] != "2026-10-26T11:00:00+08:00" {
t.Fatalf("valid_to 未直通:%v", found["valid_to"])
}
if found["channel"] == nil || found["channel"] == "" {
t.Fatalf("channel 未暴露:%v", found)
}
if _, ok := m["trace_id"]; !ok {
t.Fatalf("响应缺 trace_id:%s", body)
}
if m["status"] != "complete" {
t.Fatalf("正常召回 status 应为 complete,实际 %v", m["status"])
}
}
@@ -0,0 +1,115 @@
package routes
// 2026-10-26(P1-3):stale-return + 生命周期 fixture(10:00 生效 / 11:00 失效)。
//
// 评审缺口:「只有离线检索指标,没有 stale-return、没有生命周期 fixture」。
// 本测试把三条读侧生命周期行为锁成机械判据:
// ① bi-temporal 开启时,valid_to 已过的事实**不进 top-k**(但仍在库,non-lossy);
// ② valid_to 在未来 / 为空 ⇒ 正常返回(同一 fixture 的对照,证明不是「整类被吞」);
// ③ updated_at 超 720h ⇒ 响应 stale=true;新记录不输出该键。
//
// 时窗用真实语义:fixture 事实 10:00 生效、11:00 失效,查询时点取「当日 11:00 之后」。
import (
"testing"
"time"
"github.com/xiaoxue/memoryweave/internal/models"
)
// 时窗用**相对时间**:曾经写成 dayAt(10,0)/dayAt(11,0) 假设「跑测试时已是晚上 20:00」,
// 凌晨 02:07 跑就是 flaky —— 11:00 还没到,「已失效」的 fixture 其实仍有效。
// 生命周期判据必须与运行时刻无关:过期 = now-1h,有效期 = now+24h。
func expiredPair() (from, to, future string) {
now := time.Now()
return now.Add(-2 * time.Hour).Format(time.RFC3339),
now.Add(-1 * time.Hour).Format(time.RFC3339),
now.Add(24 * time.Hour).Format(time.RFC3339)
}
func idsOf(m map[string]interface{}) map[string]bool {
out := map[string]bool{}
if res, ok := m["results"].([]interface{}); ok {
for _, raw := range res {
if r, ok := raw.(map[string]interface{}); ok {
if id, ok := r["id"].(string); ok {
out[id] = true
}
}
}
}
return out
}
func TestRecall_Lifecycle_ExpiredFactDroppedWhenBitemporal(t *testing.T) {
from, to, future := expiredPair()
db := &fakeLanceDB{searchResults: []models.MemoryRecord{
{ID: "mem_expired_window", Content: "生命周期 fixture:10:00 生效 11:00 失效的事实",
Category: "distilled", ValidFrom: from, ValidTo: to},
{ID: "mem_still_valid", Content: "生命周期 fixture:仍在有效期内的事实",
Category: "distilled", ValidFrom: from, ValidTo: future},
}}
t.Setenv("ZHIYI_BITEMPORAL", "1")
code, m, body := recallBody(t, db, `{"query":"生命周期 fixture 有效期","limit":5,"namespace":"hermes-main"}`)
if code != 200 {
t.Fatalf("expected 200, got %d; body=%s", code, body)
}
ids := idsOf(m)
if ids["mem_expired_window"] {
t.Fatalf("11:00 已失效的事实不应进 top-k(bi-temporal 开);body=%s", body)
}
if !ids["mem_still_valid"] {
t.Fatalf("有效期未过的事实必须正常返回(对照缺失 ⇒ 可能是整类被吞);body=%s", body)
}
}
func TestRecall_Lifecycle_ExpiredFactVisibleWhenBitemporalOff(t *testing.T) {
// 开关关(默认)⇒ 行为零变化:过期事实照常返回,但 valid_to 字段可见(读侧能自己判)。
from, to, _ := expiredPair()
db := &fakeLanceDB{searchResults: []models.MemoryRecord{
{ID: "mem_expired_visible", Content: "生命周期 fixture:过期但可见", Category: "distilled",
ValidFrom: from, ValidTo: to},
}}
t.Setenv("ZHIYI_BITEMPORAL", "0")
code, m, body := recallBody(t, db, `{"query":"过期但可见","limit":5,"namespace":"hermes-main"}`)
if code != 200 {
t.Fatalf("expected 200, got %d; body=%s", code, body)
}
if !idsOf(m)["mem_expired_visible"] {
t.Fatalf("bi-temporal 关时过期事实应照常返回(non-lossy 可见性);body=%s", body)
}
var found map[string]interface{}
for _, raw := range m["results"].([]interface{}) {
if r, ok := raw.(map[string]interface{}); ok && r["id"] == "mem_expired_visible" {
found = r
}
}
if found == nil || found["valid_to"] != to {
t.Fatalf("valid_to 必须直通给读侧:%v", found)
}
}
func TestRecall_StaleReturn_DefaultThreshold(t *testing.T) {
old := time.Now().Add(-31 * 24 * time.Hour)
db := &fakeLanceDB{searchResults: []models.MemoryRecord{
{ID: "mem_old", Content: "陈年事实(31 天未更新)", Category: "distilled", UpdatedAt: old},
{ID: "mem_new", Content: "新鲜事实(今天更新)", Category: "distilled", UpdatedAt: time.Now()},
}}
code, m, body := recallBody(t, db, `{"query":"事实 新鲜度","limit":5,"namespace":"hermes-main"}`)
if code != 200 {
t.Fatalf("expected 200, got %d; body=%s", code, body)
}
got := map[string]map[string]interface{}{}
for _, raw := range m["results"].([]interface{}) {
if r, ok := raw.(map[string]interface{}); ok {
got[r["id"].(string)] = r
}
}
if got["mem_old"] == nil || got["mem_old"]["stale"] != true {
t.Fatalf("31 天未更新的记忆应 stale=true:%v", got["mem_old"])
}
if _, ok := got["mem_new"]["stale"]; ok {
t.Fatalf("新记忆不应输出 stale 键:%v", got["mem_new"])
}
}
@@ -0,0 +1,144 @@
package routes
// t_3aa2a8c1:`/api/v1/recall` 响应暴露 `truncated`(Rust 侧单次重读行数上限截断标志,
// 引擎已算、此前只打日志不上抛)。选定的省略语义 = **键缺失即未截断(omitempty)**。
// 本测试经路由层机械判定:
// ① 粗检索被截断(后端实现 SearchTruncationReporter 且上报 true)⇒ 响应含 "truncated":true
// ② 未截断 / 纯 Go 后端(不实现 reporter)⇒ 键缺失(不出现 truncated:false 以外的值)
// ③ 回归:既有字段 results/count/candidates_scanned/mode/cache_hit 行为不变
import (
"encoding/json"
"net/http/httptest"
"strings"
"testing"
"github.com/xiaoxue/memoryweave/internal/governance"
"github.com/xiaoxue/memoryweave/internal/models"
"github.com/xiaoxue/memoryweave/internal/storage"
)
// truncReportingLDB 复用同包 fakeLanceDB(recall_error_shape_test.go),
// 额外实现可选扩展接口 SearchWithTruncationReporter(t_3aa2a8c1)。
type truncReportingLDB struct {
*fakeLanceDB
reportTruncated bool
}
func (f *truncReportingLDB) SearchWithTruncation(table string, vector []float32, topK int, namespaceFilter string) ([]models.MemoryRecord, bool, error) {
if f.searchErr != nil {
return nil, false, f.searchErr
}
if len(f.searchResults) > topK {
return f.searchResults[:topK], f.reportTruncated, nil
}
return f.searchResults, f.reportTruncated, nil
}
func recallBody(t *testing.T, db storage.LanceDB, body string) (int, map[string]interface{}, string) {
t.Helper()
// 测试须可复跑(t_3aa2a8c1 实测踩坑):SearchCacheInstance 由 storage 包 init() 恒建
// (L2 = 共享 Redis,TTL 60s)⇒ 上一次 run 的 recall 结果会进 L2,下一次 run 同 key
// 命中缓存路径(Truncated 恒 false)⇒ 断言 flaky。逐测试摘除缓存(测后恢复),
// 恒走未缓存路径。包内无 t.Parallel ⇒ 改全局安全。
saved := storage.SearchCacheInstance
storage.SearchCacheInstance = nil
t.Cleanup(func() { storage.SearchCacheInstance = saved })
emb := storage.NewEmbedder("")
rerank := storage.NewReranker("", emb)
api := NewAPI(db, emb, rerank, nil, governance.NewInMemoryGraph())
req := httptest.NewRequest("POST", "/api/v1/recall", strings.NewReader(body))
w := httptest.NewRecorder()
api.Recall(w, req)
var m map[string]interface{}
_ = json.Unmarshal(w.Body.Bytes(), &m)
return w.Code, m, w.Body.String()
}
// 既有响应键回归基线(t_2d4e3b92 成功 schema + t_1c12e000 cache_hit + t_949840b1 candidates_scanned)
var recallBaselineKeys = []string{"results", "count", "candidates_scanned", "mode", "cache_hit"}
func assertBaseline(t *testing.T, m map[string]interface{}, body string) {
t.Helper()
for _, k := range recallBaselineKeys {
if _, ok := m[k]; !ok {
t.Fatalf("既有字段 %q 缺失(回归破坏),body=%s", k, body)
}
}
if res, ok := m["results"].([]interface{}); ok {
if cnt, _ := m["count"].(float64); int(cnt) != len(res) {
t.Fatalf("count=%v 与 len(results)=%d 不一致(回归破坏)", m["count"], len(res))
}
}
}
// ① 粗检索被行数上限截断 ⇒ 响应暴露 truncated:true + 既有字段回归不变
func TestRecall_TruncatedExposedWhenHitCap(t *testing.T) {
db := &truncReportingLDB{
fakeLanceDB: &fakeLanceDB{searchResults: []models.MemoryRecord{
{ID: "mem_1", Content: "test memory one", Category: "distilled"},
{ID: "mem_2", Content: "test memory two", Category: "distilled"},
}},
reportTruncated: true,
}
code, m, body := recallBody(t, db, `{"query":"test query","limit":5,"namespace":"shared"}`)
if code != 200 {
t.Fatalf("expected 200, got %d; body=%s", code, body)
}
if v, ok := m["truncated"]; !ok || v != true {
t.Fatalf("粗检索已截断,响应必须含 truncated:true;实际 key 存在=%v 值=%v body=%s", ok, m["truncated"], body)
}
assertBaseline(t, m, body)
}
// ①b keyword 主路径(kwMeta 透传)同判据
func TestRecall_Keyword_TruncatedExposed(t *testing.T) {
db := &truncReportingLDB{
fakeLanceDB: &fakeLanceDB{searchResults: []models.MemoryRecord{
{ID: "mem_1", Content: "keyword memory", Category: "distilled"},
}},
reportTruncated: true,
}
code, m, body := recallBody(t, db, `{"query":"keyword query","limit":5,"mode":"keyword","namespace":"shared"}`)
if code != 200 {
t.Fatalf("expected 200, got %d; body=%s", code, body)
}
if v, ok := m["truncated"]; !ok || v != true {
t.Fatalf("keyword 路径粗检索已截断,响应必须含 truncated:true;body=%s", body)
}
assertBaseline(t, m, body)
}
// ② 未截断 ⇒ 键缺失(选定语义:省略=未截断),不得出现 truncated:false 以外的值
func TestRecall_NotTruncated_KeyOmitted(t *testing.T) {
db := &truncReportingLDB{
fakeLanceDB: &fakeLanceDB{searchResults: []models.MemoryRecord{
{ID: "mem_1", Content: "test memory one", Category: "distilled"},
}},
reportTruncated: false,
}
code, m, body := recallBody(t, db, `{"query":"test query","limit":5,"namespace":"shared"}`)
if code != 200 {
t.Fatalf("expected 200, got %d; body=%s", code, body)
}
if v, ok := m["truncated"]; ok {
t.Fatalf("未截断场景键应缺失(省略=未截断);实际 truncated=%v body=%s", v, body)
}
assertBaseline(t, m, body)
}
// ②b 纯 Go 后端(不实现 SearchTruncationReporter)⇒ 键缺失,零值语义、行为不变
func TestRecall_PureGoBackend_NoTruncatedKey(t *testing.T) {
db := &fakeLanceDB{searchResults: []models.MemoryRecord{
{ID: "mem_1", Content: "test memory one", Category: "distilled"},
}}
code, m, body := recallBody(t, db, `{"query":"test query","limit":5,"namespace":"shared"}`)
if code != 200 {
t.Fatalf("expected 200, got %d; body=%s", code, body)
}
if _, ok := m["truncated"]; ok {
t.Fatalf("纯 Go 后端无行数上限,truncated 键必须缺失;body=%s", body)
}
assertBaseline(t, m, body)
}
+327
View File
@@ -0,0 +1,327 @@
package routes
// 织忆自检端点(2026-10-26,牧尘批「三档」)
//
// 背景:织忆相关的定时自检脚本有 ~20 个,取数方式三套并存——
// ① 直连生产库 lancedb.connect("/var/lib/memoryweave")(绕过服务、schema 一改就静默腐烂)
// ② IPC socket 自定义协议
// ③ HTTP /api/v1/{stats,popwatch,metrics/self}
// 三套契约对同一个库,schema 变更时分开损坏。典型事故:pattern 自检脚本写死
// type='pattern',库里实际是 pattern_stat ⇒ 连续数月静默 0 输出。
//
// 本端点是**唯一取数契约**:把「读织忆内部状态」收敛成一份结构化 JSON。
// 设计边界(不做什么):
// - 不做判定/告警/投递 —— 阈值、渠道、评审留在外部 cron(观测者不能住在被观测者体内:
// zhiyid 一挂自检就跟着哑,内外同时沉默是更坏的故障)。
// - 不跑重活(eval 全量、备份、vacuum)—— 那些抢生产资源,留在 systemd-run 独立 scope。
//
// level 语义:
// - cheap(默认,<50ms):只读内存态/计数,不扫全表。
// - full(秒级):额外扫全表算碎片率/冗余率/近 7 天质量/写量分布(原 quality-watch 口径)。
//
// 兼容铁律:本端点返回的比率一律给「分子/分母/样本是否足够」,缺数据不返回 0
// (0 会被下游读成"很好")。沿用 selfoptimize 的 metricNA(-1) 语义。
import (
"encoding/json"
"fmt"
"math"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/xiaoxue/memoryweave/internal/distill"
"github.com/xiaoxue/memoryweave/internal/selfoptimize"
"github.com/xiaoxue/memoryweave/internal/storage"
)
// SelfcheckSchemaVersion 变更返回结构时递增;消费方据此判兼容。
const SelfcheckSchemaVersion = "1"
// 与 zhiyi-quality-watch.py 同口径(该脚本已改为读本端点,口径只此一处)
const (
selfcheckFragLen = 20 // <20 字符 = 碎片
selfcheckDupMinSample = 5000 // 冗余率最小样本(活行),不足只报样本不判率
selfcheckScanLimit = 300000
)
type selfcheckQuality struct {
TotalRows int `json:"total_rows"`
LiveRows int `json:"live_rows"`
ScannedRows int `json:"scanned_rows"`
ScanTruncated bool `json:"scan_truncated"`
Coverage float64 `json:"coverage"` // 扫描行 / store 真实总量;<1 表示结果不完整,禁止据此下结论
FragLen int `json:"frag_len"`
FragRowsAll int `json:"frag_rows_all"`
FragRateAll float64 `json:"frag_rate_all"`
FragRowsLive int `json:"frag_rows_live"`
FragRateLive float64 `json:"frag_rate_live"`
DupGroups int `json:"dup_groups"`
DupExtraRows int `json:"dup_extra_rows"`
DupRate float64 `json:"dup_rate"`
DupSampleOK bool `json:"dup_sample_ok"`
NewRows7d int `json:"new_rows_7d"`
NewFragRows7d int `json:"new_frag_rows_7d"`
NewFragRate7d *float64 `json:"new_frag_rate_7d"` // nil = 近 7 天无新增(无分母)
}
type selfcheckDayCount struct {
Date string `json:"date"`
Rows int `json:"rows"`
Live int `json:"live"`
}
type selfcheckWriteVolume struct {
Days []selfcheckDayCount `json:"days"`
Today int `json:"today"`
Last7d int `json:"last_7d"`
Median7d float64 `json:"median_7d"`
P25_7d float64 `json:"p25_7d"`
}
// Selfcheck GET /api/v1/selfcheck?level=cheap|full
func (a *API) Selfcheck(w http.ResponseWriter, r *http.Request) {
start := time.Now()
if r.Method != http.MethodGet {
respondError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
level := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("level")))
if level == "" {
level = "cheap"
}
if level != "cheap" && level != "full" {
respondError(w, http.StatusBadRequest, "level must be cheap|full")
return
}
rep := map[string]interface{}{
"schema_version": SelfcheckSchemaVersion,
"checked_at": time.Now().Format(time.RFC3339),
"level": level,
}
degraded := []string{}
// ── 存储总量 ───────────────────────────────
if st, err := a.LanceDB.Stats(); err != nil {
degraded = append(degraded, "store: "+err.Error())
} else {
total := numOf(st["total_memories"])
tomb := numOf(st["tombstone_count"])
live := total - tomb
if live < 0 {
live = 0
}
store := map[string]interface{}{
"backend": st["backend"],
"data_dir": st["data_dir"],
"total_memories": total,
"total_episodes": numOf(st["total_episodes"]),
"tombstone_count": tomb,
"live_memories": live,
}
if total > 0 {
store["tombstone_rate"] = round4(tomb / total)
} else {
store["tombstone_rate"] = -1.0 // N/A:空库无分母
}
rep["store"] = store
}
// ── 图谱 ───────────────────────────────────
if a.GraphStore != nil {
n, e, d := a.GraphStore.Stats()
rep["graph"] = map[string]interface{}{
"node_count": n,
"edge_count": e,
"density": round4(d),
}
} else {
degraded = append(degraded, "graph: store not initialised")
}
// ── pattern 召回的两侧健康(2026-10-26 新增)──────────
// 事故背景:`POST /api/v1/patterns/detect` 是短命单发端点,全库 0 cron、0 调用方,
// 于是 graph_nodes(type='pattern') 恒 0 行、recall 恒 0 命中,而日志层面完全沉默
// (只能靠人翻 7 天日志才发现「实现了 ≠ 出过数」)。这里把「写入端有没有产数」
// 变成一次 GET 就能读到的事实:rows=0 ⇒ 生产者坏了或从未接线,别去查读侧 SQL。
rep["pattern"] = patternHealth(a)
// ── 面板指标(反馈/蒸馏/gap/cascade,含 _valid/_samples 语义)──
rep["dashboard"] = selfoptimize.Dash.Metrics()
// ── 整合链路(内存态,<1ms)────────────────
tracker := GetConsolTracker()
consolStatus, consolFails, sinceSuccess, lastErr := tracker.HealthStatus()
_, consolSkips, lastSkip, _ := tracker.Snapshot()
rep["consolidate"] = map[string]interface{}{
"status": consolStatus,
"consec_failures": consolFails,
"skipped": consolSkips,
"last_skip": lastSkip,
"last_success_age": sinceSuccess.String(),
"last_error": lastErr,
}
// ── 缓存 / FTS ─────────────────────────────
cache := map[string]interface{}{"fts_index": storage.FtsIndexStats()}
if storage.SearchCacheInstance != nil {
cache["search_cache"] = storage.SearchCacheInstance.Stats()
}
rep["cache"] = cache
// ── 空壳拦截器(t_e11207f8 取证)───────────
checks, blocks, loaded, rulesPath := distill.PayloadGuardStats()
rep["payload_guard"] = map[string]interface{}{
"checks": checks,
"blocks": blocks,
"loaded": loaded,
"rules_path": rulesPath,
}
// ── 全量质量段(level=full):读卫星快照,不在本进程扫全表 ────────
// 设计:全表扫描是重活(4 万行 × 内容),且 daemon 还有 recall 要跑。
// 取数由仓库内 tools/selfcheck_full.py 完成(列投影只读,1.7s / 250MB,独立进程),
// 写 ~/.hermes/state/zhiyi-quality.json;本端点只读快照并标注新鲜度。
// 这样"取数"只有一处实现(跟 schema 同仓),"判定/告警"仍在外部 cron。
if level == "full" {
snap, err := loadQualitySnapshot()
if err != nil {
degraded = append(degraded, "quality: "+err.Error())
} else {
rep["quality"] = snap.Quality
rep["write_volume"] = snap.WriteVolume
rep["quality_as_of"] = snap.GeneratedAt
rep["quality_age_s"] = snap.AgeSeconds()
rep["quality_stale"] = snap.AgeSeconds() > qualityStaleAfter
}
}
if len(degraded) > 0 {
rep["degraded"] = degraded
}
rep["duration_ms"] = time.Since(start).Milliseconds()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(rep)
}
// qualityStaleAfter:快照超过这个时长未刷新 ⇒ 标记 stale(消费方不得据此下结论)。
const qualityStaleAfter = 6 * 3600
// qualitySnapshot 对应仓库内 tools/selfcheck_full.py 的产出。
// 字段名与 python 侧严格一致(schema_version=1)。
type qualitySnapshot struct {
SchemaVersion int `json:"schema_version"`
GeneratedAt string `json:"generated_at"`
Source struct {
DB string `json:"db"`
Table string `json:"table"`
Mode string `json:"mode"`
} `json:"source"`
DurationMS int `json:"duration_ms"`
Quality json.RawMessage `json:"quality"`
WriteVolume json.RawMessage `json:"write_volume"`
fileModTime time.Time
}
func (q *qualitySnapshot) AgeSeconds() int64 {
if q.fileModTime.IsZero() {
return -1
}
return int64(time.Since(q.fileModTime).Seconds())
}
// QualitySnapshotPath 允许用 env 覆盖(测试/灰度)。
func QualitySnapshotPath() string {
if p := strings.TrimSpace(os.Getenv("ZHIYI_QUALITY_SNAPSHOT")); p != "" {
return p
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".hermes", "state", "zhiyi-quality.json")
}
func loadQualitySnapshot() (*qualitySnapshot, error) {
p := QualitySnapshotPath()
fi, err := os.Stat(p)
if err != nil {
return nil, fmt.Errorf("快照不存在(先跑 tools/selfcheck_full.py):%w", err)
}
raw, err := os.ReadFile(p)
if err != nil {
return nil, err
}
var snap qualitySnapshot
if err := json.Unmarshal(raw, &snap); err != nil {
return nil, fmt.Errorf("快照解析失败: %w", err)
}
snap.fileModTime = fi.ModTime()
return &snap, nil
}
// numOf 把 stats 里可能是 float64/int/json.Number 的数值统一成 float64。
func numOf(v interface{}) float64 {
switch x := v.(type) {
case float64:
return x
case float32:
return float64(x)
case int:
return float64(x)
case int64:
return float64(x)
case json.Number:
f, _ := x.Float64()
return f
default:
return 0
}
}
func round4(f float64) float64 {
if math.IsNaN(f) || math.IsInf(f, 0) {
return -1
}
return math.Round(f*10000) / 10000
}
// patternHealth 一眼判定 pattern 召回是「有生产者」还是「恒 0」。
//
// 为什么需要它:2026-10-26 排查发现 pattern 召回 0 命中,真根因是写入端从未产数
// (detect 端点无 cron、无调用方),但表面症状看起来像读侧 SQL bug —— 白查一轮方向。
// 判据三类:
//
// producing 有行、有 id、有生产时间 → 读侧再 0 命中就该查 Match 阈值/properties;
// zero_output 0 行 → 生产端问题,别去动读侧 SQL;
// no_id 有行但 id 全空 → store.ListPatterns 会全 skip(存量冻结的典型形态)。
func patternHealth(a *API) map[string]interface{} {
out := map[string]interface{}{
"rows": -1, // -1 = 未知,别当 0 用
"rows_with_id": -1,
"last_produced_at": "",
"verdict": "unknown",
}
if a == nil || a.GraphStore == nil {
out["verdict"] = "no_graph_store"
return out
}
rows, withID, last := a.GraphStore.CountNodesByType("pattern")
out["rows"], out["rows_with_id"], out["last_produced_at"] = rows, withID, last
switch {
case rows == 0:
out["verdict"] = "zero_output"
case withID == 0:
out["verdict"] = "no_id"
case last == "":
out["verdict"] = "unknown_age"
default:
out["verdict"] = "producing"
}
return out
}
+99
View File
@@ -0,0 +1,99 @@
package routes
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
)
// 快照契约测试:/api/v1/selfcheck?level=full 依赖 tools/selfcheck_full.py 的产出格式。
// 这里钉住 schema_version 与关键字段,防止 python 侧改字段名后 Go 侧静默返回空。
func TestLoadQualitySnapshot(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "zhiyi-quality.json")
body := `{
"schema_version": 1,
"generated_at": "2026-10-26T13:20:00+0800",
"source": {"db": "/var/lib/memoryweave", "table": "memories", "mode": "read_only_projection"},
"duration_ms": 1680,
"quality": {
"frag_len": 20, "total_rows": 39957, "live_rows": 39836,
"frag_rows_all": 313, "frag_rate_all": 0.0078,
"frag_rows_live": 313, "frag_rate_live": 0.0079,
"dup_groups": 34, "dup_extra_rows": 103, "dup_rate": 0.0026,
"new_rows_7d": 1176, "new_frag_rows_7d": 35, "new_frag_rate_7d": 0.0298
},
"write_volume": {
"days": [{"date": "2026-10-25", "rows": 1473, "live": 1471}],
"today": 332, "today_date": "2026-10-26", "last_7d": 7706, "nonzero_days": 60
}
}`
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
t.Setenv("ZHIYI_QUALITY_SNAPSHOT", p)
snap, err := loadQualitySnapshot()
if err != nil {
t.Fatalf("loadQualitySnapshot: %v", err)
}
if snap.SchemaVersion != 1 {
t.Fatalf("schema_version=%d want 1", snap.SchemaVersion)
}
var q struct {
TotalRows int `json:"total_rows"`
LiveRows int `json:"live_rows"`
DupRate float64 `json:"dup_rate"`
FragRowsAll int `json:"frag_rows_all"`
}
if err := json.Unmarshal(snap.Quality, &q); err != nil {
t.Fatalf("quality 段解析失败: %v", err)
}
if q.TotalRows != 39957 || q.LiveRows != 39836 {
t.Fatalf("quality 口径不符: %+v", q)
}
if q.FragRowsAll != 313 || q.DupRate != 0.0026 {
t.Fatalf("quality 数值不符: %+v", q)
}
var wv struct {
Today int `json:"today"`
Days []struct {
Date string `json:"date"`
Live int `json:"live"`
} `json:"days"`
}
if err := json.Unmarshal(snap.WriteVolume, &wv); err != nil {
t.Fatalf("write_volume 段解析失败: %v", err)
}
if wv.Today != 332 || len(wv.Days) != 1 || wv.Days[0].Live != 1471 {
t.Fatalf("write_volume 不符: %+v", wv)
}
// 新鲜度:刚写的文件 age 应 < 10s;人为改旧 → stale 判定为真
if age := snap.AgeSeconds(); age < 0 || age > 10 {
t.Fatalf("AgeSeconds=%d,本应接近 0", age)
}
old := time.Now().Add(-8 * time.Hour)
if err := os.Chtimes(p, old, old); err != nil {
t.Fatal(err)
}
snap2, err := loadQualitySnapshot()
if err != nil {
t.Fatal(err)
}
if snap2.AgeSeconds() <= qualityStaleAfter {
t.Fatalf("8h 前的快照应判 stale(>%d 秒),得到 %d", qualityStaleAfter, snap2.AgeSeconds())
}
}
// 快照缺失 ⇒ 必须报错(由 handler 转成 degraded),不能静默返回零值。
func TestLoadQualitySnapshotMissing(t *testing.T) {
t.Setenv("ZHIYI_QUALITY_SNAPSHOT", filepath.Join(t.TempDir(), "nope.json"))
if _, err := loadQualitySnapshot(); err == nil {
t.Fatal("缺失快照应返回 error")
}
}
@@ -0,0 +1,72 @@
package routes
import (
"encoding/json"
"net/http"
"github.com/xiaoxue/memoryweave/internal/storage"
)
// 2026-10-13 (t_e9a4d18f P2) 常设答案路由(Hindsight mental models 直读/写入)。
// 读取 = 一次 DB 读(question_id 主键),零检索零 LLM。写入由生成脚本
// zhiyi-standing-answers.py 调用(复用本地 2B,免费)。
// standingStore 返回全局常设答案库(server.go 注入)。未初始化 → nil。
func standingStore() *storage.StandingStore { return storage.StandingStoreInstance }
// HandleStandingAnswers GET/POST /api/v1/standing-answers
// GET ?id=<question_id> → 直读一条;无 id → 列全部
// POST {"question_id","question","answer","source_memories":[]} → 写入/改写
func HandleStandingAnswers(w http.ResponseWriter, r *http.Request) {
s := standingStore()
if s == nil {
respondError(w, http.StatusServiceUnavailable, "standing answers store not initialized")
return
}
switch r.Method {
case http.MethodGet:
id := r.URL.Query().Get("id")
if id == "" {
all, err := s.List()
if err != nil {
respondError(w, http.StatusInternalServerError, err.Error())
return
}
respond(w, http.StatusOK, map[string]interface{}{"answers": all, "count": len(all)})
return
}
a, err := s.Get(id)
if err != nil {
respondError(w, http.StatusInternalServerError, err.Error())
return
}
if a == nil {
respondError(w, http.StatusNotFound, "standing answer not found: "+id)
return
}
respond(w, http.StatusOK, a)
case http.MethodPost:
var in struct {
QuestionID string `json:"question_id"`
Question string `json:"question"`
Answer string `json:"answer"`
SourceMemories []string `json:"source_memories"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
respondError(w, http.StatusBadRequest, "bad json: "+err.Error())
return
}
if in.QuestionID == "" || in.Answer == "" {
respondError(w, http.StatusBadRequest, "question_id and answer required")
return
}
a, err := s.Upsert(in.QuestionID, in.Question, in.Answer, in.SourceMemories)
if err != nil {
respondError(w, http.StatusInternalServerError, err.Error())
return
}
respond(w, http.StatusOK, a)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
+114 -44
View File
@@ -1,117 +1,187 @@
{
"description": "织忆召回金标检查集(12条基线 + 2条目标知识)。统一判据口径(t_a70b07e1):取 recall 返回的前 N=5 条内容查关键词,ID 命中 = expected_ids 任一 ∈ 前 N=5 条;N=5 与 Go/Python 两量具(eval_test.go / golden-check.py)一致。判定:PASS=id且kw命中 / WARN=kw命中但ID漂移 / FAIL=其余 / ERROR=recall调用失败。单一真相源(t_949840b1):本文件是金标数据唯一副本;Python 量具 golden-check.py 直接读本文件(旧 ~/.hermes/scripts/tools/golden-set.json 副副本已删除),基线校准一次即两量具同步(--refresh 写回本文件)。⚠️ item4「牧尘喜欢什么样的工作方式」基线 2026-09-21 t_949840b1 校准为当前稳定值 mem_1789834428978989104(语料增长致旧基线 mem_1789837840173161705 掉出 top-5,前 N 内容仍切题)。校准后同批金标两量具分类差异=0(diff=1 的根因——两份副本 expected_ids 不同步——已消除)。",
"description": "织忆召回金标检查集(单一真相源,t_949840b1)。统一判据口径 N=5:取 recall 返回前 N=5 条查关键词,ID 命中 = expected_ids 任一 ∈ 前 N=5 条。基线已刷新(golden-check.py --refresh,两量具同源)。",
"items": [
{
"query": "织忆是什么",
"expected_ids": ["mem_1789153726378940805"],
"must_include_keywords": ["织忆"],
"expected_ids": [
"mem_1791666702950691634"
],
"must_include_keywords": [
"织忆"
],
"category": "system_fact",
"difficulty": "easy",
"id_baseline": "mem_1789153726378940805"
"id_baseline": "mem_1791666702950691634",
"namespace": "hermes-main"
},
{
"query": "织忆的图谱扩展机制是什么",
"expected_ids": ["mem_1789823837902661249"],
"must_include_keywords": ["图谱"],
"expected_ids": [
"mem_1791899478673190284"
],
"must_include_keywords": [
"图谱"
],
"category": "system_fact",
"difficulty": "medium",
"id_baseline": "mem_1789823837902661249"
"id_baseline": "mem_1791899478673190284",
"namespace": "hermes-main"
},
{
"query": "织忆如何通过 E1 提取和 MMR 实现多样去重",
"expected_ids": ["mem_1789915227303612056"],
"must_include_keywords": ["MMR"],
"expected_ids": [
"mem_1791666890486125605"
],
"must_include_keywords": [
"MMR"
],
"category": "system_fact",
"difficulty": "hard",
"id_baseline": "mem_1789915227303612056"
"id_baseline": "mem_1791666890486125605",
"namespace": "hermes-main"
},
{
"query": "牧尘的偏好是什么",
"expected_ids": ["mem_1789667544872361813"],
"must_include_keywords": ["牧尘"],
"expected_ids": [
"mem_1790448247125520424"
],
"must_include_keywords": [
"牧尘"
],
"category": "user_pref",
"difficulty": "easy",
"id_baseline": "mem_1789667544872361813"
"id_baseline": "mem_1790448247125520424",
"namespace": "hermes-main"
},
{
"query": "牧尘喜欢什么样的工作方式",
"expected_ids": ["mem_1789834428978989104"],
"must_include_keywords": ["牧尘"],
"expected_ids": [
"mem_1790448247125520424"
],
"must_include_keywords": [
"牧尘"
],
"category": "user_pref",
"difficulty": "medium",
"id_baseline": "mem_1789834428978989104"
"id_baseline": "mem_1790448247125520424",
"namespace": "hermes-main"
},
{
"query": "如何根据牧尘的偏好调整记忆检索策略",
"expected_ids": ["mem_1789190217889697697"],
"must_include_keywords": ["记忆"],
"expected_ids": [
"mem_1791747597032219499"
],
"must_include_keywords": [
"记忆"
],
"category": "user_pref",
"difficulty": "hard",
"id_baseline": "mem_1789190217889697697"
"id_baseline": "mem_1791747597032219499",
"namespace": "hermes-main"
},
{
"query": "当前项目的技术栈是什么",
"expected_ids": ["mem_1789680870868739202"],
"must_include_keywords": ["技术栈"],
"expected_ids": [
"mem_1790455676397796671"
],
"must_include_keywords": [
"技术栈"
],
"category": "proj_context",
"difficulty": "easy",
"id_baseline": "mem_1789680870868739202"
"id_baseline": "mem_1790455676397796671",
"namespace": "hermes-main"
},
{
"query": "织忆项目有哪些核心组件",
"expected_ids": ["mem_1789154058288011112"],
"must_include_keywords": ["织忆"],
"expected_ids": [
"mem_1791906354849281414"
],
"must_include_keywords": [
"织忆"
],
"category": "proj_context",
"difficulty": "medium",
"id_baseline": "mem_1789154058288011112"
"id_baseline": "mem_1791906354849281414",
"namespace": "hermes-main"
},
{
"query": "织忆和其他记忆系统相比有什么架构优势",
"expected_ids": ["mem_1789153726378940805"],
"must_include_keywords": ["织忆"],
"expected_ids": [
"mem_1791666702950691634"
],
"must_include_keywords": [
"织忆"
],
"category": "proj_context",
"difficulty": "hard",
"id_baseline": "mem_1789153726378940805"
"id_baseline": "mem_1791666702950691634",
"namespace": "hermes-main"
},
{
"query": "如何使用 recall 接口",
"expected_ids": ["mem_1787272787836687543"],
"must_include_keywords": ["recall"],
"expected_ids": [
"mem_1791796178328736793"
],
"must_include_keywords": [
"recall"
],
"category": "tool_usage",
"difficulty": "easy",
"id_baseline": "mem_1787272787836687543"
"id_baseline": "mem_1791796178328736793",
"namespace": "hermes-main"
},
{
"query": "织忆有哪些管理工具",
"expected_ids": ["mem_1789153726378940805"],
"must_include_keywords": ["织忆"],
"expected_ids": [
"mem_1791666702950691634"
],
"must_include_keywords": [
"织忆"
],
"category": "tool_usage",
"difficulty": "medium",
"id_baseline": "mem_1789153726378940805"
"id_baseline": "mem_1791666702950691634",
"namespace": "hermes-main"
},
{
"query": "如何通过 API 扩展织忆功能",
"expected_ids": ["mem_1789153726378940805"],
"must_include_keywords": ["织忆"],
"expected_ids": [
"mem_1791666188168772049"
],
"must_include_keywords": [
"织忆"
],
"category": "tool_usage",
"difficulty": "hard",
"id_baseline": "mem_1789153726378940805"
"id_baseline": "mem_1791666188168772049",
"namespace": "hermes-main"
},
{
"query": "本地 llama 服务 8080 端口",
"expected_ids": ["mem_1789229548476546193"],
"must_include_keywords": ["8080"],
"expected_ids": [
"mem_1791423503359721152"
],
"must_include_keywords": [
"8080"
],
"category": "target_knowledge",
"difficulty": "easy",
"id_baseline": "mem_1789229548476546193"
"id_baseline": "mem_1791423503359721152",
"namespace": "hermes-main"
},
{
"query": "session-to-zhiyi.py 脚本名",
"expected_ids": ["mem_1788977361686058470"],
"must_include_keywords": ["session-to-zhiyi"],
"expected_ids": [
"mem_1791688470278719760"
],
"must_include_keywords": [
"session-to-zhiyi"
],
"category": "target_knowledge",
"difficulty": "easy",
"id_baseline": "mem_1788977361686058470"
"id_baseline": "mem_1791688470278719760",
"namespace": "hermes-main"
}
]
}
}
+52
View File
@@ -0,0 +1,52 @@
// 织忆 MemoryWeave — routes 包测试隔离(两个护栏合并 · t_e5997ca4 + t_b7a8ed9c)
//
// ⚠️ 本文件是**两个 TestMain 的合并体**。两卡分支各自加了一个 TestMain:
// · t_e5997ca4 → testmain_test.go(落盘点护栏)
// · t_b7a8ed9c → scorecache_guard_test.go(打分缓存护栏)
// 在各自分支上互不干扰,合并到 main 后同包两个 TestMain **编译冲突**
// (multiple definitions of TestMain)⇒ 必须合进一个文件。
// (此点由 t_b7a8ed9c 在 scorecache_guard_test.go 顶部预先标注;小怡合并时未读文件
// 而漏看,由全套测试的 setup failed 拦下 —— 见 worklog 教训。)
//
// 护栏①:单元测试不得写到生产落盘点(t_e5997ca4)
// ---------------------------------------------------------------
// selfoptimize 包已有同款 TestMain 先例(曾实测:整套测试跑完把
// /var/lib/memoryweave/dashboard.json 写成测试数字)。
// gap close/repair 端点会触发 Dash.persist()(本地 JSON 兜底)⇒ 统一重定向到临时目录。
//
// 护栏②:默认关闭 distill 打分缓存(t_b7a8ed9c)
// ---------------------------------------------------------------
// 背景:distill 打分记忆化(0dc571d)+ Redis L2(36db9bb,键 zhiyi:distill-score:*,
// TTL 168h)后,**本包 E2E 测试(zz_commit_conflict_gate_test.go)会读写生产 Redis**:
// 第一次 run L2 miss → 真调 mock LLM 并把「测试内容哈希 → 结果」写进生产;
// 7 天内后续 run L2 hit → LLM 被静默短路 → 数 LLM 调用次数的断言(base+1)必挂。
// 实测 2026-10-17:TestCommitConflictGate_E2E 16.5s 固定 FAIL,
// 「expected >= 1 LLM distill calls, got 0」。
//
// 治法(对齐项目门禁规矩③:加缓存必须给该包 TestMain 加关闭开关):
// Setenv ZHIYI_SCORE_CACHE=0 —— distill.getScoreCache 的 Once 闭包**活读 env**
// (包级变量在 init 时已定型,跨包设不了),本进程内缓存整体禁用:
// 不读 L2(LLM 真被调用,计数断言有覆盖)、不写 L2(不再污染生产)。
package routes
import (
"os"
"path/filepath"
"testing"
)
func TestMain(m *testing.M) {
// 护栏①:落盘点重定向到临时目录(宁可测试直接失败,也不写生产落盘点)
dir, err := os.MkdirTemp("", "routes-test-dash-*")
if err != nil {
panic(err)
}
_ = os.Setenv("ZHIYI_DASHBOARD_PATH", filepath.Join(dir, "dashboard.json"))
// 护栏②:关闭 distill 打分缓存(不读/不写生产 Redis L2)
_ = os.Setenv("ZHIYI_SCORE_CACHE", "0")
code := m.Run()
_ = os.RemoveAll(dir)
os.Exit(code)
}
+49
View File
@@ -0,0 +1,49 @@
// 织忆 MemoryWeave — 召回链路追踪端点(P0-1,2026-10-26)
//
// GET /api/v1/trace/<trace_id>
//
// 返回该次召回的:各阶段耗时(encode/coarse_search/fts_recall/bm25/rerank_fusion/mmr_select/
// graph_expand/dedupe)、各通道贡献条数(channels)、每条结果的来源归因(items)、
// status/partial_reason、scope、cache_hit/truncated。
//
// 数据来源:内存环形缓冲(最近 2000 条)+ JSONL 落盘(/var/lib/memoryweave/traces/),
// 服务重启后仍可按 trace_id 回溯(落盘扫描)。
package routes
import (
"context"
"net/http"
"strings"
"time"
"github.com/xiaoxue/memoryweave/internal/storage"
)
// ── P0-3 召回硬超时:取消函数类型 + context.WithTimeout 包装 ──────────────
// core.go Recall() 在 req.DeadlineMS>0 时需要带类型标记的取消函数,以便 defer 取消。
// 2026-10-26 (t_e98cb1d6):此处补齐使 routes 包可编译(否则 routes 全包不可构建,
// capRecallResults 的单测无法跑通)。这是缺失的声明,不是功能实现;P0-3 的 trace 完整
// 接线(tr.Stage 埋点、tr 落盘)仍待 P0-3 本任务补齐。
type ctxDeadlineFunc context.CancelFunc
func contextWithTimeout(ctx context.Context, timeout time.Duration) (context.Context, ctxDeadlineFunc) {
c, cancel := context.WithTimeout(ctx, timeout)
return c, ctxDeadlineFunc(cancel)
}
// Trace 查询单条召回链路。路径形如 /api/v1/trace/tr_xxxx。
func (a *API) Trace(w http.ResponseWriter, r *http.Request) {
id := strings.TrimSpace(strings.TrimPrefix(r.URL.Path, "/api/v1/trace/"))
if id == "" {
respondErrorSchema(w, 400, "trace_id_required", "用法:GET /api/v1/trace/<trace_id>", 0)
return
}
tr, ok := storage.GetTrace(id)
if !ok {
// 404 与「trace 过期/被环形缓冲覆盖」同义:调用方据此走「重新召回」而不是当成系统故障。
respondErrorSchema(w, 404, "trace_not_found",
"trace 不存在或已被环形缓冲覆盖(内存保留最近 2000 条,落盘按天分文件)", 0)
return
}
respond(w, 200, tr)
}
+249 -41
View File
@@ -2,11 +2,16 @@
package routes
import (
"encoding/json"
"log"
"net/http"
"os"
"strings"
"sync"
"time"
"github.com/xiaoxue/memoryweave/internal/selfoptimize"
"github.com/xiaoxue/memoryweave/internal/storage"
)
// TuningConfig 可自动调优的参数
@@ -21,6 +26,15 @@ type TuningConfig struct {
// 历史调参记录
history []TuningEvent
// lastSkipped 上一次 AutoTune 里「因指标不可用而跳过」的规则与原因。
// 让外部能区分「健康所以没调」与「数据不可用所以没调」——后者需要修数据源,不是好事。
lastSkipped []string
// lastMoveAt 每个参数上一次被改动的时间 —— 用于冷却(治棘轮)。
lastMoveAt map[string]time.Time
// persistEnabled 仅全局 Tuner 为 true;测试里构造的实例为 false ⇒ 不写盘。
persistEnabled bool
}
type TuningEvent struct {
@@ -32,11 +46,149 @@ type TuningEvent struct {
}
var Tuner = &TuningConfig{
Diversity: 0.5,
// ⚠️ 初值必须等于**真实消费方的默认值**,否则接线当天就改变生产行为:
// Diversity 对齐 storage.DefaultMMRDiversity() 的 0.3(core.go 原硬编码),原为 0.5。
Diversity: 0.3,
DecayRate: 0.015,
GapThreshold: 3,
DistillInterval: 5,
ConsolidateAfter: 50,
lastMoveAt: map[string]time.Time{},
persistEnabled: true,
}
// tuneCooldown 同一参数的调参冷却时间。
// 为什么需要:**AutoTune 不幂等** —— 同一判据每调一次就推一步(实测连跑两次:
// decay_rate 0.015→0.018→0.0216、distill_interval 5→4→3),直到撞上限。
// 闭环每天跑一次 ⇒ 几天内把参数顶到极值。冷却把「持续推进」限制成
// 「同一指标须持续越界多个窗口,才多次调整」。
const tuneCooldown = 6 * time.Hour
// minTuningSamples 调参判据的最小样本数。
// 为什么需要:小样本的比率噪声极大 —— 实测现网 recall_usefulness_rate 只有 9 个样本,
// 8 好 1 坏 = 0.89,再来 1 个好评就 > 0.9 ⇒ 触发「降低 diversity」直接改生产参数。
// 少样本不是证据。
const minTuningSamples = 30
// ratioFor 取一个「可用于调参」的比率指标,不可用则返回 ok=false。
//
// 约定(internal/selfoptimize/selfoptimize.go:248-322):
//
// <name>_rate —— 比率值;**分母退化时返回 -1**(metricNA),既不是 0 也不是 1
// <name>_samples —— 该比率的分母样本数
// <name>_valid —— 分母是否非退化
//
// ⚠️ AutoTune 原先只比较数值,无视这套约定 ⇒ `-1` 会让规则对「无数据」**恒真**:
//
// gap_closure_rate = -1 < 0.5 ⇒「降低 gap 阈值」每轮都触发(按垃圾指标改生产参数)
// recall_usefulness_rate 样本 9 条 ⇒ 一次偶然就改 Diversity
//
// 本函数把「无数据 / 退化 / 样本不足」三类一律判为不可用 —— 宁可不动。
// ⚠️ 命名不是统一的:比率键为 `<base>_rate`,而 *样本/有效* 键挂在 **base** 上
// 而不是带 _rate 的那个串上 —— 实际键是 `recall_usefulness_samples`,
// 不是 `recall_usefulness_rate_samples`(我第一版就按后者拼,护栏静默失效、
// 参数照样被改;新加的 tuning_guard_test.go 当场逮住)。
// 且 `avg_distill_loss` 这类**值键本身不带 _rate 后缀** ⇒ 统一用 TrimSuffix 推 base。
func ratioFor(metrics map[string]float64, valueKey string) (float64, bool) {
base := strings.TrimSuffix(valueKey, "_rate")
v, ok := metrics[valueKey]
if !ok || v < 0 {
return 0, false // 缺失 或 -1 哨兵 = 无数据
}
if valid, ok := metrics[base+"_valid"]; ok && valid == 0 {
return 0, false // 分母退化
}
if samples, ok := metrics[base+"_samples"]; ok && samples < minTuningSamples {
return 0, false // 样本不足以支撑判断
}
return v, true
}
// canMove 判断某参数当前是否允许改动(冷却窗口内不许重复动 —— 治棘轮)。
func (tc *TuningConfig) canMove(param string) bool {
last, ok := tc.lastMoveAt[param]
return !ok || time.Since(last) >= tuneCooldown
}
func (tc *TuningConfig) markMove(param string) {
if tc.lastMoveAt == nil {
tc.lastMoveAt = map[string]time.Time{}
}
tc.lastMoveAt[param] = time.Now()
}
// pushToConsumers 把当前参数推给**真实消费方**。
// 🔴 这一步是「调参有没有用」的分水岭:2026-10-14 实测,此前 Tuner 的全部字段
// 没有任何读者(全仓只有 3 处路由注册引用它)⇒ 调参改了数字、行为零变化。
func (tc *TuningConfig) pushToConsumers() {
storage.SetDefaultMMRDiversity(tc.Diversity)
selfoptimize.SetGapThreshold(tc.GapThreshold)
}
// tuningStatePath 持久化路径(可用 ZHIYI_TUNING_STATE 覆盖,便于测试/隔离)。
func tuningStatePath() string {
if p := os.Getenv("ZHIYI_TUNING_STATE"); p != "" {
return p
}
return "/var/lib/memoryweave/tuning-state.json"
}
type tuningState struct {
Diversity float64 `json:"diversity"`
GapThreshold int `json:"gap_threshold"`
LastMoveAt map[string]string `json:"last_move_at"`
UpdatedAt string `json:"updated_at"`
}
// persist 落盘(失败只告警,不影响调参本身)。
func (tc *TuningConfig) persist() {
if !tc.persistEnabled {
return
}
st := tuningState{Diversity: tc.Diversity, GapThreshold: tc.GapThreshold,
LastMoveAt: map[string]string{}, UpdatedAt: time.Now().Format(time.RFC3339)}
for k, v := range tc.lastMoveAt {
st.LastMoveAt[k] = v.Format(time.RFC3339)
}
data, err := json.MarshalIndent(st, "", " ")
if err != nil {
log.Printf("[tuning] 序列化失败: %v", err)
return
}
if err := os.WriteFile(tuningStatePath(), data, 0o644); err != nil {
log.Printf("[tuning] 落盘失败: %v", err)
}
}
// load 启动时载入持久化参数(无文件则用默认值),并立即接线到消费方。
// 没有这一步,重启即回默认 ⇒ 调参成果每次重启清零。
func (tc *TuningConfig) load() {
data, err := os.ReadFile(tuningStatePath())
if err == nil {
var st tuningState
if json.Unmarshal(data, &st) == nil {
if st.Diversity > 0 {
tc.Diversity = st.Diversity
}
if st.GapThreshold > 0 {
tc.GapThreshold = st.GapThreshold
}
for k, v := range st.LastMoveAt {
if t, e := time.Parse(time.RFC3339, v); e == nil {
if tc.lastMoveAt == nil {
tc.lastMoveAt = map[string]time.Time{}
}
tc.lastMoveAt[k] = t
}
}
log.Printf("[tuning] 已载入持久化参数 diversity=%.3f gap_threshold=%d(%s)",
tc.Diversity, tc.GapThreshold, st.UpdatedAt)
}
}
tc.mu.Lock()
tc.pushToConsumers()
tc.mu.Unlock()
}
// AutoTune 基于仪表盘指标自动调参
@@ -45,61 +197,103 @@ func (tc *TuningConfig) AutoTune(metrics map[string]float64) []TuningEvent {
defer tc.mu.Unlock()
var events []TuningEvent
skipped := make([]string, 0, 5)
// 记一次「本规则为什么没跑」—— 让调用方能解释「为什么没调参」,
// 而不是只回 no_changes_needed(后者无法区分「健康」与「数据不可用」)。
note := func(rule, why string) {
skipped = append(skipped, rule+": "+why)
}
// 1. 召回命中率 < 0.5 → 提高 diversity(扩大搜索范围)
if hitRate, ok := metrics["recall_hit_rate"]; ok && hitRate < 0.5 {
// 1. 召回质量低 → 提高 diversity(扩大搜索范围)。
// 2026-10-17:原信号 recall_hit_rate 已退役(恒=1、从不及 0.5 ⇒ 本安全阀长期死寂)。
// 改用 recall_usefulness_rate(返回且被使用)作为召回质量的实时信号:< 0.5 ⇒ 召回太窄、扩大范围。
hitRate, hitOK := ratioFor(metrics, "recall_usefulness_rate")
if !hitOK {
note("rule1_usefulness", "指标不可用(无数据/退化/样本<20)")
}
if hitOK && hitRate < 0.5 && !tc.canMove("diversity") {
note("rule1_usefulness", "冷却中(距上次调整 <"+tuneCooldown.String()+")")
}
if hitOK && hitRate < 0.5 && tc.canMove("diversity") {
newDiversity := tc.Diversity + 0.1
if newDiversity > 1.0 {
newDiversity = 1.0
}
events = append(events, recordTuning(tc, "diversity", tc.Diversity, newDiversity, "recall_hit_rate < 0.5"))
events = append(events, recordTuning(tc, "diversity", tc.Diversity, newDiversity, "recall_usefulness_rate < 0.5"))
tc.Diversity = newDiversity
tc.markMove("diversity")
}
// 2. 召回有用率 > 0.9 → 降低 diversity(更精准)
if usefulRate, ok := metrics["recall_usefulness_rate"]; ok && usefulRate > 0.9 {
usefulRate, usefulOK := ratioFor(metrics, "recall_usefulness_rate")
if !usefulOK {
note("rule2_usefulness", "指标不可用(无数据/退化/样本<30)")
}
if usefulOK && usefulRate > 0.9 && !tc.canMove("diversity") {
note("rule2_usefulness", "冷却中(距上次调整 <"+tuneCooldown.String()+")")
}
if usefulOK && usefulRate > 0.9 && tc.canMove("diversity") {
newDiversity := tc.Diversity - 0.05
if newDiversity < 0.1 {
newDiversity = 0.1
}
events = append(events, recordTuning(tc, "diversity", tc.Diversity, newDiversity, "recall_usefulness_rate > 0.9"))
tc.Diversity = newDiversity
tc.markMove("diversity")
}
// 3. 废弃率 > 5/day → 提高衰减率(加快清理)
if deprecated, ok := metrics["deprecated_per_day"]; ok && deprecated > 5 {
newDecay := tc.DecayRate * 1.2
if newDecay > 0.05 {
newDecay = 0.05
}
events = append(events, recordTuning(tc, "decay_rate", tc.DecayRate, newDecay, "deprecated_per_day > 5"))
tc.DecayRate = newDecay
}
// 3.(2026-10-14 移除)废弃率 → 提高衰减率。
// 移除理由:这个方向危险。governance.NewForgetter 上方注释记载:
// 「2026-09-07 方案A: 0.015→0.03 曾致误删(36天库龄下 0-recall 长记忆全被清, 405字CNB经验被删)」。
// 自动调参朝这个方向推 = 把那条事故路重新铺上;而收益只是「加快清理」。
// 若要恢复,必须先有「误删可回滚 + 删除前抽样人工确认」的保护,不能只靠阈值。
// 4. 缺口闭环率 < 0.5 → 降低 gap 阈值
if gapRate, ok := metrics["gap_closure_rate"]; ok && gapRate < 0.5 {
gapRate, gapOK := ratioFor(metrics, "gap_closure_rate")
if !gapOK {
note("rule4_gap_threshold", "指标不可用(无数据/退化/样本<30)")
}
if gapOK && gapRate < 0.5 && !tc.canMove("gap_threshold") {
note("rule4_gap_threshold", "冷却中(距上次调整 <"+tuneCooldown.String()+")")
}
if gapOK && gapRate < 0.5 && tc.canMove("gap_threshold") {
newThreshold := tc.GapThreshold - 1
if newThreshold < 2 {
newThreshold = 2
}
events = append(events, recordTuningInt(tc, "gap_threshold", tc.GapThreshold, newThreshold, "gap_closure_rate < 0.5"))
tc.GapThreshold = newThreshold
tc.markMove("gap_threshold")
}
// 5. 蒸馏损失 > 0.3 → 更频繁蒸馏
if loss, ok := metrics["avg_distill_loss"]; ok && loss > 0.3 {
newInterval := tc.DistillInterval - 1
if newInterval < 1 {
newInterval = 1
}
events = append(events, recordTuningInt(tc, "distill_interval", tc.DistillInterval, newInterval, "avg_distill_loss > 0.3"))
tc.DistillInterval = newInterval
}
// 5.(2026-10-14 移除)蒸馏损失 → 缩短蒸馏间隔。
// 移除理由:**没有正确的消费方**。distill/engine.go:265 的 batchTimeout(5min)是
// 「队列冻结兜底 ticker」(2026-10-06 修的「生产者停止即永久冻结」安全网),
// **不是蒸馏节奏**。调安全网的周期治不了蒸馏质量 —— 接到错的地方比不接更坏。
// 若将来真有「蒸馏节奏」消费者,再恢复本规则并接对目标。
tc.history = append(tc.history, events...)
tc.lastSkipped = skipped
if len(events) > 0 {
tc.pushToConsumers() // 改了就必须接线,否则等于没改
tc.persist() // 不落盘则重启即丢
}
return events
}
// init 启动时载入持久化参数并接线(没有它:重启回默认 + 调参不生效)。
func init() { Tuner.load() }
// LastSkipped 返回上一次调参里被跳过的规则及原因(只读快照)。
func (tc *TuningConfig) LastSkipped() []string {
tc.mu.RLock()
defer tc.mu.RUnlock()
out := make([]string, len(tc.lastSkipped))
copy(out, tc.lastSkipped)
return out
}
func recordTuning(tc *TuningConfig, param string, oldVal, newVal float64, reason string) TuningEvent {
return TuningEvent{
Parameter: param,
@@ -135,7 +329,8 @@ func (tc *TuningConfig) StatusHandler(w http.ResponseWriter, r *http.Request) {
"distill_interval": tc.DistillInterval,
"consolidate_after": tc.ConsolidateAfter,
},
"history": tc.history,
"history": tc.history,
"last_skipped": tc.lastSkipped,
})
}
@@ -145,7 +340,11 @@ func (tc *TuningConfig) RunHandler(w http.ResponseWriter, r *http.Request) {
events := tc.AutoTune(metrics)
if len(events) == 0 {
respond(w, 200, map[string]string{"status": "no_changes_needed"})
respond(w, 200, map[string]interface{}{
"status": "no_changes_needed",
// 区分「健康」与「数据不可用」:后者要修数据源,而不是当作一切正常。
"skipped": tc.lastSkipped,
})
} else {
respond(w, 200, map[string]interface{}{
"status": "tuned",
@@ -157,25 +356,34 @@ func (tc *TuningConfig) RunHandler(w http.ResponseWriter, r *http.Request) {
// POST /api/v1/tuning/analytics — 运行因果分析
func (tc *TuningConfig) AnalyticsHandler(w http.ResponseWriter, r *http.Request) {
// 基于指标间的关系给出建议
m := selfoptimize.Dash.Metrics()
suggestions := []string{}
if m["recall_hit_rate"] < 0.5 && m["avg_distill_loss"] > 0.3 {
suggestions = append(suggestions, "蒸馏质量低导致召回率低 — 建议检查 Embedding 模型")
}
if m["deprecated_per_day"] > 10 {
suggestions = append(suggestions, "废弃率过高 — 正在流失大量记忆,检查冲突检测阈值")
}
if m["gap_closure_rate"] < 0.3 {
suggestions = append(suggestions, "缺口闭环率过低 — Agent 未回应缺口学习请求")
}
if m["auto_resolve_rate"] < 0.5 {
suggestions = append(suggestions, "自动裁决率低 — 规则覆盖不全,考虑扩展策略")
}
suggestions := AnalyticsSuggestions(m)
respond(w, 200, map[string]interface{}{
"metrics": m,
"suggestions": suggestions,
})
}
// AnalyticsSuggestions 依据指标间关系给出调优建议(纯函数,便于单测反例:N/A 不产生建议)。
//
// ⚠️ 每条建议的比率比较都必须先判 N/A(ratioFor 已封装:-1 哨兵 / valid=0 / 样本不足 ⇒ 不可用)。
// 若直接 `m[key] < 阈值`,-1 会恒真 ⇒ 无数据也误报建议(本卡 t_c70f385c 事故面)。
// deprecated_per_day 是计数(恒 ≥0,非比率),不走 ratioFor。
func AnalyticsSuggestions(m map[string]float64) []string {
suggestions := []string{}
if hr, ok := ratioFor(m, "recall_usefulness_rate"); ok && hr < 0.5 {
if dl, ok2 := ratioFor(m, "avg_distill_loss"); ok2 && dl > 0.3 {
suggestions = append(suggestions, "蒸馏质量低导致召回率低 — 建议检查 Embedding 模型")
}
}
if m["deprecated_per_day"] > 10 {
suggestions = append(suggestions, "废弃率过高 — 正在流失大量记忆,检查冲突检测阈值")
}
if g, ok := ratioFor(m, "gap_closure_rate"); ok && g < 0.3 {
suggestions = append(suggestions, "缺口闭环率过低 — Agent 未回应缺口学习请求")
}
if a, ok := ratioFor(m, "auto_resolve_rate"); ok && a < 0.5 {
suggestions = append(suggestions, "自动裁决率低 — 规则覆盖不全,考虑扩展策略")
}
return suggestions
}
@@ -0,0 +1,38 @@
// AnalyticsSuggestions N/A 哨兵护栏 —— 反例 + 正向对照。
//
// 本卡 t_c70f385c:AnalyticsHandler 的建议 builder 原为裸 `m[key] < 阈值`,
// -1 哨兵(样本不足/越界被拦)会让 `-1 < 0.5` 恒真 ⇒ 无数据也误报建议。
// 现统一走 ratioFor(已封装 N/A + valid=0 + 样本不足判定)。
package routes
import "testing"
// TestAnalyticsSuggestions_NA_NoFiring 反例:全 N/A 哨兵(-1)不产生任何建议(避免误报)。
func TestAnalyticsSuggestions_NA_NoFiring(t *testing.T) {
na := map[string]float64{
"recall_usefulness_rate": -1, "recall_usefulness_samples": 0, "recall_usefulness_valid": 0,
"avg_distill_loss": -1, "avg_distill_loss_samples": 0, "avg_distill_loss_valid": 0,
"gap_closure_rate": -1, "gap_closure_samples": 0, "gap_closure_valid": 0,
"auto_resolve_rate": -1, "auto_resolve_samples": 0, "auto_resolve_valid": 0,
"deprecated_per_day": 0,
}
if s := AnalyticsSuggestions(na); len(s) != 0 {
t.Fatalf("全 N/A 不应产生任何建议,实际 %d 条: %v", len(s), s)
}
}
// TestAnalyticsSuggestions_ValidLow 正向对照:有效且低于阈值时产生建议(护栏没把功能焊死)。
func TestAnalyticsSuggestions_ValidLow(t *testing.T) {
m := map[string]float64{
"recall_usefulness_rate": 0.2, "recall_usefulness_samples": 100, "recall_usefulness_valid": 1,
"gap_closure_rate": 0.1, "gap_closure_samples": 100, "gap_closure_valid": 1,
"auto_resolve_rate": 0.2, "auto_resolve_samples": 100, "auto_resolve_valid": 1,
"avg_distill_loss": 0.1, "avg_distill_loss_samples": 100, "avg_distill_loss_valid": 1,
"deprecated_per_day": 0,
}
s := AnalyticsSuggestions(m)
// 至少触发「gap 闭环率过低」+「自动裁决率低」两条
if len(s) < 2 {
t.Fatalf("有效且低于阈值应产生 ≥2 条建议,实际 %d 条: %v", len(s), s)
}
}
+125
View File
@@ -0,0 +1,125 @@
package routes
import (
"testing"
"time"
"github.com/xiaoxue/memoryweave/internal/selfoptimize"
"github.com/xiaoxue/memoryweave/internal/storage"
)
// TestAutoTuneGuard_SentinelAndSamples 双向验证护栏:
// ① 无数据(-1 哨兵)不许改参数 —— 这是事故面:gap_closure_rate=-1 会让
//
// 「降低 gap 阈值」每轮恒真。
//
// ② 样本不足(<30)不许改参数 —— 少样本比率噪声极大。
// ③ 数据健康时仍然要能调 —— 否则等于把功能焊死。
func TestAutoTuneGuard_SentinelAndSamples(t *testing.T) {
// ① 哨兵值:全 -1 ⇒ 一个事件都不许有
tc := &TuningConfig{Diversity: 0.5, DecayRate: 0.015, GapThreshold: 3, DistillInterval: 5, ConsolidateAfter: 50}
na := map[string]float64{
"gap_closure_rate": -1,
"gap_closure_samples": 0,
"gap_closure_valid": 0,
"recall_usefulness_rate": -1,
"avg_distill_loss": -1,
"recall_usefulness_valid": 0,
"avg_distill_loss_valid": 0,
}
if ev := tc.AutoTune(na); len(ev) != 0 {
t.Fatalf("哨兵值(-1)不应触发任何调参,实际触发 %d 条: %+v", len(ev), ev)
}
if tc.GapThreshold != 3 || tc.Diversity != 0.5 {
t.Fatalf("参数被改动:GapThreshold=%d Diversity=%v", tc.GapThreshold, tc.Diversity)
}
if len(tc.LastSkipped()) == 0 {
t.Fatal("应记录跳过原因(否则无法区分『健康』与『数据不可用』)")
}
// ② 样本不足:比率本身达标(>0.9)但只有 9 个样本 ⇒ 不许动
tc2 := &TuningConfig{Diversity: 0.5, DecayRate: 0.015, GapThreshold: 3, DistillInterval: 5, ConsolidateAfter: 50}
low := map[string]float64{
"recall_usefulness_rate": 0.95,
"recall_usefulness_samples": 9,
"recall_usefulness_valid": 1,
}
if ev := tc2.AutoTune(low); len(ev) != 0 {
t.Fatalf("样本 9<30 不应触发调参,实际 %d 条: %+v", len(ev), ev)
}
// ③ 数据健康 ⇒ 必须能调(证明护栏没把功能焊死)
tc3 := &TuningConfig{Diversity: 0.5, DecayRate: 0.015, GapThreshold: 3, DistillInterval: 5, ConsolidateAfter: 50}
good := map[string]float64{
"recall_usefulness_rate": 0.95,
"recall_usefulness_samples": 100,
"recall_usefulness_valid": 1,
}
ev := tc3.AutoTune(good)
if len(ev) != 1 {
t.Fatalf("健康数据应触发 1 条调参,实际 %d 条", len(ev))
}
if tc3.Diversity >= 0.5 {
t.Fatalf("Diversity 应被降低,实际 %v", tc3.Diversity)
}
}
// TestAutoTuneCooldown 冷却:同一参数在冷却窗口内不许被重复推进(治棘轮)。
// 事故面(实测):AutoTune 不幂等 —— 连跑两次 decay 0.015→0.018→0.0216。
func TestAutoTuneCooldown(t *testing.T) {
tc := &TuningConfig{Diversity: 0.5, DecayRate: 0.015, GapThreshold: 3, DistillInterval: 5, ConsolidateAfter: 50}
m := map[string]float64{
"recall_usefulness_rate": 0.95,
"recall_usefulness_samples": 100,
"recall_usefulness_valid": 1,
}
if ev := tc.AutoTune(m); len(ev) != 1 {
t.Fatalf("第一次应调 1 条,实际 %d", len(ev))
}
first := tc.Diversity
if ev := tc.AutoTune(m); len(ev) != 0 {
t.Fatalf("冷却窗口内不应再调,实际 %d 条: %+v", len(ev), ev)
}
if tc.Diversity != first {
t.Fatalf("冷却期内参数被改动:%v → %v(棘轮复发)", first, tc.Diversity)
}
}
// TestAutoTunePushesToConsumers 推送:改完参数必须真的到消费方手里,
// 否则就是「调参改了没人读的数字」(2026-10-14 实测的原状)。
func TestAutoTunePushesToConsumers(t *testing.T) {
orig := storage.DefaultMMRDiversity()
defer storage.SetDefaultMMRDiversity(orig)
tc := &TuningConfig{Diversity: 0.5, GapThreshold: 3}
m := map[string]float64{
"gap_closure_rate": 0.1,
"gap_closure_samples": 100,
"gap_closure_valid": 1,
}
before := selfoptimize.GapThreshold()
ev := tc.AutoTune(m)
if len(ev) != 1 {
t.Fatalf("应触发 1 条(gap 阈值),实际 %d", len(ev))
}
if tc.GapThreshold != before-1 {
t.Fatalf("期望阈值 %d→%d,实际 %d", before, before-1, tc.GapThreshold)
}
if got := selfoptimize.GapThreshold(); got != tc.GapThreshold {
t.Fatalf("没推到消费方:selfoptimize.GapThreshold()=%d,Tuner=%d", got, tc.GapThreshold)
}
}
// TestTuningPersistence 持久化:换实例后能从盘上读回(否则重启即回默认)。
func TestTuningPersistence(t *testing.T) {
dir := t.TempDir()
t.Setenv("ZHIYI_TUNING_STATE", dir+"/tuning-state.json")
a := &TuningConfig{Diversity: 0.42, GapThreshold: 4, persistEnabled: true, lastMoveAt: map[string]time.Time{}}
a.persist()
b := &TuningConfig{Diversity: 0.3, GapThreshold: 3, lastMoveAt: map[string]time.Time{}}
b.load()
if b.Diversity != 0.42 || b.GapThreshold != 4 {
t.Fatalf("未读回持久化参数:diversity=%v gap=%v", b.Diversity, b.GapThreshold)
}
}
@@ -80,10 +80,14 @@ func (m *mockLance) IncrementUseful(id string) error { r
func (m *mockLance) IncrementNotUseful(id string) error { return nil }
// GA-P0-1 (t_718d8707):拆分字段
func (m *mockLance) IncrementHumanNotUseful(id string) error { return nil }
func (m *mockLance) AppendHitEvent(memoryID, outcome, reason string) error { return nil }
func (m *mockLance) CountHitEvents(memoryID, outcome string, since time.Time) (int, error) { return 0, nil }
func (m *mockLance) GetHitEvents(memoryID string, limit int) ([]models.HitEvent, error) { return nil, nil }
func (m *mockLance) IncrementHumanNotUseful(id string) error { return nil }
func (m *mockLance) AppendHitEvent(memoryID, outcome, reason string) error { return nil }
func (m *mockLance) CountHitEvents(memoryID, outcome string, since time.Time) (int, error) {
return 0, nil
}
func (m *mockLance) GetHitEvents(memoryID string, limit int) ([]models.HitEvent, error) {
return nil, nil
}
func (m *mockLance) IncrementDedupDownweight(id string) error { return nil }
func (m *mockLance) UpdateMemoryContent(id, newContent, source string) error { return nil }
func (m *mockLance) InsertMemory(rec models.MemoryRecord) error { return nil }
@@ -116,6 +120,7 @@ func (mockGraph) ListNodesByType(nodeType string, namespace string) []map[string
}
func (mockGraph) ListNodes(namespace string) []map[string]interface{} { return nil }
func (mockGraph) Stats() (int, int, float64) { return 0, 0, 0 }
func (mockGraph) CountNodesByType(string) (int, int, string) { return 0, 0, "" }
func (mockGraph) Prune(minWeight float64) {}
func (mockGraph) ExpandFromResults(results []models.RecallResult, namespace string, maxHops int) []models.RecallResult {
return results
+303 -33
View File
@@ -21,10 +21,14 @@ import (
"github.com/xiaoxue/memoryweave/internal/api/routes"
"github.com/xiaoxue/memoryweave/internal/distill"
"github.com/xiaoxue/memoryweave/internal/governance"
"github.com/xiaoxue/memoryweave/internal/memprotect"
"github.com/xiaoxue/memoryweave/internal/metrics"
"github.com/xiaoxue/memoryweave/internal/models"
"github.com/xiaoxue/memoryweave/internal/pattern"
"github.com/xiaoxue/memoryweave/internal/peer"
"github.com/xiaoxue/memoryweave/internal/redact"
"github.com/xiaoxue/memoryweave/internal/selfoptimize"
"github.com/xiaoxue/memoryweave/internal/skillgen"
"github.com/xiaoxue/memoryweave/internal/storage"
)
@@ -53,11 +57,28 @@ func NewServer() http.Handler {
// 存储后端
ldb := initStorageBackend(os.Getenv("STORAGE_BACKEND"), emb)
// 2026-10-13 (t_e9a4d18f P2) 常设答案库(独立 SQLite,Hindsight mental models)。
// 只读写不主动注入 ⇒ 部署后行为零变化。注入由 zhiyi-standing-answers.py 生成后
// 插件侧决定是否直读。路径可经 ZHIYI_STANDING_DB 覆盖。
storage.InitStandingStore(os.Getenv("ZHIYI_STANDING_DB"))
// 召回元数据「累积延迟更新」缓冲(2026-09-12 优化B)
// 读路径不再逐条写 LanceDB(MVCC 每写一行一个版本 → _versions 膨胀根因),
// 改为窗口内合并 + 单事务批量提交(每批 1 个版本)。
storage.InitRecallWriteBuffer(ldb)
// 2026-10-14 (t_2afae95e) FTS 词面通道:给 TF-IDF 索引挂全量数据源(ScanAll,
// 只读 IPC lancedb_scan_all;不建 LanceDB tantivy 索引文件 ⇒ 零写 LanceDB)。
// 索引本体在 storage 包内后台建(启动 3s 首建 + 6h 周期 + 写路径标脏)。
// 门控 ZHIYI_RECALL_FTS 默认关 ⇒ 接线了也不改召回行为(AC4 零变化)。
if rc, ok := ldb.(*storage.RustLanceDBClient); ok {
storage.FtsProvider.FtsLdb = rc.ScanAll
log.Printf("[fts] 词面通道数据源已挂(ScanAll);门控 ZHIYI_RECALL_FTS=%s(默认关)",
os.Getenv("ZHIYI_RECALL_FTS"))
} else {
log.Printf("[fts] 后端非 RustLanceDBClient(%T)⇒ 词面通道数据源未挂(关态无影响)", ldb)
}
// 软删除累积延迟更新缓冲(2026-09-12 优化C)
// 软删除操作不再逐条写 LanceDB(每写一行一个版本 → _versions 膨胀),
// 改为窗口内合并 + 单事务批量提交(每批 1 个版本)。
@@ -72,7 +93,13 @@ func NewServer() http.Handler {
// 冲突检测器(E4.1: Commit 时矛盾检测依赖此实例)
// 2026-09-20:改用带持久化的构造 —— 此前 active 是纯内存 map,zhiyid 重启即丢
// (实测 17:17 重启后 pending 冲突全部消失)。落盘失败不致命,降级为内存模式并记日志。
conflictDetector, cdErr := governance.NewPersistentConflictDetector("/var/lib/memoryweave/conflicts.json")
// 2026-10-18 (t_23b6a940):ZHIYI_CONFLICTS_DIR 覆盖落盘目录(副本/评估场景隔离
// telemetry 写);未设 → 生产默认路径(行为不变)
confDir := os.Getenv("ZHIYI_CONFLICTS_DIR")
if confDir == "" {
confDir = "/var/lib/memoryweave"
}
conflictDetector, cdErr := governance.NewPersistentConflictDetector(filepath.Join(confDir, "conflicts.json"), filepath.Join(confDir, "conflict-seen.json"))
if cdErr != nil {
log.Printf("[conflict] 持久化加载失败,降级为内存模式: %v", cdErr)
}
@@ -112,6 +139,17 @@ func NewServer() http.Handler {
}
}()
// t_cf680fad:启动即 drain durable pending-softdelete 队列。
// 上一轮(尤其重启窗口 / 崩掉那轮)因 MVCC 写风暴耗尽而被入队的失败软删,
// 此刻风暴已退、sidecar 刚就绪,重放必落盘 —— 这是「不修则每次重启窗口都留新残留」的根治。
go func() {
time.Sleep(2 * time.Second) // 等 sidecar 完全就绪(比 metrics 的 1s 稍晚,避免抢首提交)
if n := storage.PendingSoftDeleteCount(); n > 0 {
log.Printf("[pending-softdelete] 启动发现 %d 条待重放,开始 drain", n)
storage.DrainPendingSoftDeletes(ldb)
}
}()
// 图谱(带 Navigate 结果缓存:TTL 5min,容量 500,热点实体缓存命中加速)
// 已在上面初始化
graphUpdater := governance.NewAutoGraphUpdater(graphStore)
@@ -147,6 +185,45 @@ func NewServer() http.Handler {
consolPipe := routes.NewConsolidationPipeline(ldb, graphStore, conflictDetector)
consolPipe.SetDataDir("/var/lib/memoryweave/lancedb", "/var/lib/memoryweave/graph.db")
// ─── t_d32e5f8a L2 模式检测(MemOS 血统)─────────────────────────
// 复活 L1→L2 pattern 蒸馏(原 Python daemon main_loop 退役后停摆):
// pattern 节点存 graph_nodes(type='pattern'),与旧 daemon 残留的 85 行
// type='pattern_stat' 同表但不同类,不另开新表。
// modernc sqlite 短命单发开/关(UpsertPatterns/ListPatterns 开一次用一次即关),
// 不持长连接、不碰 state.db —— 规避当年退役根因。
// 召回注入 env 门控 ZHIYI_PATTERN_RECALL,默认 0 ⇒ 不注册 Provider,行为零变化。
graphPath := os.Getenv("GRAPH_PATH")
if graphPath == "" {
graphPath = "/var/lib/memoryweave/graph.db"
}
patternStore := pattern.NewGraphPatternStore(graphPath)
patternProvider := pattern.NewPatternProvider(patternStore, pattern.NewEmbedderProvider(emb))
patternAPI := routes.NewPatternAPI(patternStore, patternProvider, func() storage.LanceDB { return ldb })
// L3 skill 草稿(t_fcadee04):装配生成器 + skills 表(graph.db 同库)+ 落盘根。
// 显式 API(draft/promote)触发,不挂 cron、不改注入行为 ⇒ 默认零行为变化。
patternAPI.SkillStore = skillgen.NewStore(graphPath)
patternAPI.Generator = skillgen.NewLocal2BGenerator()
patternAPI.AttachmentsDir = os.Getenv("ZHIYI_SKILL_ATTACHMENTS")
if os.Getenv("ZHIYI_PATTERN_RECALL") == "1" {
storage.PatternRecallProvider = patternProvider.Match
log.Printf("[zhiyid] L2 模式召回注入已启用(ZHIYI_PATTERN_RECALL=1)")
} else {
log.Printf("[zhiyid] L2 模式召回注入默认关(ZHIYI_PATTERN_RECALL 未设/≠1 ⇒ 零行为变化)")
}
// ─── t_f8fc97e6 P3 Peer Representation(Honcho 血统,设计 §5)────────────
// 四层推理画像(explicit→deductive→inductive→abductive)存 graph.db 的 peers 表。
// refresh/explain 是显式 API(不挂 cron);召回注入 env 门控 ZHIYI_PEER_RECALL 默认 0
// ⇒ 主召回管线默认零行为变化(AC2)。推理引擎生产复用本地 2B(8081 免费腿)。
peerStore := peer.NewStore(graphPath)
peerAPI := routes.NewPeerAPI(peerStore, peer.NewLocal2BReasoner(), func() storage.LanceDB { return ldb }, graphStore)
if os.Getenv("ZHIYI_PEER_RECALL") == "1" {
storage.PeerRecallProvider = peerAPI.GetRecallResults
log.Printf("[zhiyid] P3 Peer 推理画像召回注入已启用(ZHIYI_PEER_RECALL=1)")
} else {
log.Printf("[zhiyid] P3 Peer 推理画像召回注入默认关(ZHIYI_PEER_RECALL 未设/≠1 ⇒ 零行为变化)")
}
// Obsidian
obsidian := routes.NewObsidianSyncer("", ldb)
@@ -162,6 +239,14 @@ func NewServer() http.Handler {
}
distillEngine := distill.NewEngine(llmEndpoint, llmModel, llmAPIKey)
routes.DistillEngineRef = distillEngine
// t_e11207f8 治源①:加载共享「空壳正文」判据(zhiyi_payload_guard_rules.json,Python/Go 同源)。
// 失败 ⇒ fail-open(distill 照跑、不拦空壳、不崩),/api/v1/payload-guard/stats 暴露 loaded=false。
if ok, lerr := distill.LoadPayloadGuard(""); ok {
_, _, _, rp := distill.PayloadGuardStats()
log.Printf("[distill][payload-guard] 空壳判据已加载 rules=%s", rp)
} else {
log.Printf("[distill][payload-guard] ⚠️ 共享判据未加载(fail-open,不拦空壳): %v", lerr)
}
// 蒸馏完成 → 自动图谱更新 + 冲突检测 + 被动验证 + 回写内存
distill.OnDistillComplete = func(input distill.DistillInput, result distill.DistillResult) {
entityNames := make([]string, len(result.Entities))
@@ -182,22 +267,47 @@ func NewServer() http.Handler {
// 只要有任何一条真的 InsertMemory 成功 ⇒ 该 namespace 的 query 缓存作废
//("先写再问" 场景:写完之后立刻可被之前问过但当时还没内容的答案命中)。
factsLanded := false
for _, fact := range result.Facts {
// 2026-09-10 P0:直写路径同样脱敏(这里绕过 FilterQualityFacts,
// 事故里泄漏进镜像的记忆正是从这条路径入库的)。
// 2026-10-13 t_286d1310: 写库闸门同样感知准入分诊结论(lane)。
// 此前这里独立跑 IsQualityFact 长度启发式——lane=keep 的决策类内容,
// 其 <20 字碎片在 insert 前仍被第三次独立丢弃(t_2ae69bb2 只穿透到
// fact 级,没穿透到写库级)⇒ DB 里 derived_from=<episode> 0 行。
// 复用 engine.go 同一函数 FilterQualityFactsWithLane(判据单一入口,
// 不复制):
// LaneKeep → 只保留 纯疑问/状态汇报 两条硬否定,跳过全部长度类规则
// LaneDrop / LaneObserve → 委托 FilterQualityFacts,与 IsQualityFact 逐字一致
// ClassifyAdmission 是确定性分诊(同一输入永远同一 lane),此处重算即
// engine.go:537 在蒸馏时算出的同一条 lane。
lane, laneWhy := distill.ClassifyAdmission(input.Content)
writeFacts := distill.FilterQualityFactsWithLane(result.Facts, lane)
if before := len(result.Facts); before > 0 && len(writeFacts) != before {
log.Printf("[distill][LANE] write-gate dropped %d/%d facts for %s (lane=%s, %s)",
before-len(writeFacts), before, input.EpisodeID, lane, laneWhy)
}
for _, fact := range writeFacts {
// 2026-09-10 P0:直写路径同样脱敏(FilterQualityFactsWithLane 已内部脱敏,
// 这里再脱一次为幂等保险——防止任何中间层注入明文密钥。事故里泄漏进
// 镜像的记忆正是从这条路径入库的)。
fact = redact.RedactSecrets(fact)
if !distill.IsQualityFact(fact) {
continue // 碎片/噪声过滤
// t_e11207f8 治源①:空壳拒写(单一判据 = 共享 zhiyi_payload_guard_rules.json,与 Python 同源)。
// 蒸馏 LLM 偶发把「只有溯源指针/占位/纯日期、无知识载荷」的碎片当 fact(如 `证据:文件:…|日期:…`),
// 长度/实体启发式(IsQualityFact)抓不住 ⇒ 落库前用 payload-guard 拦下(未加载时 fail-open)。
if distill.ShellCheck(fact) {
log.Printf("[distill][payload-guard] 拒写空壳蒸馏 fact episode=%s fact=%.40s", input.EpisodeID, fact)
continue
}
// 查重: 同 namespace 已存在**语义等价**记忆 → 跳过
// 必须用 cosine 近重复判据(≥0.98),不能只比字符串相等——
// 否则 "牧尘在招商局工作" 与 "牧尘在招商局工作。"(多一个句号)
// 会被判为两条不同事实双双入库,造成近重复堆积。
var skip bool
if qvec, eErr := emb.EncodeSingle(fact); eErr == nil {
if sim, _ := ldb.Search("memories", qvec, 5, input.Namespace); len(sim) > 0 {
for _, ex := range sim {
if ex.Content == fact || cosineSimilarity(qvec, ex.Vector) >= 0.98 {
var qvec []float32
var sim []models.MemoryRecord
if v, eErr := emb.EncodeSingle(fact); eErr == nil {
qvec = v
if found, _ := ldb.Search("memories", v, 5, input.Namespace); len(found) > 0 {
sim = found
for _, ex := range found {
if ex.Content == fact || cosineSimilarity(v, ex.Vector) >= 0.98 {
skip = true
break
}
@@ -207,6 +317,38 @@ func NewServer() http.Handler {
if skip {
continue
}
// 2026-10-14 (t_3e98b65a):写时四操作判定(影子默认 ⇒ 零行为变化)。
// 蒸馏产出的每条候选事实,对照 top-5 相似现有记忆判 ADD/UPDATE/NOOP/CONFLICT:
// shadow:只记影子日志(本文件 write_ops.go),不真改库;apply:按判定执行。
// 复用上面已算出的 qvec/sim(不重复编码),hash 去重兜底在 JudgeWriteOp 前。
// t_a0831457 P0 解耦:门控**只决定标记动作**(supersede/conflict);基础
// InsertMemory 是 pre-feature 基线行为,必须无条件执行(shadow/off 下
// ApplyWriteOp 恒返回 shouldInsert=true ⇒ 下方不再 continue ⇒ 基础插入照跑)。
// 唯一丢弃路径 = apply + NOOP(显式记 warning,含候选内容,禁静默)。
if distill.WriteOpsEnabled() {
if dupID, dup := distill.WriteOpHashDedup(fact, sim); dup {
log.Printf("%s", distill.DedupNote(dupID, fact))
continue
}
sims := distill.FromMemoryRecords(sim, qvec)
verdict := distill.JudgeWriteOp(fact, sims.TopN(5), nil, nil)
if verdict != nil {
shouldInsert, err := distill.ApplyWriteOp(*verdict, &ldbWriteOpsStore{ldb: ldb}, memIDFor(fact, input))
if err != nil {
// apply 标记失败(MarkSuperseded/MarkConflict 落库失败):
// 丢弃本条(不做半截标记),显式记 warning(禁静默失败)。
log.Printf("[write-ops] apply 标记失败,本条事实丢弃: %v candidate=%.40s", err, fact)
continue
}
if !shouldInsert {
// 唯一丢弃路径:apply + NOOP(影子/off 不会到这里,见上)
log.Printf("[write-ops] apply NOOP 丢弃事实(判定=NOOP,候选=%.40s)", fact)
continue
}
}
// verdict==nil(off 未跑判定;理论死分支——off 不进 WriteOpsEnabled 块)
// 或 shadow(标记零调用)⇒ 基础插入无条件执行。
}
mem := models.MemoryRecord{
ID: fmt.Sprintf("mem_%d", time.Now().UnixNano()),
AgentID: input.AgentID,
@@ -219,6 +361,9 @@ func NewServer() http.Handler {
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
DerivedFrom: input.EpisodeID,
// 2026-10-13 (t_e9a4d18f P1) 证据链:全量来源 episode ID(蒸馏直写当前单亲,
// 单条证据;多 episode 聚合由 consolidate 路径补充)
SourceEvents: []string{input.EpisodeID},
Freshness: "fresh",
Source: srcOrLLMDistill(input.Source), // 2026-09-26:有笔记出处→"wiki:<路径>";否则沿用 llm_distill(2026-09-07 方案C provenance)
// 2026-10-08 (t_7e3577c5):血缘字段——写入方经 metadata 携带;缺省兜底
@@ -228,6 +373,12 @@ func NewServer() http.Handler {
// 2026-10-08 (Gap#2):信任等级透传到产物(nil 保持 NULL,不伪装 0.0)
Confidence: input.Confidence,
ConfidenceSource: input.ConfidenceSource,
// 2026-10-14 (t_3ada332b):bi-temporal 有效期 + provenance。写时识别时间敏感事实
// (含「这周/临时/截止/到 X 之前」等)自动填 valid_to(区间末端);来源归一 provenance
// (user|inferred|import)。开关默认关 ZHIYI_BITEMPORAL=0 ⇒ 读路径行为零变化。
ValidFrom: time.Now().Format(time.RFC3339),
ValidTo: distill.DetectValidTo(fact, time.Now()),
Provenance: distill.DetectProvenance(input.Source),
}
log.Printf("[srcprobe] 蒸馏落库 episode=%s input.Source=%q → mem.Source=%q", input.EpisodeID, input.Source, mem.Source)
if err := ldb.InsertMemory(mem); err != nil {
@@ -243,6 +394,9 @@ func NewServer() http.Handler {
storage.SearchCacheInstance.Invalidate(input.Namespace)
log.Printf("[cache] 蒸馏落库后失效 namespace=%s(%d 条 facts,episode=%s)",
input.Namespace, len(result.Facts), input.EpisodeID)
// 2026-10-14 (t_2afae95e):FTS 词面索引同步标脏(惰性重建,
// 下次词面查询时后台刷;关态下只是置一个 bool,零成本)
storage.FtsInvalidate()
}
// 冲突检测:加载已有记忆进行比较
// 必须用**新内容的真实向量**做语义检索取近邻;用零向量在语义索引上
@@ -314,6 +468,12 @@ func NewServer() http.Handler {
// 注入 V 值查询函数到存储层(召回排序融合)
storage.VValueProvider = func(memoryID string) float64 {
// 2026-10-13:ep_* 是蒸馏自证(恒 1.0),零区分度且淹没真实 mem_* 信号
// (实测 ep_* 4304 个 vs mem_* 24 个)。召回目标全是 mem_*,ep_* 的 V 值
// 不参与排序即可,无需删 Redis 数据(保留可回溯)。
if strings.HasPrefix(memoryID, "ep_") {
return 0
}
return vPropagator.GetMemVValue(memoryID)
}
// 在蒸馏完成回调中记录 V 值
@@ -331,8 +491,8 @@ func NewServer() http.Handler {
"distill", "success", "",
)
}
// 更新仪表盘指标
selfoptimize.Dash.RecordUseful()
// 更新仪表盘指标:蒸馏成功次数(2026-10-14 t_c06ad4d0 改,勿再灌 UsefulCount)
selfoptimize.Dash.RecordDistillSuccess()
// 记录蒸馏质量损失(avg_distill_loss = 1 - Overall)
// 仅记录真实蒸馏:Overall > 0 表示 LLM 成功返回,过滤 fallback (Overall=0)
if result.Overall > 0 {
@@ -360,6 +520,18 @@ func NewServer() http.Handler {
mux.HandleFunc("/health", routes.HandleHealth)
mux.HandleFunc("/api/v1/health", routes.HandleHealth)
// t_e11207f8 治源①:空壳拦截器「确实跑过」的取证端点。
// checks=每个蒸馏 fact 判一次的心跳计数(>0 即拦截器活着);blocks=实际拒写空壳条数;
// loaded=false 表示共享判据没加载上(fail-open,需排查 ZHIYI_PAYLOAD_GUARD_RULES 路径)。
mux.HandleFunc("/api/v1/payload-guard/stats", func(w http.ResponseWriter, r *http.Request) {
checks, blocks, loaded, rp := distill.PayloadGuardStats()
respondJSON(w, 200, map[string]interface{}{
"checks": checks,
"blocks": blocks,
"loaded": loaded,
"rules_path": rp,
})
})
// 图谱缓存统计 + 搜索缓存统计(t_1c12e000:并入 search_cache 段,
// 暴露 hits/misses/invalidated/size/ttl,写频对命中率的影响可观测)
mux.HandleFunc("/api/v1/cache/stats", func(w http.ResponseWriter, r *http.Request) {
@@ -372,8 +544,32 @@ func NewServer() http.Handler {
if storage.SearchCacheInstance != nil {
resp["search_cache"] = storage.SearchCacheInstance.Stats()
}
// 2026-10-14 (t_2afae95e):FTS 词面索引状态(docs/tokens/built_at/dirty/门控)
resp["fts_index"] = storage.FtsIndexStats()
respondJSON(w, 200, resp)
})
// 2026-10-14 (t_2afae95e):FTS 手工刷索引(admin;异步重建不阻塞)
mux.HandleFunc("/api/v1/fts/rebuild", func(w http.ResponseWriter, r *http.Request) {
storage.FtsRebuildNow()
respondJSON(w, 200, map[string]interface{}{"status": "rebuild_started"})
})
// 2026-10-14 (t_2afae95e):FTS 词面查询取证端点(AC1 判据命令):
// 返回该 query 的词面 topN 命中(id + 分 + 内容摘录),不走路过 recall 缓存。
mux.HandleFunc("/api/v1/fts/query", func(w http.ResponseWriter, r *http.Request) {
var req struct {
Query string `json:"query"`
TopN int `json:"top_n"`
CheckDocID string `json:"check_doc_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
if req.TopN <= 0 {
req.TopN = 10
}
respondJSON(w, 200, storage.FtsQuery(req.Query, req.TopN, req.CheckDocID))
})
// ─── Prometheus /metrics ─────────────────────
mux.Handle("/metrics", metrics.Handler())
@@ -397,15 +593,37 @@ func NewServer() http.Handler {
governance.PushCacheInvalidate(req.Namespace, "commit")
governance.PushMemoryCommitted(req.AgentID, req.Namespace, "")
}
// t_f8fc97e6 P3 Peer:新记忆进入 → 异步刷新内容里出现的实体画像。
// 默认关(ZHIYI_PEER_RECALL 未设/≠1)⇒ 本分支整体跳过,commit 路径零变化(AC4)。
// 开启时在已有 goroutine 内再起子 goroutine,LLM 推理绝不阻塞 HTTP commit 返回。
if os.Getenv("ZHIYI_PEER_RECALL") == "1" && peerAPI != nil {
peerAPI.RefreshFromContent(req.Content, req.Namespace)
}
}()
})
mux.HandleFunc("/api/v1/recall", api.Recall)
mux.HandleFunc("/api/v1/recall/debug", api.RecallDebug)
// 2026-10-26 (P0-1 t_trace_p0):召回链路追踪回溯(trace_id 贯穿各阶段/各通道)
mux.HandleFunc("/api/v1/trace/", api.Trace)
// t_23b6a940 AC7 热门度看门狗快照(report-only,传感器端读它转既有告警通道)
mux.HandleFunc("/api/v1/popwatch", api.PopWatch)
// 2026-10-13 (t_e9a4d18f P2) 常设答案直读/写入
mux.HandleFunc("/api/v1/standing-answers", routes.HandleStandingAnswers)
// ⑤使用追踪(t_5aa4dcac):上报 + 手动触发。env 门控 ZHIYI_USAGE_TRACK,默认关。
mux.HandleFunc("/api/v1/usage/report", api.UsageReport)
mux.HandleFunc("/api/v1/usage/detect", api.UsageDetect)
// GA-P0-7 (t_dd64345a):裸 /api/v1/feedback 已退役——其 handler 曾写旧字段 not_useful_count。
// 反馈统一走 /api/v1/feedback/{useful,not-useful,deprecate,correct}(feedback.go,写拆分后新字段)。
mux.HandleFunc("/api/v1/bootstrap", api.Bootstrap)
mux.HandleFunc("/api/v1/stats", api.Stats)
// 2026-10-26(牧尘批「三档」):自检统一取数端点。
// 唯一契约 —— 自检脚本一律读这里,禁止再 lancedb.connect() 直连生产库。
// ?level=cheap(默认,内存态计数)| full(+读 tools/selfcheck_full.py 的全量快照:碎片/冗余/近7天质量/写量)
mux.HandleFunc("/api/v1/selfcheck", api.Selfcheck)
mux.HandleFunc("/api/v1/batch-commit", api.BatchCommit)
// 主症A(t_fa6c9104):显式写入直落 memories 表(嵌入 + 立刻可召回)。
// 纯新增路由:commit/recall/蒸馏路径零改动;旧服务端 404 时插件自动回落 commit。
mux.HandleFunc("/api/v1/memory/write", api.WriteMemory)
// 自优化指标(Dashboard,含 avg_distill_loss)
// total_episodes:Rust count_rows 真实值;旧 sidecar 回退 episodes_cached(进程内缓存,非总量)
mux.HandleFunc("/api/v1/metrics", func(w http.ResponseWriter, r *http.Request) {
@@ -972,27 +1190,12 @@ func NewServer() http.Handler {
mux.HandleFunc("/api/v1/conflicts/resolve", conflictAPI.Resolve)
// 反馈 — G5: 修正记忆时填充 version_history
// 2026-10-14 (t_c06ad4d0):删除冗余重复块。MarkUseful 已做 RecordFeedback(true)+
// RecordDecision+QualityMonitor.Check+IncrementUseful+AppendHitEvent;原 handler 里
// 这段二次读 r.Body 的 RecordUseful/RecordDecision/QualityMonitor 全部重复,且
// r.Body 已被 MarkUseful 消费(io.ReadAll 恒空)⇒ 死代码 + 潜在二次计数污染。
mux.HandleFunc("/api/v1/feedback/useful", func(w http.ResponseWriter, r *http.Request) {
feedbackAPI.MarkUseful(w, r)
// 质量监控:useful → 质量上升
var req struct {
MemoryID string `json:"memory_id"`
}
if r.Body != nil {
body, _ := io.ReadAll(r.Body)
json.Unmarshal(body, &req)
r.Body = io.NopCloser(bytes.NewReader(body))
if req.MemoryID != "" {
selfoptimize.Dash.RecordUseful()
// V 值:useful = success
selfoptimize.VProp.RecordDecision(
"useful_"+req.MemoryID,
[]string{req.MemoryID},
"user_feedback", "success", "",
)
selfoptimize.QualityMonitor.Check(req.MemoryID, 0, 5) // 清除潜在告警
}
}
})
mux.HandleFunc("/api/v1/feedback/not-useful", func(w http.ResponseWriter, r *http.Request) {
feedbackAPI.MarkNotUseful(w, r)
@@ -1042,6 +1245,16 @@ func NewServer() http.Handler {
// 缺口
mux.HandleFunc("/api/v1/gaps", gapAPI.List)
mux.HandleFunc("/api/v1/gaps/detect", gapAPI.Detect)
// t_d32e5f8a L2 模式检测(MemOS 血统)
mux.HandleFunc("/api/v1/patterns", patternAPI.List)
mux.HandleFunc("/api/v1/patterns/detect", patternAPI.Detect)
// t_fcadee04 L3 skill 草稿(/patterns/{id}/draft 与 /patterns/{id}/promote)。
// 前缀路由;/patterns 与 /patterns/detect 因更长的精确路由优先,不受影响。
mux.HandleFunc("/api/v1/patterns/", patternAPI.Sub)
// t_f8fc97e6 P3 Peer Representation:显式 refresh/explain + 门控 recall。
// 前缀路由 /api/v1/peer/;/api/v1/recall/peer 独立精确路由优先。
mux.HandleFunc("/api/v1/recall/peer", peerAPI.RecallPeer)
mux.HandleFunc("/api/v1/peer/", peerAPI.Sub)
mux.HandleFunc("/api/v1/gaps/close/", func(w http.ResponseWriter, r *http.Request) {
gapAPI.Close(w, r)
topic := r.URL.Path[len("/api/v1/gaps/close/"):]
@@ -1632,6 +1845,11 @@ func NewServer() http.Handler {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
// t_cf680fad:每个 tick 先 drain durable pending-softdelete 队列(风暴退去后的
// 相对静默窗口重放,必落盘)。队列空 = 廉价 no-op,不打日志;仅在有待处理时出声。
if n := storage.PendingSoftDeleteCount(); n > 0 {
storage.DrainPendingSoftDeletes(ldb)
}
for _, t := range []struct{ id, action string }{
{"t_distill", "distill"},
{"t_merge", "merge"},
@@ -1672,7 +1890,13 @@ func NewServer() http.Handler {
memories, e := ldb.GetCandidatesForForgetting()
decayScanned := 0
decayForgotten := 0
decayProtected := 0
if e == nil {
// 2026-10-16 (t_944344b5) 遗忘保护守卫:本 tick 加载一次保护状态
// (Redis zhiyi:vprop_values + zhiyi:protected_ids),命中任一保护规则的
// 候选永不自动软删(fragment 与 ShouldForget 两条道都拦截)。
// Redis 宕机 → 降级为纯行内规则(category/freshness/关键词),不阻断衰减。
protState := memprotect.LoadState()
const maxDecayBatch = 2000
if len(memories) > maxDecayBatch {
sort.SliceStable(memories, func(a, b int) bool {
@@ -1696,6 +1920,16 @@ func NewServer() http.Handler {
continue
}
}
// 🔒 2026-10-16 (t_944344b5) 遗忘保护守卫: 命中任一保护规则的候选
// (category∈{decision,preference,pref} / V≥2.0 / freshness=verified /
// protected_ids 名单 / distilled 且内容命中 10 个决策·偏好关键词) 永不自动软删。
// 拦截 point 放在 fragment + ShouldForget 两条 SoftDelete 道之前。
if isProt, protRule := memprotect.IsProtected(m, protState); isProt {
gid, _ := m["id"].(string)
decayProtected++
log.Printf("[decay] protected skip id=%q rule=%s (遗忘保护守卫命中, 跳过自动遗忘)", gid, protRule)
continue
}
// 解析 last_recalled_at
lastAccessed := time.Now().Add(-30 * 24 * time.Hour) // 默认30天前
if t, ok := m["last_recalled_at"].(string); ok && t != "" {
@@ -1756,7 +1990,8 @@ func NewServer() http.Handler {
}
// AC-1 验收日志(P1 修复 type 断言后 last_recalled_at 可解析;batch<=2000)
if decayScanned > 0 {
log.Printf("[decay] scanned=%d forgotten=%d (batch<=2000, 最久未访问优先)", decayScanned, decayForgotten)
// 2026-10-16 (t_944344b5):protected_skip = 被遗忘保护守卫拦截、未软删的候选数
log.Printf("[decay] scanned=%d forgotten=%d protected_skip=%d (batch<=2000, 最久未访问优先)", decayScanned, decayForgotten, decayProtected)
}
case "gap_scan":
// 检查已有缺口:过期 7 天的自动关闭
@@ -1770,7 +2005,12 @@ func NewServer() http.Handler {
for _, g := range gaps {
if !g.Closed && routes.GapRepair != nil {
if routes.GapRepair.AutoRepair(g) {
selfoptimize.Dash.RecordGapClosed()
// t_e5997ca4:AutoRepair 只做「修复」不关缺口。旧代码在此
// 无条件 Dash.RecordGapClosed() 却不置 g.Closed ⇒ 同一缺口每轮
// gap_scan 都再 +1(ClosedGaps 无限虚增,线上 9 vs 2 的写侧根因 #2)。
// 现按 RepairHandler 语义:修复成功 = 关闭该缺口;+1 只发生在
// Close 的 open→closed 迁移点(内部),下一轮 !g.Closed 即跳过。
gapDetector.Close(g.Topic)
}
}
}
@@ -1997,3 +2237,33 @@ func confidenceSourceOf(c *float64) string {
}
return ""
}
// ldbWriteOpsStore 适配 storage.LanceDB 到 distill.WriteOpsStore 接口。
// 供写时四操作的 apply 模式标记 superseded/conflict(t_3e98b65a)。
type ldbWriteOpsStore struct {
ldb storage.LanceDB
}
// MarkSuperseded 旧记忆标记「被 newID 取代」(写 superseded_by + is_deleted=true;
// 历史经 supersedes/superseded_by 保留可追溯,不物理删)。
func (s *ldbWriteOpsStore) MarkSuperseded(oldID, newID string) error {
b, _ := json.Marshal([]string{newID})
return s.ldb.Update("memories", oldID, map[string]any{
"superseded_by": string(b),
"is_deleted": true,
})
}
// MarkConflict 记忆标记冲突(交人工;不删)。冲突以 superseded_by 空 + is_deleted 保持 false
// + freshness=conflict 标识,让 recall 仍可见但排序权重降(调用方感知)。
func (s *ldbWriteOpsStore) MarkConflict(id string) error {
return s.ldb.Update("memories", id, map[string]any{
"freshness": "conflict",
})
}
// memIDFor 为写时四操作 apply 预生成新记忆 ID(与下方 InsertMemory 同格式)。
func memIDFor(fact string, input distill.DistillInput) string {
_ = fact
return fmt.Sprintf("mem_%d", time.Now().UnixNano())
}
+248
View File
@@ -0,0 +1,248 @@
// 织忆 MemoryWeave — 对抗审计插槽(②蒸馏环)
//
// 背景(2026-10-13,血统 HW_MyJarvis):织忆当前所有质量判断都是单模型自证
// (本地 2B 自己打分自己提取),实测已抓到四起同类病:蒸馏评分恒 0.755、
// ep_* 的 V 值恒 1.0(4304 条自证污染)、metrics 三完美值、avg_distill_loss
// 无人复核。共同病根:一个模型自己说自己好,没有异源复核。
//
// 本插槽解法:蒸馏产出的事实,按抽样比例送【异源模型】(非本地 2B)做对抗审计,
// 找矛盾 / 幻觉 / 无据。审计结论只写【影子日志】,不改任何丢弃/准入行为
// (与 ZHIYI_SCORE_SHADOW 同一纪律:先观察,不真拦)。
//
// ── 门控(默认关 ⇒ 零行为变化,硬要求)──────────────────────────
//
// ZHIYI_ADVERSARIAL_AUDIT=1 开启(默认关)
// ZHIYI_ADVERSARIAL_AUDIT_RATIO 抽样比例(默认 0.05 = 5%)
// ZHIYI_ADVERSARIAL_AUDIT_ENDPOINT 审计端点(默认 http://127.0.0.1:3000/v1/chat/completions,
// NewAPI 免费通道,异源——非本地 2B 的 8081)
// ZHIYI_ADVERSARIAL_AUDIT_MODEL 审计模型(默认 deepseek-v4-flash,免费腿)
// ZHIYI_ADVERSARIAL_AUDIT_KEY 审计 key(默认回退 LLM_API_KEY)
// ZHIYI_ADVERSARIAL_AUDIT_LOG 影子日志路径(默认 /var/lib/memoryweave/adversarial-audit.log)
// ZHIYI_ADVERSARIAL_AUDIT_TIMEOUT 单次审计超时秒(默认 60)
//
// 关闭路径:unset ZHIYI_ADVERSARIAL_AUDIT(或置 0)→ 完全恢复,不产生任何调用、
// 不写任何日志、不占任何资源。
//
// ── 设计约束 ────────────────────────────────────────────────
// · 异源:审计模型走 NewAPI 免费通道(127.0.0.1:3000),默认 deepseek-v4-flash,
// 与蒸馏主链的本地 2B(local/minicpm5-2b via 8081)不同源不同模型。
// · 零新依赖:只用 stdlib net/http / encoding/json / os。
// · 不碰 state.db:审计只写独立影子日志文件,不改存储、不新增常驻进程
// (审计 goroutine 在 zhiyid 进程内,随调用起落,非守护)。
// · 控并发:包级信号量 advAuditSem(默认 1),异步执行,绝不阻塞/打爆蒸馏主链。
package distill
import (
"encoding/json"
"fmt"
"log"
"math/rand"
"net/http"
"os"
"strings"
"sync"
"time"
)
// advAuditSem 控制对抗审计并发(默认 1:串行,最保守,不抢本地 2B 也不突刺 NewAPI)。
var advAuditSem = make(chan struct{}, 1)
// advAuditMu 保护影子日志文件的串行追加。
var advAuditMu sync.Mutex
// envStr 读字符串环境变量,缺省/空则用 def。
func envStr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
// adversarialAuditEnabled 是否开启对抗审计(运行时读 env,unset/0/其它 → 关)。
func adversarialAuditEnabled() bool {
v := os.Getenv("ZHIYI_ADVERSARIAL_AUDIT")
return v == "1" || strings.EqualFold(v, "true")
}
// MaybeAdversarialAudit 对抗审计入口(②环插槽)。影子模式:只写日志,不改行为。
// 门控关 → 立即返回(零行为变化);开 → 按抽样比例决定是否异步审计。
// 抽样为随机比例:命中才发一次异源审计;未命中/无事实/比例≤0 → 直接返回。
func MaybeAdversarialAudit(input DistillInput, facts []string) {
if !adversarialAuditEnabled() {
return
}
if len(facts) == 0 {
return
}
ratio := envFloat("ZHIYI_ADVERSARIAL_AUDIT_RATIO", 0.05)
if ratio <= 0 {
return
}
if rand.Float64() >= ratio {
return
}
// 异步执行 + 信号量控并发:取不到槽位则放弃本次(影子审计,不排队不阻塞)。
select {
case advAuditSem <- struct{}{}:
default:
log.Printf("[adv-audit] 并发槽已满,放弃本次抽样(episode=%s)", input.EpisodeID)
return
}
go func() {
defer func() { <-advAuditSem }()
if err := auditOne(input, facts); err != nil {
log.Printf("[adv-audit] 审计失败 episode=%s: %v", input.EpisodeID, err)
}
}()
}
// AuditVerdict 单条事实的对抗审计结论。
type AuditVerdict struct {
Fact string `json:"fact"`
Verdict string `json:"verdict"` // supported | contradiction | hallucination | no_evidence
Reason string `json:"reason"`
}
// auditOne 执行一次对抗审计:送异源模型 → 解析结论 → 写影子日志。
// 返回 error 供日志记录(审计失败绝不 panic,绝不触碰主链)。
func auditOne(input DistillInput, facts []string) error {
endpoint := envStr("ZHIYI_ADVERSARIAL_AUDIT_ENDPOINT",
"http://127.0.0.1:3000/v1/chat/completions")
model := envStr("ZHIYI_ADVERSARIAL_AUDIT_MODEL", "deepseek-v4-flash")
key := envStr("ZHIYI_ADVERSARIAL_AUDIT_KEY", "")
if key == "" {
key = os.Getenv("LLM_API_KEY")
}
timeout := time.Duration(envFloat("ZHIYI_ADVERSARIAL_AUDIT_TIMEOUT", 60)) * time.Second
raw, err := callAuditModel(endpoint, model, key, buildAuditPrompt(input.Content, facts), timeout)
if err != nil {
return err
}
verdicts := parseAuditVerdicts(raw)
flag := 0
for _, v := range verdicts {
if v.Verdict == "contradiction" || v.Verdict == "hallucination" || v.Verdict == "no_evidence" {
flag++
}
}
log.Printf("[adv-audit] episode=%s facts=%d verdicts=%d flagged=%d model=%s",
input.EpisodeID, len(facts), len(verdicts), flag, model)
for _, v := range verdicts {
appendAuditLog(input, v)
}
if len(verdicts) == 0 {
log.Printf("[adv-audit] 审计模型未返回可解析结论 episode=%s raw=%q", input.EpisodeID, truncate(raw, 200))
}
return nil
}
// buildAuditPrompt 构造对抗审计 prompt:给原文 + 提取的事实,要求逐条判定。
// 输出格式要求严格 JSON 数组(异源模型普遍稳定支持)。
func buildAuditPrompt(content string, facts []string) string {
var b strings.Builder
b.WriteString("你是一个对抗性记忆审计员。下面是一段原始内容,以及系统从中\"蒸馏提取\"出的事实条目。\n")
b.WriteString("你的任务:逐条判断每个事实是否被原文支持。\n\n")
b.WriteString("原文:\n")
b.WriteString(truncate(content, 1500))
b.WriteString("\n\n提取的事实:\n")
for i, f := range facts {
fmt.Fprintf(&b, "%d. %s\n", i+1, f)
}
b.WriteString("\n对每条事实输出一个JSON对象:{\"fact\":\"<事实原文>\",\"verdict\":\"supported|contradiction|hallucination|no_evidence\",\"reason\":\"<一句话理由>\"}。\n")
b.WriteString("判据:supported=原文明确支持;contradiction=与原文矛盾;hallucination=原文根本没提到(模型编造);no_evidence=原文不足以支撑该推断。\n")
b.WriteString("只输出一个JSON数组,元素顺序与事实条目顺序一一对应,不要输出任何其他文字。")
return b.String()
}
// callAuditModel 调异源审计模型,返回 message.content 原文。
func callAuditModel(endpoint, model, key, prompt string, timeout time.Duration) (string, error) {
body := map[string]interface{}{
"model": model,
"messages": []map[string]string{{"role": "user", "content": prompt}},
"temperature": 0.1,
"max_tokens": 2000,
}
jsonBody, err := json.Marshal(body)
if err != nil {
return "", err
}
req, err := http.NewRequest("POST", endpoint, strings.NewReader(string(jsonBody)))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
if key != "" {
req.Header.Set("Authorization", "Bearer "+key)
}
client := &http.Client{Timeout: timeout}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("audit endpoint HTTP %d", resp.StatusCode)
}
var out struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return "", err
}
if len(out.Choices) == 0 {
return "", fmt.Errorf("audit: no choices in response")
}
return out.Choices[0].Message.Content, nil
}
// parseAuditVerdicts 解析审计模型返回的 JSON 数组。健壮剥离 markdown fence 与
// 前后杂质(找第一个 [ 到匹配的 ]),解析失败返回空切片(不 panic)。
func parseAuditVerdicts(raw string) []AuditVerdict {
s := strings.TrimSpace(raw)
// 剥 markdown code fence
s = strings.TrimPrefix(s, "```json")
s = strings.TrimPrefix(s, "```")
s = strings.TrimSuffix(s, "```")
s = strings.TrimSpace(s)
// 找第一个 [ 和最后一个 ]
si := strings.Index(s, "[")
ei := strings.LastIndex(s, "]")
if si < 0 || ei <= si {
return nil
}
var verdicts []AuditVerdict
if err := json.Unmarshal([]byte(s[si:ei+1]), &verdicts); err != nil {
return nil
}
return verdicts
}
// appendAuditLog 追加一条审计结论到影子日志(JSONL,串行追加)。
// 路径运行时读 env;默认 /var/lib/memoryweave/adversarial-audit.log。
func appendAuditLog(input DistillInput, v AuditVerdict) {
path := envStr("ZHIYI_ADVERSARIAL_AUDIT_LOG", "/var/lib/memoryweave/adversarial-audit.log")
line := map[string]interface{}{
"ts": time.Now().Format(time.RFC3339),
"episode": input.EpisodeID,
"source": input.Source,
"namespace": input.Namespace,
"fact": v.Fact,
"verdict": v.Verdict,
"reason": v.Reason,
}
b, _ := json.Marshal(line)
advAuditMu.Lock()
defer advAuditMu.Unlock()
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
log.Printf("[adv-audit] 影子日志打开失败 %s: %v", path, err)
return
}
defer f.Close()
f.Write(append(b, '\n'))
}
@@ -0,0 +1,142 @@
package distill
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
// ─── 测试策略 ───────────────────────────────────────────────
// 对抗审计插槽是影子(只写日志不改行为),且 env 门控默认关。测试分四类:
// ① AC1 门控默认关 → 零行为变化:开启校验函数不写日志、不发起调用。
// ② AC5 关闭路径:unset env → 完全恢复。
// ③ AC2 打开门控 → 审计日志有记录(用 httptest 假端点,确定性地验真实日志行)。
// ④ 解析健壮性:模型返回带 markdown fence / 杂质时也能解析。
//
// 注意:审计异步(goroutine),测试里用同步版 auditOne 验真实日志写入。
// 返回一个假的审计 HTTP 端点,断言收到请求并返回固定 verdict 数组。
func fakeAuditServer(t *testing.T, verdictJSON string) (*httptest.Server, *int) {
t.Helper()
var hits int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits++
w.Header().Set("Content-Type", "application/json")
// 正确嵌套:把 verdict 数组作为 content 的字符串值序列化,避免换行/引号破坏外层 JSON
out, _ := json.Marshal(map[string]interface{}{
"choices": []map[string]interface{}{{"message": map[string]interface{}{"content": verdictJSON}}},
})
w.Write(out)
}))
t.Cleanup(srv.Close)
return srv, &hits
}
func TestAdvAuditGateOffNoSideEffect(t *testing.T) {
// AC1/AC5:门控默认关(不设 env),开启函数必须零副作用。
t.Setenv("ZHIYI_ADVERSARIAL_AUDIT", "")
tmp := filepath.Join(t.TempDir(), "audit.log")
t.Setenv("ZHIYI_ADVERSARIAL_AUDIT_LOG", tmp)
t.Setenv("ZHIYI_ADVERSARIAL_AUDIT_RATIO", "1.0") // 即便抽样=100%也必须不动作
in := DistillInput{EpisodeID: "ep_gate_off", Content: "牧尘决定把蒸馏模型换掉"}
MaybeAdversarialAudit(in, []string{"一条事实"})
if _, err := os.Stat(tmp); !os.IsNotExist(err) {
t.Fatalf("门控关时不应创建影子日志文件: %v", err)
}
}
func TestAdvAuditGateOffWhenDisabledExplicit(t *testing.T) {
// 显式置 0 → 关,零行为变化。
t.Setenv("ZHIYI_ADVERSARIAL_AUDIT", "0")
tmp := filepath.Join(t.TempDir(), "audit.log")
t.Setenv("ZHIYI_ADVERSARIAL_AUDIT_LOG", tmp)
MaybeAdversarialAudit(DistillInput{EpisodeID: "ep0"}, []string{"f"})
if _, err := os.Stat(tmp); !os.IsNotExist(err) {
t.Fatalf("ZHIYI_ADVERSARIAL_AUDIT=0 时应关,不应写日志: %v", err)
}
}
func TestAdvAuditGateOnWritesLog(t *testing.T) {
// AC2:打开门控 + 同步 auditOne → 影子日志有真实记录(≥3 条 verdict)。
verdicts := `[{"fact":"牧尘决定把蒸馏模型换掉","verdict":"supported","reason":"原文明确支持"},
{"fact":"牧尘喜欢苹果","verdict":"hallucination","reason":"原文未提及"},
{"fact":"牧尘讨厌苹果","verdict":"contradiction","reason":"与原文相反"}]`
srv, _ := fakeAuditServer(t, verdicts)
t.Setenv("ZHIYI_ADVERSARIAL_AUDIT", "1")
tmp := filepath.Join(t.TempDir(), "audit.log")
t.Setenv("ZHIYI_ADVERSARIAL_AUDIT_LOG", tmp)
t.Setenv("ZHIYI_ADVERSARIAL_AUDIT_ENDPOINT", srv.URL)
t.Setenv("ZHIYI_ADVERSARIAL_AUDIT_MODEL", "deepseek-v4-flash")
err := auditOne(DistillInput{EpisodeID: "ep1", Content: "牧尘决定把蒸馏模型换掉", Source: "wiki:test"}, []string{"牧尘决定把蒸馏模型换掉"})
if err != nil {
t.Fatalf("auditOne err: %v", err)
}
b, rerr := os.ReadFile(tmp)
if rerr != nil {
t.Fatalf("读影子日志失败: %v", rerr)
}
lines := strings.Split(strings.TrimSpace(string(b)), "\n")
if len(lines) < 3 {
t.Fatalf("影子日志应有 ≥3 条,实际 %d 条:\n%s", len(lines), string(b))
}
// 校验首行字段完整(episode / fact / verdict)
var row map[string]interface{}
if err := json.Unmarshal([]byte(lines[0]), &row); err != nil {
t.Fatalf("日志首行非 JSON: %v", err)
}
if row["episode"] != "ep1" || row["fact"] != "牧尘决定把蒸馏模型换掉" || row["verdict"] != "supported" {
t.Fatalf("日志行字段不完整: %v", row)
}
// 校验确有矛盾/幻觉判定
if !strings.Contains(string(b), "contradiction") || !strings.Contains(string(b), "hallucination") {
t.Fatalf("日志应含 contradiction 与 hallucination 判定:\n%s", string(b))
}
}
func TestAdvAuditParseMarkdownFence(t *testing.T) {
// 解析健壮性:模型输出带 ```json fence + 前后杂质。
raw := "好的,这是结果:\n```json\n[{\"fact\":\"A\",\"verdict\":\"supported\",\"reason\":\"r\"},{\"fact\":\"B\",\"verdict\":\"hallucination\",\"reason\":\"r2\"}]\n```"
v := parseAuditVerdicts(raw)
if len(v) != 2 {
t.Fatalf("应解析出 2 条 verdict,实际 %d: %+v", len(v), v)
}
if v[0].Verdict != "supported" || v[1].Verdict != "hallucination" {
t.Fatalf("verdict 解析错误: %+v", v)
}
}
func TestAdvAuditParseGarbage(t *testing.T) {
// 完全无法解析 → 空切片,不 panic。
if v := parseAuditVerdicts("抱歉,我无法完成该任务。"); v != nil && len(v) != 0 {
t.Fatalf("垃圾输入应返回空,实际 %+v", v)
}
if v := parseAuditVerdicts(""); len(v) != 0 {
t.Fatalf("空输入应返回空")
}
}
func TestAdvAuditModelIsDifferentSource(t *testing.T) {
// AC3:审计模型默认 = deepseek-v4-flash(NewAPI 免费腿),
// 与蒸馏主链的本地 2B(local/minicpm5-2b)不同源不同模型。
// 本测试校验默认端点/模型指向 NewAPI 而非本地 8081。
t.Setenv("ZHIYI_ADVERSARIAL_AUDIT", "1")
ep := envStr("ZHIYI_ADVERSARIAL_AUDIT_ENDPOINT", "http://127.0.0.1:3000/v1/chat/completions")
md := envStr("ZHIYI_ADVERSARIAL_AUDIT_MODEL", "deepseek-v4-flash")
if strings.Contains(ep, "8081") {
t.Fatalf("审计端点不得指向本地 2B(8081),实际 %s", ep)
}
if strings.Contains(md, "minicpm5") {
t.Fatalf("审计模型不得为本地 2B,实际 %s", md)
}
t.Logf("默认审计端点=%s 模型=%s(异源:非本地 2B)", ep, md)
}
+159
View File
@@ -0,0 +1,159 @@
// 织忆 MemoryWeave — bi-temporal 有效期检测 + provenance(t_3ada332b,血统 Zep/Graphiti)
//
// 设计(方案C「长期正确性」):时间敏感事实用【有效区间】(valid_from/valid_to) 而非删除。
// 双时间线:episode T'(CreatedAt,何时说的)+ 语义 T(valid_to 表达的失效终点)。
// non-lossy:失效只标区间,历史永久保留(可追溯、当前状态干净)。
// provenance:来源类型枚举 user|inferred|import —— 新语句不自动覆盖旧的(来源权威性)。
//
// ⚠️ 误报刹车(AC 止损线):时间敏感识别误报(把常驻事实标成临时的)>5% → 收紧识别规则
// 只认【强时间词】+【带触发上下文的显式日期】,宽口径一律不进(宁漏不误伤)。
// 返回空串 = 永久有效(NULL 语义,读路径不降权)。
package distill
import (
"regexp"
"strings"
"time"
)
// 显式日期(绝对日期)触发词:只有紧邻这些上下文才解析,防把「生日是3月15日」这类常驻事实标成临时。
var dateTriggerRe = regexp.MustCompile(
`截止到|截止|截至|到|持续到|有效到|用到|在|于|(之前|前|为止|为止\b)`)
// dateTokenRe 匹配一个日期 token(含触发上下文内/前的日期片段)。
// 支持:2006-01-02 / 2006/01/02 / 2006年01月02日 / 01月02日 / 1月2号 / 1月2日
var dateTokenRe = regexp.MustCompile(
`(?:(\d{4})[年/\-])?(\d{1,2})月(\d{1,2})[日号]|(\d{4})[-/](\d{1,2})[-/](\d{1,2})`)
// DetectValidTo 识别时间敏感事实的失效终点(RFC3339;空串 = 永久有效)。
// now 为「本事实写入时刻」(episode T' 的审计时间),据此计算语义 T 的区间末端。
func DetectValidTo(content string, now time.Time) string {
// 0) 强时间词先于显式日期("截止到这周" 这类,「这周」优先给周末)
// 只认【绝对强词】,避免「下」「这」单独出现的误判。
if s := matchStrongTimeWord(content, now); s != "" {
return s
}
// 1) 带触发上下文的显式日期(要求有 截止/到/之前/为止 等前后缀,否则太宽进)
if !dateTriggerRe.MatchString(content) {
return ""
}
if d := parseExplicitDate(content, now); d != "" {
return d
}
return ""
}
// DetectProvenance 从写入来源(source)字符串归一到 provenance 三枚举。
// 映射口径:显式 user/口头/直接用户 段 → user;import/导入/wiki(笔记通道) → import;
// agent/推断/inferred/llm/蒸馏 → inferred;其余兜底 → inferred。
func DetectProvenance(source string) string {
s := strings.ToLower(source)
switch {
case containsAny(s, []string{"user", "口头", "直接用户", "user_said", "直接说"}):
return "user"
case containsAny(s, []string{"import", "导入", "wiki:", "wiki/", "笔记通道", "从外部"}):
return "import"
case containsAny(s, []string{"agent", "inferred", "推断", "infer", "llm", "distill", "蒸馏", "autogen"}):
return "inferred"
default:
return "inferred"
}
}
// matchStrongTimeWord 只认强时间词,命中即返回对应区间末端。
func matchStrongTimeWord(content string, now time.Time) string {
// 顺序:长词在前避免「这个月」被「这」提前命中(我们只精确匹配完整词)。
type kw struct {
word string
end func(time.Time) time.Time
}
table := []kw{
{"这个月", endOfThisMonth},
{"本月", endOfThisMonth},
{"下周", endOfNextWeek},
{"这周", endOfThisWeek},
{"本周", endOfThisWeek},
{"今晚", endOfToday},
{"今天", endOfToday},
{"今日", endOfToday},
{"明天", endOfTomorrow},
{"临时", addDays7},
{"暂定", addDays7},
{"暂时", addDays7},
}
for _, k := range table {
if strings.Contains(content, k.word) {
return k.end(now).Format(time.RFC3339)
}
}
return ""
}
func endOfToday(now time.Time) time.Time {
return dayEnd(now)
}
func endOfTomorrow(now time.Time) time.Time {
return dayEnd(now.AddDate(0, 0, 1))
}
func endOfThisWeek(now time.Time) time.Time {
// 周日起 0,距下周日天数 = (7 - weekday) % 7(今天周日 → 0 → 今天)
days := (7 - int(now.Weekday())) % 7
return dayEnd(now.AddDate(0, 0, days))
}
func endOfNextWeek(now time.Time) time.Time {
days := (7 - int(now.Weekday())) % 7
return dayEnd(now.AddDate(0, 0, days+7))
}
func endOfThisMonth(now time.Time) time.Time {
first := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
return dayEnd(first.AddDate(0, 1, -1)) // 下月1号 -1天 = 月末
}
func addDays7(now time.Time) time.Time {
return now.Add(7 * 24 * time.Hour)
}
func dayEnd(t time.Time) time.Time {
y, m, d := t.Date()
return time.Date(y, m, d, 23, 59, 59, 0, t.Location())
}
// parseExplicitDate 在带触发词的上下文里找日期 token 并解析为「当日 23:59:59」。
func parseExplicitDate(content string, now time.Time) string {
loc := now.Location()
for _, m := range dateTokenRe.FindAllStringSubmatch(content, -1) {
var y, mo, d int
switch {
case m[2] != "" && m[3] != "":
// 月日式:M月D日 / M月D号(m[1] 可空=今年)
mo = atoiSafe(m[2])
d = atoiSafe(m[3])
y = now.Year()
if m[1] != "" {
y = atoiSafe(m[1])
}
case m[4] != "" && m[5] != "" && m[6] != "":
// 完整式:YYYY-MM-DD / YYYY/MM/DD
y = atoiSafe(m[4])
mo = atoiSafe(m[5])
d = atoiSafe(m[6])
default:
continue
}
if mo < 1 || mo > 12 || d < 1 || d > 31 {
continue
}
return dayEnd(time.Date(y, time.Month(mo), d, 0, 0, 0, 0, loc)).Format(time.RFC3339)
}
return ""
}
func atoiSafe(s string) int {
n := 0
for _, c := range s {
if c < '0' || c > '9' {
return 0
}
n = n*10 + int(c-'0')
}
return n
}
+111
View File
@@ -0,0 +1,111 @@
// 织忆 MemoryWeave — bi-temporal 写时识别测试(t_3ada332b)
//
// 验证:
// ① DetectValidTo:强时间词/带上下文显式日期 → 区间末端;常驻事实(无时间词/无触发上下文)→ ""(不误伤)
// ② DetectProvenance:user/inferred/import 三来源归一
package distill
import (
"strings"
"testing"
"time"
)
// 固定审计时刻:2026-10-14(周三)UTC,便于确定性断言。
var btNow = time.Date(2026, 10, 14, 10, 0, 0, 0, time.UTC)
func TestDetectValidTo_StrongTimeWords(t *testing.T) {
cases := []struct {
in string
want string // 期望 valid_to(RFC3339)
desc string
}{
{"牧尘这周在 Berlin", "2026-10-18T23:59:59Z", "这周→本周日"},
{"本周要发布版本", "2026-10-18T23:59:59Z", "本周→本周日"},
{"下周去开会", "2026-10-25T23:59:59Z", "下周→下周日"},
{"今天要交报告", "2026-10-14T23:59:59Z", "今天→当日末"},
{"明天安排体检", "2026-10-15T23:59:59Z", "明天→次日末"},
{"这个月完成迁移", "2026-10-31T23:59:59Z", "这个月→月末"},
{"临时使用测试机", "", "临时→(非精确,仅断言非空,见下)"}, // 临时 = now+7d,逐条单测
}
for _, c := range cases {
got := DetectValidTo(c.in, btNow)
if c.desc == "临时→(非精确,仅断言非空,见下)" {
if got == "" {
t.Fatalf("DetectValidTo(%q) 应非空(临时→+7天),实际空", c.in)
}
continue
}
if got != c.want {
t.Errorf("✗ DetectValidTo(%q)=%q,want %q(%s)", c.in, got, c.want, c.desc)
} else {
t.Logf("✓ %s: %q → %s", c.desc, c.in, got)
}
}
}
// 临时/暂定 → now + 7 天(精确)。
func TestDetectValidTo_Temporary(t *testing.T) {
got := DetectValidTo("临时使用测试机", btNow)
want := btNow.Add(7 * 24 * time.Hour).Format(time.RFC3339)
if got != want {
t.Fatalf("✗ 临时→now+7d:got %q want %q", got, want)
}
t.Logf("✓ 临时→now+7d: %s", got)
}
// 常驻事实不得被误判成临时(AC 止损线:误报>5% → 收紧只认强词,宽口径不进)。
func TestDetectValidTo_NoFalsePositive(t *testing.T) {
permanent := []string{
"牧尘住在北京", // 常驻地址
"牧尘在招商局工作", // 常驻工作
"牧尘生日是3月15日", // 显式日期但【无触发上下文】→ 不解析(防生日/纪念日被标临时)
"系统使用 PostgreSQL", // 技术选型
"我偏好简洁的回答", // 用户偏好
"当前模型是 deepseek-v4", // 当前配置(无临时词)
}
for _, in := range permanent {
if got := DetectValidTo(in, btNow); got != "" {
t.Errorf("✗ 常驻事实被误判临时: %q → %s(应空=永久有效)", in, got)
}
}
t.Logf("✓ %d 条常驻事实均未被误判(误报止损线)", len(permanent))
}
// 带触发上下文的显式日期才解析。
func TestDetectValidTo_ExplicitDateWithTrigger(t *testing.T) {
cases := []struct{ in, want string }{
{"截止到2026-03-15", "2026-03-15T23:59:59Z"},
{"合同截至2026年12月31日", "2026-12-31T23:59:59Z"},
{"这个权限在3月20日前有效", "2026-03-20T23:59:59Z"},
}
for _, c := range cases {
got := DetectValidTo(c.in, btNow)
if got != c.want {
t.Errorf("✗ DetectValidTo(%q)=%q want %q", c.in, got, c.want)
} else {
t.Logf("✓ %q → %s", c.in, got)
}
}
}
func TestDetectProvenance(t *testing.T) {
cases := []struct{ in, want string }{
{"user", "user"}, {"user said", "user"}, {"牧尘口头", "user"},
{"import", "import"}, {"wiki:xxx", "import"},
{"agent", "inferred"}, {"agent inferred", "inferred"}, {"llm_distill", "inferred"},
{"", "inferred"}, // 兜底 → inferred(保守,不误标 user/import)
}
for _, c := range cases {
if got := DetectProvenance(c.in); got != c.want {
t.Errorf("✗ DetectProvenance(%q)=%q want %q", c.in, got, c.want)
}
}
// 三来源各覆盖至少一次
if !strings.Contains("user inferred import", DetectProvenance("user")) ||
!strings.Contains("user inferred import", DetectProvenance("import")) ||
!strings.Contains("user inferred import", DetectProvenance("agent")) {
t.Fatal("三来源枚举未全覆盖")
}
t.Logf("✓ DetectProvenance 三来源归一 user/inferred/import 正确")
}
+17 -2
View File
@@ -96,6 +96,10 @@ type LLMResponse struct {
}
// LLM 5维权重
// 血统:借鉴 MemOS CREATE_EVAL_PROMPT(五维评估:IS 信息重要性 / SU 战略效用 /
// PA 实际适用性 / VD 验证持久性 / RU 召回可用性)。出处登记:
// ~/mc/小怡/01-织忆/01-01-定义规范/织忆-借鉴溯源-全清单.md §2.1。
// 取值 0.20/0.20/0.15/0.25/0.20 系 MemOS 血统参数,勿随意改(改前先研究分布)。
var Weights = FiveDScore{
IS: 0.20,
SU: 0.20,
@@ -114,6 +118,9 @@ var Weights = FiveDScore{
// 影子默认开启;设 ZHIYI_SCORE_SHADOW=0 可关(关掉 = 恢复按分数真丢)。
// echoScoreVectors 是历史 prompt 里出现过的示例评分向量(照抄即命中)。
// 血统:这些数值来自旧 prompt 的「示例评分」(MemOS CREATE_EVAL_PROMPT 血统的
// 五维评估,曾把示例写成 0.8/0.7/0.6/0.9/0.7 导致模型照抄)。出处登记:
// ~/mc/小怡/01-织忆/01-01-定义规范/织忆-借鉴溯源-全清单.md §2.1(恒 0.755 之谜)。
var echoScoreVectors = [][5]float64{
{0.8, 0.7, 0.6, 0.9, 0.7}, // engine.go 旧 prompt 示例
}
@@ -604,7 +611,10 @@ func (e *Engine) distillOne(input DistillInput) DistillResult {
// 规则(只保留纯疑问/状态汇报两条硬否定),避免「lane 说放行、fact 级
// 却全丢」的静默吞噬;LaneDrop/LaneObserve 行为与原来逐字一致。
if before := len(facts); before > 0 {
facts = FilterQualityFactsWithLane(facts, lane)
// t_ffcee190:env 门控 ZHIYI_SHORTFACT_KEEP_PASSTHROUGH(默认关)——对非规则卡
// observe episode 的短事实走 LaneKeep 穿透,救真实对话自然短句误杀;规则卡/门控关
// 时 effectiveFilterLane 原样返回 lane,行为与现状逐字一致。
facts = FilterQualityFactsWithLane(facts, effectiveFilterLane(input.Content, lane))
if dropped := before - len(facts); dropped > 0 {
log.Printf("[distill] quality gate dropped %d/%d facts for %s (lane=%s)", dropped, before, input.EpisodeID, lane)
}
@@ -616,6 +626,11 @@ func (e *Engine) distillOne(input DistillInput) DistillResult {
logAAAKIndex(index)
}
// 2026-10-13 对抗审计插槽(②环,env 门控默认关):蒸馏产出的最终事实
// 按抽样比例送【异源模型】做对抗审计,结论只写影子日志,不改丢弃/准入行为。
// 门控关/未命中抽样/无事实 → 本调用立即返回,零行为变化。
MaybeAdversarialAudit(input, facts)
return DistillResult{
Facts: facts,
Entities: entities,
@@ -1193,7 +1208,7 @@ func (e *Engine) checkDailyLimit() {
// emitResult 发送蒸馏结果(回调)
func (e *Engine) emitResult(input DistillInput, result DistillResult) {
if len(result.Facts) == 0 {
if len(result.Facts) == 0 && len(result.Entities) == 0 {
return
}
// 由注册的回调处理
@@ -0,0 +1,67 @@
package distill
import (
"sync"
"testing"
)
// ── 补收口三(2026-10-13 t_62f35e62):facts==0 早退 ⇒ 实体静默丢失 ──
// 此前 emitResult 在 `len(result.Facts)==0` 时整体 return,当 facts 全被质量
// 闸门丢掉时,整个回调不触发 ⇒ 实体也不进图(图只剩噪声的真半因,实测定:
// 领域内容 5 实体一个都没进图)。修复:把早退条件放宽为「facts 与 entities
// 都空」才 return——facts 为空时回调内的 fact 落库循环自然空转、factsLanded
// 保持 false,只有 §1 实体写图会执行(正是我们要的)。
// AC4:facts==0 但 entities>0 → 回调必须触发(实体仍进图)。
func TestEmitResult_FactsZero_EntitiesStillLand(t *testing.T) {
var mu sync.Mutex
var called bool
var gotResult DistillResult
prev := OnDistillComplete
OnDistillComplete = func(input DistillInput, result DistillResult) {
mu.Lock()
defer mu.Unlock()
called = true
gotResult = result
}
defer func() { OnDistillComplete = prev }()
e := NewEngine("http://127.0.0.1:1/unused", "m", "k")
// facts 全空(被质量闸门丢光)、entities 仍保留 2 个领域实体
e.emitResult(DistillInput{EpisodeID: "ep_xx", Content: "领域内容"},
DistillResult{
Facts: []string{},
Entities: []Entity{{Name: "鹤壁淇县生物制造产业专班", Type: "entity"}, {Name: "飞天农业", Type: "entity"}},
})
mu.Lock()
defer mu.Unlock()
if !called {
t.Fatalf("facts==0 且 entities>0:OnDistillComplete 应触发(实体才能进图),实际未触发")
}
if len(gotResult.Entities) != 2 {
t.Errorf("回调收到的 Entities 应为 2,实际 %d", len(gotResult.Entities))
}
}
// AC4 对照:facts==0 且 entities==0 → 回调不触发(空结果不扰回调)。
func TestEmitResult_FactsAndEntitiesZero_NoCallback(t *testing.T) {
var mu sync.Mutex
called := false
prev := OnDistillComplete
OnDistillComplete = func(DistillInput, DistillResult) {
mu.Lock()
defer mu.Unlock()
called = true
}
defer func() { OnDistillComplete = prev }()
e := NewEngine("http://127.0.0.1:1/unused", "m", "k")
e.emitResult(DistillInput{EpisodeID: "ep_yy"}, DistillResult{})
mu.Lock()
defer mu.Unlock()
if called {
t.Fatalf("facts==0 且 entities==0:回调不应触发,实际触发了")
}
}
+181
View File
@@ -0,0 +1,181 @@
// 织忆 MemoryWeave —— 写时四操作的 LLM 判定器(t_writeops_llm_judge,2026-10-25)
//
// 背景(离线回放实测,不是推测):
// · 规则基 defaultJudge 用 ZHIYI_WRITE_SIM_UPDATE=0.70 判 UPDATE,影子日志 11 天 17179 条里
// 14435 条(84%)被判「新事实应覆盖旧事实」;人工抽检 15 条低相似度样本,15/15 不是真覆盖
// (「用户查看知乎链接」vs「用户查看微信文章」都判成覆盖)。
// · 80 条分层回放(各相似度档 20 条,LLM 三分类):REPLACE 率 0.95+ 档 25%、0.90-0.95 档 35%、
// 0.85-0.90 档 35%、0.70-0.85 档 20% —— 与相似度**不单调**,说明相似度只反映「话题相关」,
// 不反映「是否同一属性的新旧值」。低档 LLM 同样会被话题带偏(0.70-0.85 档 REPLACE 精度约 25%)。
//
// 结论与实现:
// · 只有 sim >= ZHIYI_WRITE_JUDGE_SIM(默认 0.95)才调 LLM 判三分类;低于该档直接 ADD(不调 LLM,
// 省调用 + 该档不可信)。
// · REPLACE→UPDATE / DUPLICATE→NOOP / KEEP→ADD;判不准与调用失败一律兜底 ADD(宁可不覆盖)。
// · 默认 judge 模式仍是 rule(零行为变化);apply 模式强制要求 llm(见 write_ops.go 硬保护)。
package distill
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
)
const llmJudgeSystemPrompt = `你是记忆库的写入判定器。给你两条记忆:A=库里已有的旧事实,B=刚提取出的新事实。
判断 B 与 A 的关系,只回一个词,不要解释:
REPLACE = 同一主体的同一属性,B 是 A 的更新值/修正值(B 生效后 A 就过时了)
DUPLICATE= B 与 A 语义等价,B 没有新增信息
KEEP = B 补充/细化/扩展 A,或讲不同侧面/不同对象/不同阶段;两条都要保留
判不准时回 KEEP。`
// llmJudgeSimThreshold 低于此相似度直接 ADD(实测该档 LLM 判定精度不足,且省调用)。
const llmJudgeSimThresholdDefault = 0.95
// llmJudgeCall 可注入的 LLM 调用(单测替换,生产走 HTTP)。返回模型输出的原始文本。
var llmJudgeCall = callLLMJudgeModel
// LLMJudgeMode 当前 judge 模式:rule(默认)| llm。
func LLMJudgeMode() string {
m := strings.ToLower(strings.TrimSpace(os.Getenv("ZHIYI_WRITE_JUDGE")))
if m == "llm" {
return "llm"
}
return "rule"
}
// llmJudgeSimThreshold 判定相似度门槛(env 可调)。
func llmJudgeSimThreshold() float64 {
if v := strings.TrimSpace(os.Getenv("ZHIYI_WRITE_JUDGE_SIM")); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil && f > 0 && f <= 1 {
return f
}
}
return llmJudgeSimThresholdDefault
}
// LLMJudge 用 LLM 判「B 是否应取代 A」。
// 返回的 WriteVerdict 与 defaultJudge 同构,调用方无需区分。
func LLMJudge(candidate string, similar []SimilarMemory) WriteVerdict {
v := WriteVerdict{
Op: OpAdd,
Candidate: candidate,
Reason: "llm:无相似候选 → 新建",
Timestamp: time.Now().Format(time.RFC3339),
}
if len(similar) == 0 {
return v
}
top := similar[0] // 调用方已按相似度排序(TopN)
v.Similarity = top.Similarity
// 归一化完全等价 → NOOP,不调 LLM
if NormalizeContent(top.Content) == NormalizeContent(candidate) {
v.Op = OpNoop
v.TargetID = top.ID
v.Reason = "llm:归一化等价 → 丢弃"
return v
}
// 相似度不足 → ADD(实测低档 LLM 判定不可信,且省钱)
if top.Similarity < llmJudgeSimThreshold() {
v.Reason = fmt.Sprintf("llm:sim %.3f < %.2f → 不判,新建", top.Similarity, llmJudgeSimThreshold())
return v
}
raw, err := llmJudgeCall(candidate, top.Content)
if err != nil {
// 调用失败兜底 ADD:宁可不覆盖(漏覆盖可事后补,误覆盖会丢事实)
v.Reason = "llm:调用失败兜底新建"
log.Printf("[write-ops][llm-judge] 调用失败,兜底 ADD: %v candidate=%.40s", err, candidate)
return v
}
verdict := parseLLMJudgeVerdict(raw)
switch verdict {
case "REPLACE":
v.Op = OpUpdate
v.TargetID = top.ID
v.Reason = "llm:同一属性更新 → 旧条标 superseded_by"
case "DUPLICATE":
v.Op = OpNoop
v.TargetID = top.ID
v.Reason = "llm:语义等价 → 丢弃"
default: // KEEP / 无法解析
v.Reason = "llm:保留(补充/不同侧面)→ 新建"
}
return v
}
// parseLLMJudgeVerdict 从模型输出里取三分类词;取不到返回 KEEP。
func parseLLMJudgeVerdict(raw string) string {
u := strings.ToUpper(raw)
for _, k := range []string{"REPLACE", "DUPLICATE", "KEEP"} {
if strings.Contains(u, k) {
return k
}
}
return "KEEP"
}
// callLLMJudgeModel 调判定模型,返回 message.content。配置项与对抗审计同构。
func callLLMJudgeModel(candidate, existing string) (string, error) {
endpoint := envStr("ZHIYI_WRITE_JUDGE_ENDPOINT", "http://127.0.0.1:3000/v1/chat/completions")
model := envStr("ZHIYI_WRITE_JUDGE_MODEL", "agnes-2.5-flash")
key := envStr("ZHIYI_WRITE_JUDGE_KEY", "")
if key == "" {
key = os.Getenv("LLM_API_KEY")
}
timeout := time.Duration(envFloat("ZHIYI_WRITE_JUDGE_TIMEOUT", 30)) * time.Second
body := map[string]interface{}{
"model": model,
"messages": []map[string]string{
{"role": "system", "content": llmJudgeSystemPrompt},
{"role": "user", "content": fmt.Sprintf("A(旧): %s\n\nB(新): %s", existing, candidate)},
},
"temperature": 0,
"max_tokens": 100,
"reasoning_effort": "none", // 实测:不给 none 时 reasoning 会吃光 max_tokens,content 返空
}
jsonBody, err := json.Marshal(body)
if err != nil {
return "", err
}
req, err := http.NewRequest("POST", endpoint, strings.NewReader(string(jsonBody)))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
if key != "" {
req.Header.Set("Authorization", "Bearer "+key)
}
client := &http.Client{Timeout: timeout}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("judge HTTP %d: %.120s", resp.StatusCode, string(raw))
}
var parsed struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
if err := json.Unmarshal(raw, &parsed); err != nil {
return "", fmt.Errorf("judge 响应解析失败: %w", err)
}
if len(parsed.Choices) == 0 {
return "", fmt.Errorf("judge 响应无 choices")
}
return parsed.Choices[0].Message.Content, nil
}
+159
View File
@@ -0,0 +1,159 @@
package distill
import (
"errors"
"os"
"sync/atomic"
"testing"
)
// withFakeLLMJudge 注入假 LLM 调用,返回调用次数计数器。
func withFakeLLMJudge(t *testing.T, reply string, err error) *int32 {
t.Helper()
var calls int32
orig := llmJudgeCall
llmJudgeCall = func(candidate, existing string) (string, error) {
atomic.AddInt32(&calls, 1)
return reply, err
}
t.Cleanup(func() { llmJudgeCall = orig })
return &calls
}
func similarTop(id, content string, sim float64) []SimilarMemory {
return []SimilarMemory{{ID: id, Content: content, Similarity: sim}}
}
// 低相似度档(实测 LLM 判定不可信)必须短路,不调 LLM。
func TestLLMJudge_LowSimilaritySkipsLLM(t *testing.T) {
calls := withFakeLLMJudge(t, "REPLACE", nil)
v := LLMJudge("新事实", similarTop("mem_1", "旧事实", 0.80))
if v.Op != OpAdd {
t.Fatalf("sim 0.80 应判 ADD,得到 %s", v.Op)
}
if got := atomic.LoadInt32(calls); got != 0 {
t.Fatalf("sim 低于门槛时不应调用 LLM,实际调用 %d 次", got)
}
}
func TestLLMJudge_Replace(t *testing.T) {
withFakeLLMJudge(t, "REPLACE", nil)
v := LLMJudge("executions.db 台账 max_rowid 为 185769",
similarTop("mem_x", "executions.db 台账 max_rowid 为 183371", 0.97))
if v.Op != OpUpdate {
t.Fatalf("REPLACE 应判 UPDATE,得到 %s", v.Op)
}
if v.TargetID != "mem_x" {
t.Fatalf("UPDATE 必须带 TargetID,得到 %q", v.TargetID)
}
}
func TestLLMJudge_Duplicate(t *testing.T) {
withFakeLLMJudge(t, "DUPLICATE", nil)
v := LLMJudge("该软件支持PDF与Word互转", similarTop("mem_y", "工具支持PDF与Word互转", 0.96))
if v.Op != OpNoop {
t.Fatalf("DUPLICATE 应判 NOOP,得到 %s", v.Op)
}
}
// KEEP=补充/不同侧面 → 必须新建,不能覆盖。
func TestLLMJudge_Keep(t *testing.T) {
withFakeLLMJudge(t, "KEEP", nil)
v := LLMJudge("用户查看微信文章", similarTop("mem_z", "用户查看知乎链接", 0.97))
if v.Op != OpAdd {
t.Fatalf("KEEP 应判 ADD,得到 %s", v.Op)
}
if v.TargetID != "" {
t.Fatalf("ADD 不应带 TargetID,得到 %q", v.TargetID)
}
}
// LLM 调用失败必须兜底 ADD(宁可不覆盖)。
func TestLLMJudge_CallErrorFallsBackToAdd(t *testing.T) {
withFakeLLMJudge(t, "", errors.New("503 Service Unavailable"))
v := LLMJudge("新事实", similarTop("mem_1", "旧事实", 0.99))
if v.Op != OpAdd {
t.Fatalf("调用失败应兜底 ADD,得到 %s", v.Op)
}
}
// 归一化完全等价 → NOOP,且不调 LLM。
func TestLLMJudge_NormalizedEqualSkipsLLM(t *testing.T) {
calls := withFakeLLMJudge(t, "KEEP", nil)
v := LLMJudge("用户喜欢喝咖啡。", similarTop("mem_1", "用户喜欢喝咖啡", 0.97))
if v.Op != OpNoop {
t.Fatalf("归一化等价应判 NOOP,得到 %s", v.Op)
}
if got := atomic.LoadInt32(calls); got != 0 {
t.Fatalf("归一化等价不应调用 LLM,实际 %d 次", got)
}
}
func TestParseLLMJudgeVerdict(t *testing.T) {
cases := map[string]string{
"REPLACE": "REPLACE",
"duplicate": "DUPLICATE",
"我认为应该 KEEP 两条": "KEEP",
"": "KEEP",
"无法判断": "KEEP",
"REPLACE\n因为同一属性更新": "REPLACE",
}
for in, want := range cases {
if got := parseLLMJudgeVerdict(in); got != want {
t.Errorf("parse(%q)=%q,期望 %q", in, got, want)
}
}
}
// apply 模式必须强制 llm 判定;规则基判定精度实测≈0%,不许放行改库。
func TestWriteOpsApply_RequiresLLMJudge(t *testing.T) {
t.Setenv("ZHIYI_WRITE_SEMANTIC_OPS", "apply")
t.Setenv("ZHIYI_WRITE_JUDGE", "rule")
if writeOpsApply() {
t.Fatal("apply + rule 必须被拒绝(会误覆盖上万条),实际放行")
}
t.Setenv("ZHIYI_WRITE_JUDGE", "llm")
if !writeOpsApply() {
t.Fatal("apply + llm 应放行")
}
t.Setenv("ZHIYI_WRITE_SEMANTIC_OPS", "shadow")
t.Setenv("ZHIYI_WRITE_JUDGE", "llm")
if writeOpsApply() {
t.Fatal("shadow 模式不应改库")
}
}
func TestLLMJudge_ModeDefaultIsRule(t *testing.T) {
t.Setenv("ZHIYI_WRITE_JUDGE", "")
if LLMJudgeMode() != "rule" {
t.Fatalf("默认 judge 模式必须是 rule(零行为变化),得到 %s", LLMJudgeMode())
}
}
// TestLLMJudgeLive 真实 LLM 端到端验证(默认跳过;ZHIYI_LIVE_JUDGE=1 才跑)。
// 三条样本取自 2026-10-25 影子日志回放,人类已判定正确答案。
func TestLLMJudgeLive(t *testing.T) {
if os.Getenv("ZHIYI_LIVE_JUDGE") != "1" {
t.Skip("live LLM 测试,设 ZHIYI_LIVE_JUDGE=1 才跑")
}
cases := []struct {
name string
cand string
existing string
want WriteOp
}{
{"真覆盖-同一指标新值", "executions.db台账的max_rowid为185769。", "executions.db台账的max_rowid为183371", OpUpdate},
{"假覆盖-不同对象", "用户要求查看 https://mp.weixin.qq.com/s/4nmTHWoK 这篇文章", "用户: https://www.zhihu.com/pin/2075233714007757230", OpAdd},
{"重复-同义改写", "该软件支持PDF与Word/Excel/图片互转功能。", "工具转换功能支持PDF与Word/Excel/图片互转", OpNoop},
}
for _, c := range cases {
v := LLMJudge(c.cand, similarTop("mem_x", c.existing, 0.97))
t.Logf("%-18s → op=%s reason=%s", c.name, v.Op, v.Reason)
if v.Op != c.want {
t.Errorf("%s: 期望 %s,实际 %s(%s)", c.name, c.want, v.Op, v.Reason)
}
}
}
+1
View File
@@ -15,5 +15,6 @@ import (
// 缓存本身的正确性由 score_cache_test.go 直接测结构体(不依赖全局实例)。
func TestMain(m *testing.M) {
scoreCacheDisabled = true
loadPayloadRulesForTests() // t_e11207f8 治源①:单测前确保 payload guard 规则已加载(共享 JSON 或内嵌兜底)
os.Exit(m.Run())
}
+231
View File
@@ -0,0 +1,231 @@
package distill
// 织忆 MemoryWeave — 蒸馏落库「空壳正文」拒写门禁(t_e11207f8 治源 ①)
//
// 背景:OnDistillComplete(api/server.go)把 LLM 蒸馏出的 fact 直接 InsertMemory 进
// memories(category=distilled)。蒸馏 LLM 偶发把「只有溯源指针 / 占位 / 纯日期、无知识
// 载荷」的碎片当 fact 输出(例:`证据:文件:小怡/工作笔记/已办.md|日期:2026-09-23`),
// 质量门槛 IsQualityFact 的长度/实体启发式抓不住它(>20 字)⇒ 库里持续长出新空壳。
//
// 单一真相源:判据的**规则值**读自共享 JSON(zhiyi_payload_guard_rules.json),与
// Python 侧 ~/.hermes/scripts/lib/zhiyi_payload_guard.py **读同一份文件**(不在此手抄,
// 防跨语言漂移)。改规则只改那一个 JSON。
//
// 铁律(与 Python 逐位一致,宁漏勿误删):
// · 含任一句读标点(clause_punct)⇒ 绝不判空壳(保真优先)
// · 仅 空串 / 占位变量 / 纯日期 / 溯源指针(来源/证据/文件/日期/source + 引用值) 判空壳
//
// 已知跨语言 nuance(文档,不改行为方向):refname 正则里的 \w,Go RE2 默认 ASCII、
// Python 默认 unicode ⇒ CJK-only 文件名 Go 不判引用 ⇒ Go **多保留**(安全向,宁漏勿误删)。
//
// 无缓存、无新增 Redis 键(只用原子计数),故不触及项目现成件清单的缓存条目。
import (
"encoding/json"
"os"
"path/filepath"
"regexp"
"strings"
"sync/atomic"
"unicode/utf8"
)
// defaultRulesPath — 与 Python 侧同一份共享规则文件(同机同用户 muc 可直读)。
// env ZHIYI_PAYLOAD_GUARD_RULES 可覆盖(测试/部署用)。
func defaultRulesPath() string {
if p := os.Getenv("ZHIYI_PAYLOAD_GUARD_RULES"); p != "" {
return p
}
if home, err := os.UserHomeDir(); err == nil && home != "" {
return filepath.Join(home, ".hermes", "scripts", "lib", "zhiyi_payload_guard_rules.json")
}
return "/home/muc/.hermes/scripts/lib/zhiyi_payload_guard_rules.json"
}
// payloadGuardRules 镜像共享 JSON(只取 Go 判据需要的字段;未知字段忽略)。
type payloadGuardRules struct {
ClausePunct string `json:"clause_punct"`
Placeholder struct {
Pattern string `json:"pattern"`
CaseInsensitive bool `json:"case_insensitive"`
} `json:"placeholder"`
BareDate struct {
Pattern string `json:"pattern"`
CaseInsensitive bool `json:"case_insensitive"`
} `json:"bare_date"`
ProvLabel struct {
Pattern string `json:"pattern"`
CaseInsensitive bool `json:"case_insensitive"`
} `json:"prov_label"`
Refname struct {
Pattern string `json:"pattern"`
CaseInsensitive bool `json:"case_insensitive"`
} `json:"refname"`
RefValueSubstrings []string `json:"ref_value_substrings"`
RefValueMaxLen int `json:"ref_value_max_len"`
RefValueMaxCJK int `json:"ref_value_max_cjk"`
CJKLo string `json:"cjk_lo"`
CJKHi string `json:"cjk_hi"`
}
type guard struct {
rules payloadGuardRules
placeH *regexp.Regexp
bareD *regexp.Regexp
provL *regexp.Regexp
refname *regexp.Regexp
loaded bool
path string
}
var (
g *guard
// shellChecks = 拦截器「确实跑过」的心跳计数(每个蒸馏 fact 判一次);
// shellBlocks = 实际拦下的空壳条数。二者配对即证拦截器活着(0 blocks 但 checks>0 也合法)。
shellChecks int64
shellBlocks int64
)
// LoadPayloadGuard 读共享规则 JSON 并编译正则。失败 ⇒ 未加载(fail-open:distill 照跑、
// 不拦空壳、不崩 zhiyid),并把 loaded=false 记给 /api/v1/payload-guard/stats 暴露。
func LoadPayloadGuard(explicitPath string) (loaded bool, err error) {
p := explicitPath
if p == "" {
p = defaultRulesPath()
}
raw, rerr := os.ReadFile(p)
if rerr != nil {
g = &guard{}
return false, rerr
}
var rc payloadGuardRules
if jerr := json.Unmarshal(raw, &rc); jerr != nil {
g = &guard{}
return false, jerr
}
compile := func(pat string, ci bool) (*regexp.Regexp, error) {
if ci {
pat = "(?i)" + pat
}
return regexp.Compile(pat)
}
gh := &guard{rules: rc, path: p, loaded: true}
if gh.placeH, err = compile(rc.Placeholder.Pattern, rc.Placeholder.CaseInsensitive); err != nil {
g = &guard{}
return false, err
}
if gh.bareD, err = compile(rc.BareDate.Pattern, rc.BareDate.CaseInsensitive); err != nil {
g = &guard{}
return false, err
}
if gh.provL, err = compile(rc.ProvLabel.Pattern, rc.ProvLabel.CaseInsensitive); err != nil {
g = &guard{}
return false, err
}
if gh.refname, err = compile(rc.Refname.Pattern, rc.Refname.CaseInsensitive); err != nil {
g = &guard{}
return false, err
}
if rc.RefValueMaxLen <= 0 {
rc.RefValueMaxLen = 24
}
if rc.RefValueMaxCJK <= 0 {
rc.RefValueMaxCJK = 12
}
gh.rules = rc
g = gh
return true, nil
}
// PayloadGuardStats 供 /metrics 与验收:拦截器心跳(checks) / 拦截数(blocks) / 是否加载 / 规则路径。
func PayloadGuardStats() (checks, blocks int64, loaded bool, path string) {
if g == nil {
return atomic.LoadInt64(&shellChecks), atomic.LoadInt64(&shellBlocks), false, ""
}
return atomic.LoadInt64(&shellChecks), atomic.LoadInt64(&shellBlocks), g.loaded, g.path
}
// IncShellCheck / IncShellBlock 供写路径在判定前后调用(相对增量,避免并发写竞态)。
func IncShellCheck() { atomic.AddInt64(&shellChecks, 1) }
func IncShellBlock() { atomic.AddInt64(&shellBlocks, 1) }
// ShellCheck 治源入口(带心跳计数):
// - 未加载 ⇒ fail-open(放行、checks 仍 +1,保证「拦截器跑过」可观测,不拦也不崩);
// - 已加载 ⇒ 用共享判据判定,命中空壳则 blocks +1 并返回 true(写路径据此 continue 拒写)。
func ShellCheck(content string) bool {
IncShellCheck()
if g == nil || !g.loaded {
return false
}
if g.isEmptyShell(content) {
IncShellBlock()
return true
}
return false
}
// IsEmptyShell 纯判据(无计数,供单测)。未加载时就地读一次默认路径兜底;失败则 fail-open。
func IsEmptyShell(content string) bool {
if g == nil {
if _, err := LoadPayloadGuard(""); err != nil {
return false
}
}
return g.isEmptyShell(content)
}
func (gr *guard) isEmptyShell(content string) bool {
c := strings.TrimSpace(content)
if c == "" {
return true
}
// 含任一句读标点 ⇒ 有知识断言 ⇒ 绝不当空壳(保真优先,优先于漏删)
for _, r := range []rune(gr.rules.ClausePunct) {
if strings.ContainsRune(c, r) {
return false
}
}
if gr.placeH.MatchString(c) {
return true
}
if gr.bareD.MatchString(c) {
return true
}
if gr.provL.MatchString(c) && gr.isRefValue(c) {
return true
}
return false
}
func (gr *guard) isRefValue(c string) bool {
s := strings.TrimSpace(c)
for _, sub := range gr.rules.RefValueSubstrings {
if strings.Contains(c, sub) {
return true
}
}
if gr.bareD.MatchString(s) || gr.refname.MatchString(s) {
return true
}
if utf8.RuneCountInString(s) <= gr.rules.RefValueMaxLen && gr.cjkCount(s) < gr.rules.RefValueMaxCJK {
return true
}
return false
}
// cjkCount 统计 [cjk_lo, cjk_hi] 区间的汉字数(与 Python re.findall(r"[一-鿿]") 等价)。
func (gr *guard) cjkCount(s string) int {
lo, hi := gr.rules.CJKLo, gr.rules.CJKHi
if lo == "" || hi == "" {
lo, hi = "一", "鿿" // U+4E00 / U+9FFF
}
loR, _ := utf8.DecodeRuneInString(lo)
hiR, _ := utf8.DecodeRuneInString(hi)
n := 0
for _, r := range s {
if r >= loR && r <= hiR {
n++
}
}
return n
}
+99
View File
@@ -0,0 +1,99 @@
package distill
// t_e11207f8 治源 ① Go 单测:
// ① 真样本(溯源指针)必判空壳;② 负控(真知识短句 / 含句读)必保留;③ 拦截计数。
// 判据值优先读【生产共享 JSON】(~/.hermes/scripts/lib/zhiyi_payload_guard_rules.json),
// 与 Python 同源;该文件缺失时用下方内嵌副本兜底(仅测试用,非运行时真相源)。
import (
"os"
"path/filepath"
"testing"
)
const embeddedRulesJSON = `{
"clause_punct": ",。、;!?",
"placeholder": {"pattern": "^[xX]=(?:true|false|\\\\d|[a-z_]{1,12})$", "case_insensitive": true},
"bare_date": {"pattern": "^(?:日期[::]?)?\\\\d{4}[-/]\\\\d{1,2}[-/]\\\\d{1,2}\\\\.?[。.\\\\s]*$"},
"prov_label": {"pattern": "^(?:来源|证据|文件|日期|source)[::]", "case_insensitive": true},
"refname": {"pattern": "^[\\\\w.-]+\\\\.(?:md|py|sh|json|yaml|log|txt)$", "case_insensitive": true},
"ref_value_substrings": ["|", "vault:", "/"],
"ref_value_max_len": 24, "ref_value_max_cjk": 12,
"cjk_lo": "一", "cjk_hi": "鿿"
}`
func loadPayloadRulesForTests() {
// 优先生产共享 JSON(与 Python 同源);缺失则落内嵌副本到临时目录再读(保证测试可独立跑)。
p := os.Getenv("ZHIYI_PAYLOAD_GUARD_RULES")
if p == "" {
if home, err := os.UserHomeDir(); err == nil && home != "" {
p = filepath.Join(home, ".hermes", "scripts", "lib", "zhiyi_payload_guard_rules.json")
}
}
if _, err := LoadPayloadGuard(p); err != nil {
tmp, merr := os.MkdirTemp("", "zhiyi-payload-rules-")
if merr != nil {
return
}
rp := filepath.Join(tmp, "rules.json")
if werr := os.WriteFile(rp, []byte(embeddedRulesJSON), 0o644); werr != nil {
return
}
LoadPayloadGuard(rp)
}
}
func TestIsEmptyShell_Positive_RealSample(t *testing.T) {
cases := []string{
"证据:文件:小怡/工作笔记/已办.md|日期:2026-09-23", // 蒸馏真样本(QA/小怡复现的那条)
"来源:vault:定义规范:106办公向优化方案-2026-09-18.md",
"证据:文件:小怡/工作笔记/已办.md",
"X=true",
"",
"2026-09-23",
}
for _, c := range cases {
if !IsEmptyShell(c) {
t.Errorf("应判空壳却放行: %q", c)
}
}
}
func TestIsEmptyShell_Negative_KeepRealKnowledge(t *testing.T) {
cases := []string{
"牧尘在招商局工作", // 负控:超短真断言,绝不当空壳
"活库直连写必须停服务", // 负控:真知识
"来源:t_cbc41a01 待办 #6 核销结果。", // 含句读(。)⇒ 必保留
"双向链接是 Obsidian 最核心的功能之一。", // 含句读
"决定将newapi平台中的付费key改为免费", // 真知识
"织忆端口7821", // 短但无句读、无溯源前缀、非占位 ⇒ 保守保留
}
for _, c := range cases {
if IsEmptyShell(c) {
t.Errorf("误判真知识为空壳(负控失败,违反宁漏勿误删): %q", c)
}
}
}
func TestShellCheck_Counters(t *testing.T) {
// ③ 拦截器计数:每个 fact 判一次(checks 增),命中空壳再 blocks 增。
c0, b0, loaded, _ := PayloadGuardStats()
if !loaded {
t.Fatalf("payload guard 未加载,无法验证拦截器")
}
good := "用户决定升级系统"
shell := "证据:文件:小怡/工作笔记/已办.md|日期:2026-09-23"
if ShellCheck(good) {
t.Errorf("真知识 fact 被误拦: %q", good)
}
if !ShellCheck(shell) {
t.Errorf("空壳蒸馏 fact 未被拦: %q", shell)
}
c1, b1, _, _ := PayloadGuardStats()
if c1-c0 != 2 {
t.Errorf("checks 增量应为 2(good+shell 各判一次),实得 %d", c1-c0)
}
if b1-b0 != 1 {
t.Errorf("blocks 增量应为 1(仅 shell 命中),实得 %d", b1-b0)
}
}
+31
View File
@@ -16,6 +16,7 @@
package distill
import (
"os"
"strings"
"unicode"
"unicode/utf8"
@@ -141,6 +142,36 @@ func FilterQualityFactsWithLane(facts []string, lane AdmissionLane) []string {
return out
}
// ─── 短事实 keep 档穿透(kanban t_ffcee190)──────────────────────
// shortFactKeepPassthrough 短事实 keep 档穿透总开关(**默认关 = 现状**)。
// 设环境变量 ZHIYI_SHORTFACT_KEEP_PASSTHROUGH=1 开启:对**非规则卡** episode 的
// 短事实走 LaneKeep 穿透,救回真实对话自然短句被 <20/实体动词 启发式误杀的问题。
// 规则卡(content 以 "[规则:" 前缀)维持现状 —— 规则本体在 skill 零损失,其
// 碎片进库是设计内丢弃,不能放宽。默认关 ⇒ 生产行为零变化。
var shortFactKeepPassthrough = os.Getenv("ZHIYI_SHORTFACT_KEEP_PASSTHROUGH") == "1"
// isRuleCardEpisode 结构化判别:规则卡 episode 的写入通道标识 = content 以 "[规则:" 前缀。
// 这是写入侧(skill 规则卡采集管道)统一固化的标签,非内容关键词猜测 ——
// 实测 LanceDB episodes 里规则卡 100% 以该前缀开头(8816 条规则卡 8816 条前缀命中,
// 非前缀命中 6 条全部是真实对话里转述「规则卡」一词,可用 isRuleCard 歧义极小)。
func isRuleCardEpisode(content string) bool {
return strings.HasPrefix(strings.TrimSpace(content), "[规则:")
}
// effectiveFilterLane 计算 fact 级门禁应使用的 lane。
// 语义(只对「不确定带 observe」做穿透,keep/drop 不动):
// - env 门控开启 且 lane==LaneObserve 且 本条不是规则卡 → 返回 LaneKeep(走 keep 穿透,
// 跳过长度类启发式,只保留 纯疑问/状态汇报 两条硬否定)。
// - 其余(规则卡 / lane!=observe / 门控关)→ 原样返回 lane,行为与现状逐字一致。
// 由此规则卡 episode 的 fact 门禁输出与现状恒等(见 TestFilterQualityFactsWithLane_...)。
func effectiveFilterLane(content string, lane AdmissionLane) AdmissionLane {
if shortFactKeepPassthrough && lane == LaneObserve && !isRuleCardEpisode(content) {
return LaneKeep
}
return lane
}
// ─── 纯疑问句判定 ──────────────────────────────────────────
var questionSuffixes = []string{"?", "?", "吗?", "呢?", "么?", "嘛?", "吗?", "呢?"}
+144
View File
@@ -0,0 +1,144 @@
// 织忆 MemoryWeave — 召回侧确定性判据(kanban t_2515307a)
//
// 为什么(判据优先级「确定性 > 模型」,见设计文档 §一·补):
// 实测本地 2B 的 LLM 评分只有 12 种 dims 模板(前 4 种占 84%)、退化率 4%,
// 无法用于「这条记忆能不能用」的精细排序。而 quality.go 的确定性规则
// (isPureQuestion / isStatusReportLike / isSoulfulShortFact / hasEntity /
// hasVerbPhrase)与 textfilter.IsSystemTemplate 只在**蒸馏写入侧**用,
// 召回时没人用 —— 本文件把它们复用到召回侧,给候选做**纯规则过滤/降权**。
//
// 三条设计规则(§一·补):
// 1. 凡能用确定性规则判的,不用模型 —— 本文件 0 LLM 调用。
// 2. LLM 分数只做粗筛(2~3 档),不做精细排序 —— 精细判据走这里的确定性规则。
// 3. 每个插槽可证伪:env 门控 ZHIYI_RULE_FIRST,默认关 ⇒ 零行为变化。
//
// 用法:调用方(routes 层,因 distill→storage 有 import 依赖,storage 不能
// import distill)在召回结果集上逐条调 RecallRuleVerdict,按 verdict 过滤
// (RecallDrop)或降权重排(RecallDemote)。纯函数,同输入恒同输出,可单测。
package distill
import (
"strings"
"unicode/utf8"
"github.com/xiaoxue/memoryweave/internal/textfilter"
)
// hasUrlishEntity 判定内容是否含 URL / 路径 / 域名等**强实体**形态。
// 为什么单独补:hasEntity(quality.go)只认「强实体词 + 词首大写 + 数字」,
// 而 URL(github.com/iOfficeAI/AionUi)、路径(~/projects/…)、域名
// (dart-lang)等中间无空格的实体形态命中不了它(2026-10-14 实测:8 条
// 20-50 字的仓库 URL/源码路径事实被误判为「弱内容」降权)。URL/路径是
// 确定性的强实体信号,命中即视为有实体 → 不应走弱内容降权。
func hasUrlishEntity(s string) bool {
lower := strings.ToLower(s)
urlHints := []string{"http://", "https://", "www.", ".com", ".io", ".org",
"github.com", "github", "gitlab", "gitea", ".git",
"~/", "/home/", "/var/lib/", "/usr/", "/etc/", "C:\\", "://"}
for _, h := range urlHints {
if strings.Contains(lower, h) {
return true
}
}
return false
}
// RecallVerdict 召回侧确定性判据结论。
type RecallVerdict int
const (
RecallKeep RecallVerdict = iota // 确定性可用 → 保留原序
RecallDemote // 确定性弱内容 → 降权(排到后面,不删)
RecallDrop // 确定性不可用 → 过滤(不进 top-N)
)
// RecallRuleVerdict 对一条召回候选内容做确定性判据。
//
// 判什么 / 为什么可靠(全部复用写入侧已检验的规则,零 LLM 推理):
//
// 1. 系统样板(textfilter.IsSystemTemplate)→ Drop
// 判什么:cron 投递 / 网关包裹 / 上下文摘要等系统元信息,不是用户记忆。
// 为什么可靠:结构化头子串匹配("[IMPORTANT:"、"[HEARTBEAT]"、"[System note:"…),
// 纯数据匹配,写入侧准入闸门(admission_lane.go)已用同一份判定。
//
// 2. 纯疑问句(isPureQuestion)→ Drop
// 判什么:整句是疑问(问号结尾 / 疑问前缀+语气词),不是可用的陈述性记忆。
// 为什么可靠:结尾问号+前缀集合匹配,写入侧质量门槛(quality.go)第一道硬否定。
//
// 3. 状态汇报型/进程噪声(isStatusReportLike)→ Drop
// 判什么:健康检查/重启完成/桥接等无主句进程噪声,不是用户事实。
// 为什么可靠:无主语+状态短语/完成标记匹配,写入侧质量门槛第二道硬否定。
//
// 4. 过短碎片(<20 字,非人侧结构化前缀,无实体且无动词)→ Drop
// 判什么:无信息的碎片(无主体、无动作、无实体),不该进 top-N。
// 为什么可靠:长度+实体+动词短语三重确定性判定,与 quality.go IsQualityFact
// 的 <20 字拒绝规则同源;人侧结构化前缀(画像:/心迹:/偏好:…)豁免(isSoulfulShortFact)。
//
// 5. 弱内容(20-50 字,无实体且无动词)→ Demote
// 判什么:有长度但无实体无动词,信息量弱的陈述,降权但不删除。
// 为什么可靠:20-50 字需含实体或动词短语,quality.go 同一条长度门槛,降为降权。
//
// 其余 → Keep(保留原序,不做任何改动)。
func RecallRuleVerdict(content string) (RecallVerdict, string) {
s := strings.TrimSpace(content)
if s == "" {
return RecallDrop, "空内容"
}
// 1. 系统样板
if textfilter.IsSystemTemplate(s) {
return RecallDrop, "系统样板"
}
// 2. 纯疑问句
if isPureQuestion(s) {
return RecallDrop, "纯疑问句"
}
// 3. 状态汇报/进程噪声
if isStatusReportLike(s) {
return RecallDrop, "状态汇报/进程噪声"
}
// 4/5. 长度 + 实体/动词 门槛
n := utf8.RuneCountInString(s)
if n < 20 {
// 人侧结构化短事实(画像:/心迹:/偏好:/习惯:/目标: 等)豁免 → 保留
if isSoulfulShortFact(s) {
return RecallKeep, "人侧结构化短事实"
}
if !(hasEntity(s) || hasVerbPhrase(s)) {
return RecallDrop, "过短碎片(<20字,无实体无动词)"
}
return RecallKeep, "短但含实体/动词"
}
if n <= 50 {
// URL/路径/域名 = 强实体信号,不判弱内容降权(2026-10-14 实测修正)
if hasUrlishEntity(s) {
return RecallKeep, "含URL/路径实体"
}
if !(hasEntity(s) || hasVerbPhrase(s)) {
return RecallDemote, "弱内容(20-50字,无实体无动词)"
}
}
return RecallKeep, "通过"
}
// FilterRecallByRule 对一批召回结果按确定性判据过滤/降权。
// 返回 (keep, demoted, dropped) 三组 + 每组的 reason 明细。
// env 门控由调用方决定(本函数零副作用、零 LLM)。
func FilterRecallByRule[T any](items []T, content func(T) string) (kept, demoted, dropped []T, reasons map[RecallVerdict][]string) {
kept = make([]T, 0, len(items))
demoted = make([]T, 0, len(items))
dropped = make([]T, 0, len(items))
reasons = map[RecallVerdict][]string{}
for _, it := range items {
v, why := RecallRuleVerdict(content(it))
reasons[v] = append(reasons[v], why)
switch v {
case RecallKeep:
kept = append(kept, it)
case RecallDemote:
demoted = append(demoted, it)
case RecallDrop:
dropped = append(dropped, it)
}
}
return
}
+98
View File
@@ -0,0 +1,98 @@
package distill
import "testing"
// TestRecallRuleVerdict 锁定召回侧确定性判据各分支。
// 每条用例给「判什么」与期望 verdict,验证纯确定性(同输入恒同输出)。
func TestRecallRuleVerdict(t *testing.T) {
cases := []struct {
name string
content string
want RecallVerdict
}{
// ── Drop:确定性不可用 ──────────────────────────
{"空内容", "", RecallDrop},
{"系统样板(IMPORTANT头)", "[IMPORTANT: You are running as a scheduled cron job... 请按时完成", RecallDrop},
{"系统样板(System note头)", "[System note: 这是上下文摘要,供参考]", RecallDrop},
{"纯疑问句(问号结尾)", "为什么系统总是重启?", RecallDrop},
{"纯疑问句(疑问前缀+吗)", "要不要升级系统吗", RecallDrop},
{"状态汇报(无主句)", "健康检查通过,所有进程运行中", RecallDrop},
// 有主句的状态句不是进程噪声("服务器"=主体)→ 含实体 → Keep
{"状态汇报(有主体=实体)", "服务器重启完成", RecallKeep},
{"过短碎片(无实体无动词)", "这是一个东西", RecallDrop},
// ── Demote:弱内容降权(20-50 字,无实体、无动词、无数字、无大写词)──
{"弱内容(20-50字无实体无动词)", "这是一个比较长的没有任何实际意义的填充文字用于演示降权", RecallDemote},
// URL/路径 = 强实体,不降权(2026-10-14 实测修正)
{"含URL(20-50字)", "该仓库的URL为https://github.com/iOfficeAI/AionUi", RecallKeep},
{"含路径(20-50字)", "仓库的本地源码位于 ~/projects/ai-skills-research/", RecallKeep},
// ── Keep:确定性可用,保留原序 ──────────────────
{"人侧结构化短事实", "偏好: 喜欢喝咖啡", RecallKeep},
{"短但含实体", "使用 Hermes 处理", RecallKeep},
{"长陈述含实体", "牧尘决定把织忆的召回判据改为规则优先,默认关闭,零行为变化", RecallKeep},
{"长陈述含动词", "小唯负责维护全部的记忆系统与看板", RecallKeep},
}
for _, c := range cases {
got, reason := RecallRuleVerdict(c.content)
if got != c.want {
t.Errorf("%s: RecallRuleVerdict(%q)=%v(%s) 期望 %v", c.name, c.content, got, reason, c.want)
} else {
t.Logf("%s: %v (%s)", c.name, got, reason)
}
}
}
// TestRecallRuleVerdict_Pure 断言纯函数无副作用:重复调用同输入恒同输出。
func TestRecallRuleVerdict_Pure(t *testing.T) {
inputs := []string{"为什么重启?", "健康检查通过", "牧尘定了召回规则", "偏好: 咖啡"}
for _, s := range inputs {
v1, _ := RecallRuleVerdict(s)
v2, _ := RecallRuleVerdict(s)
if v1 != v2 {
t.Fatalf("非纯函数: %q → %v vs %v", s, v1, v2)
}
}
}
// TestFilterRecallByRule 验证批处理三路分组正确。
func TestFilterRecallByRule(t *testing.T) {
items := []struct{ id, content string }{
{"a", "偏好: 咖啡"},
{"b", "为什么系统重启?"},
{"c", "健康检查通过,所有进程运行中"},
{"d", "牧尘决定把召回判据改为规则优先,默认关闭,零行为变化"},
{"e", "这是一个东西"},
}
kept, demoted, dropped, _ := FilterRecallByRule(items, func(x struct{ id, content string }) string { return x.content })
if len(kept) != 2 { // a, d
t.Fatalf("kept 期望 2 得 %d (%v)", len(kept), kept)
}
if len(demoted) != 0 {
t.Fatalf("demoted 期望 0 得 %d", len(demoted))
}
if len(dropped) != 3 { // b, c, e
t.Fatalf("dropped 期望 3 得 %d", len(dropped))
}
t.Logf("kept=%d demoted=%d dropped=%d ✓", len(kept), len(demoted), len(dropped))
}
// TestFilterRecallByRule_CleanPassthrough 全为可用记忆时:零过滤、零降权、顺序原样。
// 这就是 env=0(关闭)时召回结果逐位一致的保证——干净候选永不因规则被改动。
func TestFilterRecallByRule_CleanPassthrough(t *testing.T) {
items := []struct{ id, content string }{
{"a", "牧尘决定把召回判据改为规则优先,默认关闭,零行为变化"},
{"b", "偏好: 咖啡"},
{"c", "小唯负责维护全部的记忆系统与看板"},
}
kept, demoted, dropped, _ := FilterRecallByRule(items, func(x struct{ id, content string }) string { return x.content })
if len(kept) != 3 || len(demoted) != 0 || len(dropped) != 0 {
t.Fatalf("干净集被改动: kept=%d demoted=%d dropped=%d(期望 3/0/0)", len(kept), len(demoted), len(dropped))
}
for i := range kept {
if kept[i].id != items[i].id {
t.Fatalf("干净集顺序被重排: 第%d位 %s 期望 %s", i, kept[i].id, items[i].id)
}
}
t.Logf("干净集逐位一致 ✓ (keep=%d, 顺序未动)", len(kept))
}
+6 -1
View File
@@ -82,7 +82,12 @@ var (
func getScoreCache() *scoreCache {
scoreCacheOnce.Do(func() {
if scoreCacheDisabled {
// 活读 env(而非只认包级变量):包级变量在进程 init 时定型,跨包测试
// (如 routes)在 TestMain 里 Setenv 时已太迟 —— 让 TestMain 能真正关掉
// 缓存(测试进程确定性:数 LLM 调用次数的断言不被 L2 命中短路)。
// 生产语义不变:env 在进程生命周期内稳定,启动时的 ZHIYI_SCORE_CACHE=0
// 依然在此处被读到。
if scoreCacheDisabled || os.Getenv("ZHIYI_SCORE_CACHE") == "0" {
log.Printf("[scorecache] 已禁用(环境变量 ZHIYI_SCORE_CACHE=0 或测试环境)")
return
}
@@ -0,0 +1,160 @@
// 织忆 MemoryWeave — 短事实 keep 档穿透(kanban t_ffcee190)
//
// 背景(t_1c420e43 终审量化):observe 档真实对话的自然短事实被 <20字/无实体动词
// 长度启发式误杀(真实对话子集误杀率 ~62%);规则卡碎片整批丢弃是设计内(规则本体
// 在 skill 零损失)。本卡给非规则卡 observe episode 上移 keep 档穿透(env 门控默认关)。
//
// 本文件锁定穿透语义(确定性,内联 fixture,不依赖 DB):
// - AC2:默认关(env unset)时 effectiveFilterLane 对任何输入原样返回 lane ⇒ 行为与现状逐字一致。
// - 规则卡:env 开也不穿透(effectiveFilterLane 仍返回原 lane)⇒ 规则卡碎片零泄漏。
// - 非规则卡 observe:env 开 → keep 穿透,短自然事实救回(除 纯疑问/状态汇报 两条硬否定)。
// - 短事实救援:env 开,<20 字无实体自然陈述被救回(旧判据 IsQualityFact 拒绝)。
// 大规模 A/B(≥10000 条真实 episode OLD vs NEW 三张表)见卡交付报告,不落仓库。
package distill
import "testing"
func mustEqual[T comparable](t *testing.T, name string, got, want T) {
t.Helper()
if got != want {
t.Fatalf("%s: got %v want %v", name, got, want)
}
}
func keptCount(facts []string) int {
return len(facts)
}
// TestEffectiveFilterLane_DefaultOffEquivalence AC2:默认关 = 现状。
// env 未设(shortFactKeepPassthrough=false)时,对真实对话/规则卡/任何 lane 都返回原 lane。
func TestEffectiveFilterLane_DefaultOffEquivalence(t *testing.T) {
shortFactKeepPassthrough = false
defer func() { shortFactKeepPassthrough = false }()
cases := []struct {
name string
content string
lane AdmissionLane
}{
{"真实对话自然短句-observe", "用户NAS侧三件事全部完成", LaneObserve},
{"真实对话短句-keep", "决定采用单一采集口径", LaneKeep},
{"规则卡-observe", "[规则:software-development/lazy-senior-dev|硬性规则] 不要引入可避免的新依赖", LaneObserve},
{"结构化牵挂-keep", "牵挂: 待处理: 织忆图谱201个旧实体仍叫旧名", LaneKeep},
{"状态汇报-observe", "服务已重启完成", LaneObserve},
}
for _, c := range cases {
eff := effectiveFilterLane(c.content, c.lane)
if eff != c.lane {
t.Errorf("默认关应原样返回 lane: %s lane=%s eff=%s", c.name, c.lane, eff)
}
}
t.Logf("AC2 默认关等价:%d 例全部 effectiveFilterLane==lane", len(cases))
}
// TestEffectiveFilterLane_RuleCardNoPassthrough 规则卡:env 开也不穿透。
// 注意:规则卡正文若含 决定/不要/必须 等决策线索词会被 ClassifyAdmission 判 keep
// (t_2ae69bb2 起已穿透,与 env 无关);此处用无决策词的规则卡(真 observe)隔离验证。
func TestEffectiveFilterLane_RuleCardNoPassthrough(t *testing.T) {
shortFactKeepPassthrough = true
defer func() { shortFactKeepPassthrough = false }()
// 规则卡 observe(正文无决策/身份线索词)→ env 开仍返回 observe(不穿透)
ruleContent := "[规则:gongwen-wenxi|输出规范] 正文采用仿宋体风格,单句不超过30字"
lane, _ := ClassifyAdmission(ruleContent)
mustEqual(t, "规则卡 lane", lane, LaneObserve)
eff := effectiveFilterLane(ruleContent, lane)
mustEqual(t, "规则卡 env 开不穿透", eff, LaneObserve)
// 非规则卡 observe → env 开穿透到 keep
lane2, _ := ClassifyAdmission("用户NAS侧三件事全部完成")
mustEqual(t, "非规则卡 lane", lane2, LaneObserve)
eff2 := effectiveFilterLane("用户NAS侧三件事全部完成", lane2)
mustEqual(t, "非规则卡 env 开穿透", eff2, LaneKeep)
}
// TestShortFactKeep_NonRuleRescued env 开:非规则卡 observe 的短自然事实被救回。
func TestShortFactKeep_NonRuleRescued(t *testing.T) {
shortFactKeepPassthrough = true
defer func() { shortFactKeepPassthrough = false }()
// t_1c420e43 实测误杀短句:≥8字(非 LaneDrop)、<20 字、无强实体/动词,旧 IsQualityFact 拒绝。
miskilled := []string{
"mc-sync 保持仅含 NAS 自身",
"用户NAS侧三件事全部完成",
"定位候选池三层叠压",
"用户喜欢苹果手机",
}
for _, f := range miskilled {
lane, _ := ClassifyAdmission(f)
mustEqual(t, "误杀短句应落 observe(非 drop/keep)", lane, LaneObserve)
// 对照锚:旧判据确实拒绝这些短句(证明本测试有区分度)
if IsQualityFact(f) {
t.Errorf("对照锚失效:IsQualityFact 放行了 <20 字短句 %q,本测试无区分度", f)
continue
}
newF := FilterQualityFactsWithLane([]string{f}, effectiveFilterLane(f, lane))
if len(newF) != 1 {
t.Errorf("env 开应救回 %q(got %v)", f, newF)
}
}
}
// TestShortFactKeep_QuestionStatusStillRejected 穿透下硬否定仍生效。
func TestShortFactKeep_QuestionStatusStillRejected(t *testing.T) {
shortFactKeepPassthrough = true
defer func() { shortFactKeepPassthrough = false }()
neg := []string{
"这个闸门到底应该怎么定阈值才合理呢?", // 纯疑问
"服务已重启完成,当前版本 v0.21.6。", // 状态汇报
"健康检查完成",
}
for _, f := range neg {
lane, _ := ClassifyAdmission(f)
newF := FilterQualityFactsWithLane([]string{f}, effectiveFilterLane(f, lane))
if len(newF) != 0 {
t.Errorf("穿透下仍应拒绝 %q(got %v)", f, newF)
}
}
}
// TestShortFactKeep_RuleCardFragmentStillDropped env 开:规则卡短碎片仍被丢(零泄漏)。
// 用无决策词的规则卡(真 observe)验证:env 开下规则卡碎片仍被拒,同短非规则陈述被救回。
func TestShortFactKeep_RuleCardFragmentStillDropped(t *testing.T) {
shortFactKeepPassthrough = true
defer func() { shortFactKeepPassthrough = false }()
// 规则卡正文里的短规则碎片(无实体/动词)—— 规则本体在 skill,碎片不进库是设计内。
ruleContent := "[规则:gongwen-wenxi|输出规范] 数据前置量化优先,结果在前措施在后"
ruleFragment := "数据前置量化优先"
lane, _ := ClassifyAdmission(ruleContent)
mustEqual(t, "规则卡 lane", lane, LaneObserve)
newF := FilterQualityFactsWithLane([]string{ruleFragment}, effectiveFilterLane(ruleContent, lane))
if len(newF) != 0 {
t.Errorf("规则卡短碎片不应泄漏进库(got %v)", newF)
}
// 正对照:同样短的非规则自然陈述被救回(证明是规则卡身份在拦,不是长度)
lane2, _ := ClassifyAdmission("用户喜欢苹果手机")
newF2 := FilterQualityFactsWithLane([]string{"用户喜欢苹果手机"}, effectiveFilterLane("用户喜欢苹果手机", lane2))
if len(newF2) != 1 {
t.Errorf("非规则卡同短陈述应救回(got %v)", newF2)
}
}
// TestShortFactKeep_DefaultOffDelegates AC2 补充:默认关时非规则卡 observe 行为=现状(短句仍被拒)。
func TestShortFactKeep_DefaultOffDelegates(t *testing.T) {
shortFactKeepPassthrough = false
defer func() { shortFactKeepPassthrough = false }()
f := "用户NAS侧三件事全部完成"
lane, _ := ClassifyAdmission(f)
eff := effectiveFilterLane(f, lane)
mustEqual(t, "默认关 eff=lane", eff, lane)
got := FilterQualityFactsWithLane([]string{f}, eff)
// 默认关 = 现状:observe 委托 FilterQualityFacts → <20 字无实体短句被拒
if keptCount(got) != 0 {
t.Errorf("默认关应保持现状(拒绝 %q),got %v", f, got)
}
t.Logf("AC2 默认关:非规则卡 observe 短句仍被拒(=现状)")
}
+119
View File
@@ -0,0 +1,119 @@
package distill
import "testing"
// t_286d1310: 写库闸门(server.go OnDistillComplete)此前独立跑 IsQualityFact
// 长度启发式——lane=keep 的决策类内容,其 <20 字碎片在 insert 前被第三次独立
// 丢弃(t_2ae69bb2 只穿透到 fact 级,没穿透到写库级),DB 里 derived_from=<episode>
// 0 行。server.go 已改为复用 engine.go 同一函数 FilterQualityFactsWithLane,并在
// 调用前用 ClassifyAdmission 重算 lane(确定性分诊,与 engine.go:537 同一 lane)。
//
// 本文件把 server.go 的写库判据【原样复现】成测试:ClassifyAdmission(input.Content)
// → FilterQualityFactsWithLane(result.Facts, lane),证明决策碎片经写库判据后保留、
// 非 keep lane 行为与旧判据逐字一致。
//
// 刻意不碰:admission_lane.go 判据、isSoulfulShortFact 白名单、IsQualityFact 阈值。
// 与 quality_lane_test.go 的区别:那组直接把 lane 喂给 FilterQualityFactsWithLane;
// 本组是「从 content 重算 lane 再过滤」的完整写库路径复现(同 server.go)。
// TestWriteGate_KeepDecisionFragmentsSurvive AC1:
// LaneKeep 的 <20 字决定碎片经写库判据后保留(≥3 例,每例含对照锚证明旧判据确实全拒)。
func TestWriteGate_KeepDecisionFragmentsSurvive(t *testing.T) {
cases := []struct {
name string
content string
fragments []string
}{
{
"决定录入格式规范(原文复现)",
"【决定】决定录入格式规范:一条只写一个决定,路径与日期不混进正文,避免蒸馏碎片化后宽泛问法召回不到。",
[]string{"路径与日期不混进正文", "一条只写一个决定"},
},
{
"决策承诺(测试词表同款)",
"牧尘决定以后都用分身C来处理采集类任务。",
[]string{"采集任务交给分身C"},
},
{
"自动化边界决定(同批原文复现)",
"【决定】决定类记忆回流不挂 cron:由小怡在每次拍板后主动调用脚本,先跑顺一两周。",
[]string{"回流不挂cron", "拍板后主动调用"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
// server.go 写库判据的精确复现:先重算 lane
lane, why := ClassifyAdmission(tc.content)
if lane != LaneKeep {
t.Fatalf("前提失败: ClassifyAdmission 未判 keep (lane=%s, %s), content=%.60s", lane, why, tc.content)
}
// 再走 FilterQualityFactsWithLane(server.go 现在用的同一函数)
got := FilterQualityFactsWithLane(tc.fragments, lane)
if len(got) != len(tc.fragments) {
t.Fatalf("写库判据丢失决策碎片: got %d/%d %v", len(got), len(tc.fragments), got)
}
// 对照锚:旧写库判据(IsQualityFact)确实全拒这批 <20 字碎片 —— 证明本测试
// 测的就是行为差(server.go 改前的 write-gate 会丢掉全部这些碎片)
for _, f := range tc.fragments {
if IsQualityFact(f) {
t.Fatalf("对照锚失效: 旧判据 IsQualityFact 放行了 <20 字碎片 %q,本测试无区分度", f)
}
}
})
}
}
// TestWriteGate_KeepStillRejectsNegatives AC1 边界:
// LaneKeep 下两条硬否定(纯疑问 / 状态汇报型)仍被写库判据拒绝(任何长度)。
func TestWriteGate_KeepStillRejectsNegatives(t *testing.T) {
content := "牧尘决定以后都用分身C来处理采集类任务。"
lane, why := ClassifyAdmission(content)
if lane != LaneKeep {
t.Fatalf("前提失败: lane=%s (%s)", lane, why)
}
negatives := []string{
"为什么采集类任务要统一交给分身C来执行处理?", // 纯疑问
"健康检查完成,所有服务运行正常,无异常", // 状态汇报
}
for _, f := range negatives {
if got := FilterQualityFactsWithLane([]string{f}, lane); len(got) != 0 {
t.Fatalf("LaneKeep 下写库判据不应放行 %q: %v", f, got)
}
}
}
// TestWriteGate_NonKeepDelegates AC2:
// LaneDrop / LaneObserve 的写库判据与旧判据 FilterQualityFacts 逐字一致(负例 ≥2 条)。
func TestWriteGate_NonKeepDelegates(t *testing.T) {
dropLane, _ := ClassifyAdmission("好的") // <8 字 → 确定性 drop
if dropLane != LaneDrop {
t.Fatalf("前提失败: 期望 drop, got %s", dropLane)
}
obsLane, _ := ClassifyAdmission("服务已重启完成,当前版本 v0.21.6。") // 不确定带 → observe
if obsLane != LaneObserve {
t.Fatalf("前提失败: 期望 observe, got %s", obsLane)
}
// 负例:两种 lane 下都应被旧判据拒绝的碎片/噪声(≥2 条,且与 quality_lane_test.go
// 已有用例不重复,作为独立新增负例)
in := []string{
"重启系统", // <20 字无前缀碎片
"为什么系统会重启", // 纯疑问(无问号但疑问前缀+短句)
}
for lane, name := range map[AdmissionLane]string{dropLane: "LaneDrop", obsLane: "LaneObserve"} {
t.Run(name, func(t *testing.T) {
want := FilterQualityFacts(in)
got := FilterQualityFactsWithLane(in, lane)
if len(want) != len(got) {
t.Fatalf("非 keep lane 行为漂移: got %d want %d (%v vs %v)", len(got), len(want), got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("非 keep lane 行为漂移: got[%d]=%q want=%q", i, got[i], want[i])
}
}
if len(want) != 0 {
t.Fatalf("对照锚异常: 负例本应全拒, FilterQualityFacts 结果 = %v", want)
}
})
}
}
+460
View File
@@ -0,0 +1,460 @@
// 织忆 MemoryWeave — 写时四操作 + 写前哈希去重(t_3e98b65a,血统 Mem0 + MemoryFabric-v2 §3.1 Layer3)
//
// 背景(2026-10-14):遗忘机制研究方案B「写时四操作」悬置至今。现状痛点:
// · 无写前去重:731 条完全重复靠事后 /admin/dedup 清,新重复持续进(源头没堵)
// · 写时矛盾只 warning 不自动处理 → 矛盾记忆堆积(Mem0 issue #4956 失败模式:
// 「3个月前辞职」和「现在新工作」同时被检索 → 模型答错)
//
// 设计源:MemoryFabric-v2-自优化设计方案.md §3.1 Layer3(dedup_or_upgrade:
// sim>0.92 → ignore/upgrade;contradictory → flag conflict)
//
// 本文件实现两个写侧门:
// 1) 写前哈希去重:commit/写入前对【归一化】内容取 hash,完全重复 → 拒绝写入 + 记 log
// 2) 写时四操作:蒸馏产出候选事实后,对照 top-N 相似现有记忆判定:
// ADD 无语义等价 → 新建
// UPDATE 互补/更新 → 并入现有,旧条标 superseded_by(历史保留可追溯,不删)
// NOOP 无新增价值 → 丢弃(记 shadow log)
// CONFLICT-MARK 矛盾 → 双方标记 conflict,交人工(DELETE 保守:永不自动删除)
//
// ── 门控(默认 shadow ⇒ 零行为变化,硬要求)──────────────────────────
// 门控**只决定标记动作**(supersede/conflict),**不得拦基础 InsertMemory**
// (基础插入是 pre-feature 基线行为,必须无条件执行;2026-10-15 t_a0831457 P0
// 教训:旧实现让 shadow/off 恒不插入 ⇒ 10-14 起蒸馏事实全丢 2120 条)。
// 三模式语义表:
// · shadow(默认) ⇒ 插入新条 ✓ + 不做任何标记 ✓ + 记 shadow log ✓
// (= 注释里"零行为变化"的**字面**含义:等价于特性不存在)
// · apply ⇒ 插入新条 ✓ + 做标记 ✓(NOOP 丢弃;UPDATE 标 supersede;
// CONFLICT-MARK 标 conflict)
// · off ⇒ 插入新条 ✓ + 不跑判定 ✓(零开销)
//
// env(t_a0831457 AC10 改名,旧名兼容一档 + 弃用日志):
// ZHIYI_WRITE_SEMANTIC_OPS=shadow(默认)/apply/off
// 旧名 ZHIYI_WRITE_OPS 仍认(新名未设时兜底),日志提示弃用。
//
// 影子观察 48h → 出四操作判定分布 → 转 apply 走生产变更纪律
// (停→备份→改→启→验,db 备份走门禁)。
//
// 设计约束:
// · 零新依赖(只用 stdlib + internal/models)
// · 判定函数可注入(JudgeFunc),单测用确定性假实现,不依赖真实 LLM
// · 不碰 state.db:只写独立影子日志文件,不改存储常驻结构
package distill
import (
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"math"
"os"
"sort"
"strings"
"sync"
"time"
"github.com/xiaoxue/memoryweave/internal/models"
)
// WriteOp 写时四操作
type WriteOp string
const (
OpAdd WriteOp = "ADD" // 无语义等价 → 新建
OpUpdate WriteOp = "UPDATE" // 互补/更新 → 并入现有(supersede)
OpNoop WriteOp = "NOOP" // 无新增价值 → 丢弃
OpConflictMark WriteOp = "CONFLICT-MARK" // 矛盾 → 双方标记 conflict,交人工
)
// WriteVerdict 单条候选事实的四操作判定结果
type WriteVerdict struct {
Op WriteOp `json:"op"`
Reason string `json:"reason"`
Candidate string `json:"candidate"`
// TargetID 命中被取代/冲突的现有记忆 ID(ADD 为空)
TargetID string `json:"target_id,omitempty"`
Similarity float64 `json:"similarity"`
Timestamp string `json:"timestamp"`
}
// SimilarMemory 候选事实的相似现有记忆(供判定对照)
type SimilarMemory struct {
ID string
Content string
Similarity float64
}
// cosineSim 计算两个向量的余弦相似度(局部实现,避免跨包依赖)。
func cosineSim(a, b []float32) float64 {
var dot, na, nb float64
n := len(a)
if len(b) < n {
n = len(b)
}
for i := 0; i < n; i++ {
dot += float64(a[i]) * float64(b[i])
na += float64(a[i]) * float64(a[i])
nb += float64(b[i]) * float64(b[i])
}
if na == 0 || nb == 0 {
return 0
}
return dot / (math.Sqrt(na) * math.Sqrt(nb))
}
// SimilarMemoryList 相似记忆列表(含按相似度排序 + 取 top-N)
type SimilarMemoryList []SimilarMemory
func (s SimilarMemoryList) Len() int { return len(s) }
func (s SimilarMemoryList) Less(i, j int) bool { return s[i].Similarity > s[j].Similarity }
func (s SimilarMemoryList) Swap(i, j int) { s[i], s[j] = s[j], s[i] }
// TopN 返回相似度最高的前 n 条(稳定排序后截断)
func (s SimilarMemoryList) TopN(n int) SimilarMemoryList {
cp := make(SimilarMemoryList, len(s))
copy(cp, s)
sort.Sort(cp)
if n > len(cp) {
n = len(cp)
}
return cp[:n]
}
// FromMemoryRecords 把召回结果转成 SimilarMemoryList(用余弦相似度,需要向量)
func FromMemoryRecords(recs []models.MemoryRecord, queryVec []float32) SimilarMemoryList {
out := make(SimilarMemoryList, 0, len(recs))
for _, r := range recs {
sim := 0.0
if queryVec != nil && len(r.Vector) == len(queryVec) {
sim = cosineSim(queryVec, r.Vector)
}
out = append(out, SimilarMemory{ID: r.ID, Content: r.Content, Similarity: sim})
}
return out
}
// JudgeFunc 判定函数签名。生产走 LLM(JudgementJudge);单测注入确定性假实现。
type JudgeFunc func(candidate string, similar []SimilarMemory) WriteVerdict
// writeOpsMode 读取写时四操作模式(shadow 默认 / apply / off)。
// t_a0831457 AC10:env 改名 ZHIYI_WRITE_OPS → ZHIYI_WRITE_SEMANTIC_OPS(明确只控
// 语义增量操作,防基础写功能被误挂进此门控)。旧名保留兼容一档:新名未设时
// 回退旧名,且首次读到旧名时打一条弃用日志(只打一次,不刷)。
var writeOpsDeprecationOnce sync.Once
func writeOpsMode() string {
switch v := os.Getenv("ZHIYI_WRITE_SEMANTIC_OPS"); v {
case "apply", "off":
return v
case "":
// 新名未设 → 旧名兜底(兼容一档)
old := os.Getenv("ZHIYI_WRITE_OPS")
if old != "" {
writeOpsDeprecationOnce.Do(func() {
log.Printf("[write-ops] DEPRECATED: env ZHIYI_WRITE_OPS 已弃用,请改用 ZHIYI_WRITE_SEMANTIC_OPS(当前值=%s,语义不变,下版本移除旧名)", old)
})
}
switch old {
case "apply", "off":
return old
default:
return "shadow"
}
default:
// 非 apply/off 的值(含拼写错误)→ 默认 shadow(安全默认,零行为变化)
return "shadow"
}
}
// writeOpsEnabled 是否跑判定(off=完全不跑)
func writeOpsEnabled() bool {
return writeOpsMode() != "off"
}
// WriteOpsEnabled 导出版:供 server.go 等外部包在写路径入口判断是否开启。
func WriteOpsEnabled() bool {
return writeOpsEnabled()
}
// WriteOpsMode 导出版:返回当前模式(shadow/apply/off),供诊断/测试断言。
func WriteOpsMode() string {
return writeOpsMode()
}
// writeOpsApply 是否真改库(apply 模式)。
//
// 2026-10-25 硬保护:apply **强制要求 LLM 判定**。理由(离线回放实测,非推测):
// 规则基 defaultJudge 以 ZHIYI_WRITE_SIM_UPDATE=0.70 判 UPDATE,17179 条影子日志里
// 84%(14435 条)被判「应覆盖旧事实」;人工抽检低相似度样本 15/15 不是真覆盖
// (「用户查看知乎链接」vs「用户查看微信文章」都被判成覆盖)。放行 apply 会把上万条
// 记忆错误标记 superseded_by —— 规模是 2026-09-07 误删事故(67 条)的 200 倍。
// 因此未设 ZHIYI_WRITE_JUDGE=llm 时**降级为 shadow 并留痕**,不静默执行。
func writeOpsApply() bool {
if writeOpsMode() != "apply" {
return false
}
if LLMJudgeMode() != "llm" {
log.Printf("[write-ops] 拒绝 apply:ZHIYI_WRITE_JUDGE != llm(规则基判定精度实测≈0%%,会误覆盖上万条)→ 本次按 shadow 处理")
return false
}
return true
}
// writeOpsLogPath 影子日志路径(env 可覆盖)
func writeOpsLogPath() string {
if p := os.Getenv("ZHIYI_WRITE_OPS_LOG"); p != "" {
return p
}
return "/var/lib/memoryweave/write-ops-shadow.log"
}
var writeOpsLogMu sync.Mutex
// appendWriteOpsLog 追加一条判定记录到影子日志(JSONL)。
// 幂等:写失败只 log 不 panic;日志文件不存在自动创建。
func appendWriteOpsLog(v WriteVerdict) {
line, err := json.Marshal(v)
if err != nil {
log.Printf("[write-ops] shadow log marshal failed: %v", err)
return
}
writeOpsLogMu.Lock()
defer writeOpsLogMu.Unlock()
f, err := os.OpenFile(writeOpsLogPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
log.Printf("[write-ops] shadow log open failed (%s): %v", writeOpsLogPath(), err)
return
}
defer f.Close()
if _, err := f.Write(append(line, '\n')); err != nil {
log.Printf("[write-ops] shadow log write failed: %v", err)
}
}
// ─── 归一化 + 哈希去重 ─────────────────────────────────────────
// NormalizeContent 归一化内容:去首尾空白、折叠连续空白、去句尾标点。
// 用于哈希去重的「完全重复」判定 —— 语义相同但排版不同(多句号/多空格)判为重复。
func NormalizeContent(s string) string {
s = strings.TrimSpace(s)
// 折叠连续空白(空格/Tab/换行)为单个空格
fields := strings.Fields(s)
s = strings.Join(fields, " ")
// 去句尾标点(。.!!??,,;;::)—— "工作。" 与 "工作" 视为同一事实
s = strings.TrimRight(s, "。.!!??,,;;::")
return s
}
// ContentHash 对归一化内容取 MD5 前 12 字符(与 models.ContentMD5 同口径,精确保存去重)。
func ContentHash(s string) string {
sum := md5.Sum([]byte(NormalizeContent(s)))
return hex.EncodeToString(sum[:])[:12]
}
// WriteOpHashDedup 写前哈希去重:候选内容归一化取 hash,与存量记忆的
// content_md5(或归一化后 hash)比对,完全重复 → 返回命中记忆(拒绝写入)。
// 返回 (dupID, true) 表示重复;nil 表示无重复可写入。
// existing 是候选内容编码后的相似召回(内存里做哈希比对,不额外编码)。
func WriteOpHashDedup(candidate string, existing []models.MemoryRecord) (string, bool) {
h := ContentHash(candidate)
for _, m := range existing {
if m.ContentMD5 != "" && m.ContentMD5 == h {
return m.ID, true
}
// 存量可能没存 ContentMD5 → 归一化后直接比(兜底)
if NormalizeContent(m.Content) == NormalizeContent(candidate) {
return m.ID, true
}
}
return "", false
}
// ─── 判定(规则基 + LLM 可选)────────────────────────────────────
// RuleThresholds 规则判定的阈值(MemoryFabric-v2 §3.1 Layer3 血统,env 可调)
var (
// ruleSimNoop:相似度 ≥ 此值且语义等价 → NOOP(无新增价值)
ruleSimNoop = envFloat("ZHIYI_WRITE_SIM_NOOP", 0.95)
// ruleSimUpdate:相似度在此区间且互补 → UPDATE(supersede)
ruleSimUpdate = envFloat("ZHIYI_WRITE_SIM_UPDATE", 0.70)
// ruleSimConflict:低于此值视为语义无关(不可能矛盾,走 ADD)
ruleSimConflictFloor = envFloat("ZHIYI_WRITE_SIM_CONFLICT_FLOOR", 0.45)
)
// ContradictionFunc 矛盾检测函数(可注入:默认用 ruleContradiction 启发式;
// 生产可接 governance.IsContradiction / ConflictDetector.Scan)。
type ContradictionFunc func(candidate string, similar SimilarMemory) bool
// ruleContradiction 规则基矛盾启发式:候选与相似记忆存在显式否定对立
// (如 "在招商局" vs "不在招商局" / "离职" vs "入职" / true vs false)。
// 保守:判不出明确对立 → 返回 false(宁可 UPDATE 也不误判 CONFLICT)。
// 注意:只判「同一事实域的对立词」,不判无关事实 —— 无关性由调用方
// 先按相似度 ≥ ruleSimConflictFloor 的门槛排除(不会拿无关记忆进来比)。
func ruleContradiction(candidate string, s SimilarMemory) bool {
negPairs := [][2]string{
{"不在", "在"}, {"没", "有"}, {"离职", "入职"}, {"辞职", "就职"},
{"false", "true"}, {"False", "True"}, {"FALSE", "TRUE"},
{"否", "是"}, {"关", "开"}, {"停", "启"}, {"结束", "开始"},
{"离开", "加入"}, {"卖出", "买入"}, {"减", "增"}, {"退出", "进入"},
}
for _, pair := range negPairs {
// 候选含否定词、存量含肯定词(或反之)→ 对立
if strings.Contains(candidate, pair[0]) && strings.Contains(s.Content, pair[1]) {
return true
}
if strings.Contains(candidate, pair[1]) && strings.Contains(s.Content, pair[0]) {
return true
}
}
return false
}
// defaultJudge 规则基判定(无 LLM 兜底,可测可跑)。比对 top-1 最相似记忆:
// · 相似度 ≥ ruleSimNoop 且归一化等价 → NOOP
// · 矛盾 → CONFLICT-MARK
// · 相似度 ≥ ruleSimUpdate(互补/更新)→ UPDATE(supersede 最相似那条)
// · 否则 → ADD
func defaultJudge(candidate string, similar []SimilarMemory, contradict ContradictionFunc) WriteVerdict {
v := WriteVerdict{
Op: OpAdd,
Candidate: candidate,
Reason: "无语义等价 → 新建",
Timestamp: time.Now().Format(time.RFC3339),
}
if len(similar) == 0 {
return v
}
top := similar[0] // 调用方已按相似度排序(TopN)
v.Similarity = top.Similarity
// 1) 完全重复(归一化等价)→ NOOP
if NormalizeContent(top.Content) == NormalizeContent(candidate) {
v.Op = OpNoop
v.TargetID = top.ID
v.Reason = "语义等价/完全重复 → 无新增价值,丢弃"
return v
}
// 2) 矛盾 → CONFLICT-MARK(共享锚 + 显式否定对立)
if contradict != nil && contradict(candidate, top) && top.Similarity >= ruleSimConflictFloor {
v.Op = OpConflictMark
v.TargetID = top.ID
v.Reason = "与现有记忆矛盾 → 双方标记 conflict,交人工"
return v
}
// 3) 高相似 + 互补 → UPDATE(supersede)
if top.Similarity >= ruleSimUpdate {
v.Op = OpUpdate
v.TargetID = top.ID
v.Reason = "互补/更新 → 并入现有,旧条标 superseded_by"
return v
}
// 4) 相似度不足 → ADD
return v
}
// JudgeWriteOp 写时四操作判定入口。mode=shadow 只记日志不改库;
// mode=apply 返回 verdict 供调用方 apply。
// judge 为 nil 时用规则基 defaultJudge;contradict 为 nil 时用 ruleContradiction。
// 返回 verdict;若完全关闭(off)返回 nil。
func JudgeWriteOp(candidate string, similar []SimilarMemory, judge JudgeFunc, contradict ContradictionFunc) *WriteVerdict {
if !writeOpsEnabled() {
return nil
}
var v WriteVerdict
if judge != nil {
v = judge(candidate, similar)
} else if writeOpsApply() || LLMJudgeMode() == "llm" {
// 2026-10-25:LLM 三分类判定。规则基 defaultJudge 的 UPDATE 精度实测约 0%
// (17179 条影子日志 84% 判 UPDATE,人工抽检 15/15 误判)⇒ apply 强制 llm;
// shadow 下也可用 ZHIYI_WRITE_JUDGE=llm 先观察新判定质量。judge 显式注入时优先注入。
v = LLMJudge(candidate, similar)
} else {
if contradict == nil {
contradict = ruleContradiction
}
v = defaultJudge(candidate, similar, contradict)
}
if v.Timestamp == "" {
v.Timestamp = time.Now().Format(time.RFC3339)
}
// 影子日志:always 记(shadow 和 apply 都记,供 48h 分布统计)
appendWriteOpsLog(v)
if !writeOpsApply() {
log.Printf("[write-ops][shadow] op=%s target=%s sim=%.3f reason=%s candidate=%.40s",
v.Op, v.TargetID, v.Similarity, v.Reason, candidate)
return &v
}
log.Printf("[write-ops][apply] op=%s target=%s sim=%.3f reason=%s candidate=%.40s",
v.Op, v.TargetID, v.Similarity, v.Reason, candidate)
return &v
}
// WriteOpsStore 写操作要触碰的存储接口(apply 模式用;便于单测注入内存实现)。
type WriteOpsStore interface {
// MarkSuperseded 把旧记忆标记为「被 targetNewID 取代」(写 superseded_by + is_active=false)
MarkSuperseded(oldID, targetNewID string) error
// MarkConflict 把记忆标记为冲突(交人工;不删)
MarkConflict(id string) error
}
// ApplyWriteOp 应用四操作判定到存储。返回是否应插入新记忆 + 错误。
//
// t_a0831457 P0 解耦修复:门控**只决定标记动作**(supersede/conflict),
// **不得拦基础 InsertMemory** —— 基础插入是 pre-feature 基线行为("特性不存在
// 之前"插入总是发生),"零行为变化"的硬要求 ⇒ 默认态(shadow)必须等价于
// 特性不存在。旧实现在 shadow/off 恒返回 shouldInsert=false,调用点据此
// `continue` 跳过基础插入 ⇒ 2026-10-14 起每日蒸馏事实 2053/1094/1559 → 384 → 0
// 全丢(2120 条判定被弃),属实现违背自身注释声明的 bug。
//
// 三模式语义表(与文件头部门控节一致):
// · shadow(默认)⇒ 本函数对所有判定返回 shouldInsert=true:插入新条 ✓
// + 不做任何标记(MarkSuperseded/MarkConflict 零调用)✓ + 判定已由
// JudgeWriteOp 记入 shadow log ✓
// · off ⇒ 不跑判定(JudgeWriteOp 返回 nil,调用点不进本函数);
// 即使误调本函数也返回 true(基础插入照跑,零开销)
// · apply ⇒ 按判定执行标记 + 插入:
// · ADD → 插入新记忆(返回 true)
// · UPDATE → 旧条标 superseded_by(历史保留)+ 插入新记忆(新条 active)
// · NOOP → 不插入(丢弃,特性的预期去重行为)
// · CONFLICT-MARK→ 旧条标 conflict + 新记忆供人工复核,仍插入(不静默丢)
// 返回 shouldInsert:true = 调用方应继续 InsertMemory;false = 丢弃
// (**只有 apply + NOOP / 标记失败** 才返回 false)。
func ApplyWriteOp(v WriteVerdict, store WriteOpsStore, newID string) (shouldInsert bool, err error) {
if !writeOpsApply() {
// shadow/off:门控不参与插入决策、不做任何标记 —— 基础插入无条件执行。
return true, nil
}
if v.Op == OpNoop {
return false, nil
}
switch v.Op {
case OpAdd:
return true, nil
case OpUpdate:
if v.TargetID != "" && store != nil {
if err := store.MarkSuperseded(v.TargetID, newID); err != nil {
return false, err
}
}
return true, nil
case OpConflictMark:
// 旧条标 conflict + 新记忆也标 conflict(由调用方在 mem 上打标),仍插入供人工复核
if v.TargetID != "" && store != nil {
if err := store.MarkConflict(v.TargetID); err != nil {
return false, err
}
}
return true, nil
default:
return true, nil
}
}
// dedupNote 给 commit/distill 写路径用的统一去重提示(日志 + 响应可读)
func dedupNote(dupID, candidate string) string {
return fmt.Sprintf("write-ops dedup: 完全重复命中 %s,拒绝写入(candidate=%.40s)", dupID, candidate)
}
// DedupNote 导出版(供 server.go 日志使用)。
func DedupNote(dupID, candidate string) string {
return dedupNote(dupID, candidate)
}
@@ -0,0 +1,150 @@
// 织忆 MemoryWeave — 实验门控不得劫持基础插入(t_a0831457 P0 解耦修复的回归测试)
//
// 背景:写时四操作门控默认 shadow,旧实现让 ApplyWriteOp 在 shadow/off 恒返回
// shouldInsert=false ⇒ 调用点 server.go 的 `if !shouldInsert { continue }` 把基础
// InsertMemory 整个跳过 ⇒ 2026-10-14 起每日蒸馏事实 2053/1094/1559 → 384 → 0,
// 2120 条判定该落库的事实被丢(原始层 episodes 完整,可重蒸)。
//
// 修复语义(三模式表,与 write_ops.go 头部注释一致):
// · shadow(默认)⇒ 插入新条 ✓ + 不做任何标记 ✓ + 记 shadow log ✓(= 特性不存在之前的基线)
// · apply ⇒ 插入新条 ✓ + 做标记 ✓(NOOP 丢弃)
// · off ⇒ 插入新条 ✓ + 不跑判定 ✓(零开销)
//
// ⚠️ 本文件只用旧代码就存在的符号(ApplyWriteOp/JudgeWriteOp/withMode/…),
// 可直接在旧代码上运行且**必须失败**(先红);修复后全绿(后绿)。
// 运行:go test ./internal/distill/ -run 'TestRegression' -v
package distill
import (
"testing"
)
// 测试环境兜底:新 env 名(ZHIYI_WRITE_SEMANTIC_OPS,AC10 改名)置空 = 未设,
// 避免测试机残留的新名干扰旧名/默认路径的断言。
func clearSemanticOpsEnv(t *testing.T) {
t.Setenv("ZHIYI_WRITE_SEMANTIC_OPS", "")
}
// 四操作各造一个确定性 verdict(不依赖 LLM,与 write_ops_test.go 同型夹具)。
func mkVerdicts() []WriteVerdict {
return []WriteVerdict{
{Op: OpAdd, Candidate: "一个独立新事实A", Similarity: 0.2, Reason: "无语义等价 → 新建"},
{Op: OpUpdate, Candidate: "牧尘在招商局工作 负责算法部门", TargetID: "mem_1", Similarity: 0.92, Reason: "互补/更新"},
{Op: OpNoop, Candidate: "牧尘在招商局工作", TargetID: "mem_1", Similarity: 0.99, Reason: "语义等价/完全重复"},
{Op: OpConflictMark, Candidate: "牧尘不在招商局工作了", TargetID: "mem_1", Similarity: 0.85, Reason: "矛盾"},
}
}
// P0 主断言(AC2 + 增补):shadow 下任何判定都**不拦**基础插入,
// 且 **MarkSuperseded / MarkConflict 零调用**(门控只决定标记,不决定插入)。
func TestRegression_ShadowMode_InsertAlwaysProceeds(t *testing.T) {
clearSemanticOpsEnv(t)
defer withMode("shadow")()
defer withLogPath(t)()
for _, v := range mkVerdicts() {
t.Run(string(v.Op), func(t *testing.T) {
store := newFakeStore()
shouldInsert, err := ApplyWriteOp(v, store, "mem_new")
if err != nil {
t.Fatalf("shadow %s 不应有错:err=%v", v.Op, err)
}
if !shouldInsert {
t.Fatalf("P0:shadow %s 不得拦基础插入(旧实现此处恒 false ⇒ 蒸馏事实全丢):shouldInsert=%v", v.Op, shouldInsert)
}
// AC2 增补:shadow 下标记动作必须零调用
if len(store.superseded) != 0 || len(store.conflicted) != 0 {
t.Fatalf("P0:shadow 不得做任何标记:superseded=%v conflicted=%v", store.superseded, store.conflicted)
}
})
}
}
// 默认值安全断言(门控设计铁律 #3:默认态必须等价于「特性不存在」)。
func TestRegression_DefaultMode_InsertAlwaysProceeds(t *testing.T) {
clearSemanticOpsEnv(t)
defer withMode("")() // 空值模拟未设(writeOpsMode 回退默认 shadow)
defer withLogPath(t)()
v := JudgeWriteOp("默认态测试事实", []SimilarMemory{{ID: "mem_1", Content: "无关事实", Similarity: 0.2}}, nil, nil)
if v == nil || v.Op != OpAdd {
t.Fatalf("默认(shadow)态应跑判定且判 ADD,实际 %v", v)
}
shouldInsert, err := ApplyWriteOp(*v, newFakeStore(), "mem_new")
if err != nil || !shouldInsert {
t.Fatalf("P0:默认态不得拦基础插入:shouldInsert=%v err=%v", shouldInsert, err)
}
}
// off:不跑判定(JudgeWriteOp 恒 nil)⇒ 调用点跳过整个门控块,基础插入照常;
// 且即使 off 态误调 ApplyWriteOp,也不得拦基础插入(零开销语义)。
func TestRegression_OffMode_NoVerdict_InsertProceeds(t *testing.T) {
clearSemanticOpsEnv(t)
defer withMode("off")()
if v := JudgeWriteOp("任意事实", []SimilarMemory{{ID: "mem_1", Content: "无关", Similarity: 0.2}}, nil, nil); v != nil {
t.Fatalf("off 不得跑判定(JudgeWriteOp 应返回 nil),实际 %+v", v)
}
shouldInsert, err := ApplyWriteOp(WriteVerdict{Op: OpNoop}, newFakeStore(), "mem_new")
if err != nil || !shouldInsert {
t.Fatalf("P0:off 不得拦基础插入:shouldInsert=%v err=%v", shouldInsert, err)
}
}
// apply 语义保持(解耦修复不得顺手改掉 apply 的真行为):
// NOOP 仍丢弃(这是特性的预期去重行为,不是基础功能)。
func TestRegression_ApplyMode_NoopStillDrops(t *testing.T) {
clearSemanticOpsEnv(t)
defer withMode("apply")()
shouldInsert, err := ApplyWriteOp(WriteVerdict{Op: OpNoop, TargetID: "mem_1"}, newFakeStore(), "mem_new")
if err != nil || shouldInsert {
t.Fatalf("apply+NOOP 应丢弃:shouldInsert=%v err=%v", shouldInsert, err)
}
}
// apply + UPDATE:标记(supersede)+ 插入 —— 特性行为保持。
func TestRegression_ApplyMode_UpdateMarksAndInserts(t *testing.T) {
clearSemanticOpsEnv(t)
defer withMode("apply")()
store := newFakeStore()
shouldInsert, err := ApplyWriteOp(WriteVerdict{Op: OpUpdate, TargetID: "mem_1"}, store, "mem_new")
if err != nil || !shouldInsert {
t.Fatalf("apply+UPDATE 应插入:shouldInsert=%v err=%v", shouldInsert, err)
}
if store.superseded["mem_1"] != "mem_new" {
t.Fatalf("apply+UPDATE 应标 superseded_by=mem_new:%v", store.superseded)
}
}
// apply + CONFLICT-MARK:标记(conflict)+ 插入 —— 特性行为保持。
func TestRegression_ApplyMode_ConflictMarksAndInserts(t *testing.T) {
clearSemanticOpsEnv(t)
defer withMode("apply")()
store := newFakeStore()
shouldInsert, err := ApplyWriteOp(WriteVerdict{Op: OpConflictMark, TargetID: "mem_1"}, store, "mem_new")
if err != nil || !shouldInsert {
t.Fatalf("apply+CONFLICT-MARK 应插入(供人工复核):shouldInsert=%v err=%v", shouldInsert, err)
}
if !store.conflicted["mem_1"] {
t.Fatalf("apply+CONFLICT-MARK 应标 conflict:%v", store.conflicted)
}
}
// AC10:env 改名 —— 新名 ZHIYI_WRITE_SEMANTIC_OPS 优先;旧名兼容一档;
// 都未设 = shadow 默认(默认值不许漂移)。
func TestRegression_EnvRename_CompatMatrix(t *testing.T) {
// ① 新名生效(旧名相反值也不许干扰)
t.Setenv("ZHIYI_WRITE_SEMANTIC_OPS", "apply")
t.Setenv("ZHIYI_WRITE_OPS", "off")
if WriteOpsMode() != "apply" {
t.Fatalf("新名应优先:mode=%s", WriteOpsMode())
}
// ② 新名未设 → 旧名兜底(兼容一档)
t.Setenv("ZHIYI_WRITE_SEMANTIC_OPS", "")
t.Setenv("ZHIYI_WRITE_OPS", "off")
if WriteOpsMode() != "off" {
t.Fatalf("旧名兼容失效:mode=%s", WriteOpsMode())
}
// ③ 双名都未设 → 默认 shadow
t.Setenv("ZHIYI_WRITE_OPS", "")
if WriteOpsMode() != "shadow" {
t.Fatalf("默认值漂移(必须 shadow):mode=%s", WriteOpsMode())
}
}
+325
View File
@@ -0,0 +1,325 @@
// 织忆 MemoryWeave — 写时四操作 + 哈希去重 单测(t_3e98b65a)
package distill
import (
"os"
"path/filepath"
"testing"
"github.com/xiaoxue/memoryweave/internal/models"
)
// ─── 测试辅助 ─────────────────────────────────────────────
// fakeStore 内存版 WriteOpsStore(记录被标记的操作,供 apply 断言)。
type fakeStore struct {
superseded map[string]string // oldID → newID
conflicted map[string]bool
}
func newFakeStore() *fakeStore {
return &fakeStore{superseded: map[string]string{}, conflicted: map[string]bool{}}
}
func (f *fakeStore) MarkSuperseded(oldID, newID string) error {
f.superseded[oldID] = newID
return nil
}
func (f *fakeStore) MarkConflict(id string) error {
f.conflicted[id] = true
return nil
}
func mkMem(id, content string) models.MemoryRecord {
return models.MemoryRecord{ID: id, Content: content}
}
// fixedJudge 返回固定 verdict 的判定器。
// 2026-10-25:apply 模式改为强制 LLM 判定(规则基 UPDATE 精度实测≈0%),
// 单测注入确定性实现,避免依赖网络/模型。
func fixedJudge(op WriteOp) JudgeFunc {
return func(c string, s []SimilarMemory) WriteVerdict {
v := WriteVerdict{Op: op, Candidate: c}
if len(s) > 0 {
v.TargetID = s[0].ID
v.Similarity = s[0].Similarity
}
return v
}
}
// withMode 临时设 ZHIYI_WRITE_OPS 模式,返回恢复函数。
func withMode(mode string) func() {
old := os.Getenv("ZHIYI_WRITE_SEMANTIC_OPS")
oldJudge := os.Getenv("ZHIYI_WRITE_JUDGE")
os.Setenv("ZHIYI_WRITE_SEMANTIC_OPS", mode)
if mode == "apply" {
// 2026-10-25 硬保护:apply 强制 LLM 判定(规则基 UPDATE 精度实测≈0%)。
// apply 用例均已注入 fixedJudge,因此设置 llm 不会真调网络。
os.Setenv("ZHIYI_WRITE_JUDGE", "llm")
} else {
os.Setenv("ZHIYI_WRITE_JUDGE", "rule")
}
return func() {
os.Setenv("ZHIYI_WRITE_SEMANTIC_OPS", old)
os.Setenv("ZHIYI_WRITE_JUDGE", oldJudge)
}
}
// withLogPath 临时把影子日志指到临时文件,返回清理函数。
func withLogPath(t *testing.T) func() {
p := filepath.Join(t.TempDir(), "shadow.log")
old := os.Getenv("ZHIYI_WRITE_OPS_LOG")
os.Setenv("ZHIYI_WRITE_OPS_LOG", p)
return func() { os.Setenv("ZHIYI_WRITE_OPS_LOG", old) }
}
// ─── AC2:写前哈希去重(同内容连写 3 次 → 库内 1 条)────────────────
func TestWriteOpHashDedup_ExactDuplicate(t *testing.T) {
candidate := "牧尘在招商局工作"
existing := []models.MemoryRecord{
{ID: "mem_1", Content: "牧尘在招商局工作。"}, // 多句号,归一化后等价
{ID: "mem_2", Content: "牧尘在别的公司"},
}
dupID, dup := WriteOpHashDedup(candidate, existing)
if !dup {
t.Fatalf("期望判为重复,实际不重复(candidate=%q)", candidate)
}
if dupID != "mem_1" {
t.Fatalf("期望命中 mem_1,实际 %s", dupID)
}
}
func TestWriteOpHashDedup_NotDuplicate(t *testing.T) {
candidate := "牧尘最近在学 Go"
existing := []models.MemoryRecord{
{ID: "mem_1", Content: "牧尘在招商局工作"},
}
if _, dup := WriteOpHashDedup(candidate, existing); dup {
t.Fatalf("期望不判为重复,实际判重(candidate=%q)", candidate)
}
}
func TestNormalizeContent_HashStable(t *testing.T) {
if ContentHash("牧尘在招商局工作") != ContentHash(" 牧尘在招商局工作 ") {
t.Fatalf("归一化哈希不稳定:首尾空白应被忽略")
}
}
// AC2 全链路:同内容连写 3 次 → 3 次都判同一重复 → 只该写 1 条
func TestWriteOps_ThreeWritesOneRow(t *testing.T) {
candidate := "X=true"
// 第一次写入:库里没有 → 不重复
if _, dup := WriteOpHashDedup(candidate, nil); dup {
t.Fatalf("首次写入不应判重")
}
// 第二、三次:已有第一条 → 判重复(拒绝写入)
existing := []models.MemoryRecord{mkMem("mem_1", candidate)}
for i := 0; i < 2; i++ {
if _, dup := WriteOpHashDedup(candidate, existing); !dup {
t.Fatalf("第 %d 次重复写入未拦截", i+2)
}
}
// 结论:库里始终只有 1 条(mem_1),新重复被源头堵住
if len(existing) != 1 {
t.Fatalf("库内应 1 条,实际 %d", len(existing))
}
}
// ─── AC4:四操作各 1 个单测 ────────────────────────────────
// 操作1:ADD —— 无语义等价 → 新建
func TestWriteOps_Add(t *testing.T) {
defer withMode("apply")()
candidate := "牧尘最近在学 Rust 异步"
similar := []SimilarMemory{
{ID: "mem_1", Content: "牧尘在招商局工作", Similarity: 0.4},
}
v := JudgeWriteOp(candidate, similar, fixedJudge(OpAdd), nil)
if v == nil {
t.Fatalf("JudgeWriteOp 返回 nil")
}
if v.Op != OpAdd {
t.Fatalf("期望 ADD,实际 %s(%s)", v.Op, v.Reason)
}
shouldInsert, err := ApplyWriteOp(*v, newFakeStore(), "mem_new")
if err != nil || !shouldInsert {
t.Fatalf("ADD 应插入:shouldInsert=%v err=%v", shouldInsert, err)
}
}
// 操作2:UPDATE —— 互补/更新 → 并入现有(supersede)
func TestWriteOps_Update(t *testing.T) {
defer withMode("apply")()
candidate := "牧尘在招商局工作 负责算法部门"
similar := []SimilarMemory{
{ID: "mem_1", Content: "牧尘在招商局工作", Similarity: 0.92},
}
v := JudgeWriteOp(candidate, similar, fixedJudge(OpUpdate), nil)
if v == nil {
t.Fatalf("nil verdict")
}
if v.Op != OpUpdate {
t.Fatalf("期望 UPDATE,实际 %s(%s)", v.Op, v.Reason)
}
store := newFakeStore()
shouldInsert, err := ApplyWriteOp(*v, store, "mem_new")
if err != nil || !shouldInsert {
t.Fatalf("UPDATE 应插入:%v %v", shouldInsert, err)
}
// 旧条被标记 superseded_by=mem_new
if store.superseded["mem_1"] != "mem_new" {
t.Fatalf("旧条未标记 superseded_by=mem_new,实际 %q", store.superseded["mem_1"])
}
}
// 操作3:NOOP —— 无新增价值。
// t_a0831457 解耦修复后语义:apply 模式丢弃(特性预期去重行为);
// shadow/默认模式**不再拦基础插入**(门控只决定标记,不决定插入)——
// 旧断言「shadow+NOOP 不插入」正是 10-14 起蒸馏事实全丢的 bug 语义,已改写。
func TestWriteOps_Noop(t *testing.T) {
candidate := "牧尘在招商局工作"
similar := []SimilarMemory{
{ID: "mem_1", Content: "牧尘在招商局工作", Similarity: 0.99}, // 语义等价
}
t.Run("apply+NOOP_丢弃", func(t *testing.T) {
defer withMode("apply")()
v := JudgeWriteOp(candidate, similar, fixedJudge(OpNoop), nil)
if v == nil || v.Op != OpNoop {
t.Fatalf("期望 NOOP,实际 %v", v)
}
shouldInsert, err := ApplyWriteOp(*v, newFakeStore(), "mem_new")
if err != nil || shouldInsert {
t.Fatalf("apply+NOOP 应丢弃:shouldInsert=%v err=%v", shouldInsert, err)
}
})
t.Run("shadow+NOOP_基础插入照跑", func(t *testing.T) {
defer withMode("shadow")()
defer withLogPath(t)()
v := JudgeWriteOp(candidate, similar, nil, nil)
if v == nil || v.Op != OpNoop {
t.Fatalf("期望 NOOP,实际 %v", v)
}
store := newFakeStore()
shouldInsert, err := ApplyWriteOp(*v, store, "mem_new")
if err != nil || !shouldInsert {
t.Fatalf("P0:shadow+NOOP 不得拦基础插入:shouldInsert=%v err=%v", shouldInsert, err)
}
if len(store.superseded) != 0 || len(store.conflicted) != 0 {
t.Fatalf("shadow 不得做标记:%v %v", store.superseded, store.conflicted)
}
})
}
// 操作4:CONFLICT-MARK —— 矛盾 → 双方标记 conflict(不删),交人工
func TestWriteOps_ConflictMark(t *testing.T) {
defer withMode("apply")()
candidate := "牧尘不在招商局工作了"
similar := []SimilarMemory{
{ID: "mem_1", Content: "牧尘在招商局工作", Similarity: 0.85},
}
v := JudgeWriteOp(candidate, similar, fixedJudge(OpConflictMark), nil)
if v == nil {
t.Fatalf("nil verdict")
}
if v.Op != OpConflictMark {
t.Fatalf("期望 CONFLICT-MARK,实际 %s(%s)", v.Op, v.Reason)
}
store := newFakeStore()
shouldInsert, err := ApplyWriteOp(*v, store, "mem_new")
if err != nil || !shouldInsert {
t.Fatalf("CONFLICT 应插入(供人工复核):%v %v", shouldInsert, err)
}
// 旧条被标记 conflict
if !store.conflicted["mem_1"] {
t.Fatalf("旧条未标记 conflict")
}
}
// 矛盾判定:true/false 对立
func TestRuleContradiction_TrueFalse(t *testing.T) {
if !ruleContradiction("X=false", SimilarMemory{Content: "X=true"}) {
t.Fatalf("false/true 应判矛盾")
}
if ruleContradiction("X=1", SimilarMemory{Content: "Y=2"}) {
t.Fatalf("无关事实不应判矛盾")
}
}
// ─── AC1:写「X=true」后写「X=false」→ 旧条 conflict 标记 + 新条 active ──
func TestWriteOps_WriteTrueThenFalse(t *testing.T) {
defer withMode("apply")()
// 第一次:写 X=true(库里空 → ADD)
first := JudgeWriteOp("X=true", nil, nil, nil)
if first == nil || first.Op != OpAdd {
t.Fatalf("首次 X=true 应 ADD,实际 %+v", first)
}
// 第二次:写 X=false,库里已有 X=true → CONFLICT(矛盾)
second := JudgeWriteOp("X=false", []SimilarMemory{
{ID: "mem_1", Content: "X=true", Similarity: 0.9},
}, fixedJudge(OpConflictMark), nil)
if second == nil {
t.Fatalf("nil")
}
if second.Op != OpConflictMark {
t.Fatalf("X=false 与 X=true 矛盾,应 CONFLICT-MARK,实际 %s", second.Op)
}
store := newFakeStore()
shouldInsert, err := ApplyWriteOp(*second, store, "mem_2")
if err != nil || !shouldInsert {
t.Fatalf("CONFLICT 应插入新条:%v %v", shouldInsert, err)
}
// 旧条 conflict 标记 + 新条 active(插入)
if !store.conflicted["mem_1"] {
t.Fatalf("旧条 X=true 未标 conflict")
}
}
// ─── AC5:影子模式行为零变化(判定不改库)──────────────────────
// 影子模式:JudgeWriteOp 只记日志,不触碰 store;同 query 召回结果不受影响。
func TestWriteOps_ShadowModeNoMutation(t *testing.T) {
defer withMode("shadow")()
defer withLogPath(t)()
store := newFakeStore()
// 影子模式下连续判定 ADD/UPDATE/NOOP/CONFLICT
ops := []struct {
op WriteOp
candidate string
sim []SimilarMemory
}{
{OpAdd, "新事实A", []SimilarMemory{{"mem_1", "无关", 0.2}}},
{OpUpdate, "牧尘在招商局负责算法", []SimilarMemory{{"mem_1", "牧尘在招商局", 0.9}}},
{OpNoop, "牧尘在招商局", []SimilarMemory{{"mem_1", "牧尘在招商局", 0.99}}},
{OpConflictMark, "牧尘不在招商局", []SimilarMemory{{"mem_1", "牧尘在招商局", 0.8}}},
}
for _, o := range ops {
v := JudgeWriteOp(o.candidate, o.sim, nil, nil)
if v == nil || v.Op != o.op {
t.Fatalf("期望 %s,实际 %v", o.op, v)
}
// apply 到 store(验证影子模式语义下 store 不被改动)
_, _ = ApplyWriteOp(*v, store, "mem_new")
}
// 影子模式:store 应保持零变化(superseded/conflict 都空)
if len(store.superseded) != 0 || len(store.conflicted) != 0 {
t.Fatalf("影子模式改了 store:superseded=%v conflicted=%v", store.superseded, store.conflicted)
}
}
// 影子日志确实被写入(供 AC3 分布统计)
func TestWriteOps_ShadowLogWritten(t *testing.T) {
defer withMode("shadow")()
defer withLogPath(t)()
_ = JudgeWriteOp("测试事实", []SimilarMemory{{"mem_1", "无关", 0.1}}, nil, nil)
content, err := os.ReadFile(os.Getenv("ZHIYI_WRITE_OPS_LOG"))
if err != nil {
t.Fatalf("读影子日志失败: %v", err)
}
if len(content) == 0 {
t.Fatalf("影子日志为空,AC3 分布统计无源")
}
}
@@ -0,0 +1,57 @@
//go:build liveprobe
package distill
import (
"os"
"testing"
)
// TestAdvAuditLiveRealModel 直连 NewAPI deepseek-v4-flash 真模型,验证异源对抗审计
// 能在真实输出上给出结论(含矛盾/幻觉判定)。影子:只写日志,不改行为。
// go test ./internal/distill/ -tags liveprobe -run TestAdvAuditLiveRealModel -v
func TestAdvAuditLiveRealModel(t *testing.T) {
key := os.Getenv("ZHIYI_ADVERSARIAL_AUDIT_KEY")
if key == "" {
key = "***REMOVED***"
}
t.Setenv("ZHIYI_ADVERSARIAL_AUDIT_KEY", key)
tmp := t.TempDir() + "/audit.log"
t.Setenv("ZHIYI_ADVERSARIAL_AUDIT_LOG", tmp)
t.Setenv("ZHIYI_ADVERSARIAL_AUDIT_MODEL", "deepseek-v4-flash")
in := DistillInput{
EpisodeID: "ep_live_audit_001",
Content: "[2026-10-13] 牧尘: 决定把蒸馏评分模型从本地2B换成更强的模型,因为评分区分度不够。",
Source: "liveprobe",
}
facts := []string{
"牧尘决定把蒸馏模型换成更强的模型",
"牧尘每天吃三个苹果", // 故意编造的幻觉:原文未提及
"牧尘换了蒸馏模型因为区分度不够",
}
if err := auditOne(in, facts); err != nil {
t.Fatalf("live auditOne err: %v", err)
}
b, _ := os.ReadFile(tmp)
t.Logf("\n══════ 真实影子日志 ══════\n%s", string(b))
content := string(b)
if content == "" {
t.Fatal("影子日志为空,真模型未返回")
}
if !containsStr(content, "contradiction") && !containsStr(content, "hallucination") {
t.Logf("⚠️ 本样例未触发矛盾/幻觉判定(模型全判 supported)—— 需另取样例")
}
}
func containsStr(s, sub string) bool {
return 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
}
+19 -5
View File
@@ -219,17 +219,31 @@ func (mh *MetricsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
metrics := mh.dashboard.Metrics()
s := mh.stats()
fmt.Fprintf(w, "# HELP zhiyi_recall_usefulness_rate Recall有用率\n")
// ⚠️ 语义变更(2026-10-14):比率型指标在**分母退化**时返回 -1(无数据),
// 不再返回 1。1 曾被读成「100% 好用」——那是失明,不是满分。
// 配套 zhiyi_*_valid(1=可解读 / 0=值必为 -1,禁止当成绩用)与 zhiyi_*_samples。
// 看板/告警若直接画 *_rate 曲线,请先用 *_valid 过滤,否则 -1 会拉低均值。
fmt.Fprintf(w, "# HELP zhiyi_recall_usefulness_rate Recall有用率;-1=无数据(无负样本通道)\n")
fmt.Fprintf(w, "# TYPE zhiyi_recall_usefulness_rate gauge\n")
fmt.Fprintf(w, "zhiyi_recall_usefulness_rate %f\n", metrics["recall_usefulness_rate"])
fmt.Fprintf(w, "# HELP zhiyi_recall_usefulness_valid 该率是否可解读;0 ⇒ 值为 -1\n")
fmt.Fprintf(w, "# TYPE zhiyi_recall_usefulness_valid gauge\n")
fmt.Fprintf(w, "zhiyi_recall_usefulness_valid %f\n", metrics["recall_usefulness_valid"])
fmt.Fprintf(w, "# HELP zhiyi_recall_usefulness_samples 该率的分母样本数\n")
fmt.Fprintf(w, "# TYPE zhiyi_recall_usefulness_samples gauge\n")
fmt.Fprintf(w, "zhiyi_recall_usefulness_samples %f\n", metrics["recall_usefulness_samples"])
fmt.Fprintf(w, "# HELP zhiyi_recall_hit_rate Recall命中率\n")
fmt.Fprintf(w, "# TYPE zhiyi_recall_hit_rate gauge\n")
fmt.Fprintf(w, "zhiyi_recall_hit_rate %f\n", metrics["recall_hit_rate"])
// 2026-10-17:zhiyi_recall_hit_rate 已退役(恒=1、无区分度),由 recall_usefulness_rate 承担「返回且被使用」语义。
fmt.Fprintf(w, "# HELP zhiyi_gap_closure_rate 缺口闭环率\n")
fmt.Fprintf(w, "# HELP zhiyi_gap_closure_rate 缺口闭环率;-1=无数据\n")
fmt.Fprintf(w, "# TYPE zhiyi_gap_closure_rate gauge\n")
fmt.Fprintf(w, "zhiyi_gap_closure_rate %f\n", metrics["gap_closure_rate"])
fmt.Fprintf(w, "# HELP zhiyi_gap_closure_valid 该率是否可解读;0 ⇒ 值为 -1\n")
fmt.Fprintf(w, "# TYPE zhiyi_gap_closure_valid gauge\n")
fmt.Fprintf(w, "zhiyi_gap_closure_valid %f\n", metrics["gap_closure_valid"])
fmt.Fprintf(w, "# HELP zhiyi_gap_closure_samples 该率的分母样本数\n")
fmt.Fprintf(w, "# TYPE zhiyi_gap_closure_samples gauge\n")
fmt.Fprintf(w, "zhiyi_gap_closure_samples %f\n", metrics["gap_closure_samples"])
fmt.Fprintf(w, "# HELP zhiyi_episodes_total 总episode数\n")
fmt.Fprintf(w, "# TYPE zhiyi_episodes_total gauge\n")
+189
View File
@@ -0,0 +1,189 @@
// 织忆 MemoryWeave — 实体名称拒写门禁(t_41f5ee7a 治源)
//
// 背景:在实体抽取写库前加拒绝规则,防止非法实体名(如过长、以数字开头、包含路径/后缀)
// 进入图谱。单一真相源:判据的规则值读自共享 JSON
// (~/.hermes/scripts/lib/graph_entity_guard.json),与 Python 侧
// graph_entity_guard.py 读同一份文件;改规则只改那一个 JSON。
//
// 铁律(与 Python 逐位一致):
// · 白名单内的名称直接放行(即使匹配判据)
// · 否则匹配 j1/j2/j3 之一即判为垃圾(应被拒绝)
//
// 无缓存、无新增 Redis 键(只用原子计数),故不触及项目现成件清单的缓存条目。
package governance
import (
"encoding/json"
"os"
"path/filepath"
"regexp"
"strings"
"sync/atomic"
)
// entityGuardG is the singleton guard instance.
var entityGuardG *guard
// defaultRulesPath — 与 Python 侧同一份共享规则文件(同机同用户 muc 可直读)。
// env GRAPH_ENTITY_GUARD_RULES 可覆盖(测试/部署用)。
func defaultRulesPath() string {
if p := os.Getenv("GRAPH_ENTITY_GUARD_RULES"); p != "" {
return p
}
if home, err := os.UserHomeDir(); err == nil && home != "" {
return filepath.Join(home, ".hermes", "scripts", "lib", "graph_entity_guard.json")
}
return "/home/muc/.hermes/scripts/lib/graph_entity_guard.json"
}
// entityGuardRules 镜像共享 JSON(只取 Go 判据需要的字段;未知字段忽略)。
type entityGuardRules struct {
Whitelist []string `json:"whitelist"`
Judgments struct {
J1LenGt14 struct {
Pattern string `json:"pattern"`
CaseInsensitive bool `json:"case_insensitive"`
} `json:"j1_len_gt_14"`
J2StartsWithDigit struct {
Pattern string `json:"pattern"`
CaseInsensitive bool `json:"case_insensitive"`
} `json:"j2_startswith_digit"`
J3PathOrExt struct {
Pattern string `json:"pattern"`
CaseInsensitive bool `json:"case_insensitive"`
} `json:"j3_path_or_ext"`
} `json:"judgments"`
}
type guard struct {
rules entityGuardRules
j1Regex *regexp.Regexp
j2Regex *regexp.Regexp
j3Regex *regexp.Regexp
loaded bool
path string
// 计数(可选,供监控)
checks int64
blocks int64
}
// LoadEntityGuard 读取并编译规则。首次成功后设置单例。
// 返回 (ok, error)。ok 表示是否成功加载。
func LoadEntityGuard(path string) (bool, error) {
if path == "" {
path = defaultRulesPath()
}
data, err := os.ReadFile(path)
if err != nil {
return false, err
}
var rc entityGuardRules
if err := json.Unmarshal(data, &rc); err != nil {
return false, err
}
// 編譯判據正則
j1Regex, err := regexp.Compile(rc.Judgments.J1LenGt14.Pattern)
if err != nil {
return false, err
}
if rc.Judgments.J1LenGt14.CaseInsensitive {
j1Regex = regexp.MustCompile("(?i)" + rc.Judgments.J1LenGt14.Pattern)
}
j2Regex, err := regexp.Compile(rc.Judgments.J2StartsWithDigit.Pattern)
if err != nil {
return false, err
}
if rc.Judgments.J2StartsWithDigit.CaseInsensitive {
j2Regex = regexp.MustCompile("(?i)" + rc.Judgments.J2StartsWithDigit.Pattern)
}
j3Regex, err := regexp.Compile(rc.Judgments.J3PathOrExt.Pattern)
if err != nil {
return false, err
}
if rc.Judgments.J3PathOrExt.CaseInsensitive {
j3Regex = regexp.MustCompile("(?i)" + rc.Judgments.J3PathOrExt.Pattern)
}
g := &guard{
rules: rc,
j1Regex: j1Regex,
j2Regex: j2Regex,
j3Regex: j3Regex,
path: path,
loaded: true,
}
// assign to package-level singleton
entityGuardG = g
return true, nil
}
// EntityGuardStats 供 /metrics 与验收:检查数 / 拦截数 / 是否加载 / 规则路径。
func EntityGuardStats() (checks, blocks int64, loaded bool, path string) {
if entityGuardG == nil {
return 0, 0, false, ""
}
return atomic.LoadInt64(&entityGuardG.checks), atomic.LoadInt64(&entityGuardG.blocks), entityGuardG.loaded, entityGuardG.path
}
// IncEntityGuardCheck / IncEntityGuardBlock 供写路径在判定前后调用(相对增量,避免并发写竞态)。
func IncEntityGuardCheck() {
if entityGuardG == nil {
return
}
atomic.AddInt64(&entityGuardG.checks, 1)
}
func IncEntityGuardBlock() {
if entityGuardG == nil {
return
}
atomic.AddInt64(&entityGuardG.blocks, 1)
}
// IsJunkName 治源入口(帶心跳計數):
// - 未加載 ⇒ fail-open(放行、checks 仍 +1,保證「拦截器跑過」可觀測,不拦也不崩);
// - 已加載 ⇒ 用共享判據判定,命中垃圾則 blocks +1 並返回 true(寫路径據此 continue 拒寫)。
func IsJunkName(name string) bool {
IncEntityGuardCheck()
if entityGuardG == nil || !entityGuardG.loaded {
return false
}
if entityGuardG.isJunkName(name) {
IncEntityGuardBlock()
return true
}
return false
}
// IsJunkNamePure 純判據(無計數,供單測)。未加載時就地讀一次默認路徑兜底;失敗則 fail-open。
func IsJunkNamePure(name string) bool {
if entityGuardG == nil {
if _, err := LoadEntityGuard(""); err != nil {
return false
}
}
return entityGuardG.isJunkName(name)
}
func (gr *guard) isJunkName(name string) bool {
// 先修剪空白(與 SQL trim 一致)
n := strings.TrimSpace(name)
// 白名單直接放行
for _, w := range gr.rules.Whitelist {
if w == n {
return false
}
}
// 判定垃圾:匹配 j1/j2/j3 之一
if gr.j1Regex.MatchString(n) {
return true
}
if gr.j2Regex.MatchString(n) {
return true
}
if gr.j3Regex.MatchString(n) {
return true
}
return false
}
+142
View File
@@ -0,0 +1,142 @@
// 织忆 MemoryWeave — 图谱实体质量闸(P0-4,2026-10-26)
//
// 背景(CS249r 评审实测):graph_expanded 抽样 15 条里 8 条是 ≤4 字垃圾实体
// ("条""里存着""开发"),既污染上下文又抬延迟。根因是实体抽取「任意 2-8 字中文串都当实体」,
// 且扩展前不检查节点在图里的度数 —— 孤边节点(度=1)被当成语义邻居注入。
//
// 两道闸(都在**导航前**执行,省算力):
// ① isJunkEntity:长度 <2 rune / 纯虚词量词代词 / 常见填充短语 / 纯 ASCII 停用词 ⇒ 丢弃;
// ② 度数闸:起点实体在图中的边数 <2 ⇒ 不导航、不注入(孤边不构成「关联」)。
//
// 观测:两道闸的拒绝计数写入 debug 日志(ZHIYI_GRAPH_EXPAND_DEBUG=1)与 recall trace
// (graph_expand 阶段 note),保证「下次坏了不沉默」。
package governance
import (
"strings"
"unicode/utf8"
)
// junkEntitySet 高置信垃圾实体词表:中文虚词/代词/量词/时间副词/填充短语 + 英文停用词。
// 原则:只收**几乎不可能**是真实实体名的词;宁可漏筛(由度数闸兜底),不可误杀专名。
var junkEntitySet = map[string]struct{}{}
func init() {
words := []string{
// 虚词 / 助词 / 连词
"的", "了", "着", "过", "地", "得", "所", "以", "于", "与", "及", "或", "而", "且", "则", "即",
"因为", "所以", "但是", "而且", "然后", "如果", "虽然", "不过", "而是", "就是", "只是", "还是",
"并且", "以及", "或者", "否则", "因此", "于是", "关于", "对于", "由于", "根据", "按照", "通过",
// 代词 / 指示词
"我", "你", "他", "她", "它", "我们", "你们", "他们", "她们", "它们", "自己", "别人", "大家",
"这个", "那个", "这些", "那些", "这样", "那样", "这种", "那种", "这里", "那里", "里面", "上面",
"下面", "前面", "后面", "其中", "其他", "其它", "什么", "怎么", "怎样", "为什么", "哪个", "哪些",
// 数量 / 量词 / 程度
"一个", "一条", "一次", "一些", "一种", "一件", "一点", "一下", "一直", "一样", "一起",
"两个", "三个", "几个", "多个", "很多", "更多", "多条", "几条", "每条", "各种", "某种", "全部", "所有", "每个", "各种", "部分",
"一条条", "一条里", "条", "个", "次", "些", "种", "只", "件", "篇", "位", "名", "层",
"非常", "比较", "特别", "十分", "更加", "最为", "可能", "应该", "必须", "可以", "能够", "需要",
// 常见谓词 / 填充动词短语(低信息量,几乎不可能是实体)
"里存着", "存着", "里存在", "有着", "存在", "进行", "使用", "支持", "包括", "包含", "属于",
"作为", "成为", "变成", "做到", "做好", "来看", "来说", "来讲", "看到", "看看", "发现",
"出现", "提供", "实现", "完成", "开始", "结束", "继续", "返回", "调用", "处理", "执行",
"开发", "编写", "添加", "修改", "删除", "更新", "检查", "确认", "验证", "测试", "部署",
// 时间 / 方位
"现在", "今天", "昨天", "明天", "之前", "之后", "以前", "以后", "当时", "当前", "目前",
"最近", "刚才", "马上", "已经", "正在", "曾经", "从来", "后来", "最终", "最后", "首先",
"时候", "时间", "期间", "上面", "下面", "左边", "右边", "中间", "附近", "里面", "外面",
// 英文停用词(小写比较)
"the", "and", "for", "are", "but", "not", "you", "all", "any", "can", "had", "her", "was",
"one", "our", "out", "day", "get", "has", "him", "his", "how", "its", "new", "now", "old",
"see", "two", "way", "who", "boy", "did", "she", "use", "her", "way", "may", "say",
"this", "that", "with", "from", "have", "will", "your", "they", "than", "then", "them",
"into", "more", "some", "such", "only", "over", "also", "just", "like", "make", "made",
}
for _, w := range words {
junkEntitySet[w] = struct{}{}
}
}
// IsJunkEntity 判定实体是否应被图谱扩展丢弃(P0-4 第①道闸)。
func IsJunkEntity(e string) bool {
e = strings.TrimSpace(e)
if e == "" {
return true
}
if utf8.RuneCountInString(e) < 2 {
return true // 单字("条""里")不构成实体
}
// 归一化:去首尾标点/引号,压缩空白
e = strings.Trim(e, " \t\r\n\"'`,。;:!?、()()【】[]{}<>《》—-·.,;:!?")
if e == "" {
return true
}
if utf8.RuneCountInString(e) < 2 {
return true
}
lower := strings.ToLower(e)
if _, bad := junkEntitySet[lower]; bad {
return true
}
if _, bad := junkEntitySet[e]; bad {
return true
}
// 全由虚词/量词字组成(如「里存着」之外的「的了着」「个条」组合)
if allFunctionChars(e) {
return true
}
// 纯标点/符号(无任何字母/数字/汉字)
if !hasSemanticChar(e) {
return true
}
return false
}
// functionChars 单字虚词/量词集合:整串都由这些字组成 ⇒ 无信息量。
var functionChars = map[rune]struct{}{}
func init() {
for _, r := range "的了着过地得所以于与及或而且则即是在有和与就都也还又很更最可会能要没对为从到把被让给吗呢吧啊呀哦嗯这那我你他她它们个条次些种只件篇位名层里上下中前后内外" {
functionChars[r] = struct{}{}
}
}
func allFunctionChars(s string) bool {
for _, r := range s {
if _, ok := functionChars[r]; !ok {
return false
}
}
return true
}
func hasSemanticChar(s string) bool {
for _, r := range s {
switch {
case r >= 0x4E00 && r <= 0x9FFF: // 汉字
return true
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z':
return true
case r >= '0' && r <= '9':
return true
}
}
return false
}
// FilterEntitiesForGraph 批量过滤垃圾实体,返回保留集与被拒数(观测用)。
func FilterEntitiesForGraph(entities []string) ([]string, int) {
if len(entities) == 0 {
return entities, 0
}
out := make([]string, 0, len(entities))
rejected := 0
for _, e := range entities {
if IsJunkEntity(e) {
rejected++
continue
}
out = append(out, e)
}
return out, rejected
}
@@ -0,0 +1,44 @@
package governance
import "testing"
func TestIsJunkEntity(t *testing.T) {
junk := []string{
"条", "个", "里", "的", "了", "这个", "那些", "里存着", "开发", "进行", "the", "and",
"一个", "多条", "存在", "什么", "为什么", "", " ", ",。", "的了着",
}
for _, e := range junk {
if !IsJunkEntity(e) {
t.Errorf("%q 应判为垃圾实体", e)
}
}
keep := []string{
"织忆", "MemoryWeave", "淇县", "生物制造", "卫都街道", "bge-m3", "KOCR", "llama-server-4b",
"8081", "graph.db", "牧尘", "飞书",
}
for _, e := range keep {
if IsJunkEntity(e) {
t.Errorf("%q 是专名/标识符,不应被判为垃圾", e)
}
}
}
func TestFilterEntitiesForGraph(t *testing.T) {
in := []string{"织忆", "条", "这个", "8081", "里存着"}
out, rejected := FilterEntitiesForGraph(in)
if rejected != 3 {
t.Fatalf("应拒绝 3 个垃圾实体,实际 %d(输出 %v)", rejected, out)
}
if len(out) != 2 || out[0] != "织忆" || out[1] != "8081" {
t.Fatalf("保留集错误:%v", out)
}
}
func TestAllFunctionChars(t *testing.T) {
if !allFunctionChars("的了着") {
t.Fatal("全虚词串应判 true")
}
if allFunctionChars("织忆") {
t.Fatal("专名不应判 true")
}
}
+48 -20
View File
@@ -115,6 +115,11 @@ func (agu *AutoGraphUpdater) UpdateFromDistill(distilled *DistillInput) {
entity, distilled.EpisodeID)
continue
}
cleaned := cleanEntityName(entity)
if IsJunkName(cleaned) {
log.Printf("[graph] skip-junk-entity=%q cleaned=%q", entity, cleaned)
continue
}
validEnts = append(validEnts, entity)
}
@@ -189,24 +194,13 @@ func (agu *AutoGraphUpdater) UpdateFromDistill(distilled *DistillInput) {
}
}
// 4. DERIVED_FROM 边:蒸馏产物 → 原始 episode
if distilled.EpisodeID != "" {
for _, entity := range validEnts {
eid := entityID(entity)
derivedEdgeID := fmt.Sprintf("df_%s_%s_%d", eid, distilled.EpisodeID, time.Now().UnixNano())
agu.graph.AddEdge(derivedEdgeID, eid, distilled.EpisodeID,
"DERIVED_FROM", distilled.Namespace, 0.9)
}
for _, fact := range distilled.Facts {
if redact.ContainsSecret(fact) {
continue // 含凭证的事实不建节点(2026-09-10 P0)
}
factNodeID := "f_" + strings.ReplaceAll(entityID(fact), "n_", "")
derivedEdgeID := fmt.Sprintf("df_%s_%s_%d", factNodeID, distilled.EpisodeID, time.Now().UnixNano())
agu.graph.AddEdge(derivedEdgeID, factNodeID, distilled.EpisodeID,
"DERIVED_FROM", distilled.Namespace, 0.9)
}
}
// 4. DERIVED_FROM 边:已停写(2026-10-13 t_348cefc1 源头二收口)。
// 血缘(L1→L0 episode)由 memories.derived_from 字段承载(models/memory.go:97,
// lancedb_ipc.go:1145 有真实读取);图上的 DERIVED_FROM 边全库仅此处写、无人读
// (全仓 go/rust/py/web/docs grep:读取点 0 处),且 AddEdge 会自动为缺失的
// ep_/f_ 源/目标创建节点 → 每个蒸馏 episode 恒增 1+N 个噪声节点(N=facts 数)。
// 此路径绕过全部判据(noiseEntity 只作用于 §1 实体,§4 的 fact 节点与 episode id
// 目标节点从不经判据)。停写后血缘不丢,图节点不再膨胀。
}
// RecordCoOccurrence record recall 后的共访关系
@@ -298,7 +292,30 @@ func inSubAgentNamespace(ns string) bool {
// - "证据:无证据" 自述模板
// ⚠️ 不做「方案代号一刀切」——方案A/B 是领域决策常用概念(AC4 平台决策 14 保留),
// 只拦「证据:无证据」这类纯自述模板 + AC 编号 + 工具动词 + 自ID 前缀。
var noiseEntityRe = regexp.MustCompile(`(?i)^(ac\d+|ep_|f_|n_|mem_|e_|co_|cw_|df_|.*(证据[::]无证据|ingest.?dry.?run|健康检查|部署流程|测试结果|代码修改|grep).*)$`)
var noiseEntityRe = regexp.MustCompile(`(?i)^(ac\d+|ep_|f_|n_|mem_|e_|co_|cw_|df_|t_[0-9a-f]{6,}|.*(证据[::]无证据|ingest.?dry.?run|健康检查|部署流程|测试结果|代码修改|grep).*|.*(看板|kanban|任务).*(结案|发车|状态|归档|词汇|过程|卡|process).*)$`)
// kanbanCardIDRe 看板卡 ID 形态:t_<8 位 hex>(含 t_c6aebb8b / t_286d1310 等)。
// 非锚定(可不限于整串),嵌在复合实体名里的卡 ID 也拦。看板卡 ID 是过程标识,
// 绝不承载领域概念,无条件禁进图(与 ep_/f_ 等自ID 前缀同级)。
var kanbanCardIDRe = regexp.MustCompile(`t_[0-9a-f]{6,}`)
// noiseStopwords 停用词表(2026-10-13 t_91af03e9):结构性泛词/探针残留,精确匹配整个实体名。
// 设计原则——只收「短泛词」,长实体名(≥4 字)天然不会精确命中本表,故领域概念
// (飞天农业/中维化纤/生物基材料/玉米淀粉深加工/Galway/织忆/鹤壁淇县生物制造产业专班…)
// 一律不因本表被拦。精确匹配(整名相等)而非子串:任务/状态 等单字泛词嵌在「任务状态」「状态机」
// 「任务已结案」里照常放行(组合形态仍由下方 noiseEntityRe 覆盖)。
// 唯一例外:工具动词 虽 4 字,但纯结构性工具词、探针丙实测泄漏,显式入表(AC1 硬判据要求拦)。
var noiseStopwords = map[string]struct{}{
// 结构性泛词
"任务": {}, "过程": {}, "状态": {}, "流程": {}, "词汇": {}, "工具": {}, "工具动词": {},
"判据": {}, "结论": {}, "结果": {}, "方案": {}, "内容": {}, "信息": {}, "原因": {},
"问题": {}, "步骤": {}, "阶段": {}, "记录": {}, "清单": {}, "备注": {}, "说明": {},
// 探针/自指残留
"甲": {}, "乙": {}, "丙": {}, "探针": {}, "probe": {},
}
// noiseStopwordPrefixRe 探针前缀形态:^PROBE[-_]?.*(探针丙实测泄漏 PROBE-t62,lower 化后匹配)
var noiseStopwordPrefixRe = regexp.MustCompile(`^probe[-_]?`)
// noiseEntity 实体名命中内容形态噪声 → 跳过写图。
func noiseEntity(name string) bool {
@@ -310,7 +327,18 @@ func noiseEntity(name string) bool {
return true
}
}
// AC 编号 / 自述模板 / 工具动词
// 看板卡 ID(t_<hex>):裸形或嵌在复合词里都拦(2026-10-13 t_348cefc1,探针乙 4/4 泄)
if kanbanCardIDRe.MatchString(lower) {
return true
}
// 停用词表:结构性泛词/探针残留,精确匹配整个实体名(先于现有正则)
if _, ok := noiseStopwords[lower]; ok {
return true
}
if noiseStopwordPrefixRe.MatchString(lower) {
return true
}
// AC 编号 / 自述模板 / 工具动词 / 看板·任务流程词(组合形态,不单拦泛词)
return noiseEntityRe.MatchString(lower)
}
+174 -2
View File
@@ -155,10 +155,10 @@ func TestUpdateFromDistill_NoSelfLoop_WithDupEntities(t *testing.T) {
// 去重后唯一实体 = {Docker, Nginx, Tailscale},M=3
// 两两循环每对建 2 边(语义 configures + CO_OCCURS)= 2*C(3,2) = 6
// DERIVED_FROM = M = 3
// (DERIVED_FROM 边已停写 t_348cefc1,不再 +M)
const (
expectCoEdges = 3 // C(3,2) —— 不丢正常边
expectTotal = 9 // 2*C(3,2) + M
expectTotal = 6 // 2*C(3,2)(无 DERIVED_FROM)
)
var selfLoops, coEdges, total int
g.mu.RLock()
@@ -417,3 +417,175 @@ func TestUpdateFromDistill_SubAgentSessionZeroNodes(t *testing.T) {
t.Errorf("llm_distill 来源应 0 节点,实际 %d", n)
}
}
// ── 源头二收口(2026-10-13 t_348cefc1):看板卡 ID + 流程词内容形态 ──
// 探针乙实测:t_c6aebb8b / 任务 / 看板过程词汇 / 已结案 / 待发车 全泄漏建节点。
// 看板卡 ID(t_<hex>)无条件禁;任务/看板流程词用组合形态拦(不单拦泛词,防误杀领域概念)。
// AC2:含看板卡 ID + 流程词的探针 → 图谱零新增(领域实体照常进图)。
func TestNoiseEntity_KanbanCardIDAndProcessWordsNeverInGraph(t *testing.T) {
g := NewInMemoryGraph()
up := NewAutoGraphUpdater(g)
// 复现探针乙:t_ 卡 ID + 任务/看板流程词;Source 用常规领域来源以证明是内容形态层拦截
up.UpdateFromDistill(&DistillInput{
EpisodeID: "ep_1791826303408181661",
Content: "任务 t_c6aebb8b 与 t_286d1310 均已结案,任务 t_58d86c84 待发车,看板过程词汇整理",
Facts: []string{"任务 t_c6aebb8b 与 t_286d1310 均已结案"},
Entities: []string{"任务已结案", "任务待发车", "看板过程词汇", "t_c6aebb8b", "t_286d1310", "Docker", "牧尘"},
Namespace: "ns_kanban_guard",
Source: "wiki:vault-ingest", // 常规来源:专测内容形态穿透
})
nodes := g.ListNodes("ns_kanban_guard")
names := map[string]bool{}
for _, n := range nodes {
names[n["name"].(string)] = true
}
// 看板卡 ID + 流程词一律不得进图
for _, bad := range []string{"任务已结案", "任务待发车", "看板过程词汇", "t_c6aebb8b", "t_286d1310"} {
if names[bad] {
t.Errorf("内容形态噪声实体 %q 不应进图,实际出现: %v", bad, names)
}
}
// 领域实体照常进图(AC3 假阳性护栏)
if !names["Docker"] || !names["牧尘"] {
t.Errorf("领域实体 Docker/牧尘 应照常进图,实际: %v", names)
}
}
// AC2 判据层:看板卡 ID 与流程词形态判定。
func TestNoiseEntity_KanbanCardIDAndProcessWordDetected(t *testing.T) {
// 必拦:看板卡 ID(裸/嵌复合词)+ 流程词组合形态
for _, e := range []string{
"t_c6aebb8b", "t_286d1310", "t_58d86c84", "t_08a9a0c1",
"任务已结案", "任务待发车", "看板过程词汇", "看板卡整理", "kanban_process",
"t_c6aebb8b已结案", "任务 t_286d1310 状态",
} {
if !noiseEntity(e) {
t.Errorf("noiseEntity(%q) 应为 true(看板卡 ID/流程词)", e)
}
}
// 不误杀:领域概念(尤其含 任务/看板/状态 的合法概念)照常放行
for _, e := range []string{"Docker", "方案B", "织忆", "小怡", "看板", "系统状态", "状态机", "archive"} {
if noiseEntity(e) {
t.Errorf("noiseEntity(%q) 应为 false(领域概念误杀)", e)
}
}
}
// 停写 DERIVED_FROM 后:常规来源蒸馏,facts 不再为图新增 f_/ep_ 目标节点。
func TestUpdateFromDistill_NoDerivedFromEdges(t *testing.T) {
g := NewInMemoryGraph()
up := NewAutoGraphUpdater(g)
before := len(g.ListNodes("ns_df"))
beforeEdges := len(g.AllEdges("ns_df"))
up.UpdateFromDistill(&DistillInput{
EpisodeID: "ep_1791826302802227461",
Content: "配置 Docker Nginx",
Facts: []string{"Docker 容器已启动,配置了 Nginx 反向代理"},
Entities: []string{"Docker", "Nginx"},
Namespace: "ns_df",
Source: "wiki:vault-ingest", // 常规来源:照写实体/关系边,但不建 DERIVED_FROM
})
nodes := g.ListNodes("ns_df")
if len(nodes) != before+2 {
t.Errorf("应新增 2 个实体节点(Docker/Nginx,无 f_/ep_ 派生节点),实际 +%d", len(nodes)-before)
}
edges := g.AllEdges("ns_df")
for _, e := range edges {
if e.Relation == "DERIVED_FROM" {
t.Errorf("DERIVED_FROM 边应已停写,实际出现: %v", e)
}
}
if len(edges) != beforeEdges+2 { // 2 实体 = 1 对 → 1 语义 + 1 CO_OCCURS
t.Errorf("应有 2 条关系边(Docker-Nginx 语义+CO_OCCURS),实际 %d 条", len(edges)-beforeEdges)
}
}
// ── 补收口三(2026-10-13 t_62f35e62):旧关键词单独立可拦 ──
// 上一版把「健康检查/部署流程/测试结果/代码修改/grep」并进组合形态分支,
// 必须再配一个流程词才拦,导致这些工具动词单独出现时回退(漏网建节点)。
// 修复:拆成两个并列分支——旧关键词恢复「单独可拦」,泛词(看板/任务/kanban)仍只用组合形态。
// AC1:旧关键词恢复单独可拦(本轮回归修复判据)。
func TestNoiseEntity_OldKeywordsSingleMatchRestored(t *testing.T) {
for _, e := range []string{
"健康检查", "部署流程", "测试结果", "代码修改", "grep",
"健康检查与部署流程", "证据:无证据", "ingest dry run", "ingest-dry-run",
} {
if !noiseEntity(e) {
t.Errorf("noiseEntity(%q) 应为 true(旧关键词应单独可拦,回归)", e)
}
}
// 组合形态仍拦(不回归)
for _, e := range []string{"任务已结案", "看板过程词汇", "kanban_process"} {
if !noiseEntity(e) {
t.Errorf("noiseEntity(%q) 应为 true(流程词组合形态)", e)
}
}
// 不误杀:领域概念照常放行(AC3 假阳性护栏)
for _, e := range []string{
"鹤壁淇县生物制造产业专班", "飞天农业", "中维化纤", "织忆", "Galway",
"Docker", "方案B", "小怡", "看板", "系统状态", "状态机", "archive",
} {
if noiseEntity(e) {
t.Errorf("noiseEntity(%q) 应为 false(领域概念误杀)", e)
}
}
}
// ── 补收口三(2026-10-13 t_91af03e9):结构性泛词停用词表(精确匹配整名)──
// 探针丙实测:任务/工具动词/判据/PROBE-t62 等结构性泛词单独成实体时仍漏进图。
// 旧判据只拦「组合形态」+ 旧关键词,没给「停用词表」——本批补上:
// 结构性泛词/探针残留 精确匹配整个实体名即拦(先于现有正则)。
// 红线:领域概念(含 4 字以上实体名)不因本表被拦——本表只收短泛词,
// 长实体名天然不会精确命中,且「任务状态/状态机/任务已结案」等复合形态照常放行。
// AC1:上列泛词必须全「拦」(逐词打印 拦/放,供终审核对)。
func TestNoiseEntity_StopwordBlocked(t *testing.T) {
// 上列泛词:结构性泛词 + 探针/自指残留(工具动词 显式入表,AC1 硬判据)
block := []string{
"任务", "过程", "状态", "流程", "词汇", "工具", "工具动词",
"判据", "结论", "结果", "方案", "内容", "信息", "原因",
"问题", "步骤", "阶段", "记录", "清单", "备注", "说明",
"甲", "乙", "丙", "探针", "PROBE", "PROBE-t62",
}
fail := 0
for _, e := range block {
got := noiseEntity(e)
mark := "放"
if got {
mark = "拦"
} else {
fail++
}
t.Logf("%-12s → %s", e, mark)
if !got {
t.Errorf("noiseEntity(%q) 应为 true(停用词表应拦)", e)
}
}
if fail > 0 {
t.Fatalf("停用词表 %d/%d 未拦", fail, len(block))
}
}
// AC2:不误杀——7 个红线领域词 + 复合形态照常放行(误杀数必须 = 0)。
func TestNoiseEntity_StopwordNoFalseKill(t *testing.T) {
keep := []string{
// 红线 7 个领域词
"鹤壁淇县生物制造产业专班", "飞天农业", "中维化纤", "玉米淀粉深加工",
"生物基材料", "织忆", "Galway",
// 复合形态(含单字泛词,非精确命中 → 放行;组合形态由 noiseEntityRe 拦)
"信息收集", "状态机", "系统状态", "看板", "Docker", "方案B", "小怡", "archive",
}
kill := 0
for _, e := range keep {
if noiseEntity(e) {
t.Errorf("noiseEntity(%q) 应为 false(领域概念误杀)", e)
kill++
}
}
if kill > 0 {
t.Fatalf("误杀 %d 个领域概念", kill)
}
t.Logf("AC2 误杀数 = 0(红线 7 词 + %d 复合形态全放行)", len(keep)-7)
}
@@ -0,0 +1,205 @@
// 织忆 MemoryWeave — 图谱扩展(ExpandFromResults)锁死 + 实测(t_818f77be)
//
// 背景:旧 InMemoryGraph/FileGraph 的 ExpandFromResults 拿 r.Category(类目名,如 "distilled")
// 当实体名去 Navigate,永远匹配不到任何图节点 ⇒ 结构性零产出。修正后导航起点 = 从 r.Content
// 提实体(复用 extract*Entities + normalize*EntityID,与 ExpandWithSummary 同一套逻辑)。
//
// 本文件两类断言:
// 1) 锁死(AC#4):构造小图 + 一条 recall ⇒ 结果必含 category=graph_expanded,且 Score=0.5 不变。
// 2) 实测(AC#3):同批 10 条 recall ⇒ 补入率 > 0,给出具体数字。
// 判读方式:改前树副本跑本测试 ⇒ 两条均得 0(复现结构零);改后树 ⇒ 均 >0。
//
// 说明:FileGraph.AddNode/AddEdge 存在潜伏死锁(save() 在写锁内又取 RLock ⇒ 非重入 RWMutex 自锁),
// 故 FileGraph 用例改走「写 JSON 文件 + NewFileGraph 加载」,绕开该潜伏 bug(不在本卡修复范围)。
package governance
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/xiaoxue/memoryweave/internal/models"
)
// testNS 固定命名空间,用于证明「实体修对 + namespace 对上 ⇒ 补入 >0」,
// 以及「namespace 对不上 ⇒ 二次阻塞补 0」(次级怀疑)。
const testNS = "t-ns-expand"
// chainNodes 一条 4 节点链(中文实体,与生产 graph.db 节点命名一致:n_<名字>)。
// 抽取器 extractPotentialEntitiesFromContent 对「2~8 字中文串」最可靠(英文带空格提不到),
// 故实体一律用中文名;边端点是归一后形态(n_织忆 / n_图谱 / n_召回 / n_向量)。
var (
chainNodeIDs = []string{"n_织忆", "n_图谱", "n_召回", "n_向量"}
chainNodeNames = map[string]string{"n_织忆": "织忆", "n_图谱": "图谱", "n_召回": "召回", "n_向量": "向量"}
chainEdges = []struct{ id, src, dst string }{
{"e-ab", "n_织忆", "n_图谱"},
{"e-bc", "n_图谱", "n_召回"},
{"e-cd", "n_召回", "n_向量"},
}
)
// addChainInMemory 给 InMemoryGraph 灌入链(AddNode/AddEdge 无死锁,可直接用)。
func addChainInMemory(g *InMemoryGraph) {
for _, id := range chainNodeIDs {
_ = g.AddNode(id, chainNodeNames[id], "entity", testNS)
}
for _, e := range chainEdges {
_ = g.AddEdge(e.id, e.src, e.dst, "LINKS_TO", testNS, 1.0)
}
}
// buildFileGraphJSON 把链写成 JSON 文件,再 NewFileGraph 加载 —— 绕开 AddNode/AddEdge 潜伏死锁。
func buildFileGraphJSON(t *testing.T) (*FileGraph, string) {
t.Helper()
path := filepath.Join(t.TempDir(), "graph-expand-test.json")
data := FileGraphData{Version: 2}
for _, id := range chainNodeIDs {
data.Nodes = append(data.Nodes, &FileGraphNode{
ID: id, Name: chainNodeNames[id], Type: "entity",
Namespace: testNS, PageRank: 0.15, EvidenceCount: 1,
})
}
for _, e := range chainEdges {
data.Edges = append(data.Edges, &FileGraphEdge{
ID: e.id, Source: e.src, Target: e.dst,
Relation: "LINKS_TO", Weight: 1.0, Namespace: testNS,
})
}
if err := os.WriteFile(path, mustMarshal(data), 0644); err != nil {
t.Fatalf("write filegraph json: %v", err)
}
fg, err := NewFileGraph(path)
if err != nil {
t.Fatalf("NewFileGraph: %v", err)
}
return fg, path
}
func mustMarshal(v interface{}) []byte {
b, err := json.Marshal(v)
if err != nil {
panic(err)
}
return b
}
// tenRecalls 构造 10 条 recall 结果:正文含中文实体「织忆 图谱 召回(±向量)」
// (⇒ 归一 n_织忆/n_图谱/n_召回/n_向量,均在图内),类目名保持 "distilled"
// (与生产实测一致,证明导航起点绝不能是类目名)。
func tenRecalls() []models.RecallResult {
contents := []string{
"织忆 图谱 召回 的扩展机制",
"图谱 与 召回 相关实体",
"召回 通过 向量 关联 织忆",
"织忆 图谱 召回 向量 的拓扑",
"向量 在最末端;织忆 是起点",
"recall 提及 织忆 图谱 召回",
"链路 织忆 图谱 召回 向量",
"织忆 节点 见 于 distilled 记录",
"图谱 召回 织忆 共现",
"最终 recall:织忆 图谱 召回 向量",
}
out := make([]models.RecallResult, 0, len(contents))
for i, c := range contents {
out = append(out, models.RecallResult{
ID: "mem_expand_" + string(rune('a'+i)),
Content: c,
Category: "distilled",
Score: 0.9,
Namespace: testNS,
})
}
return out
}
// countGraphExpanded 逐条跑 10 条 recall,返回每调用 graph_expanded 条数 + 总和,并校验
// 每条补入结果 category=graph_expanded 且 Score=0.5(AC#5:不动 0.5 降权)。
func countGraphExpanded(g interface {
ExpandFromResults(results []models.RecallResult, namespace string, maxHops int) []models.RecallResult
}, t *testing.T, ns string) (perCall []int, total int) {
t.Helper()
recalls := tenRecalls()
for i, r := range recalls {
got := g.ExpandFromResults([]models.RecallResult{r}, ns, 1)
for _, e := range got {
if e.Category != "graph_expanded" {
t.Fatalf("call %d: 补入结果 category=%q 期望 graph_expanded", i, e.Category)
}
if e.Score != 0.5 {
t.Fatalf("call %d: 补入结果 score=%v 期望 0.5(AC#5 不改降权)", i, e.Score)
}
}
perCall = append(perCall, len(got))
total += len(got)
}
return perCall, total
}
// ── 锁死(AC#4):InMemoryGraph ──────────────────────────────────
// 改前树必 FAIL(起点=类目 ⇒ 0 补入);改后树 PASS。
func TestInMemoryGraph_ExpandFromResults_Inserts(t *testing.T) {
g := NewInMemoryGraph()
addChainInMemory(g)
recalls := tenRecalls()
got := g.ExpandFromResults(recalls, testNS, 1)
if len(got) == 0 {
t.Fatalf("InMemoryGraph ExpandFromResults 补 0 条 — 导航起点仍非实体(结构性零产出未修)")
}
for _, e := range got {
if e.Category != "graph_expanded" {
t.Fatalf("category=%q 期望 graph_expanded", e.Category)
}
}
t.Logf("InMemoryGraph: 一条 10-recall 批量补入 %d 条 graph_expanded", len(got))
}
// ── 锁死(AC#4):FileGraph ─────────────────────────────────────
func TestFileGraph_ExpandFromResults_Inserts(t *testing.T) {
fg, _ := buildFileGraphJSON(t)
recalls := tenRecalls()
got := fg.ExpandFromResults(recalls, testNS, 1)
if len(got) == 0 {
t.Fatalf("FileGraph ExpandFromResults 补 0 条 — 导航起点仍非实体(结构性零产出未修)")
}
for _, e := range got {
if e.Category != "graph_expanded" {
t.Fatalf("category=%q 期望 graph_expanded", e.Category)
}
}
t.Logf("FileGraph: 一条 10-recall 批量补入 %d 条 graph_expanded", len(got))
}
// ── 次级怀疑(AC「namespace 不二次阻塞」)────────────────────────
// 实体修对、但传错 namespace ⇒ 应补 0(证明 namespace 是硬约束,需对上才有产出)。
func TestInMemoryGraph_ExpandFromResults_NamespaceBlock(t *testing.T) {
g := NewInMemoryGraph()
addChainInMemory(g)
got := g.ExpandFromResults(tenRecalls(), "no-such-ns", 1)
if len(got) != 0 {
t.Fatalf("namespace 对不上却补入 %d 条 — 预期 0(namespace 过滤应生效)", len(got))
}
t.Logf("namespace 不匹配 ⇒ 补 0(证明 namespace 为硬约束;对上即有产出)")
}
// ── 实测(AC#3):同批 10 条 recall 补入率 ──────────────────────
// 改前树:perCall 全 0 ⇒ total 0(复现结构零)。改后树:total>0,输出具体数字。
func TestExpandFromResults_TenRecall_Empirical(t *testing.T) {
gm := NewInMemoryGraph()
addChainInMemory(gm)
pIn, totalIn := countGraphExpanded(gm, t, testNS)
t.Logf("InMemoryGraph 10-recall 补入: per-call=%v total=%d", pIn, totalIn)
if totalIn == 0 {
t.Fatalf("InMemoryGraph 改后仍补 0(10/10 全 0)— 结构性零产出未消除")
}
fg, _ := buildFileGraphJSON(t)
pF, totalF := countGraphExpanded(fg, t, testNS)
t.Logf("FileGraph 10-recall 补入: per-call=%v total=%d", pF, totalF)
if totalF == 0 {
t.Fatalf("FileGraph 改后仍补 0(10/10 全 0)— 结构性零产出未消除")
}
}
+59 -16
View File
@@ -3,44 +3,87 @@ package governance
import (
"fmt"
"log"
"os"
"strings"
"github.com/xiaoxue/memoryweave/internal/models"
)
// graphExpandDebugEnabled 控制「图谱扩展」debug 日志(AC 可观测性,t_818f77be)。
// 默认关;设 ZHIYI_GRAPH_EXPAND_DEBUG=1 打开。每次 recall 至多一条(低频),避免刷屏。
func graphExpandDebugEnabled() bool {
return os.Getenv("ZHIYI_GRAPH_EXPAND_DEBUG") == "1"
}
// ExpandFromResults 从 recall 结果出发,双向 BFS 扩展图谱邻接节点
// 实现 GraphStore 接口
//
// 导航起点 = 从 r.Content 提实体(复用 extractPotentialEntitiesFromContent + normalizeEntityID,
// 与同文件 ExpandWithSummary 同一套逻辑)。旧实现拿 r.Category(类目名,如 "distilled")
// 当实体去 Navigate,永远匹配不到任何图节点 ⇒ 结构性零产出(t_818f77be 实测 10/10 全 distilled)。
// 且 InMemoryGraph.Navigate 返回字段是 source/target(非 to/from),旧读法 p["to"]/p["from"]
// 恒取到空 ⇒ 即便实体修对也补 0。两处一并修正。
func (g *InMemoryGraph) ExpandFromResults(results []models.RecallResult, namespace string, maxHops int) []models.RecallResult {
var expanded []models.RecallResult
seen := make(map[string]bool)
seenEntities := make(map[string]bool)
// 收集已有结果 ID
for _, r := range results {
seen[r.ID] = true
}
// 从每个结果出发扩展
startEntities := 0 // 可观测性:去重后实际导航的起点实体数 N
totalPathsHit := 0 // 可观测性:命中路径总数 M(补前去重)
rejectedJunk := 0 // P0-4:被质量闸①丢弃的垃圾实体数
rejectedLowDegree := 0 // P0-4:被质量闸②丢弃的低度数实体数
// 从每个结果正文提实体出发扩展
for _, r := range results {
paths, err := g.Navigate(r.Category, maxHops, namespace, nil)
if err != nil {
continue
}
for _, p := range paths {
to, _ := p["to"].(string)
from, _ := p["from"].(string)
entities := extractPotentialEntitiesFromContent(r.Content)
// P0-4 第①道闸:垃圾实体不导航
entities, rejected := FilterEntitiesForGraph(entities)
rejectedJunk += rejected
for _, entity := range entities {
if seenEntities[entity] {
continue
}
seenEntities[entity] = true
startEntities++
for _, id := range []string{to, from} {
if id != "" && !seen[id] {
seen[id] = true
expanded = append(expanded, models.RecallResult{
ID: id,
Category: "graph_expanded",
Score: 0.5, // 图谱扩展降权
})
nodeID := normalizeEntityID(entity)
paths, err := g.Navigate(nodeID, maxHops, namespace, nil)
if err != nil {
continue
}
// P0-4 第②道闸:路径数 <2 ⇒ 孤边节点,不构成语义关联(内存图无 degree 查询,用路径数代理)
if len(paths) < 2 {
rejectedLowDegree++
continue
}
totalPathsHit += len(paths)
for _, p := range paths {
to, _ := p["target"].(string)
from, _ := p["source"].(string)
for _, id := range []string{to, from} {
if id != "" && !seen[id] {
seen[id] = true
expanded = append(expanded, models.RecallResult{
ID: id,
Category: "graph_expanded",
Score: 0.5, // 图谱扩展降权
})
}
}
}
}
}
// 可观测性:默认关(ZHIYI_GRAPH_EXPAND_DEBUG=1 打开),保证「下次坏了不沉默」。
if graphExpandDebugEnabled() {
log.Printf("[graph-expand] ns=%s maxHops=%d 起点实体 %d 个 / 命中 %d 条 / 补入 %d 条 / 质量闸拒 垃圾 %d 低度 %d",
namespace, maxHops, startEntities, totalPathsHit, len(expanded), rejectedJunk, rejectedLowDegree)
}
return expanded
}
+47 -15
View File
@@ -5,6 +5,7 @@ package governance
import (
"encoding/json"
"fmt"
"log"
"math"
"os"
"strings"
@@ -457,32 +458,63 @@ func (fg *FileGraph) UpdateEdgeTrustScores() error { return
func (fg *FileGraph) ExpandFromResults(results []models.RecallResult, namespace string, maxHops int) []models.RecallResult {
var expanded []models.RecallResult
seen := make(map[string]bool)
seenEntities := make(map[string]bool)
for _, r := range results {
seen[r.ID] = true
}
// 导航起点 = 从 r.Content 提实体(复用 extractFileGraphEntities + normalizeFileGraphEntityID,
// 与同文件 ExpandWithSummary 同一套逻辑)。旧实现拿 r.Category(类目名,如 "distilled")
// 当实体去 Navigate,永远匹配不到任何图节点 ⇒ 结构性零产出(t_818f77be)。
startEntities := 0 // 可观测性:去重后实际导航的起点实体数 N
totalPathsHit := 0 // 可观测性:命中路径总数 M(补前去重)
rejectedJunk := 0 // P0-4:质量闸①拒绝数
rejectedLowDegree := 0 // P0-4:质量闸②拒绝数
for _, r := range results {
paths, err := fg.Navigate(r.Category, maxHops, namespace, nil)
if err != nil {
continue
}
for _, p := range paths {
target, _ := p["target"].(string)
source, _ := p["source"].(string)
entities := extractFileGraphEntities(r.Content)
entities, rejected := FilterEntitiesForGraph(entities)
rejectedJunk += rejected
for _, entity := range entities {
if seenEntities[entity] {
continue
}
seenEntities[entity] = true
startEntities++
for _, id := range []string{target, source} {
if id != "" && !seen[id] {
seen[id] = true
expanded = append(expanded, models.RecallResult{
ID: id,
Category: "graph_expanded",
Score: 0.5,
})
nodeID := normalizeFileGraphEntityID(entity)
paths, err := fg.Navigate(nodeID, maxHops, namespace, nil)
if err != nil {
continue
}
// P0-4 第②道闸:路径数 <2 ⇒ 孤边节点不注入
if len(paths) < 2 {
rejectedLowDegree++
continue
}
totalPathsHit += len(paths)
for _, p := range paths {
target, _ := p["target"].(string)
source, _ := p["source"].(string)
for _, id := range []string{target, source} {
if id != "" && !seen[id] {
seen[id] = true
expanded = append(expanded, models.RecallResult{
ID: id,
Category: "graph_expanded",
Score: 0.5,
})
}
}
}
}
}
// 可观测性:默认关(ZHIYI_GRAPH_EXPAND_DEBUG=1 打开),保证「下次坏了不沉默」。
if graphExpandDebugEnabled() {
log.Printf("[graph-expand] ns=%s maxHops=%d 起点实体 %d 个 / 命中 %d 条 / 补入 %d 条 / 质量闸拒 垃圾 %d 低度 %d",
namespace, maxHops, startEntities, totalPathsHit, len(expanded), rejectedJunk, rejectedLowDegree)
}
return expanded
}
+17
View File
@@ -3,6 +3,7 @@ package governance
import (
"fmt"
"strings"
"sync"
)
@@ -295,6 +296,22 @@ func (g *InMemoryGraph) AllEdges(namespace string) []GraphEdge {
return out
}
// CountNodesByType 内存实现:无时间列,lastUpdated 恒为空(调用方按 unknown_age 处理)。
func (g *InMemoryGraph) CountNodesByType(typ string) (total, withID int, lastUpdated string) {
g.mu.RLock()
defer g.mu.RUnlock()
for _, n := range g.nodes {
if n.Type != typ {
continue
}
total++
if strings.TrimSpace(n.ID) != "" {
withID++
}
}
return
}
// Stats 返回图谱统计
func (g *InMemoryGraph) Stats() (nodeCount, edgeCount int, density float64) {
g.mu.RLock()
+129 -7
View File
@@ -16,6 +16,7 @@ import "C"
import (
"encoding/json"
"fmt"
"log"
"os"
"strconv"
"strings"
@@ -216,11 +217,33 @@ func (gs *SQLiteGraphStore) AddEdge(id, source, target, relation, namespace stri
return execSQL(gs.db, sql)
}
func (gs *SQLiteGraphStore) isQuarantinedNode(nodeID string) (bool, error) {
sql := fmt.Sprintf(
"SELECT namespace, pagerank FROM graph_nodes WHERE id='%s'", escape(nodeID))
rows := queryRows(gs.db, sql)
if len(rows) == 0 {
// node not found; treat as not quarantined to avoid breaking existing flows
return false, nil
}
row := rows[0]
namespace, _ := row["namespace"].(string)
pagerankVal, _ := row["pagerank"].(float64)
return namespace == "quarantine" && pagerankVal == 0, nil
}
func (gs *SQLiteGraphStore) Navigate(entity string, maxHops int, namespace string, relFilter []string) ([]map[string]interface{}, error) {
// 单源 BFS:从 entity 展开到邻居,不找路径
// E1.4: relFilter 白名单过滤关系类型
gs.mu.RLock()
defer gs.mu.RUnlock()
if quarantined, err := gs.isQuarantinedNode(entity); err != nil {
// If we cannot determine, treat as not quarantined to avoid breaking existing flows.
if err != nil {
// Continue; assume not quarantined.
} else if quarantined {
return []map[string]interface{}{}, nil
}
}
nsClause := buildNamespaceClause(namespace)
relClause := buildRelationFilterClause(relFilter)
@@ -233,7 +256,7 @@ func (gs *SQLiteGraphStore) Navigate(entity string, maxHops int, namespace strin
var next []string
for _, node := range queue {
sql := fmt.Sprintf(
"SELECT e.id, e.target, e.relation, e.weight, n.name FROM graph_edges e JOIN graph_nodes n ON e.target = n.id WHERE e.source = '%s' AND %s AND %s",
"SELECT e.id, e.target, e.relation, e.weight, n.name FROM graph_edges e JOIN graph_nodes n ON e.target = n.id WHERE e.source = '%s' AND %s AND %s AND n.namespace != 'quarantine' AND n.pagerank > 0",
escape(node), nsClause, relClause)
edges := queryRows(gs.db, sql)
for _, edge := range edges {
@@ -269,6 +292,20 @@ func (gs *SQLiteGraphStore) NavigateBiDir(source, target string, maxHops int, na
gs.mu.RLock()
defer gs.mu.RUnlock()
if quarantinedSrc, err := gs.isQuarantinedNode(source); err != nil {
if err != nil {
// Continue; assume not quarantined.
} else if quarantinedSrc {
return []map[string]interface{}{}, nil
}
}
if quarantinedTgt, err := gs.isQuarantinedNode(target); err != nil {
if err != nil {
// Continue; assume not quarantined.
} else if quarantinedTgt {
return []map[string]interface{}{}, nil
}
}
nsClause := buildNamespaceClause(namespace)
@@ -310,7 +347,7 @@ func (gs *SQLiteGraphStore) NavigateBiDir(source, target string, maxHops int, na
var next []string
for _, node := range fwdQ {
sql := fmt.Sprintf(
"SELECT e.id, e.target, e.relation, e.weight FROM graph_edges e WHERE e.source = '%s' AND %s",
"SELECT e.id, e.target, e.relation, e.weight FROM graph_edges e JOIN graph_nodes n ON e.target = n.id WHERE e.source = '%s' AND %s AND n.namespace != 'quarantine' AND n.pagerank > 0",
escape(node), nsClause)
edges := queryRows(gs.db, sql)
for _, edge := range edges {
@@ -340,7 +377,7 @@ func (gs *SQLiteGraphStore) NavigateBiDir(source, target string, maxHops int, na
for _, node := range bwdQ {
// 反向要找所有指向 node 的边(target = node)
sql := fmt.Sprintf(
"SELECT e.id, e.source, e.relation, e.weight FROM graph_edges e WHERE e.target = '%s' AND %s",
"SELECT e.id, e.source, e.relation, e.weight FROM graph_edges e JOIN graph_nodes n ON e.source = n.id WHERE e.target = '%s' AND %s AND n.namespace != 'quarantine' AND n.pagerank > 0",
escape(node), nsClause)
edges := queryRows(gs.db, sql)
for _, edge := range edges {
@@ -548,6 +585,32 @@ func (gs *SQLiteGraphStore) Stats() (nodeCount, edgeCount int, density float64)
return
}
// CountNodesByType 返回某类节点的总量、id 非空量、最近一次更新时间(2026-10-26)。
//
// 存在的理由:selfcheck 需要暴露「pattern 召回有没有生产者」这一可观测事实。
// 事故背景:`POST /api/v1/patterns/detect` 是短命单发端点,全库 0 cron、0 调用方,
// 于是 graph_nodes(type='pattern') 恒 0 行、recall 恒 0 命中,而日志层面完全沉默。
// 零产出必须能被机器读到,不能靠人翻 7 天日志才发现。
func (gs *SQLiteGraphStore) CountNodesByType(typ string) (total, withID int, lastUpdated string) {
gs.mu.RLock()
defer gs.mu.RUnlock()
t := escape(typ)
total = queryInt(gs.db, fmt.Sprintf("SELECT COUNT(*) FROM graph_nodes WHERE type='%s'", t))
withID = queryInt(gs.db, fmt.Sprintf(
"SELECT COUNT(*) FROM graph_nodes WHERE type='%s' AND id IS NOT NULL AND TRIM(id)<>''", t))
rows := queryRows(gs.db, fmt.Sprintf(
"SELECT MAX(last_updated_at) AS m FROM graph_nodes WHERE type='%s'", t))
if len(rows) > 0 && rows[0]["m"] != nil {
if v, ok := rows[0]["m"].(string); ok {
lastUpdated = v
} else {
lastUpdated = fmt.Sprintf("%v", rows[0]["m"])
}
}
return
}
func (gs *SQLiteGraphStore) Prune(minWeight float64) {
gs.mu.Lock()
defer gs.mu.Unlock()
@@ -755,6 +818,8 @@ func (gs *SQLiteGraphStore) ExpandFromResults(results []models.RecallResult, nam
seen := make(map[string]bool) // 追踪已访问的记忆 ID
seenEntities := make(map[string]bool) // 追踪已访问的实体(用于去重扩展结果)
rejectedJunk := 0
rejectedLowDegree := 0
for _, r := range results {
if seen[r.ID] {
continue
@@ -762,6 +827,9 @@ func (gs *SQLiteGraphStore) ExpandFromResults(results []models.RecallResult, nam
seen[r.ID] = true
entities := extractPotentialEntities(r.Content)
// P0-4 第①道闸:垃圾实体(虚词/量词/代词/填充短语)不导航
entities, rejected := FilterEntitiesForGraph(entities)
rejectedJunk += rejected
for _, entity := range entities {
if seenEntities[entity] {
continue
@@ -769,18 +837,25 @@ func (gs *SQLiteGraphStore) ExpandFromResults(results []models.RecallResult, nam
seenEntities[entity] = true
// SQLite 图谱节点 ID 格式: n_{entity_name},需 normalizeEntityID 转换
// Navigate 的 from/to 都是**节点 ID**(n_织忆),不能用裸名 entity 比对,
// 否则恒不相等 ⇒ 路径全被 continue ⇒ 结构性 0 条附加(t_be4d070a 诊断)。
nodeID := normalizeEntityID(entity)
// P0-4 第②道闸:度数 <2 的孤边节点不构成语义关联 ⇒ 不导航、不注入。
if gs.EdgeDegree(nodeID) < 2 {
rejectedLowDegree++
continue
}
paths, _ := gs.Navigate(nodeID, maxHops, namespace, nil)
for _, p := range paths {
// Navigate 返回字段: from, to, relation, weight, hop
// Navigate 返回字段: from, to, relation, weight, hop(均为节点 ID)
var neighbor, rel string
from, _ := p["from"].(string)
to, _ := p["to"].(string)
relVal, _ := p["relation"].(string)
if from == entity && to != "" {
if from == nodeID && to != "" {
neighbor = to
rel = relVal
} else if to == entity && from != "" {
} else if to == nodeID && from != "" {
neighbor = from
rel = "↩ " + relVal
}
@@ -792,15 +867,62 @@ func (gs *SQLiteGraphStore) ExpandFromResults(results []models.RecallResult, nam
}
seenEntities[neighbor] = true
expanded = append(expanded, models.RecallResult{
Content: fmt.Sprintf("[graph] %s --[%s]--> %s", entity, rel, neighbor),
// ID 用邻居节点 ID(n_<实体> 或 n_mem_<记忆id>),非空才不破坏 recall.go 的
// ID 去重(Step 5 按 r.ID==e.ID 判已存在);若邻居是记忆节点,ID 即真实记忆指针。
ID: neighbor,
Category: "graph_expanded",
Content: fmt.Sprintf("[graph] %s --[%s]--> %s", entity, rel, displayNodeName(neighbor)),
Score: r.Score * 0.5,
})
}
}
}
// P0-4 可观测性:两道闸的拒绝量必须能读出来(默认关,debug 开才打印,避免刷屏)。
if graphExpandDebugEnabled() && (rejectedJunk > 0 || rejectedLowDegree > 0) {
log.Printf("[graph-expand] ns=%s 质量闸:垃圾实体拒 %d / 低度数拒 %d / 补入 %d",
namespace, rejectedJunk, rejectedLowDegree, len(expanded)-len(results))
}
return expanded
}
// EdgeDegree 返回节点在图中的边数(入度+出度,P0-4 度数闸用)。
// 与 GetEntityDegree 的区别:后者返回 evidence_count 之和(证据强度),
// 本函数才是字面意义的「度数」—— 孤边节点(度=1)不足以构成语义关联。
func (gs *SQLiteGraphStore) EdgeDegree(nodeID string) int {
if nodeID == "" {
return 0
}
gs.mu.RLock()
defer gs.mu.RUnlock()
rows := queryRows(gs.db, fmt.Sprintf(
"SELECT COUNT(*) as c FROM graph_edges WHERE source = '%s' OR target = '%s'",
escape(nodeID), escape(nodeID)))
if len(rows) == 0 {
return 0
}
switch v := rows[0]["c"].(type) {
case int:
return v
case int64:
return int(v)
case float64:
return int(v)
}
return 0
}
// displayNodeName 把节点 ID 转成可读显示名(去 n_ / n_mem_ / mem_ 前缀)。
// 图节点 ID 形态:实体 n_织忆 / 概念 n_mem_<记忆id>(name 列才是 mem_<id>)。
// 仅用于格式化 Content 的可读展示,不做任何记忆正文编造。
func displayNodeName(nodeID string) string {
for _, p := range []string{"n_mem_", "n_", "mem_", "ep_"} {
if strings.HasPrefix(nodeID, p) {
return strings.TrimPrefix(nodeID, p)
}
}
return nodeID
}
// ExpandWithSummary BFS 扩展 + 生成汇总语句 — E1 图谱导航增强
// 从 recall 结果提取实体,进行多跳扩展,返回扩展关系列表和一句话汇总
func (gs *SQLiteGraphStore) ExpandWithSummary(results []models.RecallResult, namespace string, maxHops int) models.GraphBFSResult {

Some files were not shown because too many files have changed in this diff Show More