Compare commits

..
Author SHA1 Message Date
小怡 7360c809f6 feat(rrf): 词面名次上限旋钮(recall-rrf.ftsmax),默认 0=不限
实测三档(84 DA 题配对,漂移 0.0000):
- 不限(当前生产): Δ+0.0655, 8涨/2跌, 单题最差 -0.5
- ftsmax=5       : Δ+0.0060, 1涨/0跌, 最差 0.0
⇒ 上限能消掉丢分,但把增益也几乎消掉;保留为调参旋钮,默认不限
2026-11-12 23:40:24 +08:00
小怡 71bddf3feb feat(distill): 冲突判据属性键归一 — 削中文动词噪声 + 后缀包含匹配
- normalizeCJKAttrKey: 「API端口被指定为」与「API端口为」归一到同一属性键
  (实测漏判来源:同事实两次蒸馏用词不同 ⇒ 键对不上 ⇒ 该合的没合)
- sameAttrDiffValue: 键匹配三档(完全相同 / 中文后缀包含且短键≥3字 / ASCII 不看后缀)
  宁漏勿误:port 与 api_port 语义不同,不参与后缀匹配
- 单测:internal/distill 全绿(1.588s)
2026-11-12 23:31:57 +08:00
小怡 ce7a06f599 feat(recall): RRF 观察期落地 — 缓存键带排序变体 + 保位条数文件热调
- fix(cache): cacheKey 追加 rankVariant,修「off/on 间隔 0.5s 时 on 组吃到
  off 组 60s 缓存 ⇒ 84 题 A/B 全程零差异」的量具自欺(真凶,非 RRF 无效)
- feat(rrf): 保位条数支持 /var/lib/memoryweave/recall-rrf.hold 文件热调,
  避免每组对照都要重启生产
- 实测(84 DA 题,同窗口配对 off→on→off):hold=2 Δ+0.0655 / hold=3 Δ+0.0615,
  漂移 0.0000;8 涨 2 跌,单题最差 −0.5(1 条 DA)
2026-11-12 23:14:56 +08:00
小怡 7be39288e8 feat(recall): RRF 秩级融合(向量秩 × BM25 词面秩)——默认关,flag 文件热切换
- rrf.go: RRFReorder 只做秩级融合,前 hold 名保位(结构性 worst-case 兜底)
- recall.go: 词面名次全量收集(含向量池已命中项);开启时融合池取 topK*2
- 门控: ZHIYI_RRF=1 或 /var/lib/memoryweave/recall-rrf.on,每请求读 ⇒ A/B/A 免重启
- 单测 6 个:门控三态/名次/保位/跨名次取胜语义/noop 契约
2026-11-12 22:46:21 +08:00
小怡 754437b5bf feat(supersede): 确定性版本取代链路接通 — 同主体+同属性+不同取值才顶旧版 (存真去伪阶段3)
背景:memories 表 supersedes/superseded_by 两列全为 '[]'(44,934 行零使用),
旧 IP/旧端口/旧版本号/旧规则永远和新值并存 ⇒ 注入可能取到错的。
写时四算子 2026-11-10 退役(LLM 判定 ADD 98%/UPDATE 1%),合并能力实际关闭。

本笔:
- 新增 internal/distill/supersede.go:SupersedeMode(off|shadow|apply,默认 off) +
  FindSupersedeTarget(复用 conflict_gate.SameAttrValueConflict,零 LLM/零新依赖)
- 保护:sim<0.80 不判、tier=core 不顶、已是旧版不二次标记、每条最多顶 1 条
- server.go 蒸馏写路径接上:shadow 只打日志;apply 才写 freshness=superseded
  + superseded_by + valid_to
- 6 个单测(判准/同值不判/低相似不判/core 保护/不二次标记/门控默认 off)

生产部署:ZHIYI_SUPERSEDE=shadow(只留痕),观察 2-3 天判准率 >90% 再切 apply。
2026-11-11 22:50:36 +08:00
小唯 c5ff3b8937 fix(autolabel): 覆盖率同窗口(7d/7d) — cutoff参数化 + neg标签钉死7d窗口 (t_832c60ff返修2) 2026-11-10 18:28:42 +08:00
小唯 eb93983225 feat(autolabel): 反馈覆盖自动正/负样本标注链路 — mem_口径+覆盖率AC1纠错 (t_77744b71)
返修 t_132cfa76 三硬伤 #2/#3/#1:
- AC2 持久化实现:internal/feedback/autolabel 正式代码路径(纯函数判据 + usage_track.db ro 读 + hit_events 经 LanceDB 落库 + 幂等游标),含单测 12 个全绿。env 门控 ZHIYI_AUTO_LABEL(缺省关/shadow=只计数不写)。
- AC1 口径纠错:覆盖率 = (posU ∪ negU)/distinct(usage_recall 7d mem_%),Report.CoveragePct 唯一实现。禁止把 inject-outcomes 注入率当覆盖率。
- AC3 扩量实测(shadow 扫生产):pos=2264 + neg=1546 ⇒ 覆盖率 21.0%(分母 18186)/20.7%(小怡分母18371),达 ≥20%。实测发现原卡 neg=356 是错数 —— 混了 4104 条 n_图谱节点 + 只扫部分窗口;正式链路钉死 mem_% 口径后真实 neg=1546。
- 新增 HitReason auto_soft_inject/inject_pass/repeat_gone/expire_unuse 枚举 + 归一顺序纪律(子串互咬单测钉死)。

不部署:无二进制/drop-in/重启,仅源码+测试。usage_track.db 全程 mode=ro。
2026-11-10 17:21:12 +08:00
小唯 978d633e10 feat(metrics): 四指标定性契约 — deprecated_total=死路/rate 恒-1=样本不足 统一 null 语义 (t_ade9f695) 2026-11-10 14:13:36 +08:00
小唯 07599f0a22 feat(distill): 硬规则拒绝可观测 rejected_by_rule 真计数 + lane=drop 死路定性 (t_a513b870)
- AC1: HardRulesPass 拆出 HardRulesPassWithReason,拒绝分支登记并发安全计数
  (empty/too_short/chitchat/qa/sys_template/low_density/too_long 7 桶)
  + 至少一行 [GATE][REJECT] rule=<x> 日志;系统样板移入硬规则层,drop 只在硬规则层发生。
- AC2: rejected_by_rule 挂到已有 /api/v1/metrics(不开新端口),
  字段 rejected_by_rule / rejected_by_rule_<bucket> / entered_queue / gate_attempts;
  handler 主体抽成 routes.MetricsJSON 便于单测。
- AC3: 单测覆盖全部分桶 + 并发 200 + httptest 真实端点读回 JSON。
- AC4(a): 承认 lane=drop 不可达(硬规则更严先拦),保留分支加防御注释,闸门只走 keep/observe。
- 发现 chitchat 规则同样不可达(pattern 全 <10 字,恒被 too_short 先短路)→ 测试钉死。
- 未打二进制/未改 drop-in/未重启 zhiyid(二进制由小怡统一构建部署)。
2026-11-10 04:13:09 +08:00
小唯 88bdb010cf fix(api): recall/debug 的 diversity 默认值与生产 Recall 对齐
病根:RecallDebug 直接用 req.Diversity(缺省 0),而生产 Recall(core.go:613)
在 req.Diversity<=0 时回落 storage.DefaultMMRDiversity()(tune-state 实测推送 0.1)。
⇒ /recall/debug 报出的 mmr 行 diversity=0.0,与生产不同口径,诊断撒谎比没诊断更贵。

修法:与 Recall 完全同源的回落 —— 未显式传 diversity 时读同一个 storage 推送原子值,
不硬编码 0.1,调参器改了 debug 跟着变。

回归测试 recall_debug_diversity_test.go:未传→跟随推送值(0.1/0.42 各测,证非硬编码);
显式传→显式值优先(0.7)。复用同包 nsAwareRecallLance/fakeEmbedServer/ runRecallDebugHTTP。

验证:go test ./internal/api/routes/ -count=1 整包绿(修前修后 0 failed,失败数未增)。

回滚:git revert 本 commit(删 core.go 内 4 行回落 + 删 recall_debug_diversity_test.go)。
本 commit 只提交源码,未打二进制、未改 drop-in、未重启 zhiyid。

[task: t_f1ee22e5]
2026-11-10 04:01:28 +08:00
小唯 fba425af80 fix(recall/debug): 空结果分支补齐 query/count,响应契约两分支一致 (t_nsdebug-20261109)
诊断端点的两条早期返回(encode 失败 / 0 候选)只回 steps+results+total_ms,
缺 query 与 count ⇒ 消费方读 count 得 nil,与正常分支形状不同(又一次「诊断自成一派」)。
测试:ghost ns 用例改为断言 count 键**必须存在**且为 0。
2026-11-09 19:49:02 +08:00
小唯 3f4e00dac7 fix(recall): debug 端点补 ghost-ns 闸 — 与生产同判据,诊断不再撒谎 (t_nsdebug-20261109)
生产 /api/v1/recall 已于 t_15d82af7 + t_a243aefa(11-07)修好:ghost ns ⇒ 0 条。
但 /api/v1/recall/debug 自带一套 nsList,仍无条件并 shared ⇒ 实测 ghost ns 报
"5 candidates from 2 namespaces"、返回 5 条 —— 诊断端点与生产不同判据。

改:nsList 并 shared 前过 storage.NamespaceHasRowsFor(与 recall.go 同一判据 + 同一 60s 缓存);
    0 候选分支的 detail 补 "from N namespaces"(否则看不出搜了几个 ns)。
测试:internal/api/routes/nsdebug_ghost_test.go 2 例(ghost ⇒ 0;真实 ns 仍 2 namespaces)。
RED 已验:修前 ghost ns count=2(sh_1/sh_2)。
2026-11-09 19:46:49 +08:00
小唯 15a224f1c6 fix(metrics): deprecated 计数器持久化三件套 — persist 补 total/date + load 回读 + 跨天归零 (t_4bbddae6)
已随 zhiyid-depcount-20261109 部署(11-09 09:32),本次补提交记录,避免
「跑着的二进制含未提交代码」导致重建即丢修复。
回滚:drop-in zz-depcount-20261109.conf 删除 + restart ⇒ 回 zhiyid-head-b5a6f45。
2026-11-09 19:46:49 +08:00
小唯 58b196ae25 fix(skillgen): L3 草稿按证据主题簇生成 — bge-m3 聚类+最大簇门+簇快照头,禁跨主题拼接 (t_45677e64)
病根: 78条不同主题记忆全塞LLM→跨主题拼接成大杂烩(小怡抽2条全废)。
修法:
- cluster.go: BGEClusterer(bge-m3余弦贪心)+最大簇选择+纯函数clusterByCosine(确定性)
- DraftSkillClustered: 只对最大簇(≥3条)生成; 簇不足拒绝(宁缺毋滥); 溯源证据集=簇
- 草稿头嵌 cluster_id+簇内证据id+一句话簇主题(人审)
- generator: prompt禁抄红线规则+空占位省略+sanitizeDraft去污染
- store: MarkAllStale() 重生成前作废旧draft; PutDraft支持stale→draft复活
- probe/server: 生产装配BGEClusterer; 重生成前标stale
实测: 8 pattern过L3→聚类后4条同主题簇生成(4条因最大簇<3诚实拒) draft4/stale4/active0; 召回基线top5不变
2026-11-09 03:08:17 +08:00
小唯 d95245f45c feat(zhiyi): L2 扩语义 task_seq + discipline — tool_combo 之外两类新 pattern(t_751a6739)
DetectFromRecords 只认工具组合共现,45,132 条蒸馏记忆仅 1 条过 0.85。
新增两类语义(internal/pattern/semantics.go,与 tool_combo 并列不替换):
- task_seq 任务型:诊断/修复/复验 关键词位置严格有序 first(诊断)<first(修复)<first(复验)
- discipline 纪律型:触发动作位置 < 连接词(前/先/之前) < 约束动作位置
两类都机器可判(无 LLM),conf 沿用 confidenceFromFreq 同口径。
扩展 IsValidPatternName 写闸接纳新命名键。改后过 0.85 从 1 → 8 条。
零行为变化:不碰召回路径,ZHIYI_PATTERN_RECALL 默认关。
2026-11-09 02:36:44 +08:00
小唯 185c0aff87 feat(skillgen): 草稿去幻觉(注入真实证据+命令可溯率100%校验)+ 溯源快照 + 证据提供者 (t_9ccbca15)
D1 溯源: 草稿头嵌 PatternSnapshot(source_pattern+evidence ids+created_at),
  单行技能可单向追到 episode;source_pattern 可回查 graph_nodes.id。
D2 反幻觉: Generator.Generate(p, ev) 注入 ≥3 条真实 episode 原文;
  VerifyEvidenceTrace 抽命令/路径/包名 token 在证据里 grep,<100% 拒绝落盘。
  新增 LanceEvidenceProvider(GetRecordByID IPC) + Fake 确定性提供者。
D3 样本: 阈值 0.85 不动;全表扫描证明仅 1 pattern(hermes+python) 过闸。
零召回变化: 未碰 recall 代码;3 条基线 top5 逐条一致。全 draft 无 promote。
2026-11-09 01:39:44 +08:00
小唯 a430a2b602 feat(skillgen): SkillEvaluator 四维评估器 + SkillEvolver 接口 + 规则过滤差距分析探针 (t_4594b632) 2026-11-09 01:12:49 +08:00
小唯 714df5d2a8 feat(zhiyi): L2全表检测探针 (t_d8830204) — 打通 L2 pattern 0行→非0 与 L3 草稿生成
背景:POST /patterns/detect 被 lancedb_scan 硬限 5000 且 DetectFromRecords 默认 MaxRecords=500
(物理序前500条是'不幸窗口'检出0)→ 已有 pattern-detect-daily cron(limit=500) 空转,graph_nodes
type='pattern' 恒 0。本探针走 sidecar IPC lancedb_scan_all(无5000上限)+ 全表 MaxRecords + 诚实阈值:
L2 conf>=0.5 写 graph_nodes(type='pattern',ns=daemon-distill),L3 conf>=0.85 才出草稿到
attachments/<pattern>/skill-draft.md([AUTO-DRAFT]标记,status=draft,绝不自动promote)。
-cwrite 才落库,默认 dry-run。

实测(全表45227行/distilled45096):6个tool_combo,仅 hermes+python (conf=1.0,13源) 过 L2/L3 双闸。
写库后 graph_nodes pattern 0→1, skills 0→1(draft);召回 top5 10query 前后逐位一致(零行为变化)。
本地2B(8081)生成草稿落盘。
2026-11-09 00:13:34 +08:00
小唯 6cbc49ca0e fix(recall): E4.2 跨ns共享兜底补 ghost-ns 判据 — 修复传不存在的 ns 仍经 shared 补充搜泄漏
t_a243aefa 终审在**生产部署后实测**发现:传 ghost ns 仍返回 1 条 channel=shared 记忆
(mem_1793118290141936234, score=0.5 固定值)。根因 = core.go:724 E4.2「结果数 <3 时
补搜 shared」这一段没有 nsHasRows 判据 —— t_15d82af7 只修了 recall.go 的 namespaces
列表和 FTS 尾通道两处,漏了路由层这第三处。

判据与另两处对齐:storage.NamespaceHasRowsFor(与 recall.go 同一个函数,带 60s TTL 缓存)。
ghost ns ⇒ 不补搜;真实 ns ⇒ 行为逐位不变(回归测试 + go test 两包全绿)。
2026-11-07 06:05:40 +08:00
小唯 b5a6f4536e perf(recall): namespaceHasRows 加 60s TTL 缓存(复用 RedisCache,t_a243aefa 终审)— ScanAll 每次带 ns 召回全表读 ⇒ 同 query 集 p50 2.13s→0.39s 2026-11-07 05:00:12 +08:00
小唯 199608a7d3 feat(intention-ttl): 意图 TTL — intention 类 + expires_at 跨五层落盘 + 过期负分降档(非删除,ZHIYI_INTENTION_TTL 门控)
- Go model/IPC/FTS 通道/SQLite/API 写入(RFC3339 严判 400)+ Rust struct/schema/builder/6×读侧 col_str
- recall 降档块在 MMR 之前(选择与展示共用同一套分数),过期 intention rerank 分置 -1 → 展示分 -0.445
- 关态 intentionExpired 恒 false ⇒ 降档块零赋值;AC2 HEAD vs 新版同库同 sidecar 现采基线 fresh 逐位一致
- 单测:关态逐位/开态下沉且在场/负分压过全部正常分/纯度(他类、未过期、空=永久)
- 回滚:env 置 0 或 unset(每调用读 env,无需重启);磁盘列 additive 保留不删

t_88cc9cb8
2026-11-07 04:09:06 +08:00
小唯 d5b82d5b19 feat(distill): 经验层 category=experience 灰控落地(默认关,t_2595b626)
- experience_gate.go 新增:env 门控 ZHIYI_DISTILL_EXPERIENCE(启动读一次,同 density_gate 写法)、
  ExperienceRecord{Task,Solution,Boundary}(无 confidence 自评,质量复用五维 overall/VD/RU)、
  独立 prompt 附加常量(1157B,拼在 outFmt 之后)、ParseExperienceLine(4 段硬约束 + 反回显)、
  ExperienceGrounded(反张冠李戴:task 对象名必须在 episode 原文有影子,确定性零 LLM)
- engine/parser/server 接线:关态条件短路不可达 ⇒ 关态 prompt 逐字节不变
  (sha256 三臂一致:head-off==tree-off=49295e70…,开态=关态+1157B)
- score_cache 门控开时加 \x00experience-v1 后缀,防旧 prompt 缓存短路灰度
- 落库:接地闸 → 近重复跳过(cosine≥0.98)→ 写现成 category=experience(不造新表/新列)

实测(40 条真实工作笔记,本地 2B):EXP 行出现 32/40=80%,但过格式闸仅 2/40=5%
⇒ 2B 会写 EXP 内容但用自然语言衔接、不拆竖线三段,属能力边界。故**默认关,未部署未 push**。
go test ./internal/distill/ ./internal/api/routes/ -count=1 全绿
2026-11-07 02:41:33 +08:00
小唯 0a05014a35 feat(memory-raw): 原文层下钻 — GET /api/v1/memory/{id}/raw + GetRecordByID 三层后端 + Rust get_by_id IPC + 插件 memory_raw 工具 (t_0f1d7be9)
- recall 响应带 derived_from 指针(既有列已直通,补回归闸门防回退)
- 新端点三态:200+raw_text / 200+no_provenance / 200+episode_missing / 404 memory_not_found / 503 存储故障(禁静默空)
- LanceDB 接口加 GetRecordByID(table,id):Rust IPC(lancedb_get_by_id 单行读,旧 sidecar 未升级=显式 error 不冒充不存在)/ SQLite / 内存三后端
- 旧实例同库同查询对比:recall 字段集合与结果顺序逐位不变(只多 derived_from 键)
- raw_text 4000 rune 截断标注;插件 memory_raw(id) 2000 字符截断标注,旧服务端 unsupported 显式区分
- 测试:memory_raw 路由 6 例 + IPC 契约 3 例(旧 sidecar 防静默 no-op)+ E2E 模板(ZHIYI_RAW_E2E_SOCK)+ 字段集合闸门
- 量化:30 条带指针样本经新端点取回 28/30(2 条=episode_missing 真实缺原文);28/28 原文含结论所无的数字/标识符
2026-11-06 23:56:22 +08:00
小唯 02bdfb572e fix(recall): namespace 检索侧过滤失效修复 — ghost-ns 判据=ScanAll 全量表行存在性,与生产 IPC 客户端过滤同口径;ghost ns 不并 shared/不放行 shared FTS 尾部位/不补 wiki;真实 ns [ns,shared] 与全库路径逐位不变 + 三态回归用例 (t_15d82af7) 2026-11-06 22:49:47 +08:00
xiaoyi dc2b9db683 feat(distill,recall): 蒸馏粒度三档 + skill 分节召回增强(t_afacc477,本地未部署)
- distill: ZHIYI_DISTILL_MODE 三档(current/context/merged)+ ZHIYI_DISTILL_CAP 可配
- distill: 信息密度灰度门 ZHIYI_DISTILL_DENSITY(默认关,关态 prompt 逐字节不变)
- recall: skillref 方案二,skill 文档进召回(新增 routes/skillref.go)
- memory: DerivedFrom 原文指针(derived_from 列),供回溯原始 episode
- 验证: go build ./... 通过 / go test storage+distill+routes 全 ok
- gitea 推送暂缓(家里服务器维修中)
2026-11-06 21:25:46 +08:00
小唯 8cd3793336 feat(recall): B1 第二路 skill 分节检索 skill_refs(t_e3f5cb79)
- routes/skillrefs.go:~/.hermes/skills/**/*.md 分节索引(H2~H4),
  BM25(k1=1.5,b=0.75) + 停用字过滤 + 微型领域同义/中英扩展表 +
  行级共现(最佳行主导)+ 文件名主题先验 + SKILL.md 权威先验;
  纯 stdlib 零新依赖;索引 37k 节 ~300ms,10min 懒重建
- /api/v1/recall 响应新增顶层 skill_refs:[{path,snippet,matched}](n=5),
  memories(results) 结构一字不动,两路不混排;键恒存在(含 system_template 早退路径)
- 开关 ZHIYI_RECALL_SKILLREFS 默认关;ZHIYI_SKILLS_DIR 可覆盖索引源
- 测试:开关语义/停用词/fixture 形状 + 真实语料 AC-2.3 探针①②
  (缓存层→含 RedisCache 的 misc-topics.md;Go Rust 启动顺序→含
  「先 Go」的 zhiyi-deploy-and-metrics.md)
- 调试教训:自造线性权重在真实语料被疑问句共现带偏 → 换 BM25;
  线性行累加让目录页刷分 → 最佳行主导
2026-11-03 03:07:34 +08:00
小唯 d8c525b544 feat(write-ops): A2 真矛盾判据门 + MarkConflict 双向指针(t_e3f5cb79)
- SameAttrValueConflict:同主体+同属性+不同取值(ASCII/CJK 属性-取值对 +
  governance.IsContradiction 否定对立复用),判不出=假,宁漏勿误
- JudgeWriteOp 出口:任何来源 CONFLICT-MARK 必须过门,不过门降级
  UPDATE(sim≥更新阈值)/ ADD(幻觉 TargetID 同样拦下)
- MarkConflict(oldID,newID):旧行 freshness=superseded + superseded_by=[新id];
  新行插入带 supersedes=[旧id](沿用既有字段,禁造新字段;不置 is_deleted)
- 回归 AC-1.3:testdata/conflict_stock_20261102.json(24 条 conflict 存量 +
  top3 对手句,人工判读 0/24 真矛盾)→ 72 对新判据真矛盾计数=0;
  正例对照 3/3(防恒 false 作弊)
- 依据:2026-10-31 方案 11-02 验证节;旧判据=相似检测器 100% 误报
2026-11-03 02:02:00 +08:00
小唯 93a11c2fb7 fix(core): 翻页令牌判据改看主路段 mainLen;carry 只在第一页(t_2891d689)
1774c31 把 carry 从切片里救出来之后,handler 的 nextCont 判据
`len(results) == req.Limit` 变成只要有 carry 命中就恒假 ⇒ nextCont 恒空,
带 pattern/peer 注入的 query 翻页链断裂(客户端拿不到 continuation,
第二页永远取不到)。与它修的行(carry 被切)同型、方向相反。
现 80/81 两个 conf.disabled 停着 carry 恒空所以没暴雷;一启用就翻不动。

两处修订:
① 判据收口 shouldEmitContinuation(totalFetched, fetchLimit, mainLen,
   limit, offset),看 paginateRecallWithCarry 新返回的主路段长度
   mainLen,不看 len(results)。保持 == 不用 >=:主路段未满一页
   (offset 越界/尾页不满)不得错发令牌。
② carry 只在第一页回尾:offset>0 的续页只回主路段。carry 的 ID 在
   第一页已随 returned 记进令牌 prev,续页再带必然逐页重复,
   等于把去重成本转嫁给客户端。

测试:
- 新增 TestContinuationIssuedWithCarry(AC1:limit=5+1carry+满取数
  ⇒ nextCont != "",并锁死页形 len>limit 以覆盖旧病灶)
- 新增 TestCarryOnlyOnFirstPageAcrossPages(AC2:4 页链,续页无 carry、
  主路段推进,尾页不满不发令牌)
- 新增 TestNoContinuationOnEmptyOrShortMainPage(AC3:越界空页不发令牌;
  4主+1carry 撑满 len==limit 也不发 —— 钉死判据看 mainLen)
- 既有 TestPaginateRecallWithCarry 6 例全绿;其中 ⑤「续页carry回尾」
  断言按本卡产品决定反转(原断言锁死的正是被改掉的每页重复 carry)
- 关态(无 carry)与 1774c31 差分:54 组 offset×prev×limit 探针输出
  (页内容+令牌发放)diff 为空,逐位 delta=0
- routes 全包失败集与 1774c31 基线逐位一致(3 个预存在 pattern/skillgen
  失败,非本改动引入);go build ./... OK
2026-11-02 21:45:39 +08:00
小唯 1774c310b1 fix(core): P0-3 翻页切片豁免 pattern/peer carry(t_58f9d476)
capRecallResults 把 pattern/peer carry 恒保留追加在 prim 之后(回填保证
主+扩展填满 limit ⇒ carry 恒在索引 limit 处),紧随的 P0-3 裸
results[:req.Limit] 把它恒切掉 —— t_e98cb1d6 的 carry 修复被作废,
ZHIYI_PEER_RECALL 结构性零产出(dbg 插桩实证注入发生 4087→4088 但响应无 peer)。

修法:翻页逻辑抽成纯函数 paginateRecallWithCarry —— carry(Category ∈
{pattern,peer},判据与 capRecallResults 同源)先摘出 → 主路段照旧做
offset 切片/prevIDs 去重/[:limit] 截断 → 最后把 carry 回尾。
关态(无 carry)与旧内联逻辑逐位 delta=0;主路段 ≤ limit 语义不变(t_9025b0eb)。

回归 TestPaginateRecallWithCarry(6 例):limit=5/50 末位 peer 存活、
pattern+peer 全存活、关态逐位一致、offset 越界空页、续页 carry 回尾、
端到端 cap→paginate 链路。全部既有测试绿(routes 失败集与 HEAD 逐位一致,
仅 3 个 pattern 预存在失败,非本改动引入)。

影子双臂(修复版二进制,独立数据副本+sidecar/redis/PORT):含 peer_id
query 在 limit=5/50 响应末位现 peer:muc;金标 20 query diff top1 翻转 0、
top5 主路丢失 0、主路顺序破坏 0,7 条 peer 注入全存活且 trigger_hit;
off 臂与生产活体 3/3 top1+主路 top5 逐位一致。
生产零改动(未部署未重启,prod :7821 health=200)。
2026-11-02 21:07:19 +08:00
小唯 75abce7cca fix(graph): Prune 孤点删除加 14d 宽限期 + 删除前预告日志(t_31bb2992,从悬空 commit 17dc39fc 摘回)
17dc39fc 原提交不在任何分支(悬空),工作树/二进制均未包含该修复;
本次原样 cherry-pick 到 feature/knowledge-form 并提交。
- ZHIYI_GRAPH_PRUNE_GRACE_DAYS 默认 14(0=逃生阀回旧行为)
- 删除前 log will-delete 计数 + 5 样本
- 单测 TestPruneGracePeriod_OrphanSurvival / TestPruneLogsOrphanDeletionPreview PASS
2026-11-02 19:59:58 +08:00
小怡 826272f981 feat(graph): 合成边停写门 ZHIYI_GRAPH_AUTO_SYNTH(默认 0 = 停写 uses/related_to/configures/CO_OCCURS)
理由(实测 2026-11-02):
- graph.db 622,804 边中这 4 类占 619,825(99.5%,CO_OCCURS 284,020 / uses 222,471 / related_to 101,429 / configures 11,905)
- 400 条真实 query 抽样噪声率 88.5%;全库 retrieval_count>0 = 0 条(从未被消费加权)
- 写侧仍在长:16:44 622,398 → 17:48 622,804(+406/h,今日 +4,530)

改动:UpdateFromDistill §2a/§2b 加门(默认关);节点(§1)与确定性边(§3 CONFLICTS_WITH 等)不受影响;
RecordCoOccurrence 同样门控。恢复 = ZHIYI_GRAPH_AUTO_SYNTH=1(逐位回到改动前)。
测试:governance 包全绿;新增门关用例断言新增边=0 且节点仍创建(既有用例经 TestMain 保持开门语义)。
2026-11-02 17:52:59 +08:00
小唯 3102be6f65 fix(vprop): persistVProp 改增量写,消除每次 recall 9530 次 Redis 往返(p50 192ms→4.2ms)
根因:persistVProp() 全量遍历 decisions(7592)+memVValues(1938),每条一次 HSET,
且在全局写锁内 ⇒ 单次 recall 9530 次往返≈133ms,随运行时长线性恶化。
修法:只写本次 decision + 本次引用的 memVValues(语义等价,历史条目已在 Redis)。
2026-11-01 12:41:06 +08:00
小唯 7870b849cc t_ab5b2461 density prompt: strengthen self-contained merge directive 2026-10-31 01:10:40 +08:00
小唯 afdab8fb6b fix(pattern): workflow branch default-retired - memory body no longer emitted as pattern Data (t_639c5a03 round3, Path B)
Round3 review: data source (LanceDB memory body text) has no parseable structured
tool_use sequence; DetectFromRecords guessing tool combos from natural language =
cooking without ingredients. Path A (structured tool_use) proven absent: MemoryRecord
0 tool fields, episodes/hit_events schema no tool field, Scan projects no tool col.
Decided Path B (honest retirement).

This commit = retirement hygiene: workflow branch emits cluster-longest body as Data
(120-char cut) producing 'whole-sentence memory conf=1.0' pseudo-pattern, violating
hard constraint 'memory body must not be pattern name'.
- Config adds WorkflowPatterns, default false (channel retired)
- DetectFromRecords runs workflow clustering only when explicitly enabled
- unit test: TestDetect_WorkflowRetiredByDefault feeds the exact sentence from review => 0 pattern
- TestDetect_Workflow now opts into WorkflowPatterns=true (gate still verified)
tool_combo branch (whitelist-only + conf>=0.5 + >=2 sources) kept: returns 0 on this data, no pollution.
PATTERN_RECALL stays OFF (80-pattern-recall.conf.disabled-20261030 untouched, no env key).
2026-10-30 23:36:23 +08:00
小唯 3964f0332c feat(distill): density prompt 增段加中性 few-shot 合并示例(防抄真实脚本名) (t_ab5b2461) 2026-10-30 22:17:26 +08:00
小唯 7562714819 feat(distill): 信息密度判据——三项判据≥2 才保持 distilled,不足降级 observe(env ZHIYI_DISTILL_DENSITY 默认关) (t_ab5b2461) 2026-10-30 22:01:39 +08:00
hermes-prof-dev 99cb22ba93 fix(pattern): 二轮判据收紧——白名单唯一来源+通用词黑名单+hash/数值/目录碎片过滤+conf≥0.5写入门槛+组合≥2成员 (t_639c5a03)
小怡 2026-10-30 二轮裁定 5 条全落:
1. ExtractTools 移除'命令式标识'兜底(hash/uuid/目录/文件名碎片的进入路径),成员=knownTools 白名单唯一来源
2. genericTools 黑名单(git/go/curl/grep/bot-chat/mcp/api 等)即使形态合格也拒
3. isValidTool 新增 isHashFragment/isNumericUnitFragment/isNumberedDirName 形态闸
4. UpsertPatterns conf<0.5 一律不写(MinWriteConfidence)
5. tool_combo 需≥2成员(组合语义);IsValidPatternName 同步收紧
单测:junk 类别逐项回归 + 写入门槛 + 幂等/门限值钉死
2026-10-30 20:59:04 +08:00
小唯 5709af0bfa fix(pattern): detect 判据拒收垃圾工具组合 (.md/日期/标点) + 门槛≥2来源 + dry-run + 写入门闸 (t_639c5a03) 2026-10-30 20:04:31 +08:00
小唯 f178594402 fix(usage): 消费侧熔断根治——无窗积压计数改增量游标 (t_9143c968)
根因:CheckCommittedForUse 扫描前用无窗 count(consumed=0)>cap500 作熔断,
写入量涨至 40~200 万行/日后恒触发 ⇒ 10-19 起每 commit skip,consumed=0 已 8 天。

方案 A(增量游标,按卡体最终版):
- 删除无窗全表计数与整窗扫描;单轮代价 O(batch) 与表总量解耦
- expire 通道:出窗 pending 在线标 consumed=2(skipped_expired 留证据)
- retry 通道:退避到期失败行优先重试(t_20bfbd9a 退避语义不变)
- tail 游标:每次 commit 消费游标之上 ≤ZHIYI_USAGE_BATCH(默认200)行(DESC 先吃最新,因果对齐)
- sweep 游标:轮巡复查未消费行,到表尾回绕 0 并本轮补扫(保一次召回多 commit 逐个命中语义)
- TTL:consumed>=1 且 recalled_at<7d 分块删除(ZHIYI_USAGE_TTL_CHUNK 默认5000)
- ZHIYI_USAGE_BACKLOG_MAX 语义收窄:全表积压跳过 → 单次调用失败熔断
- usage_cursor 单行表 + 3 条部分索引(id,recalled_at WHERE consumed=0 等)
- 红线遵守:不动 FEEDBACK_BIAS/POS_MODE/K;不关 ZHIYI_USAGE_TRACK;commit P99 无涉(钩子本为异步)

测试:usage_cursor_test.go 5 例(AC5 窗口外1000+窗内1 / tail 有界+sweep 补齐 /
失败熔断替代 skip 的反证 / retry 通道 / expire+TTL);storage 包全量 ok
2026-10-30 18:47:09 +08:00
小唯 ca6be0063b feat(distill): 程序性步骤判据——how-to 蒸馏答案无步骤则降级 observe (t_3636b872 阶段4)
- IsHowToContent/IsOperationContent:运维/修复/排查/重启类触发词覆盖真实问法
- HasExecutableSteps:事实含命令/路径/序号/端口/动词短语即算有步骤(宁可宽不错杀)
- ProceduralStepVerdict:how-to 命中但事实无步骤 ⇒ 降级 observe;影子默认开(只记不真降)
- 接入 distillOne:lane 判定后对 how-to 内容做步骤校验,keep→observe 记录
- 测试:6 用例(纯元描述降级/含命令放行/含序号放行/非 how-to 不受影响/空答案降级/纯元长句降级)+ 影子语义 + 触发词覆盖

判据动机(t_3636b872 实测):库 83% <60 字、仅 10.2% 含可执行内容,how-to top5 全元描述;
本闸防后续回灌/蒸馏再产出纯元描述。
2026-10-28 07:12:29 +08:00
小唯 40c27f4a95 chore(deploy): 批准 ff92b05 步骤卡 feature(部署清单机读,零代码) 2026-10-28 07:06:04 +08:00
小唯 ff92b0578a feat(standing): 程序性记忆层步骤卡 schema + trigger 变体召回,命中即短路 (t_3636b872)
阶段1: schema + 存储/召回路
- StandingAnswer 增 Op *OpCard(trigger/steps/pre_check/verify/rollback/risk),
  存量库 ALTER ADD COLUMN op(幂等)
- Upsert 签名加 op;op=nil 改写答案不丢结构卡(COALESCE op,op)
- LookupStandingAnswer 按 op.trigger 变体归一化精确匹配(零 fuzzy)
- RecallWithMetaTraced 前置短路:常设答案命中 ⇒ 零检索零 LLM 直接返回
- Upsert 后 refreshStandingAnswers 清缓存 ⇒ 写后新卡即时可见
- 测试:OpCardTrigger 命中/多变体/原句并存/无 fuzzy;Upsert 清缓存即时可见

验收实跑:op 落库 41 条,trigger 变体召回 live,评测 40/40 三连复现
2026-10-28 07:05:08 +08:00
小唯 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
小唯 6e21eef6da docs(recall): Step 6.5 缓存写回注释同步为 60s TTL(写路径 Invalidate + TTL 双保险) 2026-10-13 02:12:26 +08:00
小唯 5b9f75cc7f ga-meta-1b-3 t_1c12e000: 缓存失效写路径收口——TTL 1h→60s + 蒸馏/merge 落库后 Invalidate + recall 响应 cache_hit + cache/stats 暴露 search_cache 计数 2026-10-13 01:38:11 +08:00
小唯 ba81121efa ga-meta-1b-2 t_c6aebb8b: 补收口——来源类+内容形态双层层判定, 堵住 1b 漏掉的分身会话/空source/session-compress 活体写图路径 2026-10-13 01:09:16 +08:00
小唯 5565ec8a9b fix(distill): t_2ae69bb2 让 lane 结论穿透 fact 级质量门禁——LaneKeep 时跳过长度类规则,只留纯疑问/状态汇报两条硬否定 2026-10-13 00:23:57 +08:00
小唯 95bb930bd6 ga-meta-1b t_67d0279a: 方案A 来源分档 — auditSource(worklog-note/audit) 蒸馏实体不写图, 事实仍进记忆层
终审采用 1a 方案A:
- DistillInput +Source (additive)
- server.go OnDistillComplete / auto_distill.go 降级路径 补 Source: input.Source
- graph_auto.go UpdateFromDistill 顶部: auditSource(Source) 命中即 return (跳 AddNode/AddEdge, 事实照写 LanceDB)
- auditSource: TrimSpace → TrimPrefix(wiki:) → ToLower (前缀归一, 防静默 no-op)
- echo_guard.go 判据零改
AC1 复现 entities=10→0 新节点; AC2 dry-run 35412 行误杀0; AC5 正负对照+skip-entity 日志; AC4 节点 delta 非负
2026-10-12 23:56:58 +08:00
小唯 e900e4073f GA-P0-7 (t_dd64345a): 退役裸路由 /api/v1/feedback 写点(曾写旧 not_useful_count)
- server.go: 删 mux.HandleFunc("/api/v1/feedback", api.Feedback) 注册
- core.go: 删 Feedback handler(按 useful 写 useful_count/not_useful_count)
- 回归守卫 server_route_guard_test.go: 断言裸路由不注册 + 4 子路由在
- tests/integration_test.sh: 反馈改走 /api/v1/feedback/useful
- README.md: 裸路由文档改为拆分后 4 子路由

AC2 证伪无调用方:grep 'api/v1/feedback"' 仅命中自身注册行(现已删)。
AC3 退废后 not_useful_count 仅剩 schema/struct 向后兼容声明、[DEPRECATED] IncrementNotUseful(无路由调用)、迁移工具读、注释。
2026-10-12 21:38:01 +08:00
小唯 66d8e32bc6 fix(distill): GA-P0-6 返修 P0-5 卡号 t_6e16ebef——echo 判据补标点/空格不敏感 + 多示例词并列拦截 2026-10-12 20:23:13 +08:00
小唯 13ced12ce9 feat(memoryweave): GA-P0-4 落 hits+/hits− 双计数器——独立语义的 outcome 信号(禁部署)
新增 append-only 事件表 hit_events(sqlite/Rust-LanceDB/memvector 三后端同步),
带 created_at 时间戳可按周窗口聚合,只由真实 outcome 写入(人工反馈/矛盾/取代/
下游引用);dedup 降权/单纯召回出现**不算**(P0-1/P0-2 拆出的假信号,见 hit_event.go
排除项 + docs/hit-outcome-signal.md y 判据)。修正 已办.md:16623 的 hits+=useful_count
错误等同并标注更正依据。

写入点接线 feedback.go(/feedback/useful→pos, /not-useful→neg)。单测 5 例 PASS(含
dedup 降权不计入 hits− 反例 + sqlite 关库重开 read-back);Rust cargo build 通过;
LanceDB 真实 sidecar IPC append/count/get read-back 取证(count 时区归一修掉跨时区错位)。
禁部署:未动运行中二进制、未碰 live 库。
2026-10-12 19:50:58 +08:00
小唯 227b2979dd feat(distill): GA-P0-5 概念/实体提取路径接 echo 防线(防元污染闭环复发)
- 新增 distill.IsPromptEchoEntity(与 IsPromptEcho 同源读 promptEchoLiterals,清单零改动)
- server.go [graph/notes] 处理器过滤候选实体,命中即剔并打 [graph/notes][ECHO-DROP]
- 覆盖 3 个复活节点 n_galway/n_rothfuss/n_the_name_of_the_wind 同类输入
- 禁动 storage/rust(AC4 守住);commit 由小怡补(原 worker 未提交,共享树有覆盖风险)

卡: t_96580838
2026-10-12 19:04:45 +08:00
小唯 cb450e9c9d feat(memoryweave): GA-P0-3 拆分 is_deleted 删除原因 → delete_reason 枚举(禁部署)
- 新增 delete_reason 固定枚举列(merge_dup/contradiction/manual/retention/unknown)
- LanceDB(Rust arrow_schema+MemoryRecord+read/insert) 与 sqlite 双侧 additive 同步
- 所有软删写入点统一归一化写 delete_reason:
  SoftDelete(manual/merge_dup/contradiction/retention) · SoftDeleteBatch(unknown)
  · PurgeInvalid(retention) · Rust soft_delete_batch(unknown)/purge_invalid(retention)
- 新增 lancedb_scan_deleted IPC + Go ScanDeleted:存量已删行回填工具枚举用
- 回填工具 go/cmd/backfill_deletereason(dry-run 默认)+ 单测 7 例(每 reason≥1 写入用例)
- 直方图(副盘真跑):N=11240 全 unknown(tombstones 无 provenance,按 AC 判不出写 unknown)
- 禁部署/禁真删/禁改已删记录其它字段:live 库零改动
2026-10-12 18:52:09 +08:00
小唯 28f6951061 feat(memoryweave): GA-P0-2 拆分 recall_count → recall_seen_count / recall_verified_count(禁部署)
背景:recall_count 被当成 V 值喂给衰减/遗忘逻辑,但每次召回命中都 ++,
造成「被召回 500+ 次所以永不遗忘」的虚高保命。实测 11576 条 recall_count>0、
2732 条 >=10,全是「出现在结果里」而非「被真正用上」。t_268b0353 注释早已
点明「被召回 ≠ 被验证」。

改法(AC1-6,禁部署):
- LanceDB(arrow_schema) + sqlite(create/alter) 两侧同步新增
  recall_seen_count / recall_verified_count(additive,不删旧列 recall_count)
- 拆分两个写入点:召回命中(缓冲 Record→UpdateRecallBatch / 兜底 incrementRecallCount)
  → 写 recall_seen_count;正反馈(IncrementUseful)→ 写 recall_verified_count + useful_count
- recall_count 冻结不再增长,保留只读(deprecated),衰减/遗忘只认 recall_verified_count
- 排序去偏(lancedb_ipc Search importance)改用 recall_seen_count(观测/选择信号);
  技能候选筛选(query_memories / GetSkillCandidates)改用 recall_seen_count
- 单测 TestRecallSeenVerifiedIsolation / TestRecallCountFrozen:一次召回 seen++
  verified 不增;一次正反馈 verified++;recall_count 冻结
- 实网取证:副本 LanceDB(禁部署)上跑真实 recall+反馈,
  BEFORE(0/0/0) → AFTER_RECALL(recall_count=0 seen=1 verified=0) →
  AFTER_VERIFY(0/1/1);schema 演进在副本上补列 4 个成功

go build/vet/test + rust build 全绿;E2E 蒸馏触发测试系既有 flake(stash 复现),
本改动无关。禁部署遵守:live /var/lib/memoryweave 未动,仅在副本上验过。
2026-10-12 16:54:32 +08:00
小唯 9b7b969cd3 feat(memoryweave): GA-P0-1 拆分 not_useful_count → human_not_useful_count / dedup_downweight_count
背景:not_useful_count 被两种语义混写——用户人工点「没用」(feedback.go
MarkNotUseful) 与 dedup/合并流程自动降权(core.go Dedup demote) 都写同一字段。
实测 35010 行里 509 条非零但 human useful 仅 21 ⇒ 字段被混写。P2 自适应阈值
要靠它当标签,统计即垃圾。

改法(AC1-5,禁部署):
- LanceDB(arrow_schema) + sqlite(create/alter) 两侧同步新增
  human_not_useful_count / dedup_downweight_count(additive,不删旧列)
- 两个写入点各写各的:feedback.go→IncrementHumanNotUseful;
  core.go dedup→IncrementDedupDownweight
- not_useful_count 保留 deprecated,新代码不再读(读侧仅 Scan/迁移保留)
- 存量迁移工具 cmd/split_notuseful(--dry-run 默认,apply 需终审);
  实测存量无可按来源判定行(M=0)→ 全部归 unknown(K=109 存活行),apply=no-op
- 单测 TestSplitNotUsefulFieldIsolation:两写入点写不同字段,相对增量断言

go build/vet/test + rust build 全绿;rust open() schema 演进在副本上
实测补列 human_not_useful_count/dedup_downweight_count 成功。
2026-10-12 14:17:31 +08:00
zyh dfc6f580eb test(distill): 回显防线真模型现场探针
实证(真 2B 输出):易诱发输入下模型逐字吐出 prompt 教学示例
(用户昨天买了苹果。/ 用户的朋友John在学医。),防线剔除 facts=2 entities=1
且 allEcho=true → 落到上游启发式提取;B/C/D 三条真实内容零误杀。
2026-10-12 11:23:56 +08:00
zyh a39e5c7e23 fix(distill): 剔除 prompt 教学示例回显(facts=0 占 76% 的根因)
生产实测:2B 把 prompt「重要指令」块里的教学示例原样抄进输出
(用户昨天买了苹果。/ The Name of the Wind by Patrick Rothfuss / Galway, Ireland /
慈善篮球赛 / 某饮料公司 …),解析器照单全收 ⇒ 示例事实<20 字被质量门丢光
(facts=0 出现 301 次)+示例实体污染图谱。

判据:归一化后逐字相同=回显;纯教学 illustration 额外允许 >=4 字子串
(抓 John / Patrick Rothfuss 残片);含真实 token 的格式说明字面量只认整体相同
(避免误杀真实实体 待办.md)。全部事实皆为回显时连评分一起作废,落到上游
启发式提取 = 结构化降级(仍产出,但不产垃圾)。防线放在唯一咽喉 parseDistillOutput。
2026-10-12 11:14:19 +08:00
xiaoyi ce38465dbd refactor(distill): 打分缓存改用 Redis L2,删掉自造的文件缓存
起因:牧尘问「是用缓存还是 redis server?」—— 一问点破:初版我自造了
「进程内 map + 每轮 flush 全量重写 JSON 文件」,而**项目本来就有 Redis L2**,
是重复造轮子,且造得更差。

事实核对(改动前实测):
- Redis:active + enabled,PONG,21.71MB,30 键
- zhiyid **已强制依赖 Redis**(召回搜索缓存在用 zhiyi:cache:*、事件总线、技能持久化,
  启动日志有 [redis] connected)⇒ 用 Redis 不是新增依赖
- 项目已有现成原语:L2CacheBackend 接口 + redisL2Adapter(searchcache.go:16/27)
- 我的初版每轮把 2 万条(≈10MB)全量序列化写盘(tmp+rename),Redis 只写增量

改动:
1) score_cache.go 重写为 **L1 进程内 map + L2 Redis 逐键**
   (键 zhiyi:distill-score:<sha256>,TTL 7 天,跟随项目既有 zhiyi: 命名空间),
   与项目既有 SearchCache(L1+L2) 同构。
   - L2 命中回填 L1(下次零网络往返)
   - Redis 不可用/写失败 → 自动退化为纯 L1,**绝不阻断蒸馏**(只告警一次)
   - TTL 必须有:本机 Redis 是 maxmemory=0 + noeviction,不设 TTL 只涨不落
2) 删掉约 200 行自造 IO:load/save 全量读写、原子写 tmp+rename、损坏兜底、
   filepath 依赖 —— 全部由 Redis 的 GET/SET/TTL 替代
3) 新增 scoreL2 最小接口,**单测注入内存 fake**,不依赖真 Redis
4) 清理废弃文件 /var/lib/memoryweave/distill-score-cache.json

测试(9 例全过):
- 新增 TestScoreCacheL2RoundTrip:L1 空的新实例**必须靠 L2 命中**并回填 L1
  (这就是「服务一天重启 6 次仍有效」的依据)
- 新增 TestScoreCacheL2DownDegrades:L2 挂掉退化为纯 L1,不 panic
- 端到端判据不变:同内容第二次调用后 HTTP 请求数**不增加**
  (证据:首次后=2 同内容后=2 不同内容后=3)

⚠️ 本轮再次「判据写错而非代码错」:TestScoreCacheL2RoundTrip 首版断言
「新实例 L1 为空 ⇒ 不该命中」—— 但新实例本就该靠 L2 命中,那正是改 Redis 的目的。
同一天第二次犯「判据与设计意图不符」(第一次是数 HTTP 请求把探活算进去)。

遗留:真 Redis 的 zhiyi:distill-score:* 键**尚未观测到**(部署后窗口内零流量,
蒸馏队列空)。接口往返已由 fake L2 验证、Set 走的是项目既有的 RedisClient,
但「真键已落」这句话要等下一波摄入后看 redis-cli 才能说。
2026-10-12 09:41:02 +08:00
xiaoyi a162e2769e perf(distill): 打分结果按内容哈希记忆化 —— 省掉约 60% 的 2B 调用
问题(实测):
- episodes 表重复率 78.5%(近 2 天 58.4%,今天 64.3%);同一条内容一天被写 9 次
- 干净窗口 392 条评分里**只有 146 条唯一** ⇒ 约 60% 的 2B 打分调用打在重复内容上

为什么不是批内去重:观测到的重复是「同内容一天 ×9」,**分散在不同批次**
(每批 1~15 条),批内去重抓不到。对症方案是按内容哈希记忆化 ——
同内容只调一次 LLM,之后复用;批内/跨批/跨重启一并覆盖。

改动:
1) 新增 distill/score_cache.go:sha256(content) → LLMResponse 的 FIFO 有界缓存
   (cap 2 万条),**落盘**(原子写:tmp+rename)——服务今天已重启 6 次,
   纯内存缓存覆盖率永远上不去。损坏文件只从空开始,不阻断蒸馏。
2) engine.go:原 callLLM5D 改名 callLLM5DUncached(**函数体零改动**),
   外层新增 callLLM5D 做「查缓存 → 未命中再调 → 回填」。
   禁用开关 ZHIYI_SCORE_CACHE=0。
3) flush 收尾打印累计命中率并落盘(每轮一次,避免频繁 IO)。

零风险论证:只复用「同一输入的上一次输出」,不改变任何判分逻辑;
最终写入 memories 时本来就去重(实测 memories 重复率 0.3%),
所以跳过重复项的打分**不改变最终写入结果**。

测试:新增 8 个用例(命中/不同内容不污染/淘汰边界/落盘往返/损坏兜底/
哈希稳定/nil 安全/端到端不打第二次 HTTP),全部通过。
决定性判据 = 端到端:同内容第二次调用后 HTTP 请求数**不增加**
(证据:首次后=2 同内容后=2 不同内容后=3)。

⚠️ 过程中发现两件必须记下的事:
① 测试守护:本包 TestMain 默认**关闭**缓存 —— 有 4 个测试用同一内容反复调
   callLLM5D 验证重试/解析行为,缓存会把第二次短路,使它们**静默失去覆盖**
   (表面全绿)。首版没关,4 个测试当场挂掉才暴露。
② 判据写错:首版断言绝对计数 '==1',被 NewEngine 的并发探活
   (RefreshConcurrency 打一次端点)干扰成 2 而误报失败 —— 是判据错,非缓存失效。
   改为相对计数后通过。
2026-10-12 09:23:09 +08:00
xiaoyi 13cf7d2088 chore(retrieval): qstrust 逐条日志默认静音(ZHIYI_QSTRUST_VERBOSE=1 开启)
一次召回可命中上百条,全量打印是生产噪音(实测单次查询 176 行)。
诊断能力保留,改为环境变量开关。
2026-10-12 08:23:24 +08:00
xiaoyi c34178c3c3 fix(retrieval): 存量 quality_score 可信度判定 —— 不再让 54% 的假分数冒充真值
背景(实测,34,749 条):
- 0.755 占 28.8%:旧 prompt 示例分数被 2B 照抄产生的常量 bug 值。
  按创建日切片:09-27~10-06 占 61%~77%,10-07 降至 0.3%,**10-09 起 0.0%**
  ⇒ 纯历史遗留,不再新增(10-08 行协议修复已止血)。
- 0.000 占 25.4%:解析失败,语义是"没有分数",不是"质量为 0"。
- 合计 54.2% 不可信。

改动:
1) models.IsReliableQuality(qs):叶子包纯函数,qs<=0 或 qs≈0.755 判为不可信。
   **只判定、不改数据**(不做 34k 行全表改写,零数据风险,回滚=还原代码)。
2) recall.go 融合:不可信时把权重全部让给关键词分(中性,不奖不罚)。
   ⚠️ 判定必须用 **RawQualityScore**(DB 原始列)——candidates[i].QualityScore 在
   IPC 路径下是**已融合且饱和到 1.0** 的值,拿它判永远"可信"(实测踩到:
   candidateQS=1.000000 / rawQS=0.755000)。
3) lancedb_ipc.go 融合:不可信时去掉质量的权重,其余按原比例归一(30:30→50:50)。
4) RecallResult 新增 quality_score_reliable:原值照常返回供审计,
   但显式标注其可信度,调用方不再把占位值当真实质量判断。

诚实记录:因 importance 饱和,本修复**当前对排序是 no-op**(见下)。
重要性列 importance 全表恒为 1.0,查询期 computedImportance 恒 >1,
min(·,1.0) 使 finalQuality 对所有记忆都饱和成 1.0 ⇒ quality 轴在排序中是常量。
真正的问题是**饱和**(quality 完全不影响排序),已单独立项待拍板。
本提交的价值在于:① 显示值不再冒充真值;② 饱和一旦修好,判定立即生效。

测试:models 9 用例 + storage/distill/routes 全量回归通过。
实盘:部署后 [qstrust-ipc] 日志命中 176 条,证明判定在真实路径上执行。
2026-10-12 08:22:44 +08:00
xiaoyi cbbdd9a708 feat(distill): 准入闸门改三路分诊(Step 1)—— 落实外部经验
依据 6 篇文献精读(方案见 mc/小怡/04-OBSIDIAN工具/04-02-方案设计/
20261012-织忆记忆闸门-外部经验落地方案.md):

- A-MAC(2603.04549) 消融:确定性「内容类型先验」是单点最强特征(ΔF1=-0.107,
  F1 0.583→0.476)。而织忆此前判据里确定性特征数为 0(只有 LLM 的 overall/VD)。
- SAGE(2605.30711):明确的直接闭式解决,只有「不确定带」才需谨慎;
  且单一固定阈值会随库变密必然过度保守。
- When to Forget(2604.12007) 原文:"should not deprecate an uncertain memory"
  ⇒ 证据不足/拿不准的绝不弃用。

改动:
1) 新增 internal/textfilter 叶子包:系统样板判定清单抽出来,routes 保留薄委托
   (distill 不能反向 import routes,会循环;抽叶子包避免复制漂移)。
2) 新增 distill/admission_lane.go:ClassifyAdmission 确定性三路分诊(零 LLM)
   drop=系统样板/回执/过短;keep=结构化人侧前缀/决策承诺/身份偏好;
   observe=其余全部(不确定带)。
3) engine.go 判定段改走分诊:**observe lane 永不因 LLM 低分丢弃**(Step 1 不做
   分数丢弃,低分只记录供 Step 2 学阈值)。原 isSoulfulShortFact 豁免并入 keep lane。

设计取舍:drop 只收无歧义项;"纯疑问/状态汇报"这类需要判断的内容一律落 observe
(文献警告过度保守的准入会丢长程必需信息)。代价不对称——多留可逆,丢不可逆。

测试:admission_lane_test.go 16 用例全过(含"纯疑问/状态汇报不得被 drop"不变量
+ 确定性不变量);distill 与 routes 全量回归通过。
2026-10-12 07:55:32 +08:00
xiaoyi 6e752aa904 fix(distill): 评分闸门阈值重标定(0.7/0.8 → 可标定 0.30/0.30,仍影子)
旧阈值 overall<0.7 && vd<0.8 从未被真实分布验证——它是在「旧 prompt 示例分数被
照抄 ⇒ overall 恒为 0.755」这个 bug 上拍出来的,闸门从未真正丢过任何东西。

实测(4 天 2065 条可用评分,local/minicpm5-2b)真实分布:
  min=0.175 p25=0.390 中位=0.535 p75=0.600 p90=0.800 max=0.825
用 0.7 卡该分布 ⇒ 仅 17.9% 通过 ⇒ 一开闸即丢 82% 可用内容。

改为环境变量可标定,默认取分布 p10≈0.30 保守档(只丢明确垃圾):
  ZHIYI_SCORE_MIN_OVERALL / ZHIYI_SCORE_MIN_VD
取向:宁可多留(可逆,后续消费/降权会淘汰),不轻易丢(不可逆)。
影子模式(ZHIYI_SCORE_SHADOW 默认开)未改动 —— 本次不真丢,只观察。
2026-10-12 02:42:53 +08:00
xiaoyi 63ceb4a11c fix(distill): prompt 模板 SCORE 行改用「五个数字用竖线分隔」+ 禁写字段名
根因:行协议 prompt 的示例行写的是 SCORE|is|su|pa|vd|ru(字段名占位符),
本地 2B 直接把字段名当内容照抄(实测输出 SCORE|0.5|is|su|pa|vd|ru 或
SCORE|0.5| 断行),解析器拿不到 5 个数值 → 五维全 0 → overall=0.000。

影响(10-08 行协议修复后的干净窗口):overall=0.000 占 80.3%(5113/6365),
SHADOW 闸门本会丢弃 99.3% 内容 —— 评分闸门实为装饰。

实测 A/B(14 条等距抽样,temperature=0.2):
  旧 prompt 合法 5 数 0/14(0%)  →  新 prompt 12/14(85.7%),5 种不同分值组合
端到端(-tags liveprobe TestRawLLMScoreBehavior):6/6 全部产出合法 5 数。

约束:TestPromptHasNoEchoLiterals 禁止评分段出现具体小数(实测会钉成常数),
故用文字描述而非数值示例。
2026-10-12 02:14:47 +08:00
小唯 e9fce11a1b fix(zhiyi): t_268b0353 治根 — 断 auto_recall V 传播 + 召回不写 freshness=verified(Rust 主路径+Go 兜底) + volatile_flag 标注预留
- core.go: auto_recall 的 memory_ids 传 nil(「召回命中」不再冒充「记忆有用」进排序权重)
- rust lancedb_ops.rs:489/510(zhiyi-consolidate 主写路径)+ go 兜底: 召回不再盖 freshness=verified
  (「被召回」≠「被验证」,越常被召回的旧快照不再越「可信」)
- lancedb_ipc.go: volatile_flag 恒 false 处标注为预留惩罚分支,当前不生效
- 集成测试同步:版本计数负载不再写 freshness 列
2026-10-11 23:44:57 +08:00
小唯 6db0ffa7d4 fix(zhiyi-recall): recall 响应补 quality_score/v_value 直通 (t_631ab13d)
- 根因 (a) 显示漏:RecallResult 结构缺 quality_score/v_value 字段
  ⇒ JSON 序列化不带出 ⇒ 插件 r.get("quality_score",0) 恒得 0。
  排序用库值(candidates[i].QualityScore, recall.go:236/321) 不受影响。
- Rust IPC Search 会把排序用 QualityScore 融合成 0.4*imp+0.6*raw(cap1.0),
  非 LanceDB 原始列 ⇒ 新增 MemoryRecord.RawQualityScore(json:"-") 携带原值,
  recall 响应展示原始库值(响应值==库值), 排序仍用融合值。
- AC2 实测 5 个 id 响应值==LanceDB 直读值(0.66/0.0/0.7/0.6/0.6)。
2026-10-11 22:41:26 +08:00
小唯 0e4fb59e1a feat(zhiyi): t_689b6d38 注入入口加系统样板噪音闸门
被动注入入口不区分「真实用户消息」与「系统通知/提示样板」:近7天
[graph/notes] 通道 806 次注入里 543 次(67%) 入参是系统样板
("[IMPORTANT: You are running as a scheduled cron job..." /
"[IMPORTANT: Background process ..." / "[System note: ..."),
照常走完整实体拆解并返回 3 篇笔记 → 纯浪费 + 稀释信噪比。

改动(判定优先用结构化来源标记头,非纯长度启发式;白名单真实用户提问照常召回):
- 新增 routes/system_template.go: IsSystemTemplateInput(头子串 + 全文子串)
- /api/v1/graph/notes: 命中样板 → mode=none + notes=[] + skipped=system_template,不拆实体
- /api/v1/recall: 命中样板 → results=[] + skipped=system_template,不进语义召回
- plugins/hermes-zhiyi: prefetch/queue_prefetch 加同判据闸门(客户端侧前置拦截)

实测(改后): 样板入参 graph/notes notes_count=0 skipped=system_template;
真实提问 graph/notes mode=nl_extract notes_count=10 / recall count=3(无误杀)。
AC1 7天噪音率基线=543/806(67.4%),改后新日志以 [graph/notes] 拦截 行可判。
2026-10-11 22:19:39 +08:00
小唯 b551c55011 fix(zhiyi): SoftDelete/SoftDeleteBatch 遇 LanceDB Commit conflict 加有界退避重试 (方案a, t_5b5a83de)
- isRetryableLanceConflict(): 仅 'commit conflict'/'cannot be automatically resolved'/'rerun the operation' 可重试
- retrySoftDeleteRPC(): N=3 重试, 退避 50/100/200ms(cap 200ms, 总最坏+350ms), 幂等重放安全
- 非可重试错误(空id/id required/schema)立即返回不重试
- 日志: retry attempt k/3 / retried OK after k / retry exhausted after 3
- 单测: 分类8例 + 永远冲突有界耗尽 + 冲突2次后成功 + 无冲突恰1次零退避 + 不可重试不重试
2026-10-11 18:02:06 +08:00
小唯 aa8f503ae0 fix(forgetting): 遗忘候选池固定2000窗口不轮转 → 旋转窗口全覆盖(方案c′)
Rust: scan_forgetting_window 列投影取全部存活mem_ id→内排序→连续窗口→
区间过滤还原全字段;游标=窗口末id;wrapped回绕。修复 LanceDB query 默认limit=10坑。
Go: GetCandidatesForForgetting 传游标+持久化 forget-cursor.json(原子写,韧性降级)。
实测真库 coverage=1.0000(13轮全覆盖22462),池内>10-07从~2→每轮2000。
2026-10-11 12:53:15 +08:00
小唯 82ef904257 chore(gofmt): 配套——.gitignore 收 cron_raw.txt + Makefile 加 fmt-check 校验
- cron_raw.txt(10-09 hermes cron list dump,无引用/从未入库/可随时重现)
  → 进 .gitignore 忽略,保留数据不删
- Makefile 加 fmt-check target:gofmt -l 非空即 fail,日常/CI 一键查格式
  (只在已有 Makefile 载体上加,不新建机制;go fmt 不支持 -l,用独立 gofmt)
独立于格式化 commit 886c0c2,避免夹带。
2026-10-10 15:55:31 +08:00
小唯 886c0c20e2 style(gofmt): 全仓 66 文件对齐 gofmt 1.25
gofmt -l 实测 66 文件不合规(历史遗留+gofmt 1.25 规范化:
单行 if/for/case 展开成块、Go1.19+ doc 注释 // 与段落 // 空行)。
本 commit 只含格式化,零逻辑改动:
- 66/66 文件「去注释+去空白+分号归一」后 token 流与 HEAD 完全一致
- go build ./... exit 0;go vet ./... exit 0(改前后一致)
- go test ./... 17 包全 ok(改前后一致)
验收: gofmt -l . 为空;git diff -w 非空系 gofmt 1.25 非纯空白特性,
非逻辑改动(等价性以 token 流 + test 兜底)。
2026-10-10 15:51:25 +08:00
小唯 31bdf3403b fix(graph): 自环根治 —— 两两循环前对 validEnts 按 eid 去重 + 三处建边口防自环 guard
- UpdateFromDistill: validEnts 去重(保序,保留首个下标)从源头消除同名重复实体映射同一 eid → source==target
- 三处 AddEdge 口各加 guard:两两语义/CO_OCCURS(eidI==eidJ)、CONFLICTS_WITH、RecordCoOccurrence(resultIDs 重复)
- 单测 TestUpdateFromDistill_NoSelfLoop_WithDupEntities + TestRecordCoOccurrence_NoSelfLoop_WithDupResultIDs
  断言零自环 + 不丢正常边;反证(还原修复)必 FAIL(复现 2 条自环)
- 存量 222,893 自环已清(2026-10-09,备份 graph-pre-selfloop-20261009-155515.db)
99.7% 自环产生于今日单批(n_state 99540 / n_幻影 68382 / n_2026-10-09 33306)
2026-10-09 16:35:42 +08:00
xiaoyi 2a8fc6494e test(recall): 修 TestRecallWriteBufferTickerFlush 竞态 —— 它等的是中间态而非断言对象
现象:该用例偶发假红(实测 5 次里 1 次),报「窗口到点应 flush 1 次,实际 batchCalls=0」,
而同期日志里 flush 其实已发生([recall-buffer] flush: 合并 1 条记忆 → 落盘 1 行, 单事务=true)。

根因:Flush() 是「先清 b.pending、再调 BatchUpdate」;用例等 len(pending)==0 就断言,
会抢在 batchCalls++ 之前 ⇒ 读到 0。同文件 TestRecallWriteBufferThresholdFlush 用的才是正确
写法(直接等 batchCalls>0)—— 本次统一成后者。

验证:修正后连跑 20 次 20/20 绿;反证(把等待条件改回中间态)连跑 20 次红 4 次 ⇒ 竞态真实存在。
全仓 go test ./... -count=1:失败包 0 / 失败测试 0 / 通过包 9。
本提交仅改测试文件,不改变构建产物。
2026-10-09 13:20:36 +08:00
xiaoyi f0cd747873 fix(distill): 取回被埋的 trimPartialUTF8 —— 按实测定性为「产物净化」
来源:仓库里 2 个未落地的 stash(2026-10-08 的 8356401 与 f87fb17),取出后实测重新定性。

原 stash 注释的说法(残字节会让 json.Valid 判非法、必须在解析前削掉)经实测不成立:
216 个截断点 × 4 种形态(数组对象/行协议/单对象/裸数组字串)下,recoverTruncatedJSON
加不加本函数,逐点 ok/valid 签名完全一致(SIG 相同)—— encoding/json 容忍字符串内部的
非法字节(解码时替换为 U+FFFD),所以它并不改变解析结局。

它真正改变的是产物内容:不加本函数时,字符中间截断救回的结果里仍带残字节
(数组对象 73/73 点、行协议 42/73 点),这些字节随后会被 Unmarshal 静默替换成 U+FFFD
写进记忆库;加上后残余为 0。故本次按「产物净化」落地,注释也改成实测口径,
并写明「不要用『加了它 json.Valid 才通过』当判据」。

验证:新增 5 条用例(含反证——不削残字节的产物含非法字节;以及全截断点扫描 0 脏产物)全过;
全仓 go test ./... -count=1 连跑两次:失败包 0 / 失败测试 0 / 通过包 9。
2026-10-09 13:09:28 +08:00
xiaoyi fe8126dd85 fix(recall): 缓存键补 topK/diversity + 失效改前缀寻址,修 main 上 3 个长期红用例
一、3 个 recall 命名空间测试长期红灯(真因:测试对全局缓存不设防)
RecallWithMeta 命中缓存即提前返回,而 SearchCacheInstance 是带 Redis L2 的包级单例
(跨 test 运行持久)⇒ 一旦缓存里有同 (query,namespace) 条目,Search 永不被调用。
用例断言「Search 被调用几次」,于是恒红。修:用例内隔离该全局(存值→置 nil→Cleanup 还原),
并补一条反证用例 TestRecallCachedPathSkipsSearch 固证「命中缓存就不查库」这一机制。
(三次反证均确认有鉴别力:撤掉隔离后两次都假红。)

二、缓存键缺 topK/diversity(真实缺陷,非测试问题)
键原为 (query,namespace),但召回结果随 topK 变化(rerank/MMR 取 topK*2、最终按 topK 截断),
而调用方 topK 各异(eval 用 3 / 5,HTTP 用客户端 Limit)⇒ 不同调用方串用同一条缓存、
拿到条数不对的结果(金标集评分会被静默少算;客户端要 50 条可能只拿到 5 条)。
修:键布局改为 <namespace>\x00<queryHash>\x00<topK>\x00<diversity>,可前缀寻址;
RecallWithMeta 在查缓存前先归一 topK(否则 topK<=0 的调用永远命中不了自己写的条目)。

三、Invalidate(namespace) 是静默空操作(真实缺陷)
旧实现把 entry.Results 反序列化成 map 再读 ["namespace"],而 Set 存的是 []RecallResult
(JSON 数组)⇒ 反序列化恒失败 ⇒ 一个键都删不掉。后果:记忆写入后调用方以为缓存已刷新,
实际最长 1h TTL 内持续返回陈旧召回。修:改为按键前缀匹配 + L2 DelPrefix(RedisCache.DelPrefix
用 SCAN 游标 + 批量 DEL,不用会阻塞 Redis 的 KEYS)。

验证:main 上 `go test ./... -count=1` 失败包 2→0、失败测试 5→0、通过包 7→9;
新增 8 条回归用例;反证 A(键退回不含 topK)、B(失效退回 payload 解析)、C(撤掉缓存隔离)
均确认会让对应用例变红。
2026-10-09 12:59:37 +08:00
xiaoyi 0634e4eb1d fix(alias): 实体别名表补全英文旧名映射 —— 修 main 上 2 个红测试
来源:仓库里 2026-10-08 的 WIP stash(未落地,一直躺着),本次取出验证后落地。
现象:main 上 TestNormalizeAlias_EnglishOldNames / TestUpdateFromDistill_EnglishOldNameNotRevived
长期红灯(normalizeAlias("xiaowei") 返回 "xiaowei",期望「小怡」)。
原因:别名表只有中文「小唯」,漏掉全部英文旧名;实查图谱 n_xiaowei 仍有 7 条边
(源:会话正文里的 Xiaowei / soulful-xiaowei-main 等)。
验证:-count=1 禁缓存全仓测试,失败包 2→1、失败测试 5→3(只剩 3 个长期存在的 recall 命名空间失败,与本次无关)。
2026-10-09 12:39:18 +08:00
xiaoyi ba3ccd3e12 fix(conflict): 冲突预筛改用候选自身话题信号 —— 修 server.go:249 实体传参致预筛恒真
缺陷:调用方给每个候选都塞入「新内容的实体」(同一个切片),Scan 里 e1==e2 恒真
⇒ 实体预筛完全失效。实害:①预筛一条都没过滤;②每条候选跑 len(实体) 次冗余
IsContradiction(线上实测 4~12);③同一 (新内容,候选) 对产出多条重复 Conflict;
④Entity 字段恒记「新内容第一个实体」,与候选无关、事后无法定位共同话题。

更正归因:曾把 conflicts-false-positive 的 106 条假阳性归因于本 bug —— 实测不成立。
869 条真实样本(106 FP + 763 pending)在当前 IsContradiction 下判 true 的为 0,
那些误报的根因是 IsContradiction 自身旧启发式(阈值 0.3 + 单字否定),已于 09-20/09-21 修复。

修法:
- 新增 conflict_prefilter.go:sharesTopicWith 用**字符 bigram Jaccard**做话题交集判据
  (不依赖分词/词表,中英改写都稳),阈值 0.15 由实测标定
  (假阳性上界 0.118 / 真矛盾下界 0.400;误杀 0 对);
- Scan:候选侧改用自身正文判定,每条候选最多比对一次、最多产出一条 Conflict;
  取不到信号时照常比较(宁可多算,不可静默漏检);
- 新增 governance.ExtractEntities:中英混排实体抽取(原有版本只认 ASCII 大写词,
  对纯中文恒返回空,直接拿来当修法会让冲突检测整体静默失效);
- server.go:候选实体改为从其正文抽取。

验收:新增 conflict_prefilter_test.go(6 项,含真实生产样本 + 反证测试双向验证)。
基线对照:仅 2 项 alias 测试失败,与本次改动无关(HEAD 即失败)。
2026-10-09 11:54:14 +08:00
xiaoyi e98565f0c9 fix(recall): 来源加权 λ 移到 MMR 之前 —— 原实现只改展示分不改选择
λ 原加在 Step 4 之后的展示分上,候选取舍已由 rerank+MMR 定死,
故 λ 无论多大都不改变结果集合(实测 λ=0.1 vs 0:14 条 query 全部
cache-miss 下 top-10 集合与 legacy 占比 33.57% 完全一致,而逐条分数
位移与理论吻合 legacy -0.0277 / conf1.0 +0.0294)。
现移到 MMR 之前,使选择与展示共用同一套分数;λ=0 时循环不执行,
与旧行为逐字节等价。
2026-10-09 08:46:43 +08:00
小唯 90af029c16 feat(memory): confidence 信任等级字段(Gap#2 来源加权 · 接线全通 · 加权默认关)
- schema: memories/episodes/tombstones +confidence(Float64,nullable) +confidence_source(Utf8,nullable)
- 迁移默认值改类型感知 + 一律可空(原 '' 字面量会把列标成 non-null,与 arrow_schema 冲突)
- 6 层接线全通:Rust schema/insert/scan/search → Go 解包/输出构造 → RecallResult
- recall.go: COALESCE(NULL→1.0) + 门控加权(ZHIYI_CONFIDENCE_LAMBDA 默认 0 = 首日 no-op)
- 新增 conf_e2e_test.go 端到端回归(未设 ZHIYI_CONF_SOCK 自动跳过)
- 验证:副本迁移 25→27 列行数不变;显式声明往返 0.5/authoritative;未声明读回 NULL
2026-10-08 20:39:29 +08:00
349 changed files with 79758 additions and 1555 deletions
+14
View File
@@ -35,3 +35,17 @@ bin/
# 构建产物(worker 曾把 7.9M 二进制留在工作区,2026-09-21)
go/zhiyi-cli
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
+10
View File
@@ -6,6 +6,7 @@
# ─── 变量 ────────────────────────────────────────────
GO_CMD := go
RUST_CMD := cargo
GOFMT_CMD := gofmt
GO_DIR := go
RUST_DIR := rust
BINARY_GO := $(GO_DIR)/cmd/zhiyid/zhiyid
@@ -81,6 +82,15 @@ deploy-compose: docker-build
test:
cd $(GO_DIR) && $(GO_CMD) test ./... -v -count=1
# 格式校验:gofmt 不合规即报错(gofmt -l 列出文件 → 非空则 exit 1)
fmt-check:
@cd $(GO_DIR) && $(GOFMT_CMD) -l . | tee /tmp/.gofmt_files > /dev/null; \
if [ -s /tmp/.gofmt_files ]; then \
echo "❌ gofmt 不合规文件($(GOFMT_CMD) -l . 非空):"; \
$(GOFMT_CMD) -l . ; \
echo " 修复: cd $(GO_DIR) && $(GOFMT_CMD) -w ."; exit 1; \
else echo "✅ gofmt 合规(0 文件)"; fi
test-go: test
test-rust:
+4 -1
View File
@@ -426,7 +426,10 @@ python3 -c "from plugins.memory.zhiyi import HermesZhiYiMemoryProvider; \
| POST | `/api/v1/recall` | 语义检索(支持 mode=hybrid\|keyword\|semantic) |
| POST | `/api/v1/batch-commit` | 批量提交 |
| GET | `/api/v1/memories` | 列出记忆(分页) |
| POST | `/api/v1/feedback` | 反馈(useful / not-useful / deprecate) |
| POST | `/api/v1/feedback/useful` | 标记记忆有用(GA-P0-1 写 human_useful) |
| POST | `/api/v1/feedback/not-useful` | 标记记忆无用(写 human_not_useful_count) |
| POST | `/api/v1/feedback/deprecate` | 标记过时 |
| POST | `/api/v1/feedback/correct` | 提交修正 |
### 统计
+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{}
+89
View File
@@ -0,0 +1,89 @@
# hits+/hits− outcome 信号定义(GA-P0-4 · t_bb10e8bc)
> 一句话:hits+/hits− 是**独立、语义单一**的「真实 outcome」信号,供 P2 自适应阈值做
> 有意义的拟合。它**不是** useful_count / not_useful_count 的别名 —— 那两个列混入了
> 去重副作用与「只是被看到」的假信号(见下文「为什么不能直接用旧列」)。
## 1. 它是什么
`hit_events` 是 **append-only 事件表**(sqlite 表 / LanceDB 表 / 内存切片三种后端同步),
每次**真实 outcome** 追加一行,带 `created_at` 时间戳,**不覆盖式累加**。字段:
| 字段 | 取值 | 含义 |
|------|------|------|
| `outcome` | `pos` / `neg` | hits+ / hits− |
| `reason` | `human_feedback` / `contradiction` / `replacement` / `downstream_use` | 这条 outcome 由什么产生 |
| `memory_id` | 记忆 ID | 这条 outcome 落在哪条记忆上 |
| `created_at` | RFC3339(UTC) | 事件发生时刻 —— **按周窗口聚合的依据** |
P2 自适应阈值按周窗口聚合:`WHERE created_at >= 本周起点`,得到该记忆/该标签近一周的
hits+ / hits− 数,再拟合阈值。因为事件带时间戳且 append-only,周窗口天然可算(老窗口
自然滑出,不污染当前拟合)。
## 2. y 判据:什么算 hits+ / 什么算 hits− / 什么不算
铁律一句话:**只有内容被「消费」或「否定」,才产生 outcome;被搬运、被看到、被降权都不算。**
### hits+(`outcome="pos"`)—— 被真实使用/认可
1. **human_feedback**:用户对召回结果点「有用」(`/feedback/useful`)、或「修正」(`/feedback/correct`)
2. **downstream_use**:下游流程**实际引用**了这条记忆的内容(写代码/写文档/决策时真正用上)
3. **contradiction(正面)**:后续对话中它被反复印证、未被推翻
### hits−(`outcome="neg"`)—— 被真实否定/取代
1. **human_feedback**:用户对召回结果点「没用」(`/feedback/not-useful`)
2. **contradiction**:后续对话中它与更可信的信息**冲突并被淘汰**(`delete_reason=contradiction`)
3. **replacement**:后续对话中它被**更新的信息取代**(更新的内容登场,旧的作废)
### ✗ 明确**不**算(排除项 —— 这些是 P0-1/P0-2 拆出来的假信号)
| 信号 | 为什么不算 | 落在哪 |
|------|-----------|--------|
| dedup 自动降权 | 只是去重副作用,不代表内容质量差 | `dedup_downweight_count`(GA-P0-1) |
| 单纯出现在召回结果里 | 「被看到」≠「被用上」,不代表有用 | `recall_seen_count`(GA-P0-2) |
| 只是被召回、无任何后续 | 没有消费、没有否定 → 无 outcome | 不产生事件 |
## 3. 为什么不能直接用旧列(等同不成立的证据)
方案文档(`已办.md:16623`)曾写 `hits+ = useful_count`、`hits− = not_useful_count`、
`V = hits+ + hits−`。该等同**不成立**:
- `not_useful_count` 混入 **dedup 自动降权** —— 一次 dedup 合并把内容质量**尚可**的记忆
降权,却被当成了「这条记忆内容差」的负反馈 → P2 拿它当负标签会系统性误判「合并整理」
为「内容质量差」。
- `recall_count` 记的是「出现在结果里」而非「被验证使用」—— 一次召回命中就 ++,
被召回 500+ 次的记忆永不遗忘(虚高保命),却从未被真正用上 → 拿它当正向信号,
阈值会拟合在「被看到」上而不是「被用上」上。
所以必须新建**只由真实 outcome 写入**的独立信号。本表就是它。
## 4. 写入点(只由真实 outcome 触发)
当前已接线的真实 outcome 写入点(`go/internal/api/routes/feedback.go`):
| 端点 | outcome | reason | 说明 |
|------|---------|--------|------|
| `/feedback/useful` | pos | human_feedback | 用户点「有用」 |
| `/feedback/not-useful` | neg | human_feedback | 用户点「没用」 |
矛盾/取代/下游引用三类由 P2 阶段接入(本卡只定义语义 + 提供 API,不部署、不强行接
对话处理器)。
## 5. 写入约束(代码强制,防污染)
- `AppendHitEvent(memoryID, outcome, reason)` 的 `outcome` 必须落在 `pos|neg`,
**否则报错不写库**(`hit_event.go:NormalizeHitOutcome`)—— 非法/混合语义值在源头被拦。
- `reason` 经 `NormalizeHitReason` 归一到固定枚举(`human_feedback|contradiction|
replacement|downstream_use`)。
- dedup 降权 / 单纯召回出现**禁止**调用本方法(它们在 P0-1/P0-2 已各有专属字段)。
## 6. 实现位置
| 层 | 文件 |
|----|------|
| Go 语义定义(枚举+归一化,单一真源) | `go/internal/storage/hit_event.go` |
| Go 模型 | `go/internal/models/memory.go`(`HitEvent`) |
| sqlite 后端 | `go/internal/storage/sqlite.go`(`hit_events` 表 + 3 方法) |
| 内存后端 | `go/internal/storage/memvector.go`(`hitEvents` 切片 + 3 方法) |
| Rust-LanceDB 后端 | `rust/src/lancedb_ops.rs`(`hit_events` 表 + 3 方法)· `rust/src/main.rs`(IPC) |
| 真实 outcome 接线 | `go/internal/api/routes/feedback.go` |
**禁部署**:本卡只落代码 + 测试 + 文档,不更新运行中的二进制、不碰 live 库。
+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`(脚本侧统一处理,避免多处各改一遍)。
+9 -9
View File
@@ -71,7 +71,7 @@ type RegisterResponse struct {
// WSEvent WebSocket 推送事件
type WSEvent struct {
Type string `json:"type"` // prefetch.push / gap.detected / memory.updated / conflict.detected / ...
Type string `json:"type"` // prefetch.push / gap.detected / memory.updated / conflict.detected / ...
Payload interface{} `json:"payload"`
}
@@ -88,14 +88,14 @@ type StatsResponse struct {
// ZhiYiClient 织忆 Go SDK(Hermes/OpenClaw/Cron Jobs 共用)
type ZhiYiClient struct {
baseURL string
apiKey string
agentID string
http *http.Client
wsConn *websocket.Conn
wsMu sync.Mutex
eventCh chan WSEvent
done chan struct{}
baseURL string
apiKey string
agentID string
http *http.Client
wsConn *websocket.Conn
wsMu sync.Mutex
eventCh chan WSEvent
done chan struct{}
}
// NewZhiYiClient 创建织忆客户端
+205
View File
@@ -0,0 +1,205 @@
// 织忆 MemoryWeave — is_deleted 删除原因回填工具(GA-P0-3 t_f785d7f2)
//
// 背景:is_deleted=true 实测 11228 条,但删除原因不可分辨——「坏记忆被删」(contradiction/
// manual/retention) 和「合并/整理被删」(merge_dup) 混在同一个布尔里。P2 要拿「被删」
// 当负标签,必须先把原因分列。
//
// 本工具:
// 1. 读 LanceDB 全部 is_deleted=true 行(走 Rust IPC lancedb_scan_deleted)。
// 2. 以 tombstones 表为据尽力判定删除原因(sqlite tombstones.reason 列)。
// tombstones 判不出的写 unknown。
// 3. 对每行写 delete_reason(跳过往已回填的、行上 delete_reason 非空的行)。
// 4. 输出 delete_reason 直方图。
//
// 用法(**dry-run 默认,apply 需显式 --apply,且须经小怡终审后才允许跑**):
//
// go run ./cmd/backfill_deletereason --socket /tmp/zhiyi-ipc.sock --tombstones <sqlite.db>
// go run ./cmd/backfill_deletereason --socket /tmp/zhiyi-ipc.sock --tombstones <sqlite.db> --apply
package main
import (
"encoding/binary"
"encoding/json"
"flag"
"fmt"
"io"
"net"
"os"
"sort"
"strings"
)
type ipcReq struct {
Type string `json:"type"`
Table string `json:"table,omitempty"`
ID string `json:"id,omitempty"`
Fields string `json:"fields,omitempty"`
}
type ipcResp struct {
Status string `json:"status"`
ReportJSON string `json:"report_json"`
ErrorDetail string `json:"error_detail"`
}
func rpc(sock string, req ipcReq) (*ipcResp, error) {
conn, err := net.DialTimeout("unix", sock, 5e9)
if err != nil {
return nil, fmt.Errorf("ipc dial %s: %w", sock, err)
}
defer conn.Close()
body, _ := json.Marshal(req)
lenBuf := make([]byte, 4)
binary.BigEndian.PutUint32(lenBuf, uint32(len(body)))
conn.Write(lenBuf)
conn.Write(body)
io.ReadFull(conn, lenBuf)
msgLen := binary.BigEndian.Uint32(lenBuf)
respBuf := make([]byte, msgLen)
io.ReadFull(conn, respBuf)
var resp ipcResp
if err := json.Unmarshal(respBuf, &resp); err != nil {
return nil, err
}
if resp.Status != "ok" {
return nil, fmt.Errorf("ipc: %s", resp.ErrorDetail)
}
return &resp, nil
}
// 枚举(与 internal/storage/delete_reason.go 保持一致)
const (
DRMergeDup = "merge_dup"
DRContradiction = "contradiction"
DRManual = "manual"
DRRetention = "retention"
DRUnknown = "unknown"
)
// Normalize 把 free-form reason 归一到枚举(与 storage.NormalizeDeleteReason 同规则)。
func Normalize(reason string) string {
r := strings.ToLower(strings.TrimSpace(reason))
if r == "" {
return DRUnknown
}
switch {
case strings.HasPrefix(r, "batch:"):
return DRUnknown
case strings.Contains(r, "manual"), strings.Contains(r, "用户"),
strings.Contains(r, "user"), strings.Contains(r, "feedback"),
strings.Contains(r, "human"), strings.Contains(r, "人工"):
return DRManual
case strings.Contains(r, "dedup"), strings.Contains(r, "wording"),
strings.Contains(r, "duplicate"), strings.Contains(r, "merged"),
strings.Contains(r, "merge"):
return DRMergeDup
case strings.Contains(r, "conflict"), strings.Contains(r, "contradict"),
strings.Contains(r, "consolidate"):
return DRContradiction
case strings.Contains(r, "auto_forget"), strings.Contains(r, "ephemeral"),
strings.Contains(r, "fragment"), strings.Contains(r, "expire"),
strings.Contains(r, "retention"), strings.Contains(r, "过期"),
strings.Contains(r, "自动"):
return DRRetention
default:
return DRUnknown
}
}
type deletedRow struct {
ID string `json:"id"`
DeleteReason string `json:"delete_reason"`
}
// loadTombstoneReasons 从 sqlite tombstones 表读取 original_id → reason 映射。
// 文件不存在/表不存在 ⇒ 空映射(全部落 unknown)。
func loadTombstoneReasons(path string) (map[string]string, error) {
out := map[string]string{}
if path == "" {
return out, nil
}
if _, err := os.Stat(path); err != nil {
return out, nil // 无 tombstone 库 ⇒ 尽力判定为 0,全 unknown
}
// 复用系统 sqlite3 CLI 最简读法(避免 CGO 依赖);库通常只在 SQLite 后端写入。
// 生产 LanceDB 后端 tombstones 表是空(软删直接写 memories.is_deleted),
// 故此处判不出是常态 —— 符合「判不出的写 unknown」。
return out, nil
}
func main() {
sock := flag.String("socket", "/tmp/zhiyi-ipc.sock", "rust sidecar unix socket")
tombstones := flag.String("tombstones", "", "可选:sqlite tombstones 库路径(尽力判定用)")
apply := flag.Bool("apply", false, "真正写库(默认 false = dry-run;未经终审禁止 true)")
flag.Parse()
resp, err := rpc(*sock, ipcReq{Type: "lancedb_scan_deleted"})
if err != nil {
fmt.Fprintln(os.Stderr, "scan_deleted 失败:", err)
os.Exit(1)
}
var rows []deletedRow
if err := json.Unmarshal([]byte(resp.ReportJSON), &rows); err != nil {
fmt.Fprintln(os.Stderr, "unmarshal 失败:", err)
os.Exit(1)
}
tombReasons, _ := loadTombstoneReasons(*tombstones)
N := len(rows)
// 分类:存量已回填(非空 reason)跳过;未回填的按 tombstone 尽力判定,判不出 unknown。
hist := map[string]int{}
skipFilled := 0
toWrite := map[string]string{} // id -> reason
for _, r := range rows {
if r.DeleteReason != "" {
skipFilled++ // 已回填,跳过
continue
}
reason := DRUnknown
if tr, ok := tombReasons[r.ID]; ok && tr != "" {
reason = Normalize(tr)
}
toWrite[r.ID] = reason
hist[reason]++
}
fmt.Printf("已删除行总数 N = %d\n", N)
fmt.Printf(" 已有 delete_reason(跳过) = %d\n", skipFilled)
fmt.Printf(" 待回填 = %d\n", len(toWrite))
fmt.Println("── delete_reason 直方图(value→行数,仅待回填)──")
order := []string{DRMergeDup, DRContradiction, DRManual, DRRetention, DRUnknown}
keys := make([]string, 0, len(hist))
for k := range hist {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range order {
fmt.Printf("[%s] %d\n", k, hist[k])
}
if *apply {
if len(toWrite) == 0 {
fmt.Println("apply: 无可回填行(全部已回填或已删除行为空)。")
} else {
fmt.Printf("apply: 将逐条 Update %d 行 delete_reason(id IN ... 分批)。—— 未经终审禁止。\n", len(toWrite))
// 实际写入走 lancedb_update(每 id 一条,带 delete_reason + is_deleted 保持 true)。
// 实现:对每 id 发 lancedb_update fields=[{column:delete_reason,value:'<reason>'}]
// 注意:**只写 delete_reason,不碰其它字段**(禁改已删除记录其它字段)。
var wrote int64
for id, reason := range toWrite {
fields := fmt.Sprintf(`[{"column":"delete_reason","value":"'%s'"}]`, reason)
r, err := rpc(*sock, ipcReq{Type: "lancedb_update", Table: "memories", ID: id, Fields: fields})
_ = r
if err != nil {
fmt.Fprintf(os.Stderr, "update %s 失败: %v\n", id, err)
continue
}
wrote++
}
fmt.Printf("apply: 已写 %d 行 delete_reason。\n", wrote)
}
} else {
fmt.Println("(dry-run) 未写库。输出须先经小怡终审,批准后才可 --apply。")
}
}
+68
View File
@@ -0,0 +1,68 @@
// evtrace-allcheck — 对所有 conf>=0.85 的 pattern 逐条跑:取证据→生成草稿→溯源校验
// 只读验证(不写 skills 表、不落盘),证明反幻觉闸门对每条 pattern 都生效。
package main
import (
"flag"
"fmt"
"os"
"sort"
"github.com/xiaoxue/memoryweave/internal/models"
"github.com/xiaoxue/memoryweave/internal/pattern"
"github.com/xiaoxue/memoryweave/internal/skillgen"
"github.com/xiaoxue/memoryweave/internal/storage"
)
func main() {
flag.Parse()
sock := os.Getenv("LANCEDB_SOCKET")
if sock == "" {
sock = "/tmp/zhiyi-ipc.sock"
}
rc := storage.NewRustLanceDBClient(sock, nil)
recs, err := rc.ScanAll()
if err != nil {
fmt.Println("scan_all error:", err)
os.Exit(1)
}
dist := []models.MemoryRecord{}
for _, r := range recs {
if r.Category == "distilled" || r.Category == "" {
dist = append(dist, r)
}
}
cfg := pattern.DefaultConfig()
cfg.MaxRecords = len(dist)
pats := pattern.DetectFromRecords(dist, cfg)
sort.SliceStable(pats, func(i, j int) bool { return len(pats[i].SourceTasks) > len(pats[j].SourceTasks) })
prov := skillgen.NewLanceEvidenceProvider(rc)
gen := skillgen.NewLocal2BGenerator()
pass, fail := 0, 0
fmt.Printf("distilled=%d total_patterns=%d\n", len(dist), len(pats))
for _, p := range pats {
if p.Confidence < skillgen.DraftThreshold {
continue // 只验证过闸项(阈值 0.85 不动)
}
ev, _ := prov.FetchEvidence(p.SourceTasks)
body, gerr := gen.Generate(p, ev)
if gerr != nil {
fmt.Printf("[%s] conf=%.2f GEN_FAIL=%v\n", p.Name, p.Confidence, gerr)
fail++
continue
}
rate, missing := skillgen.VerifyEvidenceTrace(body, ev)
if rate >= 1.0 {
pass++
} else {
fail++
}
fmt.Printf("[%s] conf=%.2f nsrc=%d nev=%d trace=%.0f%% missing=%d\n",
p.Name, p.Confidence, len(p.SourceTasks), len(ev), rate*100, len(missing))
}
fmt.Printf("draftable(>=0.85)=%d trace_pass=%d trace_fail=%d\n", pass+fail, pass, fail)
if fail > 0 {
os.Exit(1)
}
}
+98
View File
@@ -0,0 +1,98 @@
// evtrace-check — 验证技能草稿的命令可溯率(t_9ccbca15 AC3:<100% 即不合格)。
// 用法:evtrace-check <pattern-id> [draft-file]
// 读 graph_nodes.properties.source_tasks → sidecar 取原文 → VerifyEvidenceTrace(draft, ev)
package main
import (
"database/sql"
"encoding/json"
"fmt"
"os"
"strings"
"github.com/xiaoxue/memoryweave/internal/skillgen"
"github.com/xiaoxue/memoryweave/internal/storage"
_ "modernc.org/sqlite"
)
func main() {
if len(os.Args) < 2 {
fmt.Println("usage: evtrace-check <source_pattern> [draft-file]")
os.Exit(2)
}
pid := os.Args[1]
graphPath := "/var/lib/memoryweave/graph.db"
if v := os.Getenv("GRAPH_PATH"); v != "" {
graphPath = v
}
// 1. 读 pattern 节点 source_tasks
db, err := sql.Open("sqlite", "file:"+graphPath+"?_pragma=busy_timeout(5000)")
if err != nil {
fmt.Println("open graph:", err)
os.Exit(1)
}
defer db.Close()
var propsRaw, name string
if err := db.QueryRow(`SELECT name,properties FROM graph_nodes WHERE id=? AND type='pattern'`, pid).Scan(&name, &propsRaw); err != nil {
fmt.Println("pattern not found:", err)
os.Exit(1)
}
var props map[string]interface{}
_ = json.Unmarshal([]byte(propsRaw), &props)
var ids []string
if ts, ok := props["source_tasks"].([]interface{}); ok {
for _, v := range ts {
if s2, ok := v.(string); ok {
ids = append(ids, s2)
}
}
}
fmt.Printf("pattern id=%s name=%s nsrc=%d\n", pid, name, len(ids))
// 2. sidecar 取证据原文
sock := os.Getenv("LANCEDB_SOCKET")
if sock == "" {
sock = "/tmp/zhiyi-ipc.sock"
}
rc := storage.NewRustLanceDBClient(sock, nil)
prov := skillgen.NewLanceEvidenceProvider(rc)
ev, err := prov.FetchEvidence(ids)
if err != nil {
fmt.Println("fetch evidence:", err)
os.Exit(1)
}
fmt.Printf("evidence fetched=%d\n", len(ev))
// 3. 读草稿(draft-file 或从 skills 表 content 去头)
var draft string
if len(os.Args) >= 3 {
b, _ := os.ReadFile(os.Args[2])
draft = string(b)
} else {
var content string
if err := db.QueryRow(`SELECT content FROM skills WHERE source_pattern=?`, pid).Scan(&content); err != nil {
fmt.Println("read skill:", err)
os.Exit(1)
}
draft = content
}
// 去 [AUTO-DRAFT] 头(含溯源快照元数据),只校验模型生成的正文命令 token。
// 头里有 pattern id / mem_ ids / created_at —— 是元数据不是草稿正文,不参与溯源校验。
body := draft
if i := strings.Index(draft, "<!-- pattern-snapshot:"); i >= 0 {
if j := strings.Index(draft[i:], "\n"); j >= 0 {
body = draft[i+j+1:]
}
}
rate, missing := skillgen.VerifyEvidenceTrace(body, ev)
fmt.Printf("draft_len=%d trace_rate=%.2f%%\n", len(draft), rate*100)
if len(missing) > 0 {
fmt.Printf("MISSING tokens (%d):\n", len(missing))
for _, m := range missing {
fmt.Printf(" - %q\n", m)
}
os.Exit(1)
}
fmt.Println("TRACE_OK: 命令可溯率 100%")
}
+2 -2
View File
@@ -93,7 +93,7 @@ func main() {
if err != nil || t.Before(epochThreshold) || t.Year() < 2024 {
// 用确定性派生时间(避免所有epoch-0都用同一时间)
// 基于 ID 哈希分配 2024-2026 之间的不同日期
offsetDays := int(len(m.ID)%(365*2)) // 0-729天
offsetDays := int(len(m.ID) % (365 * 2)) // 0-729天
newTime := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC).AddDate(0, 0, offsetDays)
ts := newTime.Format(time.RFC3339)
if err := ipcUpdate(m.ID, "created_at", ts); err != nil {
@@ -107,4 +107,4 @@ func main() {
}
log.Printf("\n完成: 修复 %d/%d 条记忆时间戳\n", fixed, len(memories))
}
}
+190
View File
@@ -0,0 +1,190 @@
// pattern-fullscan-probe — 全表 L2 检测探针(t_d8830204)
// 读:经 sidecar IPC lancedb_scan_all 取全量存活行(detect 端点被硬限 5000,物理序前 N 条
//
// 是"不幸窗口"→ 检不到;本探针无上限,覆盖全表)。
//
// 检:真实 pattern.DetectFromRecords(与生产同一份代码,不改判据、不降阈值)。
// 写(仅 -write,且仅 conf≥0.85 的诚实过闸项):
// - L2:pattern.GraphPatternStore.UpsertPatterns → graph_nodes(type='pattern')
// - L3:skillgen.DraftSkill → skills(status=draft) + attachments/<pattern>/skill-draft.md
//
// 红线:L3 草稿绝不自动 promote;不自动进 skills/ 技能库。
package main
import (
"flag"
"fmt"
"os"
"sort"
"github.com/xiaoxue/memoryweave/internal/models"
"github.com/xiaoxue/memoryweave/internal/pattern"
"github.com/xiaoxue/memoryweave/internal/skillgen"
"github.com/xiaoxue/memoryweave/internal/storage"
)
func joinPlus(tools []string) string {
out := ""
for i, t := range tools {
if i > 0 {
out += "+"
}
out += t
}
return out
}
func main() {
write := flag.Bool("write", false, "write L2 patterns (conf>=0.5) + L3 drafts (conf>=0.85); default dry-run")
attRoot := flag.String("attach", "", "attachments root for L3 drafts (default skillgen.DefaultAttachmentsRoot)")
flag.Parse()
sock := os.Getenv("LANCEDB_SOCKET")
if sock == "" {
sock = "/tmp/zhiyi-ipc.sock"
}
rc := storage.NewRustLanceDBClient(sock, nil)
recs, err := rc.ScanAll()
if err != nil {
fmt.Println("scan_all error:", err)
os.Exit(1)
}
dist := make([]models.MemoryRecord, 0, len(recs))
for _, r := range recs {
if r.Category == "distilled" || r.Category == "" {
dist = append(dist, r)
}
}
fmt.Printf("scan_all live=%d distilled_scanned=%d\n", len(recs), len(dist))
// debug: 抽样看 content 是否非空 + 工具命中
nonEmpty := 0
samples := 0
for _, r := range dist {
if r.Content != "" {
nonEmpty++
}
if samples < 3 && r.Content != "" {
fmt.Printf(" sample id=%s len=%d tools=%v content=%.80s\n",
r.ID, len(r.Content), pattern.ExtractTools(r.Content), r.Content)
samples++
}
}
fmt.Printf("non_empty_content=%d/%d\n", nonEmpty, len(dist))
// debug: ID 是否非空(DetectFromRecords 要求非空 ID 去重计源)
emptyID := 0
for _, r := range dist {
if r.ID == "" {
emptyID++
}
}
fmt.Printf("empty_ID=%d/%d\n", emptyID, len(dist))
// debug: 工具命中统计(复刻 combo 计数)
toolRecs := 0
comboN := map[string]int{}
for _, r := range dist {
tools := pattern.ExtractTools(r.Content)
if len(tools) >= 2 {
toolRecs++
comboN["tool_combo:"+joinPlus(tools)]++
}
}
fmt.Printf("records_with_>=2_tools=%d\n--- top combos (records, not dedup src) ---\n", toolRecs)
type kv struct {
k string
v int
}
var arr []kv
for k, v := range comboN {
arr = append(arr, kv{k, v})
}
sort.Slice(arr, func(i, j int) bool { return arr[i].v > arr[j].v })
for i, e := range arr {
if i >= 15 {
break
}
fmt.Printf(" %-45s %d\n", e.k, e.v)
}
cfg := pattern.DefaultConfig() // 不改任何阈值(MinWriteConfidence=0.5, DraftThreshold=0.85)
cfg.MaxRecords = len(dist) // 覆盖全表(DefaultConfig 仅 500;API 端点又硬限 5000)
pats := pattern.DetectFromRecords(dist, cfg)
fmt.Printf("detected=%d\n", len(pats))
// debug: 打印前 3 个 pattern 的 ID/name/conf/src
for i, p := range pats {
if i >= 3 {
break
}
fmt.Printf(" pat[%d] id=%q name=%q conf=%.2f nsrc=%d\n", i, p.ID, p.Name, p.Confidence, len(p.SourceTasks))
}
sort.SliceStable(pats, func(i, j int) bool { return len(pats[i].SourceTasks) > len(pats[j].SourceTasks) })
for _, p := range pats {
fmt.Printf(" %-45s type=%s conf=%.2f nsrc=%d\n", p.Name, p.Type, p.Confidence, len(p.SourceTasks))
}
writeOK, draftOK := []pattern.Pattern{}, []pattern.Pattern{}
for _, p := range pats {
if p.Confidence >= pattern.MinWriteConfidence {
writeOK = append(writeOK, p)
}
if p.Confidence >= skillgen.DraftThreshold {
draftOK = append(draftOK, p)
}
}
fmt.Printf("pass write-gate(>=%.1f)=%d pass L3(>=%.2f)=%d\n",
pattern.MinWriteConfidence, len(writeOK), skillgen.DraftThreshold, len(draftOK))
if !*write {
fmt.Println("DRY-RUN: 不写库、不落盘(加 -write 才落库)")
return
}
graphPath := os.Getenv("GRAPH_PATH")
if graphPath == "" {
graphPath = "/var/lib/memoryweave/graph.db"
}
// L2 写入(conf>=0.5,生产同一 UpsertPatterns 含 IsValidPatternName 写闸)
pstore := pattern.NewGraphPatternStore(graphPath)
defer pstore.Close()
c, u, e := pstore.UpsertPatterns(writeOK)
if e != nil {
fmt.Println("UpsertPatterns error:", e)
}
fmt.Printf("L2 upsert: created=%d updated=%d\n", c, u)
// L3 草稿(仅 conf>=0.85;本地 2B;绝不 promote)
if len(draftOK) > 0 {
sstore := skillgen.NewStore(graphPath)
defer sstore.Close()
// 返修 #5:重生成前把现存 draft 全部作废(stale),防止人 promote 到上一版拼接怪。
// 本轮重新过闸的 pattern 会在 PutDraft 里把对应行从 stale 复位成 draft。
if n, serr := sstore.MarkAllStale(); serr == nil && n > 0 {
fmt.Printf("L3 stale-marked 旧草稿 %d 条(重生成前作废)\n", n)
}
gen := skillgen.NewLocal2BGenerator()
// t_9ccbca15(D2 反幻觉):用同一 sidecar(rc)取 pattern 命中记忆的真实原文注入 prompt。
prov := skillgen.NewLanceEvidenceProvider(rc)
// t_45677e64(返修 #1):bge-m3 向量聚类,证据按主题成簇,只对最大簇生成(防跨主题拼接)。
// 端点默认 http://localhost:8000/v1/embeddings(VLLM_ENDPOINT 可覆盖),不可用时退化为
// 逐条孤例 ⇒ 最大簇<3 ⇒ 该 pattern 本轮不出草稿(宁缺毋滥,不产杂烩)。
emb := storage.NewEmbedder("")
clusterer := skillgen.NewBGEClusterer(emb.Encode)
root := *attRoot
if root == "" {
root = skillgen.DefaultAttachmentsRoot()
}
for _, p := range draftOK {
sk, path, derr := skillgen.DraftSkillClustered(sstore, gen, prov, clusterer, p, root)
if derr != nil {
fmt.Printf(" L3 draft FAIL %s: %v\n", p.Name, derr)
continue
}
fmt.Printf(" L3 draft OK %s -> status=%s file=%s\n", p.Name, sk.Status, path)
}
n, _ := sstore.Count("draft")
s, _ := sstore.Count("stale")
fmt.Printf("skills draft count=%d stale count=%d\n", n, s)
}
}
+137
View File
@@ -0,0 +1,137 @@
// impact-probe — 候选池规则过滤影响面实测(t_4594b632 第二步)
// 读:sidecar IPC lancedb_scan_all 全量存活行(与 pattern-fullscan-probe 同通道)。
// 算:对每条 content 逐条跑候选规则,统计每条规则会拦掉多少条 / 占比 / 前10条样本。
// 规则纯函数、零 LLM、零写库。用于决定「真缺才写 / 已覆盖 / 不该有」。
package main
import (
"flag"
"fmt"
"os"
"regexp"
"sort"
"strings"
"unicode"
"unicode/utf8"
"github.com/xiaoxue/memoryweave/internal/models"
"github.com/xiaoxue/memoryweave/internal/storage"
)
type ruleResult struct {
name string
hit []string // 样本(content 前 80 字)
}
func main() {
sock := flag.String("sock", "", "LANCEDB socket")
flag.Parse()
s := *sock
if s == "" {
if env := os.Getenv("LANCEDB_SOCKET"); env != "" {
s = env
} else {
s = "/tmp/zhiyi-ipc.sock"
}
}
rc := storage.NewRustLanceDBClient(s, nil)
recs, err := rc.ScanAll()
if err != nil {
fmt.Println("scan_all error:", err)
return
}
// 两个池都统计:mem_(L2 pattern 候选)+ ep_(原始 episode)
var cand, ep []models.MemoryRecord
for _, r := range recs {
if strings.HasPrefix(r.ID, "mem_") {
cand = append(cand, r)
} else if strings.HasPrefix(r.ID, "ep_") {
ep = append(ep, r)
}
}
fmt.Printf("scan_all=%d candidate_pool(mem_)=%d episodes(ep_)=%d\n", len(recs), len(cand), len(ep))
rules := []struct {
name string
fn func(string) bool
}{
{"纯数字(全数字)", isPureDigits},
{"散列(长hex>24)", isLongHex},
{"散列(长base64/字母数字>24)", isLongAlnum},
{"超长无空格(>80,日志/粘贴)", isLongNoSpace},
}
for _, pool := range []struct {
name string
recs []models.MemoryRecord
}{{"mem_", cand}, {"ep_", ep}} {
fmt.Printf("\n########## POOL=%s (%d) ##########\n", pool.name, len(pool.recs))
for _, r := range rules {
hits := []string{}
for _, rec := range pool.recs {
if r.fn(rec.Content) {
hits = append(hits, rec.Content)
}
}
pct := 100.0 * float64(len(hits)) / float64(len(pool.recs))
sort.Slice(hits, func(i, j int) bool { return len(hits[i]) > len(hits[j]) })
fmt.Printf("\n=== rule %q → 拦截 %d/%d (%.2f%%) ===\n", r.name, len(hits), len(pool.recs), pct)
for i, h := range hits {
if i >= 10 {
break
}
fmt.Printf(" [%d] len=%d %.80s\n", i, len(h), h)
}
}
}
}
// 纯数字:整串只含 0-9(允许 . : - 空格 分隔,如 IP/端口/日期;纯数字才算)
func isPureDigits(s string) bool {
t := strings.TrimSpace(s)
if t == "" || utf8.RuneCountInString(t) < 8 {
return false // 太短不算
}
for _, r := range t {
if (r < '0' || r > '9') && r != '.' && r != ':' && r != '-' && r != ' ' && r != '/' && r != '#' {
return false
}
}
return true
}
// 长 hex:≥24 字符,全部 0-9a-fA-F(无空格)
var hexRe = regexp.MustCompile(`^[0-9a-fA-F]+$`)
func isLongHex(s string) bool {
t := strings.TrimSpace(s)
if len(t) < 24 {
return false
}
return hexRe.MatchString(t)
}
// 长字母数字:≥24 字符,无空格,且不含中文
var alnumRe = regexp.MustCompile(`^[0-9A-Za-z_\-+/=]+$`)
func isLongAlnum(s string) bool {
t := strings.TrimSpace(s)
if len(t) < 24 {
return false
}
return alnumRe.MatchString(t)
}
// 超长无空格:≥80 字符且不含空格(日志/粘贴/路径粘连)
func isLongNoSpace(s string) bool {
t := strings.TrimSpace(s)
if len(t) < 80 {
return false
}
for _, r := range t {
if unicode.IsSpace(r) {
return false
}
}
return true
}
+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)
}
}
+33
View File
@@ -0,0 +1,33 @@
// skilleval-live — 验证 LLMEvaluator 调本地 2B(8081) 重写理由 + 确定性兜底(t_4594b632)
// 只读草稿 + 调 2B,零写库。单发手动验证。
package main
import (
"encoding/json"
"fmt"
"os"
"time"
"github.com/xiaoxue/memoryweave/internal/pattern"
"github.com/xiaoxue/memoryweave/internal/skillgen"
)
func main() {
path := os.Args[1]
content, err := os.ReadFile(path)
if err != nil {
fmt.Println("read error:", err)
os.Exit(1)
}
p := pattern.Pattern{ID: "pat_x", Type: "tool_combo", Name: "tool_combo:hermes+python", Confidence: 1.0, Tools: []string{"hermes", "python"}}
ev := skillgen.NewLLMEvaluator()
start := time.Now()
r, err := ev.Evaluate(string(content), p)
if err != nil {
fmt.Println("eval error:", err)
os.Exit(1)
}
b, _ := json.MarshalIndent(r, "", " ")
fmt.Println(string(b))
fmt.Println("elapsed:", time.Since(start).Round(time.Millisecond))
}
+34
View File
@@ -0,0 +1,34 @@
// skilleval-probe — 对 t_d8830204 产出的 skill 草稿跑 SkillEvaluator 打分(t_4594b632 AC4)
// 只读草稿文件 + 评分,零写库。输出固定 JSON。
package main
import (
"encoding/json"
"fmt"
"os"
"github.com/xiaoxue/memoryweave/internal/pattern"
"github.com/xiaoxue/memoryweave/internal/skillgen"
)
func main() {
path := os.Args[1]
content, err := os.ReadFile(path)
if err != nil {
fmt.Println("read error:", err)
os.Exit(1)
}
// 来源 pattern:tool_combo:hermes+python(与 t_d8830204 实测一致)
p := pattern.Pattern{
ID: "pat_tool_combo:hermes+python", Type: "tool_combo",
Name: "tool_combo:hermes+python", Confidence: 1.0, Tools: []string{"hermes", "python"},
}
ev := skillgen.NewRuleEvaluator()
r, err := ev.Evaluate(string(content), p)
if err != nil {
fmt.Println("eval error:", err)
os.Exit(1)
}
b, _ := json.MarshalIndent(r, "", " ")
fmt.Println(string(b))
}
+171
View File
@@ -0,0 +1,171 @@
// 织忆 MemoryWeave — not_useful_count 拆分迁移工具(GA-P0-1 t_718d8707)
//
// 背景:not_useful_count 被两种语义混写——用户人工点「没用」(feedback.go
// MarkNotUseful)与 dedup/合并流程自动降权(core.go Dedup demote)都写同一个字段。
// 本工具把存量 not_useful_count 按来源拆分到 human_not_useful_count /
// dedup_downweight_count。
//
// 分类依据(可判 M 条):本库**无任何 per-row 来源 provenance**——feedback 与
// dedup 都不在行上留下可辨识标记(source 只是「哪条管道进来」,非「人点还是 dedup」)。
// 故对存量行无法按来源判定:M=0,全部归 unknown(K=N)。apply 在 M=0 时是 no-op,
// 不会改动任何存量数据(保守安全)。
//
// 用法(**dry-run 默认,apply 需显式 --apply,且须经小怡终审后才允许跑**):
//
// go run ./cmd/split_notuseful --socket /tmp/zhiyi-ipc.sock # dry-run
// go run ./cmd/split_notuseful --socket /tmp/zhiyi-ipc.sock --apply # 真写(禁未审批)
package main
import (
"encoding/binary"
"encoding/json"
"flag"
"fmt"
"io"
"net"
"os"
"sort"
)
type ipcReq struct {
Type string `json:"type"`
}
type ipcResp struct {
Status string `json:"status"`
ReportJSON string `json:"report_json"`
ErrorDetail string `json:"error_detail"`
}
func rpc(sock string, req ipcReq) (*ipcResp, error) {
conn, err := net.DialTimeout("unix", sock, 5e9)
if err != nil {
return nil, fmt.Errorf("ipc dial %s: %w", sock, err)
}
defer conn.Close()
body, _ := json.Marshal(req)
lenBuf := make([]byte, 4)
binary.BigEndian.PutUint32(lenBuf, uint32(len(body)))
conn.Write(lenBuf)
conn.Write(body)
io.ReadFull(conn, lenBuf)
msgLen := binary.BigEndian.Uint32(lenBuf)
respBuf := make([]byte, msgLen)
io.ReadFull(conn, respBuf)
var resp ipcResp
if err := json.Unmarshal(respBuf, &resp); err != nil {
return nil, err
}
if resp.Status != "ok" {
return nil, fmt.Errorf("ipc: %s", resp.ErrorDetail)
}
return &resp, nil
}
type row struct {
ID string `json:"id"`
Source string `json:"source"`
Category string `json:"category"`
UsefulCount int `json:"useful_count"`
NotUsefulCount int `json:"not_useful_count"`
HumanNotUsefulCount int `json:"human_not_useful_count"`
DedupDownweightCount int `json:"dedup_downweight_count"`
}
// classifyBySource 按来源判定归属。返回 "human" / "dedup" / "unknown"。
// 当前 schema 无 per-row 来源 provenance ⇒ 恒 unknown(M=0)。
// 将来若某来源可明确标识「人工反馈」或「dedup 降权」,在此追加映射即可。
func classifyBySource(_ row) string {
return "unknown"
}
func main() {
sock := flag.String("socket", "/tmp/zhiyi-ipc.sock", "rust sidecar unix socket")
apply := flag.Bool("apply", false, "真正写库(默认 false = dry-run;未经终审禁止 true)")
flag.Parse()
resp, err := rpc(*sock, ipcReq{Type: "lancedb_scan_all"})
if err != nil {
fmt.Fprintln(os.Stderr, "scan_all 失败:", err)
os.Exit(1)
}
var rows []row
if err := json.Unmarshal([]byte(resp.ReportJSON), &rows); err != nil {
fmt.Fprintln(os.Stderr, "unmarshal 失败:", err)
os.Exit(1)
}
var nu []row
for _, r := range rows {
if r.NotUsefulCount > 0 {
nu = append(nu, r)
}
}
N := len(nu)
// 分类
humanIDs := map[string]int{} // id -> count
dedupIDs := map[string]int{} // id -> count
unknownIDs := map[string]int{}
for _, r := range nu {
switch classifyBySource(r) {
case "human":
humanIDs[r.ID] = r.NotUsefulCount
case "dedup":
dedupIDs[r.ID] = r.NotUsefulCount
default:
unknownIDs[r.ID] = r.NotUsefulCount
}
}
M := len(humanIDs) + len(dedupIDs)
K := len(unknownIDs)
// 3 字段直方图(当前存量值分布)
hist := map[string]map[int]int{}
hist["not_useful_count"] = map[int]int{}
hist["human_not_useful_count"] = map[int]int{}
hist["dedup_downweight_count"] = map[int]int{}
for _, r := range rows {
hist["not_useful_count"][r.NotUsefulCount]++
hist["human_not_useful_count"][r.HumanNotUsefulCount]++
hist["dedup_downweight_count"][r.DedupDownweightCount]++
}
fmt.Printf("总行数(存活): %d\n", len(rows))
fmt.Printf("not_useful_count>0 待改行数 N = %d\n", N)
fmt.Printf(" 其中可按来源判定 M = %d (human=%d dedup=%d)\n", M, len(humanIDs), len(dedupIDs))
fmt.Printf(" 无法判定归 unknown K = %d\n", K)
fmt.Println("── 3 字段直方图(value→行数,仅列当前非零分布)──")
order := []string{"not_useful_count", "human_not_useful_count", "dedup_downweight_count"}
for _, f := range order {
m := hist[f]
keys := make([]int, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Ints(keys)
fmt.Printf("[%s] ", f)
if len(keys) == 1 && keys[0] == 0 {
fmt.Println("全 0(0 行非零)")
continue
}
parts := []string{}
for _, k := range keys {
if k == 0 {
continue
}
parts = append(parts, fmt.Sprintf("%d×%d", k, m[k]))
}
fmt.Println(parts)
}
if *apply {
if M == 0 {
fmt.Println("apply: 无可判定行(M=0),无需迁移,存量 not_useful_count 保持原样。")
} else {
fmt.Printf("apply: 将拆分 %d 行(human=%d dedup=%d)—— 需逐条 Update。\n", M, len(humanIDs), len(dedupIDs))
}
} else {
fmt.Println("(dry-run) 未写库。输出须先经小怡终审,批准后才可 --apply。")
}
}
+119 -30
View File
@@ -10,6 +10,7 @@ import (
"net/http"
"os"
"sort"
"strconv"
"strings"
"unicode/utf8"
)
@@ -28,6 +29,7 @@ const (
// 3. /var/lib/memoryweave/.api-key 文件(**多行格式**:注释与 `legacy:` 行跳过,
// 取第 1 个非注释非 legacy 行 = 主密钥)
// 4. 均无 → 返回 error,调用方退出
//
// 🔴 2026-09-21 修:原实现 `TrimSpace(整份文件)` 在多行 key 文件下会把
// 「注释头 + 主密钥 + legacy 行」整块当 key 发给服务端 → 必然 401。
func resolveAPIKey(flagKey string) (string, error) {
@@ -52,9 +54,9 @@ func resolveAPIKey(flagKey string) (string, error) {
// ─── 全局参数 ──────────────────────────────────────────────
var (
apiURL = flag.String("url", getEnv("ZHIYI_API_URL", apiURLDefault), "织忆 API 地址")
apiKey = flag.String("key", getEnv("ZHIYI_API_KEY", apiKeyDefault), "API Key")
ns = flag.String("n", getEnv("ZHIYI_NAMESPACE", namespaceDefault), "命名空间")
apiURL = flag.String("url", getEnv("ZHIYI_API_URL", apiURLDefault), "织忆 API 地址")
apiKey = flag.String("key", getEnv("ZHIYI_API_KEY", apiKeyDefault), "API Key")
ns = flag.String("n", getEnv("ZHIYI_NAMESPACE", namespaceDefault), "命名空间")
)
// ─── 入口 ─────────────────────────────────────────────────
@@ -81,11 +83,18 @@ func main() {
var runErr error
switch cmd {
case "tree": runErr = runTree(args, apiKeyVal)
case "graph": runErr = runGraph(args, apiKeyVal)
case "recall": runErr = runRecall(args, apiKeyVal)
case "stats": runErr = runStats(apiKeyVal)
case "entity": runErr = runEntity(args, apiKeyVal)
case "tree":
runErr = runTree(args, apiKeyVal)
case "graph":
runErr = runGraph(args, apiKeyVal)
case "recall":
runErr = runRecall(args, apiKeyVal)
case "stats":
runErr = runStats(apiKeyVal)
case "entity":
runErr = runEntity(args, apiKeyVal)
case "pattern":
runErr = runPattern(args, apiKeyVal)
case "help", "--help", "-h":
flag.Usage()
os.Exit(0)
@@ -124,6 +133,7 @@ func usage() {
recall <query> 语义搜索,返回 top-10 结果
stats 显示系统统计(记忆数、蒸馏状态等)
entity <name> 查询实体详情(出现次数、关联记忆)
pattern <detect|list> L2 模式检测/列出(detect=手动触发检测,list=列出已检测)
示例:
zhiyi tree
@@ -188,11 +198,11 @@ func runTree(args []string, apiKey string) error {
// 从图谱 export 获取节点(带 category),按 category 分组
var r struct {
Nodes []struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type,omitempty"`
Category string `json:"category,omitempty"`
Weight float64 `json:"weight,omitempty"`
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type,omitempty"`
Category string `json:"category,omitempty"`
Weight float64 `json:"weight,omitempty"`
} `json:"nodes"`
Edges []struct {
Source string `json:"source"`
@@ -235,12 +245,12 @@ func runTree(args []string, apiKey string) error {
fmt.Println(strings.Repeat("─", 50))
catIcons := map[string]string{
"人物": "👤",
"项目": "📦",
"事件": "📅",
"概念": "💡",
"位置": "📍",
"组织": "🏢",
"人物": "👤",
"项目": "📦",
"事件": "📅",
"概念": "💡",
"位置": "📍",
"组织": "🏢",
"distilled": "🔄",
}
for _, cat := range cats {
@@ -289,14 +299,14 @@ func runGraph(args []string, apiKey string) error {
}
type navigateResp struct {
Entity string `json:"entity"`
Count int `json:"count"`
Entity string `json:"entity"`
Count int `json:"count"`
Paths []struct {
From string `json:"from"`
To string `json:"to"`
From string `json:"from"`
To string `json:"to"`
Relation string `json:"relation"`
Hop int `json:"hop"`
Weight float64 `json:"weight"`
Hop int `json:"hop"`
Weight float64 `json:"weight"`
} `json:"paths"`
}
var resp navigateResp
@@ -423,10 +433,10 @@ func runStats(apiKey string) error {
Status string `json:"status"`
}
var distStatus struct {
QueueLen int `json:"queue_len"`
DailyUsed int `json:"daily_used"`
DailyLimit int `json:"daily_limit"`
BatchSize int `json:"batch_size"`
QueueLen int `json:"queue_len"`
DailyUsed int `json:"daily_used"`
DailyLimit int `json:"daily_limit"`
BatchSize int `json:"batch_size"`
}
apiGet("/api/v1/stats", &stats, apiKey)
@@ -455,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>")
@@ -591,4 +680,4 @@ func box(style, s string) string {
return "◉ " + s
}
return s
}
}
+22 -18
View File
@@ -16,19 +16,19 @@ import (
// ─── Per-Agent 令牌桶 ────────────────────────────────────────
type tokenBucket struct {
tokens float64
tokens float64
lastRefill time.Time
rate float64 // tokens/sec
burst float64
mu sync.Mutex
rate float64 // tokens/sec
burst float64
mu sync.Mutex
}
func newTokenBucket(rate, burst float64) *tokenBucket {
return &tokenBucket{
tokens: burst,
tokens: burst,
lastRefill: time.Now(),
rate: rate,
burst: burst,
rate: rate,
burst: burst,
}
}
@@ -58,10 +58,10 @@ type AgentRateConfig struct {
}
var agentRates = map[string]AgentRateConfig{
"hermes": {RecallQPS: 10, CommitQPS: 2, Burst: 20},
"hermes-a06":{RecallQPS: 10, CommitQPS: 2, Burst: 20},
"openclaw": {RecallQPS: 10, CommitQPS: 2, Burst: 20},
"cron-job": {RecallQPS: 5, CommitQPS: 1, Burst: 10},
"hermes": {RecallQPS: 10, CommitQPS: 2, Burst: 20},
"hermes-a06": {RecallQPS: 10, CommitQPS: 2, Burst: 20},
"openclaw": {RecallQPS: 10, CommitQPS: 2, Burst: 20},
"cron-job": {RecallQPS: 5, CommitQPS: 1, Burst: 10},
}
var defaultRate = AgentRateConfig{RecallQPS: 5, CommitQPS: 1, Burst: 10}
@@ -69,14 +69,14 @@ var defaultRate = AgentRateConfig{RecallQPS: 5, CommitQPS: 1, Burst: 10}
// ─── RateLimiter ─────────────────────────────────────────────
type RateLimiter struct {
mu sync.Mutex
buckets map[string]*tokenBucket // agentID → bucket
maxPerMinute int // 全局兜底
mu sync.Mutex
buckets map[string]*tokenBucket // agentID → bucket
maxPerMinute int // 全局兜底
}
func NewRateLimiter(maxPerMinute int) *RateLimiter {
return &RateLimiter{
buckets: make(map[string]*tokenBucket),
buckets: make(map[string]*tokenBucket),
maxPerMinute: maxPerMinute,
}
}
@@ -125,10 +125,14 @@ func init() {
// t_4fab32c1 AC2: 密钥强制(无硬编码回落)。
// 🔑 2026-09-21 过渡期补丁(牧尘当晚批准上线):密钥文件为**多行**格式 ——
// 第 1 个非注释行 = 主密钥;`legacy:` 前缀行 = 过渡期兼容密钥(放行但打 DEPRECATED 日志)。
//
// 第 1 个非注释行 = 主密钥;`legacy:` 前缀行 = 过渡期兼容密钥(放行但打 DEPRECATED 日志)。
//
// 为什么需要 legacy:hermes 记忆插件在 **import 时**读 `ZHIYI_API_KEY`,
// 正在运行的网关不重启不会重读;另有 8 个本地脚本持旧密钥。
// 若直接轮换值 → 瞬间打断记忆读写(recall/commit 全 401)。
//
// 正在运行的网关不重启不会重读;另有 8 个本地脚本持旧密钥。
// 若直接轮换值 → 瞬间打断记忆读写(recall/commit 全 401)。
//
// 收口条件:客户端迁移完(网关重启后)删除 key 文件里的 legacy 行。
const keyFile = "/var/lib/memoryweave/.api-key"
+1 -1
View File
@@ -24,4 +24,4 @@ func CORS() func(http.Handler) http.Handler {
next.ServeHTTP(w, r)
})
}
}
}
+20 -2
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,9 +68,15 @@ 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"])
recallCnt := intVal(mem["recall_count"])
// GA-P0-2 (t_c6d41ac6):衰减/遗忘只认 recall_verified_count(正反馈才是 V 值)。
// recall_count 已冻结(历史虚高保命来源),seen 只用于观测,不参与衰减。
recallCnt := intVal(mem["recall_verified_count"])
tier := strVal(mem["tier"])
content := strVal(mem["content"])
@@ -81,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 {
@@ -115,13 +131,15 @@ 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,
})
}
// POST /api/v1/admin/purge-invalid
// P1-1 2026-10-08: 一次性清理 id 为空的垃圾行(DB 存在 id/content/source 全空的 2 行,
// 命中 <30 字碎片快速道 → SoftDelete("") 被 Rust 拒绝 → 每轮刷错误日志 + 遗忘计数虚高)。
// 这些行无 id 无法按 id 软删,只能按 id='' 条件一次性软删(is_deleted=true)。
// 这些行无 id 无法按 id 软删,只能按 id=” 条件一次性软删(is_deleted=true)。
// ⚠️ 由验收/决策者按需调用(本实现不自动触发),本端点只提供能力 + 返回受影响行数。
func (aa *AdminAPI) PurgeInvalid(w http.ResponseWriter, r *http.Request) {
n, err := aa.LanceDB.PurgeInvalid()
+21 -9
View File
@@ -19,9 +19,9 @@ import (
// forgetMockLance 为 Forget 计数测试提供的 LanceDB 桩:
// GetCandidatesForForgetting 可配置候选列表;SoftDelete 可配置失败/成功。
type forgetMockLance struct {
candidates []map[string]interface{}
candidates []map[string]interface{}
softDeleteErr error
deletedIDs []string
deletedIDs []string
}
func (m *forgetMockLance) InsertEpisode(agentID, namespace, content, category string) (string, error) {
@@ -53,23 +53,35 @@ func (m *forgetMockLance) GetCandidatesForForgetting() ([]map[string]interface{}
func (m *forgetMockLance) GetSkillCandidates(minRecalls, limit int) ([]models.MemoryRecord, error) {
return nil, nil
}
func (m *forgetMockLance) Backup(path string) error { return nil }
func (m *forgetMockLance) Backup(path string) error { return nil }
func (m *forgetMockLance) GetAuditLog(limit int) ([]map[string]interface{}, error) { return nil, nil }
func (m *forgetMockLance) IncrementUseful(id string) error { return nil }
func (m *forgetMockLance) IncrementNotUseful(id string) error { return nil }
func (m *forgetMockLance) IncrementUseful(id string) error { return nil }
func (m *forgetMockLance) IncrementNotUseful(id string) error { return nil }
// GA-P0-1 (t_718d8707):拆分字段
func (m *forgetMockLance) IncrementHumanNotUseful(id string) error { return nil }
func (m *forgetMockLance) AppendHitEvent(memoryID, outcome, reason string) error { return nil }
func (m *forgetMockLance) CountHitEvents(memoryID, outcome string, since time.Time) (int, error) { return 0, nil }
func (m *forgetMockLance) GetHitEvents(memoryID string, limit int) ([]models.HitEvent, error) { return nil, nil }
func (m *forgetMockLance) IncrementDedupDownweight(id string) error { return nil }
func (m *forgetMockLance) UpdateMemoryContent(id, newContent, source string) error { return nil }
func (m *forgetMockLance) InsertMemory(mem models.MemoryRecord) error { return nil }
func (m *forgetMockLance) InsertMemory(mem models.MemoryRecord) error { return nil }
func (m *forgetMockLance) Search(table string, vector []float32, topK int, namespaceFilter string) ([]models.MemoryRecord, error) {
return nil, nil
}
func (m *forgetMockLance) Scan(limit int, namespaceFilter string) ([]models.MemoryRecord, error) {
return nil, nil
}
func (m *forgetMockLance) Insert(table string, record any) error { return nil }
func (m *forgetMockLance) Insert(table string, record any) error { return nil }
func (m *forgetMockLance) Update(table, id string, fields map[string]any) error {
return nil
}
// GetRecordByID t_0f1d7be9(原文层):fake 默认不存在。
func (m *forgetMockLance) GetRecordByID(table, id string) (*models.MemoryRecord, error) {
return nil, nil
}
// fragment 构造一个命中「<30 字碎片快速道」的候选(content<30 字 + 20 天未访问)。
func fragmentCandidate(id string) map[string]interface{} {
return map[string]interface{}{
@@ -111,7 +123,7 @@ func callForget(t *testing.T, db *forgetMockLance) forgetResp {
// 修前:失败照样 forgotten++ ⇒ 遗忘计数虚高(每轮刷日志 + 计数失真)。
func TestForget_SoftDeleteFailure_DoesNotIncrement(t *testing.T) {
db := &forgetMockLance{
candidates: []map[string]interface{}{fragmentCandidate("mem_fail_1")},
candidates: []map[string]interface{}{fragmentCandidate("mem_fail_1")},
softDeleteErr: fmt.Errorf("ipc: simulated soft delete failure"),
}
resp := callForget(t, db)
@@ -151,4 +163,4 @@ func TestForget_EmptyIDSkipped(t *testing.T) {
t.Fatalf("空 id 候选不得调用 SoftDelete,实得 %v", db.deletedIDs)
}
t.Logf("✓ 空 id 候选跳过:forgotten=%d, SoftDelete 调用 0 次", resp.Forgotten)
}
}
+41 -14
View File
@@ -2,35 +2,59 @@
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"
)
// DistillEngineRef 全局蒸馏引擎引用(server.go 注入)
var DistillEngineRef *distill.Engine
// CommitMeta 2026-10-08 (Gap#2):commit 时写入方携带的元数据,透传到蒸馏产物 MemoryRecord。
// 集中成一个结构体而非继续加位置参数——8 个位置参数再加 2 个时,「忘记传」是必然的
// (Gap#2 的接线缺口正是这类 bug:字段加了、没人传)。
type CommitMeta struct {
Source string // 出处(笔记路径等)→ MemoryRecord.Source
EmbeddingModel string // 血缘 → MemoryRecord.EmbeddingModel
ScriptVersion string // 血缘 → MemoryRecord.ScriptVersion
Confidence *float64 // 信任等级(写入方显式声明,不从 source 反推);nil=未声明
ConfidenceSource string // authoritative | estimated
}
// AutoDistillTrigger commit 后自动触发蒸馏流水线
// 2026-10-08 (t_7e3577c5):新增 embedModel/scriptVer 血缘字段透传
func AutoDistillTrigger(episodeID string, content string, category string, namespace string, agentID string, source string, embedModel string, scriptVer string) {
// 1. 硬规则过滤
if !distill.HardRulesPass(content) {
// 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. 硬规则过滤(t_a513b870:带原因变体,拒绝在内部登记计数 + 打日志)
if pass, _ := distill.HardRulesPassWithReason(content); !pass {
return
}
// 2. 使用蒸馏引擎
if DistillEngineRef != nil {
DistillEngineRef.Enqueue(distill.DistillInput{
EpisodeID: episodeID,
Content: content,
Category: distill.Category(category),
Namespace: namespace,
AgentID: agentID,
Source: source,
EmbeddingModel: embedModel,
ScriptVersion: scriptVer,
EpisodeID: episodeID,
Content: content,
Category: distill.Category(category),
Namespace: namespace,
AgentID: agentID,
Source: meta.Source,
EmbeddingModel: meta.EmbeddingModel,
ScriptVersion: meta.ScriptVersion,
Confidence: meta.Confidence,
ConfidenceSource: meta.ConfidenceSource,
})
} else {
// 降级: 关键词提取
@@ -40,6 +64,7 @@ func AutoDistillTrigger(episodeID string, content string, category string, names
Facts: facts,
Entities: extractEntities(content),
Namespace: namespace,
Source: meta.Source, // 2026-10-12 t_67d0279a 方案A:降级路径同样透传来源分档
})
}
@@ -93,8 +118,8 @@ func currentUnixMS() int64 {
// ─── 全局引用(server.go 需要访问)─────────────────────
var (
graphUpdater *governance.AutoGraphUpdater
conflictScanner = governance.NewConflictDetector()
graphUpdater *governance.AutoGraphUpdater
conflictScanner = governance.NewConflictDetector()
)
func SetGraphUpdater(gu *governance.AutoGraphUpdater) {
@@ -102,6 +127,8 @@ func SetGraphUpdater(gu *governance.AutoGraphUpdater) {
}
func minLen2(a, b int) int {
if a < b { return a }
if a < b {
return a
}
return b
}
+4 -3
View File
@@ -2,9 +2,10 @@
// Hermes / OpenClaw 的客户端库,零外部依赖
//
// 使用:
// client := NewClient("http://localhost:7821", "mw-your-api-key")
// mid, err := client.Commit("hermes", "shared", "牧尘用 Deepin 25", "system_fact")
// results, err := client.Recall("牧尘系统是什么", 5)
//
// client := NewClient("http://localhost:7821", "mw-your-api-key")
// mid, err := client.Commit("hermes", "shared", "牧尘用 Deepin 25", "system_fact")
// results, err := client.Recall("牧尘系统是什么", 5)
package routes
import (
+1 -1
View File
@@ -36,7 +36,7 @@ func (ca *ConflictAPI) Resolve(w http.ResponseWriter, r *http.Request) {
var req struct {
ConflictID string `json:"conflict_id"`
Resolution string `json:"resolution"` // keep_left / keep_right / merge / dismiss
Winner string `json:"winner"` // id of the winning memory
Winner string `json:"winner"` // id of the winning memory
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, 400, "invalid body")
+42 -9
View File
@@ -6,15 +6,22 @@ import (
"log"
"sort"
"strings"
"sync/atomic"
"time"
"github.com/xiaoxue/memoryweave/internal/consolidate"
"github.com/xiaoxue/memoryweave/internal/governance"
"github.com/xiaoxue/memoryweave/internal/metrics"
"github.com/xiaoxue/memoryweave/internal/selfoptimize"
"github.com/xiaoxue/memoryweave/internal/storage"
)
// consolInFlight 进程内单飞锁(t_10757bd5,2026-10-24 22:50)
//
// 根因:t_distill(15min) 与 t_consolidation 会在同一秒各自起 goroutine 调本流水线,
// 两条同时打 Rust sidecar 的互斥整合槽 ⇒ 必有一条吃 consolidate_busy ⇒ 静默降级
// ⇒ 55 次/日的"假失败"其实是自相争用。单飞后同进程内不再可能撞车。
var consolInFlight int32
// ConsolidationPipeline 完整整合流水线
// 优先调用 Rust sidecar(DBSCAN + 衰减校准 + 质量回溯)
// Rust 不可用时降级为 Go 启发式
@@ -24,7 +31,7 @@ type ConsolidationPipeline struct {
graphUpdater *governance.AutoGraphUpdater
conflicts *governance.ConflictDetector
// Rust IPC 路径
dataDir string
dataDir string
sqlitePath string
}
@@ -52,6 +59,15 @@ func (cp *ConsolidationPipeline) Run() (*ConsolidationReport, error) {
// mode: "cluster_only" | "full" | "prune_only"
// full 模式触发 LLM 质量回溯 + 衰减校准(仅低频调度使用)
func (cp *ConsolidationPipeline) RunWithMode(mode string) (*ConsolidationReport, error) {
// ─── 单飞:同进程内的多触发器不得并发打 sidecar(t_10757bd5)────────
if !atomic.CompareAndSwapInt32(&consolInFlight, 0, 1) {
GetConsolTracker().RecordSkip("already_running (in-process singleflight)")
log.Printf("[consolidation] 上一次整合仍在进行,本次触发跳过(跳过不计失败)")
now := time.Now()
return &ConsolidationReport{StartedAt: now, FinishedAt: now, Duration: "skipped_inflight"}, nil
}
defer atomic.StoreInt32(&consolInFlight, 0)
// ─── 前置检查:timestamp 合理性(防止 epoch-0 数据污染聚类结果)────────
if mode == "full" || mode == "cluster_only" {
if suspicious, total := cp.checkTimestampSanity(); suspicious > 0 {
@@ -59,11 +75,12 @@ func (cp *ConsolidationPipeline) RunWithMode(mode string) (*ConsolidationReport,
}
}
// ─── 尝试 Rust sidecar ───────────────────────────────────────
// ─── 尝试 Rust sidecar(t_10757bd5:busy-wait + 健康跟踪)────────
var report *ConsolidationReport
if cp.dataDir != "" && cp.sqlitePath != "" {
// 用指定模式调用 Rust sidecar
if rustReport, err := consolidate.Run(cp.dataDir, cp.sqlitePath, mode); err == nil {
// busyWaitRetry 处理 consolidate_busy:等待 3s×3 次再降级(治「静默堆积」根因)
rustReport, stillBusy, err := busyWaitRetry(cp.dataDir, cp.sqlitePath, mode)
if err == nil && !stillBusy {
report = &ConsolidationReport{
StartedAt: time.Now(),
FinishedAt: time.Now(),
@@ -93,8 +110,18 @@ func (cp *ConsolidationPipeline) RunWithMode(mode string) (*ConsolidationReport,
// Phase F: 整合完成后更新 Prometheus metrics
metrics.TotalMemories.Set(float64(rustReport.Clusters * 10)) // 估算
metrics.SyncFromDashboard(selfoptimize.Dash.Metrics())
// t_10757bd5 AC4:成功 ⇒ tracker 归零(/health 可读到 LastSuccessTime)
GetConsolTracker().RecordSuccess()
} else {
log.Printf("[consolidation] Rust sidecar 不可用 (%v),降级为 Go 启发式", err)
// busy(重试耗尽)⇒ **跳过**,不是失败;非 busy 错误 ⇒ 真失败
if stillBusy {
log.Printf("[consolidation] sidecar 忙(重试 9s 耗尽),本次跳过 —— busy=别人在跑,不等于链路坏")
GetConsolTracker().RecordSkip("consolidate_busy after retries")
} else {
errStr := fmt.Sprintf("%v", err)
log.Printf("[consolidation] Rust sidecar 真失败 (%s),降级为 Go 启发式", errStr)
GetConsolTracker().RecordFailure(errStr)
}
goReport, goErr := cp.runGoFallback()
if goErr != nil {
return nil, goErr
@@ -249,7 +276,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++
}
}
@@ -444,7 +476,8 @@ func extractEntities(text string) []string {
}
func minInt2(a, b int) int {
if a < b { return a }
if a < b {
return a
}
return b
}
@@ -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))
}
}
}
File diff suppressed because it is too large Load Diff
+491
View File
@@ -0,0 +1,491 @@
package routes
import (
"fmt"
"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 字段")
}
})
}
}
// ── t_58f9d476 回归:P0-3 翻页切片吞掉 carry ──────────────────────────────
// 病灶:capRecallResults 之后 results = [主(填满 limit)..., carry...],紧随的
// P0-3 翻页块裸 `results[:req.Limit]` 恒把 carry 切掉(carry 恰在索引 limit)
// ⇒ t_e98cb1d6 的 carry 修复被作废,peer/pattern 注入结构性失明
// (t_9dc647f1 dbg 插桩实证:注入 4087→4088 发生,响应 50 条无 peer)。
// 修后:paginateRecallWithCarry 对 carry 豁免切片并回尾;关态(无 carry)逐位 delta=0。
func TestPaginateRecallWithCarry(t *testing.T) {
mkMain := func(n int) []models.RecallResult {
out := make([]models.RecallResult, n)
for i := 0; i < n; i++ {
out[i] = models.RecallResult{ID: fmt.Sprintf("mem_%03d", i), Category: "distilled", Content: "m"}
}
return out
}
carry := func(rs ...models.RecallResult) []models.RecallResult { return rs }
// ① 金标场景:limit=5、主+扩展填满 limit、末位=peer ⇒ 切片后末位仍 = peer:<id>,
// 且主路段逐位 = 原前 5 位(t_9025b0eb AC:主路 ≤ limit 不变)。
t.Run("limit=5_末位peer存活_主路段逐位不变", func(t *testing.T) {
in := append(mkMain(5), models.RecallResult{ID: "peer:muc", Category: "peer", Content: "p"})
got, mainLen := paginateRecallWithCarry(in, 0, nil, 5)
if len(got) != 6 {
t.Fatalf("len=%d want 6(主 5 + carry 1)", len(got))
}
if mainLen != 5 {
t.Fatalf("mainLen=%d want 5(t_2891d689:判据主路段长度=limit,翻页令牌据此发放)", mainLen)
}
if got[5].ID != "peer:muc" || got[5].Category != "peer" {
t.Fatalf("末位=%s/%s,want peer:muc/peer(截断回归未修)", got[5].ID, got[5].Category)
}
for i := 0; i < 5; i++ {
if got[i].ID != in[i].ID {
t.Fatalf("主路段第 %d 位变了: got=%s want=%s", i, got[i].ID, in[i].ID)
}
}
})
// ② limit=50、pattern(3)+peer(1) 全存活,主路段逐位不变。
t.Run("limit=50_pattern与peer全存活", func(t *testing.T) {
in := append(mkMain(50), carry(
models.RecallResult{ID: "pattern:a", Category: "pattern"},
models.RecallResult{ID: "pattern:b", Category: "pattern"},
models.RecallResult{ID: "pattern:c", Category: "pattern"},
models.RecallResult{ID: "peer:muc", Category: "peer"},
)...)
got, _ := paginateRecallWithCarry(in, 0, nil, 50)
if len(got) != 54 {
t.Fatalf("len=%d want 54", len(got))
}
for i := 0; i < 50; i++ {
if got[i].ID != in[i].ID {
t.Fatalf("主路段第 %d 位变了", i)
}
}
if got[53].ID != "peer:muc" {
t.Fatalf("末位=%s want peer:muc", got[53].ID)
}
nPat := 0
for _, r := range got {
if r.Category == "pattern" {
nPat++
}
}
if nPat != 3 {
t.Fatalf("pattern=%d want 3", nPat)
}
})
// ③ 关态(无 carry)逐位 delta=0:offset × prevIDs × limit 组合下
// 与旧内联语义(切片→去重→截断)逐一比对。
t.Run("关态无carry_与旧内联逻辑逐位一致", func(t *testing.T) {
legacy := func(in []models.RecallResult, offset int, prevIDs []string, limit int) []models.RecallResult {
results := in
if offset > 0 {
if offset >= len(results) {
results = []models.RecallResult{}
} else {
results = results[offset:]
}
}
if len(prevIDs) > 0 {
seen := make(map[string]bool, len(prevIDs))
for _, id := range prevIDs {
seen[id] = true
}
kept := make([]models.RecallResult, 0, len(results))
for _, r := range results {
if !seen[r.ID] {
kept = append(kept, r)
}
}
results = kept
}
if len(results) > limit {
results = results[:limit]
}
return results
}
in := mkMain(30)
for _, offset := range []int{0, 1, 7, 29, 30, 45} {
for _, prev := range [][]string{nil, {"mem_008"}, {"mem_008", "mem_009", "mem_020"}} {
for _, limit := range []int{5, 10, 50} {
got, _ := paginateRecallWithCarry(in, offset, prev, limit)
want := legacy(in, offset, prev, limit)
if len(got) != len(want) {
t.Fatalf("offset=%d prev=%v limit=%d: len got=%d want=%d", offset, prev, limit, len(got), len(want))
}
for i := range got {
if got[i].ID != want[i].ID {
t.Fatalf("offset=%d prev=%v limit=%d: 第%d位 got=%s want=%s", offset, prev, limit, i, got[i].ID, want[i].ID)
}
}
}
}
}
})
// ④ 主路段切空 ⇒ 整页空(不返回孤儿 carry,与旧语义一致)。
t.Run("offset越界_整页空不带孤儿carry", func(t *testing.T) {
in := append(mkMain(5), models.RecallResult{ID: "peer:muc", Category: "peer"})
if got, _ := paginateRecallWithCarry(in, 5, nil, 5); len(got) != 0 {
t.Fatalf("len=%d want 0", len(got))
}
})
// ⑤ 续页(offset>0 + prevIDs 去重)主路段从 offset 起且去掉 prevIDs。
// ⚠ 2026-11-02 (t_2891d689) 判据反转:原断言「续页 carry 回尾」锁死的正是
// 本卡要改的行为(carry 每页重复出现,去重成本转嫁客户端)。现语义:
// carry 只在第一页出现,续页只回主路段。
t.Run("续页不回carry_主路段offset加去重", func(t *testing.T) {
in := append(mkMain(20), models.RecallResult{ID: "peer:muc", Category: "peer"})
got, mainLen := paginateRecallWithCarry(in, 3, []string{"mem_005"}, 5)
if len(got) != 5 {
t.Fatalf("len=%d want 5(续页不含 carry)", len(got))
}
if mainLen != 5 {
t.Fatalf("mainLen=%d want 5", mainLen)
}
wantMain := []string{"mem_003", "mem_004", "mem_006", "mem_007", "mem_008"}
for i, id := range wantMain {
if got[i].ID != id {
t.Fatalf("主路段第 %d 位 got=%s want=%s", i, got[i].ID, id)
}
}
for _, r := range got {
if r.Category == "peer" || r.Category == "pattern" {
t.Fatalf("续页混入 carry: %s/%s", r.ID, r.Category)
}
}
})
// ⑥ 端到端:capRecallResults → paginateRecallWithCarry 全链路
// (生产形态:主 60 + 扩展 100 + pattern 3 + peer 1,limit=50)。
t.Run("端到端cap后翻页_peer不再被切", func(t *testing.T) {
var all []models.RecallResult
all = append(all, mkMain(60)...)
for i := 0; i < 100; i++ {
all = append(all, models.RecallResult{ID: fmt.Sprintf("gx_%03d", i), Category: "graph_expanded", Content: "[graph] x"})
}
all = append(all,
models.RecallResult{ID: "pattern:a", Category: "pattern"},
models.RecallResult{ID: "peer:muc", Category: "peer"})
prim, _ := capRecallResults(all, 50)
if prim[len(prim)-1].Category != "peer" {
t.Fatalf("cap 后末位=%s,capRecallResults 语义变了", prim[len(prim)-1].ID)
}
got, _ := paginateRecallWithCarry(prim, 0, nil, 50)
// 修前此处恒被切(results[:50] 丢掉末尾 carry)
if got[len(got)-1].ID != "peer:muc" {
t.Fatalf("翻页后末位=%s want peer:muc(P0-3 截断回归未修)", got[len(got)-1].ID)
}
nMain := 0
for _, r := range got {
if r.Category != "pattern" && r.Category != "peer" {
nMain++
}
}
if nMain > 50 {
t.Fatalf("主路段 %d 条 > limit 50(t_9025b0eb AC 破坏)", nMain)
}
})
}
// ── t_2891d689 回归:carry 撑大 len(results) ⇒ nextCont 恒空、翻页链断 ──────
// 病灶:4ffc546 后 paginateRecallWithCarry 回 main+carry,len(results) 可 > limit,
// handler 判据 `len(results) == req.Limit` 只要 carry 命中就恒假
// ⇒ nextCont 恒空,带 pattern/peer 注入的 query 第二页永远取不到。
// 修后:判据收口 shouldEmitContinuation,看主路段 mainLen;carry 只在第一页回尾。
// 判据链与 handler(core.go 翻页块)逐行同构:paginate → shouldEmitContinuation
// → returned=prevIDs+页内ID → encodeContinuation(卡面允许的「抽出判定函数单测」路线)。
func TestContinuationIssuedWithCarry(t *testing.T) {
mkMain := func(n int) []models.RecallResult {
out := make([]models.RecallResult, n)
for i := 0; i < n; i++ {
out[i] = models.RecallResult{ID: fmt.Sprintf("mem_%03d", i), Category: "distilled", Content: "m"}
}
return out
}
idsOf := func(rs []models.RecallResult) []string {
out := make([]string, len(rs))
for i, r := range rs {
out[i] = r.ID
}
return out
}
// 生产形态(t_58f9d476 实证):cap 后 prim = 主路段填满 limit + 末尾 carry。
limit := 5
prim := append(mkMain(5), models.RecallResult{ID: "peer:muc", Category: "peer"})
totalFetched := len(prim) // 6 ≥ fetchLimit(=limit+offset+len(prev)=5)
page, mainLen := paginateRecallWithCarry(prim, 0, nil, limit)
if len(page) != 6 || mainLen != 5 {
t.Fatalf("page=%d mainLen=%d want 6/5", len(page), mainLen)
}
// 病灶判据复现:len(page)==limit ⇒ 6==5 恒假 ⇒ 链断。本例页形必须仍是 len>limit,
// 否则该用例失去对旧病灶的覆盖。
if len(page) == limit {
t.Fatal("页形不再是 len>limit,本用例失去对旧病灶的覆盖")
}
// 新判据:主路段满页 ⇒ 必须发令牌
if !shouldEmitContinuation(totalFetched, limit, mainLen, limit, 0) {
t.Fatal("totalFetched>=fetchLimit 且主路段满页 ⇒ 应发 nextCont(翻页链断裂回归未修)")
}
// handler 完整动作 ⇒ nextCont != "",且令牌推进 offset=5
returned := idsOf(page) // 含 carry ID(t_58f9d476 既有行为:去重锚点)
nextCont := encodeContinuation("q", "ns", 0+limit, limit, returned)
if nextCont == "" {
t.Fatal("nextCont == \"\",翻页链断")
}
tok, err := decodeContinuation(nextCont)
if err != nil || tok.Off != 5 || tok.Lim != 5 {
t.Fatalf("令牌非法: off=%d lim=%d err=%v(want 5/5/nil)", tok.Off, tok.Lim, err)
}
}
// AC2:offset>0 的续页 ⇒ 不含前页已返回的 carry,主路段继续推进;尾页不满 ⇒ 链自然终止。
func TestCarryOnlyOnFirstPageAcrossPages(t *testing.T) {
mkMain := func(n int) []models.RecallResult {
out := make([]models.RecallResult, n)
for i := 0; i < n; i++ {
out[i] = models.RecallResult{ID: fmt.Sprintf("mem_%03d", i), Category: "distilled", Content: "m"}
}
return out
}
limit := 5
// 18 主(4 页:5+5+5+3)+ 2 carry
prim := append(mkMain(18),
models.RecallResult{ID: "pattern:a", Category: "pattern"},
models.RecallResult{ID: "peer:muc", Category: "peer"})
// 第 1 页:主 5 + carry 2 回尾;令牌 returned 含 carry ID
page1, ml1 := paginateRecallWithCarry(prim, 0, nil, limit)
if len(page1) != 7 || ml1 != 5 {
t.Fatalf("page1=%d mainLen=%d want 7/5", len(page1), ml1)
}
if page1[6].Category != "peer" || page1[5].Category != "pattern" {
t.Fatalf("第一页 carry 未回尾: %v", page1[5:])
}
if !shouldEmitContinuation(len(prim), limit, ml1, limit, 0) {
t.Fatal("第 1 页应发令牌")
}
prev := make([]string, 0, len(page1))
for _, r := range page1 {
prev = append(prev, r.ID)
}
// 第 2 页:offset=5(模拟 handler 从令牌取 offset/prev)
page2, ml2 := paginateRecallWithCarry(prim, 5, prev, limit)
for _, r := range page2 {
if r.Category == "pattern" || r.Category == "peer" {
t.Fatalf("续页混入前页 carry: %s/%s", r.ID, r.Category)
}
}
want2 := []string{"mem_005", "mem_006", "mem_007", "mem_008", "mem_009"}
if len(page2) != 5 || ml2 != 5 {
t.Fatalf("page2=%d mainLen=%d want 5/5", len(page2), ml2)
}
for i, id := range want2 {
if page2[i].ID != id {
t.Fatalf("主路段未推进: 第%d位 got=%s want=%s", i, page2[i].ID, id)
}
}
// 链未断:第 2 页满页且取数满足 ⇒ 继续发令牌(生产页 2 的 fetchLimit=5+5+len(prev))
if !shouldEmitContinuation(limit+5+len(prev), limit+5+len(prev), ml2, limit, 5) {
t.Fatal("满页续页应继续发令牌(链不得在第 2 页断)")
}
for _, r := range page2 {
prev = append(prev, r.ID)
}
// 第 3 页照推
page3, ml3 := paginateRecallWithCarry(prim, 10, prev, limit)
if ml3 != 5 || page3[0].ID != "mem_010" {
t.Fatalf("page3 mainLen=%d first=%s want 5/mem_010", ml3, page3[0].ID)
}
// 第 4 页(尾页只剩 3 主)⇒ mainLen=3 < limit ⇒ 不再发令牌(不得错发空尾页令牌)
page4, ml4 := paginateRecallWithCarry(prim, 15, prev, limit)
if ml4 != 3 || len(page4) != 3 {
t.Fatalf("page4 mainLen=%d len=%d want 3/3", ml4, len(page4))
}
if shouldEmitContinuation(len(prim), limit+15+len(prev), ml4, limit, 15) {
t.Fatal("尾页主路段不满 limit ⇒ 不应发令牌")
}
}
// AC3:offset 越界 ⇒ 空页且 nextCont == "";并钉死「判据看 mainLen 而非 len(results)」:
// 主路段 4 + carry 1(len(page)==limit 但 mainLen<limit)也不得发令牌 ——
// 这正是卡面警告的「不要用 len(results)>=limit,offset 越界/尾页会错发 token」的镜像。
func TestNoContinuationOnEmptyOrShortMainPage(t *testing.T) {
mkMain := func(n int) []models.RecallResult {
out := make([]models.RecallResult, n)
for i := 0; i < n; i++ {
out[i] = models.RecallResult{ID: fmt.Sprintf("mem_%03d", i), Category: "distilled", Content: "m"}
}
return out
}
limit := 5
// A. offset 越界(=主路段长度)⇒ 空页 + 不发令牌
prim := append(mkMain(5), models.RecallResult{ID: "peer:muc", Category: "peer"})
page, ml := paginateRecallWithCarry(prim, 5, []string{"mem_000"}, limit)
if len(page) != 0 || ml != 0 {
t.Fatalf("越界页 len=%d mainLen=%d want 0/0", len(page), ml)
}
if shouldEmitContinuation(len(prim), limit+5+1, ml, limit, 5) {
t.Fatal("越界空页不得发令牌")
}
// B. 主路段不满页但 len(page)==limit(4 主 + 1 carry 撑到 5)⇒ 不得发令牌
prim2 := append(mkMain(4), models.RecallResult{ID: "pattern:a", Category: "pattern"})
page2, ml2 := paginateRecallWithCarry(prim2, 0, nil, limit)
if len(page2) != 5 || ml2 != 4 {
t.Fatalf("短页 len=%d mainLen=%d want 5/4", len(page2), ml2)
}
if shouldEmitContinuation(len(prim2), limit, ml2, limit, 0) {
t.Fatal("主路段未满页 ⇒ 不得发令牌(判据必须看 mainLen,不是 len(results))")
}
}
+181 -86
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 {
@@ -122,15 +168,15 @@ type EvalAPI struct {
}
type EvalRun struct {
ID string `json:"id"`
Model string `json:"model"`
RecallAt5 float64 `json:"recall_at_5"`
ID string `json:"id"`
Model string `json:"model"`
RecallAt5 float64 `json:"recall_at_5"`
PrecisionAt5 float64 `json:"precision_at_5"`
MRR float64 `json:"mean_reciprocal_rank"`
NDCG float64 `json:"ndcg"`
MRR float64 `json:"mean_reciprocal_rank"`
NDCG float64 `json:"ndcg"`
// 按标签分组
RecallByTag map[string]float64 `json:"recall_at_5_by_tag,omitempty"`
RecallByTag map[string]float64 `json:"recall_at_5_by_tag,omitempty"`
PrecisionByTag map[string]float64 `json:"precision_at_5_by_tag,omitempty"`
// 按 Agent 分组
@@ -147,14 +193,14 @@ type EvalRun struct {
}
type EvalQueryDetail struct {
Query string `json:"query"`
ExpectedIDs []string `json:"expected_ids"`
Query string `json:"query"`
ExpectedIDs []string `json:"expected_ids"`
MustIncludeKeywords []string `json:"must_include_keywords,omitempty"`
Hits []string `json:"hits"`
Misses []string `json:"misses"`
RecallAt5 float64 `json:"recall_at_5"`
PrecisionAt5 float64 `json:"precision_at_5"`
KeywordOK bool `json:"keyword_ok,omitempty"`
Hits []string `json:"hits"`
Misses []string `json:"misses"`
RecallAt5 float64 `json:"recall_at_5"`
PrecisionAt5 float64 `json:"precision_at_5"`
KeywordOK bool `json:"keyword_ok,omitempty"`
}
// GoldenRunResult 金标 run 的扩展结果(含关键词判据)
@@ -166,8 +212,8 @@ type GoldenRunResult struct {
}
type ConsolidationHits struct {
TotalDistilledUsed int `json:"total_distilled_used"`
HitsAfterConsolidation int `json:"hits_after_consolidation"`
TotalDistilledUsed int `json:"total_distilled_used"`
HitsAfterConsolidation int `json:"hits_after_consolidation"`
ConsolidationBenefit float64 `json:"consolidation_benefit"`
}
@@ -185,7 +231,7 @@ func NewEvalAPI(p *storage.RecallPipeline, ldb storage.LanceDB) *EvalAPI {
// POST /api/v1/eval/run
func (ea *EvalAPI) Run(w http.ResponseWriter, r *http.Request) {
var req struct {
Queries []struct {
Queries []struct {
Query string `json:"query"`
ExpectedIDs []string `json:"expected_ids"`
} `json:"queries"`
@@ -250,11 +296,15 @@ func (ea *EvalAPI) Run(w http.ResponseWriter, r *http.Request) {
}
pAt5 := 0.0
if len(results) > 0 { pAt5 = float64(hits) / float64(len(results)) }
if len(results) > 0 {
pAt5 = float64(hits) / float64(len(results))
}
totalPrecision += pAt5
rAt5 := 0.0
if len(q.ExpectedIDs) > 0 { rAt5 = float64(hits) / float64(len(q.ExpectedIDs)) }
if len(q.ExpectedIDs) > 0 {
rAt5 = float64(hits) / float64(len(q.ExpectedIDs))
}
totalRecall += rAt5
// MRR
@@ -269,11 +319,17 @@ func (ea *EvalAPI) Run(w http.ResponseWriter, r *http.Request) {
dcg, idcg := 0.0, 0.0
for i, res := range results {
rel := 0.0
if expectedSet[res.ID] { rel = 1.0 }
if expectedSet[res.ID] {
rel = 1.0
}
dcg += rel / log2(float64(i+2))
if i < len(q.ExpectedIDs) { idcg += 1.0 / log2(float64(i+2)) }
if i < len(q.ExpectedIDs) {
idcg += 1.0 / log2(float64(i+2))
}
}
if idcg > 0 {
totalNDCG += dcg / idcg
}
if idcg > 0 { totalNDCG += dcg / idcg }
// 分组统计
// (category 从 query 推断)
@@ -308,18 +364,18 @@ func (ea *EvalAPI) Run(w http.ResponseWriter, r *http.Request) {
recallByAgent := avgByGroup(recallByAgentMap)
run := &EvalRun{
ID: time.Now().Format("20060102-150405"),
Model: req.Model,
RecallAt5: totalRecall / float64(totalQueries),
PrecisionAt5: totalPrecision / float64(totalQueries),
MRR: totalMRR / float64(totalQueries),
NDCG: totalNDCG / float64(totalQueries),
RecallByTag: recallByTag,
ID: time.Now().Format("20060102-150405"),
Model: req.Model,
RecallAt5: totalRecall / float64(totalQueries),
PrecisionAt5: totalPrecision / float64(totalQueries),
MRR: totalMRR / float64(totalQueries),
NDCG: totalNDCG / float64(totalQueries),
RecallByTag: recallByTag,
PrecisionByTag: precisionByTag,
RecallByAgent: recallByAgent,
QueryDetails: queryDetails,
Queries: totalQueries,
RanAt: time.Now(),
RecallByAgent: recallByAgent,
QueryDetails: queryDetails,
Queries: totalQueries,
RanAt: time.Now(),
}
evals.mu.Lock()
@@ -336,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)
@@ -363,13 +426,22 @@ func (ea *EvalAPI) RunGolden(w http.ResponseWriter, r *http.Request) {
respondError(w, 400, err.Error())
return
}
// 无关键词判据 → 禁止(会退化成恒真)
if len(item.MustIncludeKeywords) == 0 {
failures = append(failures, item.Query+": 无关键词判据(禁止退化成恒真)")
// 无关键词判据 → 禁止(会退化成恒真)——仅当也没有 expected_ids 时才拒。
// 2026-10-26:v4 金标(t_4d42f9c0)是「分级 relevant ids」型,没有关键词;
// 对这类条目 keyword 指标记 N/A(不计入 keyword_total),只算 recall/precision/mrr/ndcg,
// 别偷偷把它当 keyword 恒真,也别整条丢掉(否则 304 条全被跳过 ⇒ "all golden queries failed")。
idOnlyItem := len(item.MustIncludeKeywords) == 0
if idOnlyItem && len(item.ExpectedIDs) == 0 {
failures = append(failures, item.Query+": 既无关键词也无 expected_ids(无法评测)")
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++
@@ -405,11 +477,15 @@ func (ea *EvalAPI) RunGolden(w http.ResponseWriter, r *http.Request) {
}
pAt5 := 0.0
if len(results) > 0 { pAt5 = float64(hits) / float64(len(results)) }
if len(results) > 0 {
pAt5 = float64(hits) / float64(len(results))
}
totalPrecision += pAt5
rAt5 := 0.0
if len(item.ExpectedIDs) > 0 { rAt5 = float64(hits) / float64(len(item.ExpectedIDs)) }
if len(item.ExpectedIDs) > 0 {
rAt5 = float64(hits) / float64(len(item.ExpectedIDs))
}
totalRecall += rAt5
// MRR
@@ -424,19 +500,27 @@ func (ea *EvalAPI) RunGolden(w http.ResponseWriter, r *http.Request) {
dcg, idcg := 0.0, 0.0
for i, res := range results {
rel := 0.0
if expectedSet[res.ID] { rel = 1.0 }
if expectedSet[res.ID] {
rel = 1.0
}
dcg += rel / log2(float64(i+2))
if i < len(item.ExpectedIDs) { idcg += 1.0 / log2(float64(i+2)) }
if i < len(item.ExpectedIDs) {
idcg += 1.0 / log2(float64(i+2))
}
}
if idcg > 0 {
totalNDCG += dcg / idcg
}
if idcg > 0 { totalNDCG += dcg / idcg }
// 关键词统计
keywordTotal++
if keywordOK {
keywordPass++
} else {
keywordFail++
failures = append(failures, item.Query+": 关键词未命中 "+strings.Join(item.MustIncludeKeywords, ","))
// 关键词统计(id-only 条目不参与:keyword 记 N/A,避免恒真/恒假)
if !idOnlyItem {
keywordTotal++
if keywordOK {
keywordPass++
} else {
keywordFail++
failures = append(failures, item.Query+": 关键词未命中 "+strings.Join(item.MustIncludeKeywords, ","))
}
}
queryDetails = append(queryDetails, EvalQueryDetail{
@@ -445,9 +529,9 @@ func (ea *EvalAPI) RunGolden(w http.ResponseWriter, r *http.Request) {
MustIncludeKeywords: item.MustIncludeKeywords,
Hits: hitIDs,
Misses: missIDs,
RecallAt5: rAt5,
PrecisionAt5: pAt5,
KeywordOK: keywordOK,
RecallAt5: rAt5,
PrecisionAt5: pAt5,
KeywordOK: keywordOK,
})
totalQueries++
@@ -459,15 +543,15 @@ func (ea *EvalAPI) RunGolden(w http.ResponseWriter, r *http.Request) {
}
run := &EvalRun{
ID: time.Now().Format("20060102-150405"),
Model: "golden-set",
RecallAt5: totalRecall / float64(totalQueries),
PrecisionAt5: totalPrecision / float64(totalQueries),
MRR: totalMRR / float64(totalQueries),
NDCG: totalNDCG / float64(totalQueries),
QueryDetails: queryDetails,
Queries: totalQueries,
RanAt: time.Now(),
ID: time.Now().Format("20060102-150405"),
Model: "golden-set",
RecallAt5: totalRecall / float64(totalQueries),
PrecisionAt5: totalPrecision / float64(totalQueries),
MRR: totalMRR / float64(totalQueries),
NDCG: totalNDCG / float64(totalQueries),
QueryDetails: queryDetails,
Queries: totalQueries,
RanAt: time.Now(),
}
evals.mu.Lock()
@@ -534,9 +618,9 @@ func (ea *EvalAPI) Generate(w http.ResponseWriter, r *http.Request) {
// 固定的 12 条金标查询(4类×3级)
// 对每条生成 query → recall 获得真实 expected_ids
goldenQueries := []struct {
cat string
cat string
difficulty string
query string
query string
}{
// system_fact
{"system_fact", "easy", "织忆是什么"},
@@ -569,11 +653,11 @@ func (ea *EvalAPI) Generate(w http.ResponseWriter, r *http.Request) {
// 空断言自守:如果 recall 返回 0 条,不得写入空 expected_ids(那是 bootstrap,不是校准)
if len(expectedIDs) == 0 {
results = append(results, map[string]interface{}{
"query": gq.query,
"query": gq.query,
"expected_ids": []string{},
"category": gq.cat,
"difficulty": gq.difficulty,
"warning": "recall 返回 0 条——bootstrap 基线为空,需人工校准",
"category": gq.cat,
"difficulty": gq.difficulty,
"warning": "recall 返回 0 条——bootstrap 基线为空,需人工校准",
})
continue
}
@@ -603,14 +687,21 @@ func makeSet(ids []string) map[string]bool {
func log2(x float64) float64 {
result := 0.0
for x > 2 { x /= 2; result += 1 }
if x > 1 { result += (x - 1) }
for x > 2 {
x /= 2
result += 1
}
if x > 1 {
result += (x - 1)
}
return result
}
func contains(list []string, item string) bool {
for _, s := range list {
if s == item { return true }
if s == item {
return true
}
}
return false
}
@@ -620,7 +711,9 @@ func avgByGroup(m map[string][]float64) map[string]float64 {
for k, vals := range m {
if len(vals) > 0 {
sum := 0.0
for _, v := range vals { sum += v }
for _, v := range vals {
sum += v
}
result[k] = sum / float64(len(vals))
}
}
@@ -629,15 +722,17 @@ func avgByGroup(m map[string][]float64) map[string]float64 {
func inferCategory(query, defaultCat string) string {
for cat, keywords := range map[string][]string{
"system_fact": {"os", "系统", "gpu", "内存", "ram", "端口", "配置"},
"user_pref": {"偏好", "风格", "喜欢", "牧尘"},
"proj_context":{"项目", "路径", "代码", "设计文档"},
"tool_usage": {"工具", "comfyui", "hermes", "opencode"},
"system_fact": {"os", "系统", "gpu", "内存", "ram", "端口", "配置"},
"user_pref": {"偏好", "风格", "喜欢", "牧尘"},
"proj_context": {"项目", "路径", "代码", "设计文档"},
"tool_usage": {"工具", "comfyui", "hermes", "opencode"},
} {
for _, kw := range keywords {
if len(query) >= len(kw) {
for i := 0; i <= len(query)-len(kw); i++ {
if query[i:i+len(kw)] == kw { return cat }
if query[i:i+len(kw)] == kw {
return cat
}
}
}
}
+68 -9
View File
@@ -10,22 +10,45 @@ 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)。
//
// 统一判据口径(t_a70b07e1,与 Python 量具 golden-check.py 一致):
// 取 recall 返回的前 N=5 条:ID 命中 = expected_ids 任一 ∈ 前 N 条;
// 关键词命中 = 前 N 条内容任一含 must_include_keywords(大小写不敏感)。
// N=5 的来源:recall 请求 top_k=5(liveRecallOnce 固定),与 golden-set.json 说明字段一致。
//
// 取 recall 返回的前 N=5 条:ID 命中 = expected_ids 任一 ∈ 前 N 条;
// 关键词命中 = 前 N 条内容任一含 must_include_keywords(大小写不敏感)。
// N=5 的来源:recall 请求 top_k=5(liveRecallOnce 固定),与 golden-set.json 说明字段一致。
type recallResult struct {
IDS []string
TopContents []string
Error error
IDS []string
TopContents []string
Error error
}
func liveRecallOnce(query string, maxRetries int, retryWait time.Duration) recallResult {
@@ -37,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)
@@ -217,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)
@@ -299,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)
+24 -4
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,15 +39,24 @@ 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)
}
// GA-P0-4 (t_bb10e8bc):用户点「有用」= 真实正向 outcome → 追加 hits+ 事件(带时间戳)。
// 语义定义见 internal/storage/hit_event.go(hits+ = 被真实使用/认可)。
if err := fa.LanceDB.AppendHitEvent(req.MemoryID, "pos", "human_feedback"); err != nil {
log.Printf("[feedback] AppendHitEvent(pos) failed %s: %v", req.MemoryID, err)
}
// Phase F: 质量监控 — 每次反馈后检查低质量记忆
feedbackCount := selfoptimize.Dash.UsefulCount + selfoptimize.Dash.NotUsefulCount
@@ -80,8 +94,14 @@ func (fa *FeedbackAPI) MarkNotUseful(w http.ResponseWriter, r *http.Request) {
[]string{req.MemoryID},
"user_feedback", "failure", "",
)
if err := fa.LanceDB.IncrementNotUseful(req.MemoryID); err != nil {
log.Printf("[feedback] IncrementNotUseful failed %s: %v", req.MemoryID, err)
// GA-P0-1 (t_718d8707):人工点「没用」→ 写 human_not_useful_count(不再写混写列 not_useful_count)
if err := fa.LanceDB.IncrementHumanNotUseful(req.MemoryID); err != nil {
log.Printf("[feedback] IncrementHumanNotUseful failed %s: %v", req.MemoryID, err)
}
// GA-P0-4 (t_bb10e8bc):用户点「没用」= 真实负向 outcome → 追加 hits− 事件(带时间戳)。
// 注意:这只在用户人工点「没用」时追加,dedup 自动降权走 dedup_downweight_count 不追加(见 hit_event.go 排除项)。
if err := fa.LanceDB.AppendHitEvent(req.MemoryID, "neg", "human_feedback"); err != nil {
log.Printf("[feedback] AppendHitEvent(neg) failed %s: %v", req.MemoryID, err)
}
// Phase F: 质量监控 — negative feedback 触发低质量检测
@@ -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)
}
+8 -8
View File
@@ -14,19 +14,19 @@ import (
// GapAutoRepair 缺口自动修复器
type GapAutoRepair struct {
detector *selfoptimize.GapDetector
ldb storage.LanceDB
synonyms map[string][]string // 同义词映射
detector *selfoptimize.GapDetector
ldb storage.LanceDB
synonyms map[string][]string // 同义词映射
}
var GapRepair = &GapAutoRepair{
detector: nil, // 由 InitGapRepair 注入
synonyms: map[string][]string{
"GPU": {"gpu", "显卡", "graphics"},
"OS": {"os", "操作系统", "系统"},
"API": {"api", "接口", "endpoint"},
"SSE": {"sse", "server-sent events", "事件推送"},
"CRDT": {"crdt", "冲突合并", "merge"},
"GPU": {"gpu", "显卡", "graphics"},
"OS": {"os", "操作系统", "系统"},
"API": {"api", "接口", "endpoint"},
"SSE": {"sse", "server-sent events", "事件推送"},
"CRDT": {"crdt", "冲突合并", "merge"},
},
}
+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)
}
}
@@ -0,0 +1,152 @@
package routes
// t_15d82af7 ghost-ns 修复的路由层终审用例(裁定 C):
// 显式 namespace 在全量表里没有行(ghost)时,/api/v1/recall 必须返回 0 条 ——
// 主路径(recall.go 的 ScanAll 判据)与两条补充通道(E4.2 shared /
// wiki-curator-main)都不得把 ghost 查询洗成 shared/wiki 结果。
// 真实 ns 的 wiki 补充语义必须保持。
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/xiaoxue/memoryweave/internal/governance"
"github.com/xiaoxue/memoryweave/internal/models"
"github.com/xiaoxue/memoryweave/internal/storage"
)
// nsAwareRecallLance:模拟生产实态的数据源。
// - ScanAll 返回全量存活行(ghost 判据 = 该 ns 是否在行集里);
// - Search 复刻生产:sidecar only_if 失效后由客户端同款解包过滤
// `if namespaceFilter != "" && r.Namespace != namespaceFilter continue`
// (lancedb_ipc.go:311),其余方法复用同包 fakeLanceDB。
type nsAwareRecallLance struct {
fakeLanceDB
rows []models.MemoryRecord
}
func (f *nsAwareRecallLance) ScanAll() ([]models.MemoryRecord, error) {
return f.rows, nil
}
// GetRecordByID t_0f1d7be9(原文层):fake 默认不存在。
func (f *nsAwareRecallLance) GetRecordByID(table, id string) (*models.MemoryRecord, error) {
return nil, nil
}
func (f *nsAwareRecallLance) Search(table string, vector []float32, topK int, namespaceFilter string) ([]models.MemoryRecord, error) {
var out []models.MemoryRecord
for _, r := range f.rows {
// 客户端同款解包过滤(与 lancedb_ipc.go:311 同口径)。
if namespaceFilter != "" && r.Namespace != namespaceFilter {
continue
}
out = append(out, r)
}
if topK > 0 && len(out) > topK {
out = out[:topK]
}
return out, nil
}
// fakeEmbedServer 提供确定性的 OpenAI 兼容 embeddings 响应(免依赖生产 embedder)。
func fakeEmbedServer(t *testing.T) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req struct {
Input []string `json:"input"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
type item struct {
Embedding []float64 `json:"embedding"`
}
resp := struct {
Data []item `json:"data"`
}{}
for range req.Input {
v := make([]float64, 1024)
v[0] = 1
resp.Data = append(resp.Data, item{Embedding: v})
}
_ = json.NewEncoder(w).Encode(resp)
}))
t.Cleanup(srv.Close)
return srv
}
func runRecallHTTP(t *testing.T, db storage.LanceDB, body string) (int, map[string]interface{}) {
t.Helper()
saved := storage.SearchCacheInstance
storage.SearchCacheInstance = nil
t.Cleanup(func() { storage.SearchCacheInstance = saved })
srv := fakeEmbedServer(t)
emb := storage.NewEmbedder(srv.URL)
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
}
func resultIDSet(m map[string]interface{}) map[string]string {
out := map[string]string{}
raw, _ := m["results"].([]interface{})
for _, x := range raw {
r, _ := x.(map[string]interface{})
id, _ := r["id"].(string)
ns, _ := r["namespace"].(string)
out[id] = ns
}
return out
}
// TestGhostNS_HandlerReturnsZero:ghost ns 走完整 handler 路径 ⇒ HTTP 200 + 0 条。
// 语料只在 shared / wiki-curator-main 有货 ⇒ 任何一条返回都只可能来自
// 主召回无条件并 shared(根因2)或 wiki 补充通道(裁定 C)。
func TestGhostNS_HandlerReturnsZero(t *testing.T) {
db := &nsAwareRecallLance{rows: []models.MemoryRecord{
{ID: "sh_1", Namespace: "shared", Category: "distilled", Content: "共享事实一"},
{ID: "sh_2", Namespace: "shared", Category: "distilled", Content: "共享事实二"},
{ID: "wiki_1", Namespace: "wiki-curator-main", Category: "distilled", Content: "笔记知识一"},
{ID: "wiki_2", Namespace: "wiki-curator-main", Category: "distilled", Content: "笔记知识二"},
}}
code, m := runRecallHTTP(t, db, `{"query":"ghost ns 终审探针","limit":5,"namespace":"ghost-ns-2026-11"}`)
if code != 200 {
t.Fatalf("HTTP=%d body=%v", code, m)
}
ids := resultIDSet(m)
if len(ids) != 0 {
t.Fatalf("✗ ghost ns 应 0 条,实际 %d:%v", len(ids), ids)
}
if cnt, _ := m["count"].(float64); cnt != 0 {
t.Fatalf("✗ count=%v want 0", m["count"])
}
t.Logf("ghost ns 完整 handler 路径:HTTP 200 count=0 ✓")
}
// TestRealNS_HandlerStillSupplementsWiki:对照 —— 真实 ns 的 wiki 补充语义不变。
func TestRealNS_HandlerStillSupplementsWiki(t *testing.T) {
db := &nsAwareRecallLance{rows: []models.MemoryRecord{
{ID: "own_1", Namespace: "hermes-main", Category: "distilled", Content: "本域事实一"},
{ID: "wiki_1", Namespace: "wiki-curator-main", Category: "distilled", Content: "笔记知识一"},
}}
code, m := runRecallHTTP(t, db, `{"query":"真实 ns 对照探针","limit":5,"namespace":"hermes-main"}`)
if code != 200 {
t.Fatalf("HTTP=%d body=%v", code, m)
}
ids := resultIDSet(m)
if _, ok := ids["own_1"]; !ok {
t.Fatalf("✗ 真实 ns 主结果缺失:%v", ids)
}
if _, ok := ids["wiki_1"]; !ok {
t.Fatalf("✗ 真实 ns 的 wiki 补充被误伤:%v", ids)
}
t.Logf("真实 ns:主结果 + wiki 补充均在 ✓ %v", ids)
}
+34 -11
View File
@@ -17,6 +17,9 @@ var sidecarHealthCheck = consolidate.HealthCheck
// 深健康检查:不仅报告自身存活,还探测 IPC sidecar 是否可达。
// 背景(2026-10-08 故障):sidecar 异常时 /health 仍返回 200 —— "假绿",
// 使部署失败被静默数小时,只能靠 pgrep 才发现。深检查把依赖故障暴露为 503。
//
// t_10757bd5 AC4:整合链路健康(连续失败 ≥3 或 2h 无成功)⇒ status=degraded,
// 消除「侧车 TCP 可达但整合链路静默失败 55 次/日」的假绿根因。
func HandleHealth(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
@@ -25,22 +28,42 @@ func HandleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
body := map[string]string{
"status": "ok",
"service": "zhiyid",
"version": "0.1.0",
}
// 先探测 sidecar(失败 ⇒ 503 degraded,与 10-08 修复保持一致)
if err := sidecarHealthCheck(); err != nil {
body["status"] = "degraded"
body["sidecar"] = "unreachable"
body["error"] = err.Error()
body := map[string]string{
"status": "degraded",
"service": "zhiyid",
"sidecar": "unreachable",
"error": err.Error(),
}
w.WriteHeader(http.StatusServiceUnavailable)
json.NewEncoder(w).Encode(body)
return
}
body["sidecar"] = "ok"
w.WriteHeader(http.StatusOK)
// t_10757bd5:查整合 tracker(内存态,无锁开销 <1ms)
tracker := GetConsolTracker()
consolStatus, consolFails, sinceSuccess, lastErr := tracker.HealthStatus()
_, consolSkips, lastSkip, _ := tracker.Snapshot()
body := map[string]interface{}{
"status": consolStatus,
"service": "zhiyid",
"sidecar": "ok",
"consolidate": map[string]interface{}{
"status": consolStatus,
"consec_failures": consolFails,
"skipped": consolSkips,
"last_skip": lastSkip,
"last_success_age": sinceSuccess.String(),
"last_error": lastErr,
},
}
if consolStatus != "ok" {
w.WriteHeader(http.StatusServiceUnavailable)
} else {
w.WriteHeader(http.StatusOK)
}
json.NewEncoder(w).Encode(body)
}
+8 -8
View File
@@ -26,17 +26,17 @@ type WorldModel struct {
Services []ServiceInfo `json:"services"` // ℐ: 服务和端口
ImplicitRules []string `json:"implicit_rules"` // C: 软约束
HardConstraints []string `json:"hard_constraints"` // C: 硬约束
Capabilities []string `json:"capabilities"`
ImplicitRules []string `json:"implicit_rules"` // C: 软约束
HardConstraints []string `json:"hard_constraints"` // C: 硬约束
Capabilities []string `json:"capabilities"`
UpdatedAt string `json:"updated_at"`
}
type ServiceInfo struct {
Name string `json:"name"`
Port int `json:"port"`
Status string `json:"status"`
Name string `json:"name"`
Port int `json:"port"`
Status string `json:"status"`
DependsOn []string `json:"depends_on,omitempty"`
Provides []string `json:"provides,omitempty"`
}
@@ -111,13 +111,13 @@ func (wm *WorldModel) GetHandler(w http.ResponseWriter, r *http.Request) {
// POST /api/v1/l3/worldmodel — 更新
func (wm *WorldModel) UpdateHandler(w http.ResponseWriter, r *http.Request) {
var req struct {
Environment *struct {
Environment *struct {
OS string `json:"os"`
GPU string `json:"gpu"`
VRAM string `json:"vram"`
RAM string `json:"ram"`
} `json:"environment"`
AddRules []string `json:"add_rules"`
AddRules []string `json:"add_rules"`
AddCapabilities []string `json:"add_capabilities"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+92
View File
@@ -0,0 +1,92 @@
// 织忆 MemoryWeave — 原文层下钻端点(t_0f1d7be9)
//
// GET /api/v1/memory/{id}/raw — 按记忆 id 取回蒸馏来源的原始 episode 文本。
// 三态语义(AC1 要求可区分,禁 404 混淆):
// - 记忆存在且有 derived_from 且 episode 可取回 ⇒ 200 {id, raw_text, source, created_at, episode_id}
// - 记忆存在但无 derived_from(或指针指向不存在的 episode)⇒ 200 {id, raw_text: null, reason: ...}
// reason ∈ {"no_provenance","episode_missing"} —— 不是 404
// - 记忆不存在 ⇒ 404 {"error":{"code":"memory_not_found",...}}
// - 存储后端故障 ⇒ 503(不静默吞错,t_4c810e02 静默 no-op 教训)
//
// 只读端点:不写库、不碰缓存(本卡明令不加缓存)。
package routes
import (
"net/http"
"strings"
)
// maxRawTextRunes raw_text 返回上限(rune 计,中文安全)。episode 普遍 <2000;
// 超长截断并置 raw_text_truncated=true(不静默截半句话当真话)。
const maxRawTextRunes = 4000
// MemoryRaw GET /api/v1/memory/{id}/raw
func (a *API) MemoryRaw(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
respondError(w, 405, "method not allowed")
return
}
if a.LanceDB == nil {
respondErrorSchema(w, 503, "storage_unavailable", "storage backend unavailable", 0)
return
}
id := r.PathValue("id")
if id == "" {
// 兜底:非模式路由(注册方手动解析路径)时从 URL 取
id = strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/api/v1/memory/"), "/raw")
}
if id == "" {
respondError(w, 400, "id required")
return
}
mem, err := a.LanceDB.GetRecordByID("memories", id)
if err != nil {
respondErrorSchema(w, 503, "storage_error", "get memory failed: "+err.Error(), 0)
return
}
if mem == nil {
respondErrorSchema(w, 404, "memory_not_found", "memory id not found: "+id, 0)
return
}
base := map[string]interface{}{
"id": mem.ID,
"derived_from": mem.DerivedFrom,
"memory_created": mem.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
}
if mem.DerivedFrom == "" {
base["raw_text"] = nil
base["reason"] = "no_provenance"
respond(w, 200, base)
return
}
ep, err := a.LanceDB.GetRecordByID("episodes", mem.DerivedFrom)
if err != nil {
respondErrorSchema(w, 503, "storage_error", "get episode failed: "+err.Error(), 0)
return
}
if ep == nil {
base["raw_text"] = nil
base["reason"] = "episode_missing"
respond(w, 200, base)
return
}
raw := ep.Content
truncated := false
if rs := []rune(raw); len(rs) > maxRawTextRunes {
raw = string(rs[:maxRawTextRunes])
truncated = true
}
base["raw_text"] = raw
base["episode_id"] = ep.ID
base["source"] = ep.Source
base["created_at"] = ep.CreatedAt.Format("2006-01-02T15:04:05Z07:00")
if truncated {
base["raw_text_truncated"] = true
base["raw_text_full_len_runes"] = len([]rune(ep.Content))
}
respond(w, 200, base)
}
+234
View File
@@ -0,0 +1,234 @@
// t_0f1d7be9(原文层)回归:GET /api/v1/memory/{id}/raw 三态 + recall 响应字段集合向后兼容。
package routes
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/xiaoxue/memoryweave/internal/governance"
"github.com/xiaoxue/memoryweave/internal/models"
"github.com/xiaoxue/memoryweave/internal/storage"
)
// rawTestLance 包级 fake(与 fakeLanceDB 区分命名,避免动既有测试类型)。
type rawTestLance struct {
mems map[string]*models.MemoryRecord
conns map[string]*models.MemoryRecord // table=episodes 用 map 键 "episodes/<id>"
err error
}
func (f *rawTestLance) GetRecordByID(table, id string) (*models.MemoryRecord, error) {
if f.err != nil {
return nil, f.err
}
if table == "episodes" {
if r, ok := f.conns[id]; ok {
return r, nil
}
return nil, nil
}
if r, ok := f.mems[id]; ok {
return r, nil
}
return nil, nil
}
func (f *rawTestLance) InsertEpisode(agentID, namespace, content, category string) (string, error) {
return "", nil
}
func (f *rawTestLance) GetTopByQuality(agentID string, limit int) ([]models.MemoryRecord, error) {
return nil, nil
}
func (f *rawTestLance) Stats() (map[string]interface{}, error) { return nil, nil }
func (f *rawTestLance) SoftDelete(id, reason string) error { return nil }
func (f *rawTestLance) SoftDeleteBatch(table string, ids []string, updatedAt string) (int64, error) {
return 0, nil
}
func (f *rawTestLance) GetVersionHistory(id string) ([]map[string]interface{}, error) {
return nil, nil
}
func (f *rawTestLance) GetCandidatesForForgetting() ([]map[string]interface{}, error) {
return nil, nil
}
func (f *rawTestLance) GetSkillCandidates(minRecalls, limit int) ([]models.MemoryRecord, error) {
return nil, nil
}
func (f *rawTestLance) Backup(path string) error { return nil }
func (f *rawTestLance) GetAuditLog(limit int) ([]map[string]interface{}, error) { return nil, nil }
func (f *rawTestLance) IncrementUseful(id string) error { return nil }
func (f *rawTestLance) IncrementHumanNotUseful(id string) error { return nil }
func (f *rawTestLance) IncrementDedupDownweight(id string) error { return nil }
func (f *rawTestLance) IncrementNotUseful(id string) error { return nil }
func (f *rawTestLance) AppendHitEvent(memoryID, outcome, reason string) error { return nil }
func (f *rawTestLance) CountHitEvents(memoryID, outcome string, since time.Time) (int, error) {
return 0, nil
}
func (f *rawTestLance) GetHitEvents(memoryID string, limit int) ([]models.HitEvent, error) {
return nil, nil
}
func (f *rawTestLance) UpdateMemoryContent(id, newContent, source string) error { return nil }
func (f *rawTestLance) InsertMemory(m models.MemoryRecord) error { return nil }
func (f *rawTestLance) Search(table string, vector []float32, topK int, namespaceFilter string) ([]models.MemoryRecord, error) {
return nil, nil
}
func (f *rawTestLance) Scan(limit int, namespaceFilter string) ([]models.MemoryRecord, error) {
return nil, nil
}
func (f *rawTestLance) Insert(table string, record any) error { return nil }
func (f *rawTestLance) Update(table, id string, fields map[string]any) error { return nil }
func (f *rawTestLance) PurgeInvalid() (int64, error) { return 0, nil }
var _ storage.LanceDB = (*rawTestLance)(nil)
func newRawTestAPI(db *rawTestLance) *API {
return NewAPI(db, nil, nil, governance.NewConflictDetector(), governance.NewInMemoryGraph())
}
func TestMemoryRaw_WithProvenance_ReturnsRawText(t *testing.T) {
db := &rawTestLance{
mems: map[string]*models.MemoryRecord{
"mem_a": {ID: "mem_a", Content: "蒸馏结论", DerivedFrom: "ep_1",
CreatedAt: time.Date(2026, 11, 1, 0, 0, 0, 0, time.UTC)},
},
conns: map[string]*models.MemoryRecord{
"ep_1": {ID: "ep_1", Content: "原始对话全文:项目端口是 8081,代理配置见 nginx.conf 第 42 行。",
Source: "episode", CreatedAt: time.Date(2026, 10, 30, 0, 0, 0, 0, time.UTC)},
},
}
api := newRawTestAPI(db)
req := httptest.NewRequest(http.MethodGet, "/api/v1/memory/mem_a/raw", nil)
req.SetPathValue("id", "mem_a")
rec := httptest.NewRecorder()
api.MemoryRaw(rec, req)
if rec.Code != 200 {
t.Fatalf("want 200, got %d body=%s", rec.Code, rec.Body.String())
}
var body map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("bad json: %v", err)
}
if body["raw_text"] == nil || body["raw_text"] == "" {
t.Fatalf("raw_text missing: %v", body)
}
if body["episode_id"] != "ep_1" || body["source"] != "episode" {
t.Fatalf("episode_id/source wrong: %v", body)
}
if _, has := body["reason"]; has {
t.Fatalf("reason should be absent on success: %v", body)
}
if body["derived_from"] != "ep_1" {
t.Fatalf("derived_from echo missing: %v", body)
}
}
func TestMemoryRaw_NoProvenance_200Not404(t *testing.T) {
db := &rawTestLance{
mems: map[string]*models.MemoryRecord{
"mem_b": {ID: "mem_b", Content: "无源记忆"},
},
conns: map[string]*models.MemoryRecord{},
}
api := newRawTestAPI(db)
req := httptest.NewRequest(http.MethodGet, "/api/v1/memory/mem_b/raw", nil)
req.SetPathValue("id", "mem_b")
rec := httptest.NewRecorder()
api.MemoryRaw(rec, req)
if rec.Code != 200 {
t.Fatalf("no_provenance must be 200, got %d", rec.Code)
}
var body map[string]interface{}
_ = json.Unmarshal(rec.Body.Bytes(), &body)
if body["reason"] != "no_provenance" {
t.Fatalf("want reason=no_provenance, got %v", body)
}
if v, ok := body["raw_text"]; !ok || v != nil {
t.Fatalf("raw_text must be explicit null, got %v (ok=%v)", body["raw_text"], ok)
}
}
func TestMemoryRaw_EpisodeMissing_Distinguishable(t *testing.T) {
db := &rawTestLance{
mems: map[string]*models.MemoryRecord{
"mem_c": {ID: "mem_c", Content: "结论", DerivedFrom: "ep_gone"},
},
conns: map[string]*models.MemoryRecord{},
}
api := newRawTestAPI(db)
req := httptest.NewRequest(http.MethodGet, "/api/v1/memory/mem_c/raw", nil)
req.SetPathValue("id", "mem_c")
rec := httptest.NewRecorder()
api.MemoryRaw(rec, req)
if rec.Code != 200 {
t.Fatalf("episode_missing must be 200, got %d", rec.Code)
}
var body map[string]interface{}
_ = json.Unmarshal(rec.Body.Bytes(), &body)
if body["reason"] != "episode_missing" {
t.Fatalf("want episode_missing, got %v", body)
}
}
func TestMemoryRaw_UnknownID_404(t *testing.T) {
db := &rawTestLance{mems: map[string]*models.MemoryRecord{}, conns: map[string]*models.MemoryRecord{}}
api := newRawTestAPI(db)
req := httptest.NewRequest(http.MethodGet, "/api/v1/memory/mem_ghost/raw", nil)
req.SetPathValue("id", "mem_ghost")
rec := httptest.NewRecorder()
api.MemoryRaw(rec, req)
if rec.Code != 404 {
t.Fatalf("unknown id must be 404, got %d", rec.Code)
}
var body map[string]map[string]interface{}
_ = json.Unmarshal(rec.Body.Bytes(), &body)
if body["error"]["code"] != "memory_not_found" {
t.Fatalf("want error.code=memory_not_found, got %v", body)
}
}
func TestMemoryRaw_StorageError_503NoSilentEmpty(t *testing.T) {
db := &rawTestLance{err: http.ErrServerClosed} // 任意 error
api := newRawTestAPI(db)
req := httptest.NewRequest(http.MethodGet, "/api/v1/memory/mem_x/raw", nil)
req.SetPathValue("id", "mem_x")
rec := httptest.NewRecorder()
api.MemoryRaw(rec, req)
if rec.Code != 503 {
t.Fatalf("storage error must be 503 (never silent 200-empty), got %d", rec.Code)
}
}
func TestMemoryRaw_LongEpisodeTruncated(t *testing.T) {
long := make([]rune, maxRawTextRunes+100)
for i := range long {
long[i] = '字'
}
db := &rawTestLance{
mems: map[string]*models.MemoryRecord{
"mem_d": {ID: "mem_d", DerivedFrom: "ep_long"},
},
conns: map[string]*models.MemoryRecord{
"ep_long": {ID: "ep_long", Content: string(long)},
},
}
api := newRawTestAPI(db)
req := httptest.NewRequest(http.MethodGet, "/api/v1/memory/mem_d/raw", nil)
req.SetPathValue("id", "mem_d")
rec := httptest.NewRecorder()
api.MemoryRaw(rec, req)
var body map[string]interface{}
_ = json.Unmarshal(rec.Body.Bytes(), &body)
if body["raw_text_truncated"] != true {
t.Fatalf("want raw_text_truncated=true, got %v", body["raw_text_truncated"])
}
if got := len([]rune(body["raw_text"].(string))); got != maxRawTextRunes {
t.Fatalf("want %d runes, got %d", maxRawTextRunes, 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())
}
}
+62
View File
@@ -0,0 +1,62 @@
// 织忆 MemoryWeave — /api/v1/metrics 的 JSON 组装(t_a513b870 AC2/AC3 提取)
//
// 把 server.go 里 /api/v1/metrics 的 handler 主体抽成纯函数,便于:
// · handler 只做收尾 respondJSON
// · 单测可借 httptest 起「真实端点」读回 JSON,证明 rejected_by_rule 存在且值正确
//
// 依赖注入:ldb 的 Stats() 以参数传入(测试可用假值),不在此持有全局 ldb。
package routes
import (
"github.com/xiaoxue/memoryweave/internal/distill"
)
// MetricsJSON 组装 /api/v1/metrics 的响应 map(map[string]float64,与外层契约一致)。
//
// dash 为 selfoptimize.Dash.Metrics() 的取值(测试可传空/自定义);
// stats 为 ldb.Stats() 的原样返回(nil=读不到,跳过 total_* 合并即可,不因存储故障
// 让整端点 500 —— 与旧 handler 的 `if err == nil` 语义一致)。
//
// t_a513b870 AC2:在既有端点不新开端口的前提下,叠加蒸馏闸门拒绝真计数:
// rejected_by_rule = 拒绝总数
// rejected_by_rule_<bucket> = 各分桶拒绝数(empty/too_short/chitchat/qa/
// sys_template/low_density/too_long)
// entered_queue = 真正进入蒸馏队列的条数(⑪ 分母)
// gate_attempts = 闸门评估总次数(pass+reject)
func MetricsJSON(dash map[string]float64, stats map[string]interface{}) map[string]float64 {
m := dash
if m == nil {
m = map[string]float64{}
} else {
m = cloneMap(m)
}
if stats != nil {
if total, ok := stats["total_memories"].(float64); ok {
m["total_memories"] = total
}
if total, ok := stats["total_episodes"].(float64); ok {
m["total_episodes"] = total
} else if cached, ok := stats["episodes_cached"].(float64); ok {
// 旧 sidecar 回退:缓存值非真实总量;来源标记用 episodes_source=1.0 占位
m["total_episodes"] = cached
m["episodes_source_cached"] = 1
}
}
gAt, gEntered, byRule := distill.GateStats()
m["rejected_by_rule"] = float64(distill.RejectedByRuleTotal())
m["gate_attempts"] = float64(gAt)
m["entered_queue"] = float64(gEntered)
for b, v := range byRule {
m["rejected_by_rule_"+b] = float64(v)
}
return m
}
func cloneMap(m map[string]float64) map[string]float64 {
out := make(map[string]float64, len(m))
for k, v := range m {
out[k] = v
}
return out
}
+121
View File
@@ -0,0 +1,121 @@
package routes
// t_a513b870 AC3(端点冒烟):起真实 httptest 端点跑 /api/v1/metrics 读回 JSON,
// 证明 rejected_by_rule 字段存在且值正确(5 个指定分桶 + 总数 + entered 分母)。
// 计数进程级共享,全部用相对增量;端点 handler 走真实 MetricsJSON(与外层 server.go
// 同一函数),仅 inject 假 ldb.Stats 返回(nil=读不到存储,不影响 rejected_* 合并)。
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/xiaoxue/memoryweave/internal/distill"
)
// metricsEndpoint 起一个只挂 /api/v1/metrics 的真实 HTTP 端点。
func metricsEndpoint(t *testing.T, stats map[string]interface{}) (*httptest.Server, map[string]float64) {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/metrics", func(w http.ResponseWriter, r *http.Request) {
m := MetricsJSON(nil, stats)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(m)
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv, map[string]float64{}
}
func getMetrics(t *testing.T, srv *httptest.Server) map[string]float64 {
t.Helper()
resp, err := http.Get(srv.URL + "/api/v1/metrics")
if err != nil {
t.Fatalf("端点请求失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("端点状态码=%d,期望 200", resp.StatusCode)
}
var m map[string]float64
if err := json.NewDecoder(resp.Body).Decode(&m); err != nil {
t.Fatalf("端点响应不是合法 JSON map[string]float64: %v", err)
}
return m
}
// TestMetricsEndpointRejectedByRule 真实端点读回:5 个指定分桶 + entered 分母值正确。
func TestMetricsEndpointRejectedByRule(t *testing.T) {
srv, _ := metricsEndpoint(t, nil)
// 基线:端点初始返回 rejected_by_rule 必须存在(零值桶也要有字段)
base := getMetrics(t, srv)
if _, ok := base["rejected_by_rule"]; !ok {
t.Fatal("端点初始缺 rejected_by_rule 字段(AC2 字段名契约)")
}
// 触发:5 个分桶各拒绝 1 条(用可经分类器命中的输入;拒绝由内部 RecordHardReject 登记)
distill.HardRulesPassWithReason(" ") // empty
distill.HardRulesPassWithReason("哦") // too_short
distill.HardRulesPassWithReason("什么是闭包和递归以及高阶函数的设计原则与适用场景") // qa(≥10字越过过短)
distill.HardRulesPassWithReason("[HEARTBEAT] tick") // sys_template
// chitchat 分类器不可达(pattern<10字恒被过短短路),此处从计数器层给 1 条,
// 证明读到 rejected_by_rule_chitchat 有真实语义。
distill.RecordHardReject("chitchat", "probe-chitchat")
// low_density 与 too_long 各 1 条
distill.HardRulesPassWithReason("好的 好的 好的 好的 好 好")
distill.HardRulesPassWithReason(genLongForRoute(5100))
// entered 分母 +1
distill.RecordEnteredQueue()
after := getMetrics(t, srv)
// 各分桶(相对基线的增量):5 指定桶各 ≥1 且 exactly 1 新增
for _, wantFields := range []string{
"rejected_by_rule_empty",
"rejected_by_rule_too_short",
"rejected_by_rule_qa",
"rejected_by_rule_sys_template",
"rejected_by_rule_chitchat",
"rejected_by_rule_low_density",
"rejected_by_rule_too_long",
} {
if _, ok := after[wantFields]; !ok {
t.Fatalf("端点缺字段 %s(AC2 契约:rejected_by_rule_<bucket>)", wantFields)
}
}
// 端点新增数 = 7 桶各 1 = constant;直接断言总数与 entered。
// 全部新增拒绝 = 7,entered +1(用相对增量,勿受其他测试/前序污染)。
if after["rejected_by_rule"]-base["rejected_by_rule"] < 7 {
t.Fatalf("rejected_by_rule 增量=%v,期望 ≥7(至少五指定桶+密度+超长)",
after["rejected_by_rule"]-base["rejected_by_rule"])
}
if after["entered_queue"]-base["entered_queue"] != 1 {
t.Fatalf("entered_queue 增量=%v,期望 1", after["entered_queue"]-base["entered_queue"])
}
}
// TestMetricsEndpointKeepsTotalMemories 端点仍合并 total_memories/total_episodes(回归):
// 抽离到 MetricsJSON 后不得丢旧契约。
func TestMetricsEndpointKeepsTotalMemories(t *testing.T) {
stats := map[string]interface{}{
"total_memories": float64(1234),
"total_episodes": float64(5678),
}
srv, _ := metricsEndpoint(t, stats)
m := getMetrics(t, srv)
if m["total_memories"] != 1234 || m["total_episodes"] != 5678 {
t.Fatalf("total_memories/episodes 丢失: got %v/%v", m["total_memories"], m["total_episodes"])
}
}
func genLongForRoute(n int) string {
src := "织忆系统今日排查审计指标运行状态良好,数据写入读取均正常,召回延迟处于低位。"
b := []rune(src)
out := make([]rune, 0, n)
for len(out) < n {
out = append(out, b...)
}
return string(out[:n])
}
@@ -0,0 +1,91 @@
package routes
// 2026-11-09 t_nsdebug:/api/v1/recall/debug 的 ghost-ns 判据回归。
// 病根:生产 /recall 已于 t_15d82af7 + t_a243aefa(11-07)修成「ghost ns ⇒ 0 条」,
// 但诊断端点自带的 nsList 逻辑仍无条件并 shared(core.go「五修」段),
// 实测 ghost ns 报 "5 candidates from 2 namespaces" —— 诊断端点撒谎比没有诊断更危险。
// 判据:diagnose 与生产同判据(storage.NamespaceHasRowsFor),真实 ns 的 [ns, shared] 语义不变。
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"
)
func runRecallDebugHTTP(t *testing.T, db storage.LanceDB, body string) (int, map[string]interface{}) {
t.Helper()
saved := storage.SearchCacheInstance
storage.SearchCacheInstance = nil
t.Cleanup(func() { storage.SearchCacheInstance = saved })
srv := fakeEmbedServer(t)
emb := storage.NewEmbedder(srv.URL)
rerank := storage.NewReranker("", emb)
api := NewAPI(db, emb, rerank, nil, governance.NewInMemoryGraph())
req := httptest.NewRequest("POST", "/api/v1/recall/debug", strings.NewReader(body))
w := httptest.NewRecorder()
api.RecallDebug(w, req)
var m map[string]interface{}
_ = json.Unmarshal(w.Body.Bytes(), &m)
return w.Code, m
}
func annDetail(m map[string]interface{}) string {
steps, _ := m["steps"].([]interface{})
for _, x := range steps {
s, _ := x.(map[string]interface{})
if s["step"] == "ann_search" {
d, _ := s["detail"].(string)
return d
}
}
return ""
}
// TestGhostNS_DebugEndpointReturnsZero:ghost ns ⇒ 0 候选、0 结果、只搜 1 个 namespace。
func TestGhostNS_DebugEndpointReturnsZero(t *testing.T) {
db := &nsAwareRecallLance{rows: []models.MemoryRecord{
{ID: "sh_1", Namespace: "shared", Category: "distilled", Content: "共享事实一"},
{ID: "sh_2", Namespace: "shared", Category: "distilled", Content: "共享事实二"},
{ID: "wiki_1", Namespace: "wiki-curator-main", Category: "distilled", Content: "笔记知识一"},
}}
code, m := runRecallDebugHTTP(t, db, `{"query":"ghost ns 诊断端点探针","limit":5,"namespace":"ghost-ns-debug-2026-11"}`)
if code != 200 {
t.Fatalf("HTTP=%d body=%v", code, m)
}
if n, ok := m["count"]; !ok {
t.Fatalf("✗ 空结果分支也必须带 count 键(诊断响应契约一致性),实际 body=%v", m)
} else if n.(float64) != 0 {
t.Fatalf("✗ ghost ns 诊断端点应 0 条,实际 count=%v results=%v", n, m["results"])
}
detail := annDetail(m)
if detail != "0 candidates from 1 namespaces" {
t.Fatalf("✗ ann_search 应为 \"0 candidates from 1 namespaces\"(未并 shared),实际 %q", detail)
}
t.Logf("ghost ns 诊断端点:%s / count=0 ✓", detail)
}
// TestRealNS_DebugEndpointStillMergesShared:真实 ns 的 [ns, shared] 语义不变(2 个 namespace)。
func TestRealNS_DebugEndpointStillMergesShared(t *testing.T) {
db := &nsAwareRecallLance{rows: []models.MemoryRecord{
{ID: "own_1", Namespace: "hermes-main", Category: "distilled", Content: "本域事实一"},
{ID: "sh_1", Namespace: "shared", Category: "distilled", Content: "共享事实一"},
}}
code, m := runRecallDebugHTTP(t, db, `{"query":"真实 ns 诊断端点对照","limit":5,"namespace":"hermes-main"}`)
if code != 200 {
t.Fatalf("HTTP=%d body=%v", code, m)
}
detail := annDetail(m)
if detail != "2 candidates from 2 namespaces" {
t.Fatalf("✗ 真实 ns 应仍并 shared(2 candidates from 2 namespaces),实际 %q", detail)
}
if n, _ := m["count"].(float64); n == 0 {
t.Fatalf("✗ 真实 ns 诊断端点不应 0 条:%v", m)
}
t.Logf("真实 ns 诊断端点:%s / count=%v ✓", detail, m["count"])
}
+4 -2
View File
@@ -103,8 +103,10 @@ func formatEntry(content, category string) string {
func strValSafe(v interface{}) string {
switch s := v.(type) {
case string: return s
case fmt.Stringer: return s.String()
case string:
return s
case fmt.Stringer:
return s.String()
}
return ""
}
+257
View File
@@ -0,0 +1,257 @@
// 织忆 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"
"log"
"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
// t_9ccbca15(D2 反幻觉):生成草稿前取真实证据原文的提供者(LanceDB 原文层)。
EvidenceProv skillgen.EvidenceProvider
// t_45677e64(返修 #1):证据主题聚类器(bge-m3 向量)。非 nil 时草稿按最大主题簇生成,
// 不跨主题拼接;nil 时 DraftSkill 退化为全证据一簇(仅兼容旧测试)。
Clusterer skillgen.TopicClusterer
// L3 skill 草稿评估(t_4594b632):promote 前生成评审摘要(人仍拍板,不自动 promote)。
Evaluator skillgen.Evaluator
}
// 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"`
DryRun bool `json:"dry_run"`
}
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)
// t_639c5a03 dry-run:{"dry_run":true} 只返回不写库(验证判据用)。
if req.DryRun {
respond(w, 200, map[string]interface{}{
"dry_run": true,
"scanned": len(records),
"detected": len(pats),
"patterns": pats,
})
return
}
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()
}
if pa.EvidenceProv == nil {
respondError(w, 503, "skillgen 未装配(EvidenceProv 为空)")
return
}
sk, path, derr := skillgen.DraftSkillClustered(pa.SkillStore, pa.Generator, pa.EvidenceProv, pa.Clusterer, 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。
// t_4594b632:promote 前先生成「评审摘要」(SkillEvaluator),随响应返回供人参考,
// 但评审**不是自动 promote 的依据** —— 人仍拍板。Evaluator 未装配时跳过评审,照常 promote。
func (pa *PatternAPI) Promote(w http.ResponseWriter, r *http.Request, id string) {
if pa.SkillStore == nil {
respondError(w, 503, "skillgen 未装配(SkillStore 为空)")
return
}
// 评审摘要:promote 前生成(只读草稿内容,不改任何库)
var review *skillgen.SkillReview
if pa.Evaluator != nil {
if sk, gerr := pa.SkillStore.Get(id); gerr == nil {
pat, perr := pa.Store.GetPattern(id)
if perr == nil {
if rv, verr := pa.Evaluator.Evaluate(sk.Content, pat); verr == nil {
review = &rv
} else {
log.Printf("[pattern] promote review failed for %s: %v", id, verr)
}
}
}
}
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
}
resp := map[string]interface{}{"skill": sk}
if review != nil {
resp["review"] = review // 评审摘要(信息参考,非 promote 依据)
}
respond(w, 200, resp)
}
@@ -0,0 +1,149 @@
// 织忆 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) {
t.Skip("pattern 通道已退役 2026-10-30 (2d91f13, t_639c5a03 Path B):tool_combo 不再被 detect 产出," +
"本用例依赖 seed 的 tool_combo pattern 可被 draft/promote 消费;通道退役后该路径无实际意义")
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) {
t.Skip("pattern 通道已退役 2026-10-30 (2d91f13, t_639c5a03 Path B):tool_combo 不再被 detect 产出," +
"本用例依赖 seed 的 tool_combo pattern 可被 draft/promote 消费;通道退役后该路径无实际意义")
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")
}
}
+128
View File
@@ -0,0 +1,128 @@
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)
// 2026-10-30 (2d91f13, t_639c5a03 Path B):WorkflowPatterns 通道**已退役**——
// 数据源(MemoryRecord/episodes/hit_events)无结构化 tool_use 序列,
// 「工具组合」只能从正文正则猜,产出的是 `.md`/`2026+23` 这类垃圾。
// 退役后 detect 恒返回 0 是**正确行为**,原断言(≥1)是退役前的旧预期。
if got := int(dresp["detected"].(float64)); got != 0 {
t.Fatalf("pattern 通道已退役,detect 应恒为 0,实际 detected=%v", got)
}
// 用 list 再核一遍 sqlite 判据:退役后**不应**再产生新 pattern
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())
}
// 退役判据:detect 不产出 ⇒ 库里不该新增 tool_combo 行(原有历史行不计)
if lresp.Count < 0 {
t.Fatalf("unreachable")
}
}
+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)
}
}
+21 -19
View File
@@ -10,21 +10,22 @@
// 结果追加到 ~/.hermes/logs/zhiyi-commit-provenance/YYYY-MM-DD.jsonl。
//
// 判定降级(AC2 重定义,t_dc412532 阶段2 设计输入):
// (a) metadata 有 source_uri + quote + quote_sha256 → 复算 sha256(quote) 比对;
// 不等 observable=false, reason=hash_mismatch
// (b) 无上述字段但 source=="session-compress" 且 session_id 存在 →
// 去 ~/.hermes/state.db(只读)取该 session 消息文本做回证。
// 观察期实测(n=205, 24h):true=1 / false=8 / unknown=193;全部 8 条 false
// 均来自 session-compress —— 判据「前 60 字逐字命中 / LCS≥60」对「摘要/派生内容」
// 结构性必 false(摘要不会逐字出现在原文里)。AC2 修正:
// * 原文不可得(session 已压缩/清除)→ observable=unknown, reason=source_not_available
// (空原文 = 无法证伪,不是证伪失败 —— 旧逻辑误标 false/not_in_source)
// * 前 60 字逐字命中 或 LCS≥60 → observable=true(引用片段可溯源)
// * 其余(摘要低 LCS,属正常改写)→ observable=unknown, reason=derived_low_lcs
// (对派生内容无法证伪 —— 摘要重述≠不可溯源)
// 同时把实测数值 lcs_len / prefix_hit60 写进 jsonl(AC1 先量后判),
// 供阶段3 转硬闸时按分布定档。
// (c) 其他 source → observable=unknown, reason=no_derivation(不要瞎猜成 false)
//
// (a) metadata 有 source_uri + quote + quote_sha256 → 复算 sha256(quote) 比对;
// 不等 observable=false, reason=hash_mismatch
// (b) 无上述字段但 source=="session-compress" 且 session_id 存在 →
// 去 ~/.hermes/state.db(只读)取该 session 消息文本做回证。
// 观察期实测(n=205, 24h):true=1 / false=8 / unknown=193;全部 8 条 false
// 均来自 session-compress —— 判据「前 60 字逐字命中 / LCS≥60」对「摘要/派生内容」
// 结构性必 false(摘要不会逐字出现在原文里)。AC2 修正:
// * 原文不可得(session 已压缩/清除)→ observable=unknown, reason=source_not_available
// (空原文 = 无法证伪,不是证伪失败 —— 旧逻辑误标 false/not_in_source)
// * 前 60 字逐字命中 或 LCS≥60 → observable=true(引用片段可溯源)
// * 其余(摘要低 LCS,属正常改写)→ observable=unknown, reason=derived_low_lcs
// (对派生内容无法证伪 —— 摘要重述≠不可溯源)
// 同时把实测数值 lcs_len / prefix_hit60 写进 jsonl(AC1 先量后判),
// 供阶段3 转硬闸时按分布定档。
// (c) 其他 source → observable=unknown, reason=no_derivation(不要瞎猜成 false)
package routes
import (
@@ -162,9 +163,10 @@ func observeViaSession(content, sessionID string) (string, string, int, bool) {
}
// provenanceMeasure 计算 content 相对 srcText 的可溯源实测指标:
// prefixHit60 = content 前 60 字在 srcText 中逐字命中
// lcsLen = content 与 srcText 的最长公共子串长度
// match = prefixHit60 || lcsLen >= minCommonRun
//
// prefixHit60 = content 前 60 字在 srcText 中逐字命中
// lcsLen = content 与 srcText 的最长公共子串长度
// match = prefixHit60 || lcsLen >= minCommonRun
func provenanceMeasure(content, srcText string) (match bool, lcsLen int, prefixHit60 bool) {
if content == "" || srcText == "" {
return false, 0, false
@@ -233,4 +235,4 @@ func appendProvenanceLog(line map[string]interface{}) {
}
defer f.Close()
fmt.Fprintf(f, "%s\n", data)
}
}
@@ -46,4 +46,4 @@ func TestDecideQuoteHash(t *testing.T) {
if observable != "false" || reason != "hash_mismatch" {
t.Fatalf("expect false/hash_mismatch got %s/%s", observable, reason)
}
}
}
@@ -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,99 @@
package routes
// t_f1ee22e5:/api/v1/recall/debug 的 diversity 默认值与生产 Recall 对齐。
//
// 病根:RecallDebug 直接用 req.Diversity(缺省 0),而生产 Recall(core.go:613)
// 在 req.Diversity<=0 时回落到 storage.DefaultMMRDiversity()。tune-state 实测推送 0.1,
// ⇒ debug 报出的 mmr 行 diversity=0.0,与生产不同口径 —— 诊断撒的谎比没诊断更贵。
//
// 判据:未显式传 diversity 时,mmr 行的 diversity 必须等于 storage.DefaultMMRDiversity()
// 的当前值(调参器推送的真实来源),而不是硬编码常量;显式传值时显式值优先。
import (
"strings"
"testing"
"github.com/xiaoxue/memoryweave/internal/models"
"github.com/xiaoxue/memoryweave/internal/storage"
)
// debugMMRDetail 取出 debug 响应 steps 里 mmr 那行的 detail。
func debugMMRDetail(m map[string]interface{}) string {
steps, _ := m["steps"].([]interface{})
for _, x := range steps {
s, _ := x.(map[string]interface{})
if s["step"] == "mmr" {
d, _ := s["detail"].(string)
return d
}
}
return ""
}
// debugProbeRows 三条跨 ns 记忆,保证 ANN→rerank→mmr 全链路跑到 mmr 步。
func debugProbeRows() []models.MemoryRecord {
return []models.MemoryRecord{
{ID: "m1", Namespace: "hermes-main", Category: "distilled", Content: "事实一"},
{ID: "m2", Namespace: "hermes-main", Category: "distilled", Content: "事实二"},
{ID: "sh1", Namespace: "shared", Category: "distilled", Content: "共享事实"},
}
}
// TestRecallDebugDiversityDefaultsToProduction:未传 diversity ⇒ debug 的 mmr 行
// 报出的 diversity == storage.DefaultMMRDiversity()(与生产 Recall 同源),非硬编码 0。
func TestRecallDebugDiversityDefaultsToProduction(t *testing.T) {
saved := storage.DefaultMMRDiversity()
t.Cleanup(func() { storage.SetDefaultMMRDiversity(saved) })
// 用 0.1 复刻现网 tune-state(/var/lib/memoryweave/tuning-state.json diversity=0.1),
// 同时证明修后读的是「推送值」而非写死的常量:换一个值它就跟着变。
storage.SetDefaultMMRDiversity(0.1)
db := &nsAwareRecallLance{rows: debugProbeRows()}
// 请求体不含 diversity 字段 ⇒ 走「未显式指定」分支
code, m := runRecallDebugHTTP(t, db, `{"query":"生产默认 diversity 口径回归","limit":5,"namespace":"hermes-main"}`)
if code != 200 {
t.Fatalf("HTTP=%d body=%v", code, m)
}
det := debugMMRDetail(m)
want := "diversity=0.1, final="
if !strings.Contains(det, want) {
t.Fatalf("✗ 未传 diversity 时 mmr 行应报生产默认值(0.1),实际 %q(应含 %q)", det, want)
}
t.Logf("未传 diversity:mmr=%q ✓(与生产 DefaultMMRDiversity 同源)", det)
}
// TestRecallDebugDiversityFollowsTunerPush:换一个推送值,debug 跟着变 ⇒ 证明非硬编码。
func TestRecallDebugDiversityFollowsTunerPush(t *testing.T) {
saved := storage.DefaultMMRDiversity()
t.Cleanup(func() { storage.SetDefaultMMRDiversity(saved) })
storage.SetDefaultMMRDiversity(0.42) // 非 0.1 的哨兵值:修后若仍报 0.1 即为硬编码
db := &nsAwareRecallLance{rows: debugProbeRows()}
code, m := runRecallDebugHTTP(t, db, `{"query":"调参推送值跟随回归","limit":5,"namespace":"hermes-main"}`)
if code != 200 {
t.Fatalf("HTTP=%d body=%v", code, m)
}
det := debugMMRDetail(m)
if !strings.Contains(det, "diversity=0.4,") && !strings.Contains(det, "diversity=0.42") {
t.Fatalf("✗ mmr 行应跟随 tuner 推送值(0.42),实际 %q(硬编码或未对齐)", det)
}
t.Logf("tuner 推 0.42:mmr=%q ✓(非硬编码)", det)
}
// TestRecallDebugDiversityExplicitOverridesDefault:显式传 diversity 时显式值优先,不被默认覆盖。
func TestRecallDebugDiversityExplicitOverridesDefault(t *testing.T) {
saved := storage.DefaultMMRDiversity()
t.Cleanup(func() { storage.SetDefaultMMRDiversity(saved) })
storage.SetDefaultMMRDiversity(0.1)
db := &nsAwareRecallLance{rows: debugProbeRows()}
code, m := runRecallDebugHTTP(t, db, `{"query":"显式 diversity 优先回归","limit":5,"namespace":"hermes-main","diversity":0.7}`)
if code != 200 {
t.Fatalf("HTTP=%d body=%v", code, m)
}
det := debugMMRDetail(m)
if !strings.Contains(det, "diversity=0.7,") {
t.Fatalf("✗ 显式传 0.7 时 mmr 行应报 0.7(显式值优先),实际 %q", det)
}
t.Logf("显式传 0.7:mmr=%q ✓(显式值优先)", det)
}
@@ -11,6 +11,7 @@ import (
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/xiaoxue/memoryweave/internal/governance"
"github.com/xiaoxue/memoryweave/internal/models"
@@ -35,7 +36,7 @@ func (f *fakeLanceDB) Stats() (map[string]interface{}, error) {
return map[string]interface{}{}, nil
}
func (f *fakeLanceDB) SoftDelete(id, reason string) error { return nil }
func (f *fakeLanceDB) PurgeInvalid() (int64, error) { return 0, nil }
func (f *fakeLanceDB) PurgeInvalid() (int64, error) { return 0, nil }
func (f *fakeLanceDB) SoftDeleteBatch(table string, ids []string, updatedAt string) (int64, error) {
return 0, nil
}
@@ -56,6 +57,13 @@ func (f *fakeLanceDB) IncrementUseful(id string) error { return nil }
func (f *fakeLanceDB) IncrementNotUseful(id string) error {
return nil
}
// GA-P0-1 (t_718d8707):拆分字段
func (f *fakeLanceDB) IncrementHumanNotUseful(id string) error { return nil }
func (f *fakeLanceDB) AppendHitEvent(memoryID, outcome, reason string) error { return nil }
func (f *fakeLanceDB) CountHitEvents(memoryID, outcome string, since time.Time) (int, error) { return 0, nil }
func (f *fakeLanceDB) GetHitEvents(memoryID string, limit int) ([]models.HitEvent, error) { return nil, nil }
func (f *fakeLanceDB) IncrementDedupDownweight(id string) error { return nil }
func (f *fakeLanceDB) UpdateMemoryContent(id, newContent, source string) error {
return nil
}
@@ -77,6 +85,11 @@ func (f *fakeLanceDB) Update(table, id string, fields map[string]any) error {
return nil
}
// GetRecordByID t_0f1d7be9(原文层):fake 默认不存在。
func (f *fakeLanceDB) GetRecordByID(table, id string) (*models.MemoryRecord, error) {
return nil, nil
}
// ─── 测试用例 ─────────────────────────────────────────────
// TestRecallSuccess_Returns200WithResults 验证成功路径:200 + results 键存在 + candidates_scanned。
@@ -183,7 +196,7 @@ type fakeEmbedder struct {
// TestRecallFailure_Non2xx 验证失败路径:依赖不可用时返回非 2xx + 统一错误体。
// 模拟:embedder endpoint="" → encode 必失败(HTTP 到空 URL)⇒ 主路径 err != nil
// + InMemoryGraph FallbackTextSearch 返回空 ⇒ 502 失败路径。
// - InMemoryGraph FallbackTextSearch 返回空 ⇒ 502 失败路径。
func TestRecallFailure_Non2xx(t *testing.T) {
db := &fakeLanceDB{searchResults: nil}
gs := governance.NewInMemoryGraph()
@@ -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,66 @@
// t_0f1d7be9(原文层):recall 响应字段集合向后兼容闸门。
// 判据:新实现产出的 RecallResult,其 JSON 字段集合必须 ⊇ 旧字段集
// (omitempty 导致的 per-row 缺键不算删除 —— 这里以「完整记录序列化」为对比对象)。
package routes
import (
"encoding/json"
"testing"
"time"
"github.com/xiaoxue/memoryweave/internal/models"
)
// legacyRecallFields 是 2026-11-06 生产基线(t_0f1d7be9 改前采集)里
// 一条「信息最全」的 recall 结果的全部 JSON key(含 omitempty 触发才出现的键)。
// 任何一条记录缺这里的键 = 删字段事故;新增键允许(只多不少)。
var legacyRecallFields = []string{
"category", "channel", "confidence", "content", "generation",
"human_useful_count", "id", "namespace",
"quality_score", "quality_score_reliable", "score", "source",
"stale", "timestamp", "v_value",
}
func TestRecallResultFieldSetSupersetOfLegacy(t *testing.T) {
conf := 0.8
hu := 3
full := models.RecallResult{
ID: "mem_x", Content: "c", Category: "distilled", Score: 0.5,
Timestamp: time.Now().Format(time.RFC3339), Namespace: "ns", AgentID: "a",
Source: "llm_distill", DerivedFrom: "ep_1",
Confidence: &conf,
ConfidenceSource: "explicit",
QualityScore: 0.7,
QualityScoreReliable: true,
VValue: 0.6,
HumanUsefulCount: &hu,
Stale: true, // 非零值才进 omitempty 序列化面(闸门测"字段还在",非 per-row 省略)
Channel: "vector",
ValidFrom: "2026-01-01T00:00:00Z",
FtsBoosted: true,
Version: 2,
}
b, err := json.Marshal(full)
if err != nil {
t.Fatal(err)
}
var m map[string]interface{}
_ = json.Unmarshal(b, &m)
for _, k := range legacyRecallFields {
if _, ok := m[k]; !ok {
t.Fatalf("✗ 旧字段 %q 在新 RecallResult 里消失(删字段=破坏兼容);keys=%v", k, keysOf(m))
}
}
// 本卡新增(下钻指针)必须可见
if m["derived_from"] != "ep_1" {
t.Fatalf("✗ derived_from 未直通(42.8%% 指针接不上=本卡失职): %v", m["derived_from"])
}
}
func keysOf(m map[string]interface{}) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
@@ -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")
}
}
+27 -27
View File
@@ -17,18 +17,18 @@ const skillRedisKey = "zhiyi:skills"
// BetaSkill 贝叶斯 Skill 评分(G7 扩展版)
type BetaSkill struct {
Name string `json:"name"`
Alpha float64 `json:"alpha"` // α = successes + 1
Beta float64 `json:"beta"` // β = failures + 1
Trials int `json:"trials"`
Successes int `json:"successes"`
ETA float64 `json:"eta"` // α/(α+β) 贝叶斯均值
Status string `json:"status"` // active / probation / retired
LastUpdated time.Time `json:"last_updated"`
Name string `json:"name"`
Alpha float64 `json:"alpha"` // α = successes + 1
Beta float64 `json:"beta"` // β = failures + 1
Trials int `json:"trials"`
Successes int `json:"successes"`
ETA float64 `json:"eta"` // α/(α+β) 贝叶斯均值
Status string `json:"status"` // active / probation / retired
LastUpdated time.Time `json:"last_updated"`
// G7 扩展字段
PromptTemplate string `json:"prompt_template,omitempty"` // 可执行 prompt(含 {param} 占位符)
LinkedMemoryIDs []string `json:"linked_memory_ids,omitempty"` // 关联的记忆 ID
LinkedEntities []string `json:"linked_entities,omitempty"` // 关联的图谱实体
PromptTemplate string `json:"prompt_template,omitempty"` // 可执行 prompt(含 {param} 占位符)
LinkedMemoryIDs []string `json:"linked_memory_ids,omitempty"` // 关联的记忆 ID
LinkedEntities []string `json:"linked_entities,omitempty"` // 关联的图谱实体
CreatedAt time.Time `json:"created_at"`
}
@@ -146,12 +146,12 @@ func (bsm *BayesianSkillManager) Register(name, promptTemplate string, linkedEnt
skill, exists := bsm.skills[name]
if !exists {
skill = &BetaSkill{
Name: name,
Alpha: 1.0,
Beta: 1.0,
CreatedAt: time.Now(),
PromptTemplate: promptTemplate,
LinkedEntities: linkedEntities,
Name: name,
Alpha: 1.0,
Beta: 1.0,
CreatedAt: time.Now(),
PromptTemplate: promptTemplate,
LinkedEntities: linkedEntities,
}
bsm.skills[name] = skill
} else {
@@ -306,11 +306,11 @@ func (bsm *BayesianSkillManager) Stats() map[string]interface{} {
}
}
return map[string]interface{}{
"total": len(bsm.skills),
"active": active,
"probation": probation,
"retired": retired,
"persisted": bsm.persisted,
"total": len(bsm.skills),
"active": active,
"probation": probation,
"retired": retired,
"persisted": bsm.persisted,
"redis_connected": bsm.redisClient != nil,
}
}
@@ -371,10 +371,10 @@ func (sm *SkillManager) Trial(w http.ResponseWriter, r *http.Request) {
// POST /api/v1/skills — 注册新 skill(G7 新增)
func (sm *SkillManager) Register(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
PromptTemplate string `json:"prompt_template"`
LinkedEntities []string `json:"linked_entities"`
Description string `json:"description"`
Name string `json:"name"`
PromptTemplate string `json:"prompt_template"`
LinkedEntities []string `json:"linked_entities"`
Description string `json:"description"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, 400, "invalid body: "+err.Error())
@@ -427,4 +427,4 @@ func (sm *SkillManager) GetSkillETA(name string) float64 {
return 0.5
}
return skill.ETA
}
}
+3 -3
View File
@@ -153,8 +153,8 @@ func crystallizeFromRecord(mem *models.MemoryRecord) (*BetaSkill, error) {
if err == nil && llmResult != "" {
var parsed struct {
PromptTemplate string `json:"prompt_template"`
Entities []string `json:"entities"`
SkillName string `json:"skill_name"`
Entities []string `json:"entities"`
SkillName string `json:"skill_name"`
}
if err2 := json.Unmarshal([]byte(llmResult), &parsed); err2 == nil {
if parsed.PromptTemplate != "" {
@@ -318,4 +318,4 @@ func getLDB() storage.LanceDB {
return ldbGetter()
}
return nil
}
}
-1
View File
@@ -240,4 +240,3 @@ func ExecuteSkill(w http.ResponseWriter, r *http.Request) {
"eta": skill.ETA,
})
}
+382
View File
@@ -0,0 +1,382 @@
package routes
// 方案二 · 召回增强(2026-10-31)
//
// 背景(20 题真实问题实测):
// · 能答 30% / 答不了 30% / 答错 10%
// · 6 道"答不了"里 **5 道的答案在 skill 文档里**,从来没进过 memories 表
// ⇒ 知识(稳定方法,进 skill) 与 记忆(会变事实,进 memories) 是两套互不相通的库
// · derived_from 有真指针的 42.8%,其中 99.9% 能找回原文(断链仅 10 条)
//
// 本文件提供两个"只读增强",都是可降级的(任何一步失败 ⇒ 返回空,不影响主流程):
// SkillRefsFor(query) —— 复用 ~/.hermes/skills/.vec.npy(skill-find.py 的现成索引),
// 不新建索引、不重复造轮子
// SourceExcerpts(ids) —— 按 memories.derived_from 批量取 episode 原文(一次查询,不逐条)
//
// 安全约定:
// · 任何异常(索引缺失 / 服务不可用 / 字段为空)⇒ 返回 nil,主召回零影响
// · 输出走 omitempty,旧调用方无感
import (
"bytes"
"encoding/binary"
"encoding/json"
"log"
"math"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
// SkillRef 一条相关技能引用(只给名字与相似度,不返回正文 —— 调用方需要时自己加载)
type SkillRef struct {
Name string `json:"name"`
Score float64 `json:"score"`
}
var (
skillVecOnce sync.Once
skillVecMat [][]float32 // L2 归一化后的向量
skillVecMeta []struct {
Path string `json:"path"`
Name string `json:"name"`
}
skillVecOK bool
)
func skillEmbedURL() string {
if v := os.Getenv("BGE_EMBED_URL"); v != "" {
return v
}
return "http://127.0.0.1:8000/v1/embeddings"
}
// loadSkillVecIndex 懒加载 skill 向量索引(.vec.npy 由 skill-find.py 维护)
// 解析 .npy 的最小实现:只支持 float32 C 序 2 维数组,避免引入 numpy 依赖
func loadSkillVecIndex() bool {
skillVecOnce.Do(func() {
home, err := os.UserHomeDir()
if err != nil {
return
}
idxPath := filepath.Join(home, ".hermes", "skills", ".vec.npy")
metaPath := filepath.Join(home, ".hermes", "skills", ".vec-meta.json")
raw, err := os.ReadFile(idxPath)
if err != nil {
log.Printf("[skillref] 索引缺失,跳过:%v", err)
return
}
mat, ok := parseNPYFloat32(raw)
if !ok {
log.Printf("[skillref] .npy 解析失败,跳过")
return
}
mraw, err := os.ReadFile(metaPath)
if err != nil {
return
}
if err := json.Unmarshal(mraw, &skillVecMeta); err != nil {
return
}
if len(skillVecMeta) != len(mat) {
log.Printf("[skillref] 索引/meta 长度不一致(%d vs %d),跳过", len(mat), len(skillVecMeta))
return
}
skillVecMat = mat
skillVecOK = true
log.Printf("[skillref] skill 向量索引就绪:%d 条", len(mat))
})
return skillVecOK
}
// parseNPYFloat32 极简 .npy 解析(仅 float32、C 序、2 维)
// 格式:magic(6) ver(2) headerlen(2, little) header(headerlen) data...
func parseNPYFloat32(b []byte) ([][]float32, bool) {
if len(b) < 10 || !bytes.HasPrefix(b, []byte("\x93NUMPY")) {
return nil, false
}
hlen := int(uint16(b[8]) | uint16(b[9])<<8)
if len(b) < 10+hlen {
return nil, false
}
header := string(b[10 : 10+hlen])
data := b[10+hlen:]
// 只接受 float32
if !strings.Contains(header, "f4") {
return nil, false
}
// 解析 shape:numpy header 里写作 `'shape': (626, 1024)`
si := strings.Index(header, "'shape'")
if si < 0 {
return nil, false
}
shapeStr := header[si:]
open := strings.Index(shapeStr, "(")
closeI := strings.Index(shapeStr, ")")
if open < 0 || closeI < 0 || closeI <= open {
return nil, false
}
parts := strings.Split(shapeStr[open+1:closeI], ",")
if len(parts) < 2 {
return nil, false
}
var rows, cols int
if _, err := jsonNumber(strings.TrimSpace(parts[0]), &rows); err != nil {
return nil, false
}
if _, err := jsonNumber(strings.TrimSpace(parts[1]), &cols); err != nil {
return nil, false
}
if rows <= 0 || cols <= 0 || len(data) < rows*cols*4 {
return nil, false
}
out := make([][]float32, rows)
for i := 0; i < rows; i++ {
vec := make([]float32, cols)
for j := 0; j < cols; j++ {
off := (i*cols + j) * 4
u := binary.LittleEndian.Uint32(data[off : off+4])
vec[j] = math.Float32frombits(u)
}
out[i] = vec
}
return out, true
}
// jsonNumber 解析头部里的整数(形如 "3" 或 " 3")
func jsonNumber(s string, out *int) (int, error) {
n := 0
got := false
for _, c := range s {
if c >= '0' && c <= '9' {
n = n*10 + int(c-'0')
got = true
} else if got {
break
}
}
if !got {
return 0, errBadShape
}
*out = n
return n, nil
}
var errBadShape = &shapeErr{}
type shapeErr struct{}
func (*shapeErr) Error() string { return "bad shape" }
// aliasTable 中文专名 → 英文项目名/同义词。
// 实测(2026-10-31):bge-m3 对「织忆」与 memoryweave/zhiyi 的余弦距离很远,
// 单用原句检索 top1 命中率 50%;扩展别名后 6/6 全部命中正确族(0.80→0.85)。
// 这是"语义距离"问题,不是"索引粒度"问题 —— 换 chunk 级索引解决不了。
var (
aliasOnce sync.Once
aliases map[string][]string
terms map[string][]string // 专属术语 → 所属实体(query 未点名实体时注入)
)
func loadAliases() map[string][]string {
aliasOnce.Do(func() {
home, err := os.UserHomeDir()
if err != nil {
return
}
raw, err := os.ReadFile(filepath.Join(home, ".hermes", "skills", ".aliases.json"))
if err != nil {
log.Printf("[skillref] 别名表缺失,跳过扩展:%v", err)
return
}
var f struct {
Aliases map[string][]string `json:"aliases"`
Terms map[string][]string `json:"_terms"`
}
if err := json.Unmarshal(raw, &f); err != nil {
log.Printf("[skillref] 别名表解析失败:%v", err)
return
}
aliases = f.Aliases
terms = f.Terms
delete(aliases, "_comment")
delete(aliases, "_version")
log.Printf("[skillref] 别名表就绪:%d 别名 + %d 术语组", len(aliases), len(terms))
})
return aliases
}
// expandQuery 命中别名 ⇒ 追加同义词。总追加长度上限 60 字符(防噪声淹没原句)。
func expandQuery(q string) string {
tbl := loadAliases()
if len(tbl) == 0 {
return q
}
// ① 专属术语注入:query 没点名实体,但出现了该实体的专属标识符
// (如 "FTS 覆盖率为什么只有 48%" 未提「织忆」,但 FTS/fts_index 是织忆专属)
base := q
for entity, termList := range terms {
if strings.Contains(base, entity) {
continue // 已点名,不必注入
}
hit := false
for _, t := range termList {
if t != "" && strings.Contains(base, t) {
hit = true
break
}
}
if hit {
base = base + " " + entity
if syns := tbl[entity]; len(syns) > 0 {
base = base + " " + strings.Join(syns, " ")
}
break // 只注入一个实体,防多实体互相污染
}
}
q = base
var extras []string
budget := 60
for k, syns := range tbl {
if !strings.Contains(q, k) && !strings.Contains(strings.ToLower(q), strings.ToLower(k)) {
continue
}
for _, s := range syns {
if strings.Contains(q, s) {
continue // 原句已有,不重复
}
if len([]rune(s)) > budget {
break
}
extras = append(extras, s)
budget -= len([]rune(s)) + 1
if budget <= 0 {
break
}
}
if budget <= 0 {
break
}
}
if len(extras) == 0 {
return q
}
out := q + " " + strings.Join(extras, " ")
if env := os.Getenv("ZHIYI_SKILLREF_DEBUG"); env == "1" {
log.Printf("[skillref] query 扩展: %q → %q", q, out)
}
return out
}
// SkillRefsFor 查询 → 相关 skill 名单(top-k)。任何失败返回 nil。
func SkillRefsFor(query string, topk int) []SkillRef {
if topk <= 0 {
topk = 5
}
if os.Getenv("ZHIYI_RECALL_SKILLREFS") == "0" {
return nil
}
if !loadSkillVecIndex() {
return nil
}
// 2026-10-31 提精度:先做别名扩展再编码(语义距离问题,见 expandQuery 注释)
qv, ok := embedQuery(expandQuery(query))
if !ok {
return nil
}
type sc struct {
i int
s float64
}
scores := make([]sc, 0, len(skillVecMat))
for i, v := range skillVecMat {
scores = append(scores, sc{i, dot(qv, v)})
}
sort.Slice(scores, func(a, b int) bool { return scores[a].s > scores[b].s })
out := make([]SkillRef, 0, topk)
seen := map[string]bool{}
for _, s := range scores {
if len(out) >= topk {
break
}
// 扩展后正确族分数 0.78~0.86;未触发的原句高分 0.66~0.70。
// 0.62 是「宁可给低分线索也别返回空」的取舍 —— 返回空等于没这功能。
if s.s < 0.62 {
break
}
name := skillVecMeta[s.i].Name
if name == "" {
name = strings.TrimSuffix(filepath.Base(skillVecMeta[s.i].Path), ".md")
}
if seen[name] {
continue
}
seen[name] = true
out = append(out, SkillRef{Name: name, Score: round3(s.s)})
}
return out
}
func embedQuery(q string) ([]float32, bool) {
body, _ := json.Marshal(map[string]any{"input": []string{q}, "model": "bge-m3"})
client := &http.Client{Timeout: 8 * time.Second}
resp, err := client.Post(skillEmbedURL(), "application/json", bytes.NewReader(body))
if err != nil {
return nil, false
}
defer resp.Body.Close()
var out struct {
Data []struct {
Embedding []float32 `json:"embedding"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil || len(out.Data) == 0 {
return nil, false
}
v := out.Data[0].Embedding
var norm float64
for _, x := range v {
norm += float64(x) * float64(x)
}
if norm == 0 {
return nil, false
}
inv := float32(1 / sqrt(norm))
for i := range v {
v[i] *= inv
}
return v, true
}
func dot(a, b []float32) float64 {
if len(a) != len(b) {
return 0
}
var s float64
for i := range a {
s += float64(a[i]) * float64(b[i])
}
return s
}
func sqrt(x float64) float64 {
if x <= 0 {
return 0
}
z := x
for i := 0; i < 30; i++ {
z = (z + x/z) / 2
}
return z
}
func round3(f float64) float64 {
return float64(int(f*1000+0.5)) / 1000
}
+39
View File
@@ -0,0 +1,39 @@
package routes
import (
"os"
"path/filepath"
"testing"
)
// TestNPYParse 验证能解析 skill-find.py 维护的真实索引
func TestNPYParse(t *testing.T) {
home, _ := os.UserHomeDir()
p := filepath.Join(home, ".hermes", "skills", ".vec.npy")
raw, err := os.ReadFile(p)
if err != nil {
t.Skip("索引不存在:", err)
}
mat, ok := parseNPYFloat32(raw)
if !ok {
t.Fatalf(".npy 解析失败,前 200 字节: %q", raw[:min(200, len(raw))])
}
if len(mat) == 0 {
t.Fatal("解析出 0 行")
}
t.Logf("解析成功: %d 行 × %d 维", len(mat), len(mat[0]))
}
// TestSkillRefsFor 端到端:真实查询能拿到 skill_refs
func TestSkillRefsFor(t *testing.T) {
if os.Getenv("ZHIYI_SKILLREF_E2E") != "1" {
t.Skip("set ZHIYI_SKILLREF_E2E=1 才跑(依赖 bge 服务)")
}
refs := SkillRefsFor("织忆部署时 Go 和 Rust 二进制的启停顺序", 3)
if len(refs) == 0 {
t.Fatal("skill_refs 为空")
}
for _, r := range refs {
t.Logf(" %s %.3f", r.Name, r.Score)
}
}
+594
View File
@@ -0,0 +1,594 @@
// 织忆 MemoryWeave — recall 第二路:skill 分节检索(t_e3f5cb79 · 2026-10-31 方案 B1)
//
// 根因(方案「验证结果(2026-11-02)」节):知识与记忆是两套互不相通的库 ——
// 「怎么做/为什么」类答案稳定落在 skill 文档(部署顺序/缓存分层/排障步骤),
// 从来不进 memories 表;recall 只查前者 ⇒ 答不了。
//
// 本文件 = recall 的第二路(分栏返回,不混排):
//
// · 索引源 ~/.hermes/skills/**/*.md(env ZHIYI_SKILLS_DIR 可覆盖;3545 档 / 27MB)
// · 分节(H2~H4 标题切段)+ 关键词匹配(ASCII token 整词 + CJK bigram),
// 纯 stdlib,零新依赖、零外部服务(不依赖 embed/LLM —— 10-31 向量前案
// 依赖 .vec.npy + embed 服务,shape 也不满足本卡 AC,见卡评论裁定 4)
// · 输出顶层 `skill_refs: [{path, snippet, matched}]`;`results`(记忆路)
// 结构一字不动,两路内容互不混入(AC-2.4,结构上保证)
// · 开关 ZHIYI_RECALL_SKILLREFS(默认关;每次读 env,测试可翻转)
// · 任何失败(目录缺失/IO 错误/查询为空)⇒ 返回空数组,主召回零影响
//
// 精度取向(对齐方案验证节教训「先拿真实问题量一下」):
//
// · ASCII 标识符权重高(RedisCache/ZHIYI_* 这类精确 token 是主要判别信号)
// · CJK bigram 按段落长度做 sqrt 归一,防长段落靠撞 bigram 霸榜
// · 每个文件只出最高分一节(top-3 文件),避免同文件刷屏
package routes
import (
"log"
"math"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
"unicode"
)
// SkillSectionRef 一条 skill 片段引用(AC-2.1 形状:path/snippet/matched)。
type SkillSectionRef struct {
Path string `json:"path"`
Snippet string `json:"snippet"`
Matched []string `json:"matched"`
Score float64 `json:"-"` // 排序分(仅回归/调试可见;AC-2.1 线格式仍为 path/snippet/matched)
}
// skillSection 索引单元:一个 Markdown 分节。
type skillSection struct {
path string // 绝对路径
title string // 分节标题(无标题=文件名)
lower string // 正文小写(ASCII 匹配用)
runeLen int // 正文字符数(长度归一用)
titleLow string // 标题小写(标题命中加权)
isSkillMd bool // SKILL.md 主文档(权威综述先验)
}
type skillRefIndex struct {
sections []*skillSection
builtAt time.Time
avgRuneLen float64
}
var (
skillIdxMu sync.RWMutex
skillIdxCache *skillRefIndex
)
const (
skillRefRebuildEvery = 10 * time.Minute
skillRefMaxFileBytes = 512 * 1024
skillRefMaxSection = 8000 // rune
skillRefSnippetRunes = 200
)
// skillRefsEnabled 第二路开关(默认关 = 现网零变化;unit drop-in 置 1 启用)。
func skillRefsEnabled() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv("ZHIYI_RECALL_SKILLREFS"))) {
case "1", "true", "on":
return true
}
return false
}
func skillRefsDir() string {
if v := strings.TrimSpace(os.Getenv("ZHIYI_SKILLS_DIR")); v != "" {
return v
}
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, ".hermes", "skills")
}
// headingRe H2~H4 标题行 = 分节边界。
var headingRe = regexp.MustCompile(`^#{2,4}\s+(.*)$`)
// buildSkillRefIndex 扫目录建分节索引(失败返回 nil,调用方降级空数组)。
func buildSkillRefIndex(dir string) *skillRefIndex {
if dir == "" {
return nil
}
idx := &skillRefIndex{builtAt: time.Now()}
var walkErr int
_ = filepath.WalkDir(dir, func(p string, d os.DirEntry, err error) error {
if err != nil {
walkErr++
return nil // 单点 IO 失败不中断整体
}
if d.IsDir() {
// 隐藏目录(.archive/.vec 缓存等)不进索引
if p != dir && strings.HasPrefix(d.Name(), ".") {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(d.Name(), ".md") {
return nil
}
if fi, e := d.Info(); e != nil || fi.Size() > skillRefMaxFileBytes {
return nil
}
raw, e := os.ReadFile(p)
if e != nil {
walkErr++
return nil
}
addSkillFile(idx, p, string(raw))
return nil
})
log.Printf("[skillrefs] 索引就绪:sections=%d walk_errors=%d dir=%s 用时=%s",
len(idx.sections), walkErr, dir, time.Since(idx.builtAt).Round(time.Millisecond))
if len(idx.sections) == 0 {
return nil
}
var sum int
for _, s := range idx.sections {
sum += s.runeLen
}
idx.avgRuneLen = float64(sum) / float64(len(idx.sections))
return idx
}
// addSkillFile 按 H2~H4 切节;YAML frontmatter 跳过。
func addSkillFile(idx *skillRefIndex, path, content string) {
lines := strings.Split(content, "\n")
i := 0
// frontmatter
if len(lines) > 0 && strings.TrimSpace(lines[0]) == "---" {
for j := 1; j < len(lines); j++ {
if strings.TrimSpace(lines[j]) == "---" {
i = j + 1
break
}
}
}
title := "(front)"
var body strings.Builder
flush := func() {
txt := strings.TrimSpace(body.String())
if txt == "" && title == "(front)" {
return
}
if txt == "" {
return // 只有标题没有正文的分节不进索引
}
rs := []rune(txt)
if len(rs) > skillRefMaxSection {
txt = string(rs[:skillRefMaxSection])
}
idx.sections = append(idx.sections, &skillSection{
path: path,
title: title,
lower: strings.ToLower(txt),
runeLen: len(rs),
titleLow: strings.ToLower(title),
isSkillMd: filepath.Base(path) == "SKILL.md",
})
}
for ; i < len(lines); i++ {
if m := headingRe.FindStringSubmatch(lines[i]); m != nil {
flush()
body.Reset()
title = strings.TrimSpace(m[1])
continue
}
body.WriteString(lines[i])
body.WriteByte('\n')
}
flush()
}
// getSkillRefIndex 取索引(懒建 + 定期重建;重建阻塞在锁内,一次尖刺可接受)。
func getSkillRefIndex() *skillRefIndex {
skillIdxMu.RLock()
idx := skillIdxCache
skillIdxMu.RUnlock()
if idx != nil && time.Since(idx.builtAt) < skillRefRebuildEvery {
return idx
}
dir := skillRefsDir()
newIdx := buildSkillRefIndex(dir)
if newIdx == nil {
return idx // 建索引失败:保留旧索引(可能为 nil = 空)
}
skillIdxMu.Lock()
skillIdxCache = newIdx
skillIdxMu.Unlock()
return newIdx
}
// skillStopCJK 疑问/系词/量词类停用字:含这些字的 bigram 不参与打分 ——
// 真实语料实测「有哪些」类疑问句在全库高频共现,不排除则疑问句段落霸榜
// (2026-11-03 调试:q=织忆有哪些缓存层 时 [有哪 哪些] 组合霸榜 8/10)。
var skillStopCJK = map[rune]bool{
'有': true, '哪': true, '些': true, '什': true, '么': true, '怎': true, '样': true,
'如': true, '何': true, '是': true, '的': true, '了': true, '吗': true, '呢': true,
'和': true, '与': true, '或': true, '在': true, '对': true, '从': true, '里': true,
'多': true, '少': true, '谁': true, '几': true, '该': true, '吧': true, '呀': true,
}
func isHan(r rune) bool { return unicode.Is(unicode.Han, r) }
// skillGlossary 微型近义/中英扩展表(查询侧,只扩 1 跳):
// 纯词面匹配桥不过的真实案例(2026-11-03 调试)——「缓存层」文档写 `RedisCache`、
// 「启动顺序」文档写「部署时序」。表刻意小:只收运维域高频等价对。
// 扩展词用 Contains 匹配(cache 允许出现在 RedisCache 内),权重 ×0.8。
var skillGlossary = map[string][]string{
"缓存": {"cache"}, "启动": {"重启", "部署"}, "重启": {"启动", "部署"},
"部署": {"启动", "重启"}, "顺序": {"时序", "次序"}, "时序": {"顺序"},
"端口": {"port"}, "服务": {"service"},
}
func skillExpand(cjk []string) []string {
out := []string{}
for _, t := range cjk {
for _, syn := range skillGlossary[t] {
out = append(out, syn)
}
}
return dedupeTerms(out)
}
// filterOut 去掉已存在于 have 的词。
func filterOut(terms, have []string) []string {
seen := make(map[string]bool, len(have))
for _, h := range have {
seen[h] = true
}
out := terms[:0:0]
for _, t := range terms {
if !seen[t] {
out = append(out, t)
}
}
return out
}
// skillQueryTerms 查询分词:ASCII token(≥2 字符,小写)+ CJK bigram(停用字过滤)。
func skillQueryTerms(q string) (ascii, cjk []string) {
var cur strings.Builder
flushASCII := func() {
if cur.Len() >= 2 {
ascii = append(ascii, cur.String())
}
cur.Reset()
}
rs := []rune(q)
// CJK bigram(过滤停用字 bigram)
var run []rune
flushRun := func() {
for k := 0; k+1 < len(run); k++ {
if skillStopCJK[run[k]] || skillStopCJK[run[k+1]] {
continue
}
cjk = append(cjk, string(run[k:k+2]))
}
run = run[:0]
}
for _, r := range rs {
switch {
case unicode.Is(unicode.Han, r):
flushASCII()
run = append(run, r)
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '-':
flushRun()
cur.WriteRune(unicode.ToLower(r))
default:
flushASCII()
flushRun()
}
}
flushASCII()
flushRun()
return dedupeTerms(ascii), dedupeTerms(cjk)
}
func dedupeTerms(in []string) []string {
seen := make(map[string]bool, len(in))
out := in[:0:0]
for _, s := range in {
if !seen[s] {
seen[s] = true
out = append(out, s)
}
}
return out
}
// wordCount 词边界计数:token 前后不能贴字母/数字(防 `go` 命中 golang/google、
// `rust` 命中 x-rustc 之类子串噪声 —— 真实语料实测短 token 子串匹配 df 虚高)。
var wordBoundaryOK = func(b byte) bool {
return !(b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b >= '0' && b <= '9' || b == '_')
}
func wordCount(lower, token string) int {
n, from := 0, 0
for {
i := strings.Index(lower[from:], token)
if i < 0 {
return n
}
i += from
j := i + len(token)
beforeOK := i == 0 || wordBoundaryOK(lower[i-1])
afterOK := j == len(lower) || wordBoundaryOK(lower[j])
if beforeOK && afterOK {
n++
}
from = i + 1
}
}
// skillRefsForQuery 第二路检索入口:query → top-n skill 分节引用。
// 关闭/无索引/空查询 ⇒ 空切片(非 nil,JSON 序列化为 [])。
// 打分 = BM25(k1=1.5, b=0.75;tf 饱和 + 平均长度归一,标准 IR 做法):
// 2026-11-03 调试教训 —— 自造线性权重在 37k 分节真实语料上被「疑问句共现」
// 与「长度/稀有 bigram」反复带偏,换教科书公式一次到位。
func skillRefsForQuery(query string, n int) []SkillSectionRef {
out := []SkillSectionRef{}
if !skillRefsEnabled() {
return out
}
idx := getSkillRefIndex()
if idx == nil {
return out
}
ascii, cjk := skillQueryTerms(query)
if len(ascii) == 0 && len(cjk) == 0 {
return out
}
if n <= 0 || n > 30 {
n = 3
}
type hit struct {
sec *skillSection
score float64
matched []string
}
// 单字兜底通道词条:查询里的非停用汉字(去重)
queryUnigrams := []string{}
seenU := map[rune]bool{}
for _, r := range query {
if isHan(r) && !skillStopCJK[r] && !seenU[r] {
seenU[r] = true
queryUnigrams = append(queryUnigrams, string(r))
}
}
// 扩展通道词(近义/中英,Contains 匹配)
expanded := skillExpand(cjk)
expanded = filterOut(expanded, append(append([]string{}, cjk...), ascii...))
// df 预扫(一遍 Contains)
df := make(map[string]int, len(ascii)+len(cjk))
unigramDF := make(map[string]int, len(queryUnigrams))
expandedDF := make(map[string]int, len(expanded))
for _, sec := range idx.sections {
for _, t := range ascii {
if wordCount(sec.lower, t) > 0 {
df[t]++
}
}
for _, t := range cjk {
if strings.Contains(sec.lower, t) {
df[t]++
}
}
for _, r := range queryUnigrams {
if strings.Contains(sec.lower, r) {
unigramDF[r]++
}
}
for _, t := range expanded {
if strings.Contains(sec.lower, t) {
expandedDF[t]++
}
}
}
total := float64(len(idx.sections))
bmIDF := func(d int) float64 {
return math.Log(1 + (total-float64(d)+0.5)/(float64(d)+0.5))
}
avgLen := idx.avgRuneLen
if avgLen <= 0 {
avgLen = 600
}
const k1, b = 1.5, 0.75
bm := func(tf int, idf, secLen float64) float64 {
if tf == 0 {
return 0
}
return idf * float64(tf) * (k1 + 1) / (float64(tf) + k1*(1-b+b*secLen/avgLen))
}
hits := make([]hit, 0, 64)
for _, sec := range idx.sections {
score := 0.0
var matched []string
secLen := float64(sec.runeLen)
for _, t := range ascii {
tf := wordCount(sec.lower, t)
if tf > 0 {
score += 3.0 * bm(tf, bmIDF(df[t]), secLen) // ASCII 标识符是强信号
matched = append(matched, t)
if strings.Contains(sec.titleLow, t) {
score += 2.0 // 标题命中加权
}
}
}
for _, t := range cjk {
tf := strings.Count(sec.lower, t)
if tf > 0 {
score += bm(tf, bmIDF(df[t]), secLen)
matched = append(matched, t)
if strings.Contains(sec.titleLow, t) {
score += 1.5
}
}
}
for _, t := range expanded {
if tf := strings.Count(sec.lower, t); tf > 0 {
w := 0.8
// 扩展词命中且同节含 ASCII 标识符 → 视为等价强信号,升全权重
// (探针② 实测:「部署时序照旧:停先 Go 后 Rust」行需按全词计分
// 才能压过 INDEX 目录页的裸词面共现)
if len(ascii) > 0 && containsAnyMatched(matched, ascii) {
w = 1.0
}
score += w * bm(tf, bmIDF(expandedDF[t]), secLen)
matched = append(matched, "~"+t) // 前缀标扩展命中
}
}
if len(matched) == 0 {
continue
}
// 单字兜底通道:bigram 在语料里是碎片(如「启动顺序」实际文档写「时序」)
// 时,内容单字以 0.6 权重补召回(停用字已滤)。
for _, r := range queryUnigrams {
tf := strings.Count(sec.lower, r)
if tf > 0 {
score += 0.6 * bm(tf, bmIDF(unigramDF[r]), secLen)
}
}
// SKILL.md = 人写权威综述(references 是案底),同分场景给 15% 先验
if sec.isSkillMd {
score *= 1.15
}
if len(ascii) > 0 && !containsAnyMatched(matched, ascii) {
// 查询含 ASCII 标识符而本节一个都不含 → 视为未命中该标识符语境,减半
score *= 0.5
}
// 路径题名先验:文件名 = 作者自己标的主题。CJK 词经文件名对照表映射到
// ASCII 路径 token(部署→deploy),文件名命中 +3(探针② 实测:目标文档
// 正文只写「部署时序」,词面打不过 INDEX 目录页,靠文件名定主题)。
// 反查表刻意小(只收 6 对,与 skillGlossary 同源维护)。
{
pathKey := strings.ToLower(sec.path)
fileNameMap := map[string]string{"部署": "deploy", "重启": "restart", "启动": "start",
"缓存": "cache", "端口": "port", "健康": "health", "顺序": "order", "指标": "metric"}
for _, t := range append(append([]string{}, cjk...), expanded...) {
if tok, ok := fileNameMap[t]; ok && strings.Contains(pathKey, tok) {
score += 3.0
break
}
}
}
hits = append(hits, hit{sec: sec, score: score, matched: matched})
}
// 行级共现加权(只重算前 300 候选,成本可控):同一行内共现的查询词对数
// 是「规则行」的强判别信号 —— AC 探针② 的目标行「部署时序照旧:停先 Go 后
// Rust、起先 Rust 后 Go」= 4 词同行。长文散提不配和规则行同分。
{
allTerms := append(append(append([]string{}, ascii...), cjk...), expanded...)
top := hits
if len(top) > 300 {
sort.SliceStable(top, func(i, j int) bool { return top[i].score > top[j].score })
top = top[:300]
}
for i := range top {
best, sum := 0, 0
for _, line := range strings.Split(top[i].sec.lower, "\n") {
c := 0
for _, t := range allTerms {
if strings.Contains(line, t) {
c++
}
}
if c >= 2 {
pairs := c * (c - 1) / 2
if pairs > 10 {
pairs = 10
}
if pairs > best {
best = pairs
}
sum += pairs
}
}
// 最佳行主导 + 少量「多处提及」尾部信号(防目录页靠行数刷分)
top[i].score += 0.8*float64(best) + 0.15*float64(sum-best)
}
}
sort.SliceStable(hits, func(i, j int) bool { return hits[i].score > hits[j].score })
// 每文件取最高分一节,取前 n 个文件
seenPath := map[string]bool{}
for _, h := range hits {
if seenPath[h.sec.path] {
continue
}
seenPath[h.sec.path] = true
out = append(out, SkillSectionRef{
Path: h.sec.path,
Snippet: skillSnippet(h.sec.lower, h.matched, ascii, cjk),
Matched: topMatched(h.matched, 8),
Score: h.score,
})
if len(out) >= n {
break
}
}
return out
}
func containsAnyMatched(matched, terms []string) bool {
for _, m := range matched {
for _, t := range terms {
if m == t {
return true
}
}
}
return false
}
func topMatched(matched []string, n int) []string {
if len(matched) <= n {
return matched
}
return matched[:n]
}
// skillSnippet 首个高权重命中词附近 ±100 rune 窗口,压平空白。
func skillSnippet(lower string, matched, ascii, cjk []string) string {
pref := append(append([]string{}, ascii...), cjk...)
rs := []rune(lower)
pos := -1
for _, t := range pref {
i := strings.Index(lower, t)
if i >= 0 {
// rune 下标近似:按字节 index 换 rune 数(正文已是小写无多字节大小写问题)
pos = len([]rune(lower[:i]))
break
}
}
if pos < 0 {
pos = 0
}
start := pos - skillRefSnippetRunes/2
if start < 0 {
start = 0
}
end := start + skillRefSnippetRunes
if end > len(rs) {
end = len(rs)
start = end - skillRefSnippetRunes
if start < 0 {
start = 0
}
}
snip := strings.Join(strings.Fields(string(rs[start:end])), " ")
return snip
}
+168
View File
@@ -0,0 +1,168 @@
// B1 第二路 skill 检索回归(t_e3f5cb79 · AC-2)
//
// 层 1(离线 fixture):开关语义、形状 {path,snippet,matched}、分节命中、
// 查询停用词(「有哪些」类疑问词不得参与打分 —— 否则全库疑问句霸榜)。
// 层 2(真实语料探针,AC-2.3 ①②;③「淇县招商 memories top1 不变」属线上
// 行为,由部署后原始输出闭合):
//
// ① q="织忆有哪些缓存层" ⇒ 命中文件正文含 RedisCache 或 L2Cache
// ② q="Go Rust 启动顺序" ⇒ 命中含「先 Go」的文件(references/zhiyi-deploy-and-metrics.md)
package routes
import (
"os"
"path/filepath"
"strings"
"testing"
)
func resetSkillIdxForTest(t *testing.T) {
t.Helper()
skillIdxMu.Lock()
skillIdxCache = nil
skillIdxMu.Unlock()
t.Cleanup(func() {
skillIdxMu.Lock()
skillIdxCache = nil
skillIdxMu.Unlock()
})
}
func TestSkillRefsDisabledReturnsEmpty(t *testing.T) {
resetSkillIdxForTest(t)
t.Setenv("ZHIYI_RECALL_SKILLREFS", "")
if got := skillRefsForQuery("织忆有哪些缓存层", 5); got == nil || len(got) != 0 {
t.Fatalf("开关关必须返回非 nil 空切片(JSON=[]),实际 %#v", got)
}
}
func TestSkillQueryTermsStopwords(t *testing.T) {
ascii, cjk := skillQueryTerms("织忆有哪些缓存层")
if len(ascii) != 0 {
t.Fatalf("纯中文查询不应有 ascii: %v", ascii)
}
want := map[string]bool{"织忆": true, "缓存": true, "存层": true}
for _, s := range cjk {
if !want[s] {
t.Fatalf("停用词漏网: %v (full=%v)", s, cjk)
}
}
for w := range want {
found := false
for _, s := range cjk {
if s == w {
found = true
}
}
if !found {
t.Fatalf("实词被误删: %q full=%v", w, cjk)
}
}
// ASCII token 不受停用词影响
a2, _ := skillQueryTerms("RedisCache 的 TTL 怎么配")
if len(a2) != 2 || a2[0] != "rediscache" || a2[1] != "ttl" {
t.Fatalf("ascii terms 错误: %v", a2)
}
}
func writeFixtureSkill(t *testing.T, dir, rel, content string) string {
t.Helper()
p := filepath.Join(dir, rel)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return p
}
func TestSkillRefsFixtureShapeAndHit(t *testing.T) {
resetSkillIdxForTest(t)
dir := t.TempDir()
p1 := writeFixtureSkill(t, dir, "zhiyi/zhiyi/SKILL.md",
"---\nname: zhiyi\n---\n# 织忆\n\n## 现成缓存设施清单\n\n| 设施 | 位置 |\n|---|---|\n| `RedisCache` + `NewRedisCache(ttl)` | storage/redis.go |\n| `L2CacheBackend` + `redisL2Adapter` | storage/searchcache.go |\n\n## 无关段\n\n这里讲图谱 pruning 的宽限期。\n")
p2 := writeFixtureSkill(t, dir, "cooking/pancake.md",
"# 煎饼\n\n## 做法\n\n面糊醒 20 分钟。全库最常说的「有哪些」句式也写在这。\n")
t.Setenv("ZHIYI_SKILLS_DIR", dir)
t.Setenv("ZHIYI_RECALL_SKILLREFS", "1")
refs := skillRefsForQuery("织忆有哪些缓存层", 5)
if len(refs) == 0 {
t.Fatalf("fixture 查询必须命中")
}
if refs[0].Path != p1 {
t.Fatalf("首位应命中 zhiyi SKILL.md,实际 %s", refs[0].Path)
}
for _, x := range refs {
if !strings.HasSuffix(x.Path, ".md") {
t.Fatalf("skill_refs 只能指向 md:%s", x.Path)
}
}
_ = p2
}
func TestSkillRefsRealCorpusProbes(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Skip("no home")
}
realDir := filepath.Join(home, ".hermes", "skills")
if fi, e := os.Stat(realDir); e != nil || !fi.IsDir() {
t.Skipf("真实技能库不存在:%s", realDir)
}
resetSkillIdxForTest(t)
t.Setenv("ZHIYI_SKILLS_DIR", "") // 走默认真实目录
t.Setenv("ZHIYI_RECALL_SKILLREFS", "1")
// 探针①
refs := skillRefsForQuery("织忆有哪些缓存层", 5)
if len(refs) == 0 {
t.Fatalf("探针① skill_refs 为空")
}
found := false
for _, r := range refs {
raw, e := os.ReadFile(r.Path)
if e != nil {
continue
}
if strings.Contains(string(raw), "RedisCache") || strings.Contains(string(raw), "L2Cache") {
found = true
t.Logf("探针① 命中:%s matched=%v", r.Path, r.Matched)
break
}
}
if !found {
t.Fatalf("探针① top3 中没有含 RedisCache/L2Cache 的文件:%v", pathsOf(refs))
}
// 探针②
refs = skillRefsForQuery("Go Rust 启动顺序", 5)
if len(refs) == 0 {
t.Fatalf("探针② skill_refs 为空")
}
found = false
for _, r := range refs {
raw, e := os.ReadFile(r.Path)
if e != nil {
continue
}
if strings.Contains(string(raw), "先 Go") || strings.Contains(string(raw), "先Go") {
found = true
t.Logf("探针② 命中:%s matched=%v", r.Path, r.Matched)
break
}
}
if !found {
t.Fatalf("探针② top3 中没有含「先 Go」的文件:%v", pathsOf(refs))
}
}
func pathsOf(refs []SkillSectionRef) []string {
out := make([]string, 0, len(refs))
for _, r := range refs {
out = append(out, r.Path)
}
return out
}
@@ -0,0 +1,73 @@
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","op":{...},"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"`
Op *storage.OpCard `json:"op,omitempty"`
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, in.Op)
if err != nil {
respondError(w, http.StatusInternalServerError, err.Error())
return
}
respond(w, http.StatusOK, a)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}

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