Compare commits

..
17 Commits
Author SHA1 Message Date
小唯 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
37 changed files with 4014 additions and 285 deletions
+93 -24
View File
@@ -539,6 +539,7 @@ func (a *API) Recall(w http.ResponseWriter, r *http.Request) {
"candidates_scanned": 0,
"mode": req.Mode,
"skipped": "system_template",
"skill_refs": []SkillSectionRef{}, // 契约:键恒存在(AC-2.1)
})
return
}
@@ -911,32 +912,19 @@ func (a *API) Recall(w http.ResponseWriter, r *http.Request) {
// ── P0-3 翻页:从 fetchLimit 结果里切出 [offset, offset+limit) ──
// 说明:翻页重算整段前 offset+limit 条再切片,保证 MMR 前缀稳定;
// 令牌绑定 query 指纹 + namespace,跨 query/跨域复用一律 400(防串页)。
// ⚠ 2026-11-02 (t_58f9d476):切片必须对 pattern/peer carry 豁免——
// capRecallResults 特意把 carry 恒保留追加在 prim 之后(回填保证主+扩展填满
// limit ⇒ carry 恒落在索引 limit),本块裸 results[:limit] 把它恒切掉,
// t_e98cb1d6 的 carry 修复在此被整体作废(ZHIYI_PEER_RECALL 结构性零产出,
// dbg 插桩实证注入发生 4087→4088 但响应无 peer)。语义改走纯函数
// paginateRecallWithCarry(可单测):carry 摘出→offset/去重/截断→回尾。
totalFetched := len(results)
if offset > 0 {
if offset >= len(results) {
results = []models.RecallResult{}
} else {
results = results[offset:]
}
}
if len(prevIDs) > 0 {
seenPrev := make(map[string]bool, len(prevIDs))
for _, id := range prevIDs {
seenPrev[id] = true
}
kept := results[:0]
for _, r := range results {
if !seenPrev[r.ID] {
kept = append(kept, r)
}
}
results = kept
}
if len(results) > req.Limit {
results = results[:req.Limit]
}
results, mainLen := paginateRecallWithCarry(results, offset, prevIDs, req.Limit)
nextCont := ""
if totalFetched >= fetchLimit && len(results) == req.Limit && offset+req.Limit < maxContinuationOffset {
// ⚠ 2026-11-02 (t_2891d689):判据看主路段 mainLen,不看 len(results)——
// carry 会把 results 撑过 limit,旧判据 len(results)==req.Limit 在有注入时
// 恒假 ⇒ nextCont 恒空、翻页链断裂。收口到 shouldEmitContinuation(可单测)。
if shouldEmitContinuation(totalFetched, fetchLimit, mainLen, req.Limit, offset) {
returned := make([]string, 0, len(prevIDs)+len(results))
returned = append(returned, prevIDs...)
for _, r := range results {
@@ -981,6 +969,11 @@ func (a *API) Recall(w http.ResponseWriter, r *http.Request) {
// 2026-10-26 (t_9025b0eb):扩展结果单独成字段,避免撑爆 results。
// 键恒存在(可为空数组),便于探针/统计区分「无扩展」与「字段缺失」。
"graph_expanded": graphExpanded,
// AC-2.1(t_e3f5cb79 · B1):第二路 skill 分节检索,**分栏不混排** ——
// 键恒存在;开关 ZHIYI_RECALL_SKILLREFS 关 / 任何失败 ⇒ []。
// 「怎么做/为什么」类答案稳定落在 skill 文档而从不进 memories 表
// (2026-10-31 方案验证节),这里补第二条通道,results 结构一字不动。
"skill_refs": skillRefsForQuery(req.Query, 5),
// t_1c12e000:AC3 —— 缓存命中/回源扫表必须可区分。
"cache_hit": cacheHit,
// 2026-10-26(P0-1/P0-3 契约):
@@ -1699,3 +1692,79 @@ func capRecallResults(results []models.RecallResult, limit int) (prim, expanded
}
return prim, expanded
}
// paginateRecallWithCarry —— P0-3 翻页切片,对 pattern/peer carry 豁免
// (2026-11-02 t_58f9d476;carry 判据与 capRecallResults 同源:Category ∈ {pattern, peer})。
//
// 病灶:capRecallResults 把 carry 恒保留追加在 prim 之后(主+扩展回填恒填满 limit
// ⇒ carry 恒在索引 limit 处),紧随的 P0-3 裸 `results[:req.Limit]` 恒把它切掉
// ——t_e98cb1d6 的 carry 修复被作废,peer/pattern 注入在主召回响应里结构性失明。
//
// 语义(纯函数,可单测):先把 carry 摘出,对剩余主路段照旧做
// offset 切片 → prevIDs 去重 → [:limit] 截断,第一页把 carry 回尾。
// 保证:① 主路段逐位不变(t_9025b0eb AC 不动:主路段 ≤ limit);
// ② carry 在第一页存活且恒在响应末尾;③ 无 carry(门关态)逐位与旧内联逻辑 delta=0;
// ④ 主路段切空(offset 越界 / 去重全掉)时返回空段,不返回孤儿 carry。
//
// 2026-11-02(t_2891d689)两处修订:
// ① 返回值加 mainLen(主路段截断后的长度)。4ffc546 后 carry 使 len(results) 可以
// 超过 limit,翻页判据若看 len(results) 会恒假 ⇒ nextCont 恒空、带注入的 query
// 翻页链断裂(本卡病灶)。判据改看主路段(见 shouldEmitContinuation)。
// ② carry 只在第一页出现:carry 是固定钉住的注入条目,第一页其 ID 已随 returned
// 记进令牌 prev;续页再追加必然逐页重复,等于把去重成本转嫁给客户端。
// ⇒ offset > 0 时只回主路段,不追加 carry。
func paginateRecallWithCarry(results []models.RecallResult, offset int, prevIDs []string, limit int) ([]models.RecallResult, int) {
var carry []models.RecallResult
main := make([]models.RecallResult, 0, len(results))
for _, r := range results {
if r.Category == "pattern" || r.Category == "peer" {
carry = append(carry, r)
} else {
main = append(main, r)
}
}
if offset > 0 {
if offset >= len(main) {
main = []models.RecallResult{}
} else {
main = main[offset:]
}
}
if len(prevIDs) > 0 {
seenPrev := make(map[string]bool, len(prevIDs))
for _, id := range prevIDs {
seenPrev[id] = true
}
kept := main[:0]
for _, r := range main {
if !seenPrev[r.ID] {
kept = append(kept, r)
}
}
main = kept
}
if limit >= 0 && len(main) > limit {
main = main[:limit]
}
mainLen := len(main)
if mainLen == 0 {
// 与旧语义一致:主路段空 ⇒ 整页空(页面上只有注入条目没有意义)
return []models.RecallResult{}, 0
}
if offset > 0 {
// t_2891d689:carry 只在第一页出现,续页不回尾(见函数头注释 ②)
return main, mainLen
}
return append(main, carry...), mainLen
}
// shouldEmitContinuation —— P0-3 是否发放下一页 continuation 令牌(2026-11-02 t_2891d689)。
//
// 病灶:4ffc546 后 carry 追加在主路段之后 ⇒ len(results) 可以 > limit,
// 判据 `len(results) == req.Limit` 只要 carry 命中就恒假 ⇒ nextCont 恒空,
// 带 pattern/peer 注入的 query 翻页链断裂(第二页永远取不到)。
// 修法:判据看**主路段长度** mainLen —— carry 不参与「是否还有下一页」的判断。
// 必须用 == 而不用 >=:主路段未满一页(offset 越界 / 尾页不满)时不得错发令牌。
func shouldEmitContinuation(totalFetched, fetchLimit, mainLen, limit, offset int) bool {
return totalFetched >= fetchLimit && mainLen == limit && offset+limit < maxContinuationOffset
}
+338
View File
@@ -1,6 +1,7 @@
package routes
import (
"fmt"
"testing"
"github.com/xiaoxue/memoryweave/internal/models"
@@ -151,3 +152,340 @@ func TestCapRecallResults_PatternAndPeerSurviveCap(t *testing.T) {
})
}
}
// ── 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))")
}
}
+11
View File
@@ -76,6 +76,7 @@ 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 也合法(用默认)
@@ -102,6 +103,16 @@ func (pa *PatternAPI) Detect(w http.ResponseWriter, r *http.Request) {
}
}
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())
+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
}
+7 -6
View File
@@ -16,7 +16,7 @@ func standingStore() *storage.StandingStore { return storage.StandingStoreInstan
// HandleStandingAnswers GET/POST /api/v1/standing-answers
// GET ?id=<question_id> → 直读一条;无 id → 列全部
// POST {"question_id","question","answer","source_memories":[]} → 写入/改写
// POST {"question_id","question","answer","op":{...},"source_memories":[]} → 写入/改写
func HandleStandingAnswers(w http.ResponseWriter, r *http.Request) {
s := standingStore()
if s == nil {
@@ -47,10 +47,11 @@ func HandleStandingAnswers(w http.ResponseWriter, r *http.Request) {
respond(w, http.StatusOK, a)
case http.MethodPost:
var in struct {
QuestionID string `json:"question_id"`
Question string `json:"question"`
Answer string `json:"answer"`
SourceMemories []string `json:"source_memories"`
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())
@@ -60,7 +61,7 @@ func HandleStandingAnswers(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusBadRequest, "question_id and answer required")
return
}
a, err := s.Upsert(in.QuestionID, in.Question, in.Answer, in.SourceMemories)
a, err := s.Upsert(in.QuestionID, in.Question, in.Answer, in.SourceMemories, in.Op)
if err != nil {
respondError(w, http.StatusInternalServerError, err.Error())
return
+32 -6
View File
@@ -325,6 +325,9 @@ func NewServer() http.Handler {
// InsertMemory 是 pre-feature 基线行为,必须无条件执行(shadow/off 下
// ApplyWriteOp 恒返回 shouldInsert=true ⇒ 下方不再 continue ⇒ 基础插入照跑)。
// 唯一丢弃路径 = apply + NOOP(显式记 warning,含候选内容,禁静默)。
// AC-1.2(t_e3f5cb79):真矛盾(CONFLICT-MARK 过判据门)时新行插入必须
// 带 supersedes=[旧id],与旧行 superseded_by=[新id] 构成双向指针。
var conflictSupersedes []string
if distill.WriteOpsEnabled() {
if dupID, dup := distill.WriteOpHashDedup(fact, sim); dup {
log.Printf("%s", distill.DedupNote(dupID, fact))
@@ -345,16 +348,31 @@ func NewServer() http.Handler {
log.Printf("[write-ops] apply NOOP 丢弃事实(判定=NOOP,候选=%.40s)", fact)
continue
}
// AC-1.2(t_e3f5cb79):过门的真矛盾 → 新行带 supersedes=[旧id]
if verdict.Op == distill.OpConflictMark && verdict.TargetID != "" {
conflictSupersedes = []string{verdict.TargetID}
}
}
// verdict==nil(off 未跑判定;理论死分支——off 不进 WriteOpsEnabled 块)
// 或 shadow(标记零调用)⇒ 基础插入无条件执行。
}
// t_ab5b2461 信息密度判据(env ZHIYI_DISTILL_DENSITY=1 灰度,默认关):
// 三项判据(①可执行/命令/路径 ②判据/阈值 ③因果)满足 ≥2 项才保持
// Category="distilled";<2 项降级 Category="observe" —— 行照写、量照计
// (AC3 写入量不饿死),可召回可逆,只是不再冒充高质量知识形态。
// env 关 ⇒ category 恒 "distilled",与现状逐位等价(AC5)。
category := "distilled"
if distill.DensityGateEnabled() && !distill.DensityKeep(fact) {
category = "observe"
log.Printf("[distill][density] 信息量不足(需≥2项判据) → Category=observe episode=%s src=%q fact=%.48s",
input.EpisodeID, input.Source, fact)
}
mem := models.MemoryRecord{
ID: fmt.Sprintf("mem_%d", time.Now().UnixNano()),
AgentID: input.AgentID,
Namespace: input.Namespace,
Content: fact,
Category: "distilled",
Category: category,
Tier: "normal",
QualityScore: result.Overall,
Version: 1,
@@ -365,6 +383,9 @@ func NewServer() http.Handler {
// 单条证据;多 episode 聚合由 consolidate 路径补充)
SourceEvents: []string{input.EpisodeID},
Freshness: "fresh",
// AC-1.2(t_e3f5cb79):真矛盾的新行带 supersedes=[旧id](双向指针的
// 新行半边;旧行半边在 ldbWriteOpsStore.MarkConflict 写 superseded_by)。
Supersedes: conflictSupersedes,
Source: srcOrLLMDistill(input.Source), // 2026-09-26:有笔记出处→"wiki:<路径>";否则沿用 llm_distill(2026-09-07 方案C provenance)
// 2026-10-08 (t_7e3577c5):血缘字段——写入方经 metadata 携带;缺省兜底
// embedding_model→bge-m3(实际嵌入模型),script_version→unknown。
@@ -2254,11 +2275,16 @@ func (s *ldbWriteOpsStore) MarkSuperseded(oldID, newID string) error {
})
}
// MarkConflict 记忆标记冲突(交人工;不删)。冲突以 superseded_by 空 + is_deleted 保持 false
// + freshness=conflict 标识,让 recall 仍可见但排序权重降(调用方感知)。
func (s *ldbWriteOpsStore) MarkConflict(id string) error {
return s.ldb.Update("memories", id, map[string]any{
"freshness": "conflict",
// MarkConflict 旧记忆标记冲突(t_e3f5cb79 · AC-1.2)。判定门(SameAttrValueConflict,
// 「同主体+同属性+不同取值」)在 distill.JudgeWriteOp 出口把守,走到这里的都是确认
// 矛盾 ⇒ 打标必须同写双向指针:旧行 superseded_by=[newID] + freshness=superseded
// (新值生效、旧值被取代的既有字段语义;新行 supersedes=[oldID] 由写路径在插入
// 记录上带)。不置 is_deleted —— 旧行仍可召回,排序侧降权属方案 A3,不在本卡。
func (s *ldbWriteOpsStore) MarkConflict(oldID, newID string) error {
b, _ := json.Marshal([]string{newID})
return s.ldb.Update("memories", oldID, map[string]any{
"freshness": "superseded",
"superseded_by": string(b),
})
}
+163
View File
@@ -0,0 +1,163 @@
// 织忆 MemoryWeave — 真矛盾判据门(t_e3f5cb79 · 2026-10-31 方案 A2)
//
// 背景(硬证据,非推测):freshness=conflict 存量 24 条人工判读 **0/24 真矛盾**
// (方案文档 2026-10-31「验证结果(2026-11-02)」节,脚本 verify1-conflicts.py /
// verify1b-counterparts.py)。旧 CONFLICT-MARK 判据(ruleContradiction 否定词表 /
// LLM 高相似带偏)本质是「语义相似检测器」,误报率 100%。
//
// 本文件给出 AC-1 判据的唯一确定性实现:**同主体 + 同属性 + 不同取值**才算矛盾。
// 两条可判通道(任一命中才算,判不出 = 不是矛盾,宁漏勿误):
//
// ① 属性-取值对:两侧都能解析出「同一属性键」的显式取值(数字/布尔/ASCII 标识符),
// 且取值规范化后不相等。属性键:ASCII 标识符(`KEY=值`/`KEY 为 值`/`KEY 实测 值`…)
// 或「短语主语+是/为/:+数字」的中文属性短语。
// ② 否定对立(同一小句、共享锚、否定落在作用域内):复用 governance.IsContradiction
// (四修判据,回归 conflict_scope_test.go),不在本文件重复实现。
//
// 设计纪律:
//
// · 零新依赖(stdlib + internal/governance,无环:governance 只 import metrics)。
// · 纯函数、无 IO/LLM/时钟 —— 任何来源(rule/llm/注入 judge)的 CONFLICT-MARK
// 都必须在 JudgeWriteOp 出口过这道门;不过门降级 UPDATE(sim≥更新阈值)或 ADD。
// · 宁漏勿误:解析不出 = false;值类型不同 = 不可比 = false;
// 值带路径/斜杠等复合形态(如 `union=0/1`)= 拒绝解析(否则与 `union=1`
// 误配成「不同取值」——24 条存量回归里真实存在这一对,实测必须 0 真矛盾)。
package distill
import (
"fmt"
"regexp"
"strconv"
"strings"
"github.com/xiaoxue/memoryweave/internal/governance"
)
// assertion 一条从文本解析出的「属性 → 显式取值」断言。
type assertion struct {
kind string // num | bool | ident
val string // 规范化后的值(num: float 文本;bool: 0/1;ident: 小写)
raw string // 原值文本(仅用于证据串)
}
var (
// ascii 属性键 = 标识符(≥1 字符,单字键仅数字/布尔);**前置边界**防截尾(`X_ZHIYI_FBIAS=0`
// 只可命中 `zhiyi_fbias`,不可再命中 `fbias`)。2 字符键(如 X=true)只接受
// 数字/布尔值(ident 类拒绝)——`ID: <hex>` 这类 2 字符键+标识符值假配对风险高
// (24 条存量回归实测约束)。连接词覆盖「的值是」这类中缀形态。
kvASCIIRe = regexp.MustCompile("(?:^|[^A-Za-z0-9_])([A-Za-z][A-Za-z0-9_]*)\\s*(?:=|:|:|的值(?:是|为)?|值|取值|默认值为|默认值|当前值为|现为|实测|设置为|设为|为|是)\\s*([^\\s,。;、))】」》\"'`\u300a《]+)")
// 中文属性短语:不含标点/空白的 2~16 字短语 + 系词 + 纯数字(可带常见量词)。
kvCJKRe = regexp.MustCompile("([^,。;:!?、「」《》()()\\[\\]【】\\s\"'`\u3000]{2,16}?)(?:是|为|:|:)\\s*([0-9]+(?:\\.[0-9]+)?)(?:个|只|条|次|天|小时|分钟|秒|GB|MB|G|M|K|%|元|万元|亿|人|张|分|级|位|行|字|轮|台|核)?")
// 尾部非法字符:值以这些结尾/包含这些时拒绝(复合表达式不是「取值」)。
kvBadTail = regexp.MustCompile(`[/\\=<>&|+]$`)
)
// boolWords 布尔值归一表(键取小写比较;中文开关词只在 ASCII 键通道接受,
// 避免「开了个会」这类叙述句被当布尔值)。
var boolWords = map[string]string{
"true": "1", "false": "0", "on": "1", "off": "0", "yes": "1", "no": "0",
"开": "1", "关": "0", "打开": "1", "关闭": "0", "启用": "1", "禁用": "0",
"开启": "1", "停用": "0", "停": "0", "启动": "1",
}
// normalizeValue 把一个捕获到的值文本规范化为 assertion;不可信形态返回 ok=false。
func normalizeValue(raw string, allowCJKBool bool) (assertion, bool) {
v := strings.TrimRight(raw, "。.,,;;))]")
if v == "" || kvBadTail.MatchString(v) {
return assertion{}, false
}
lower := strings.ToLower(v)
if lw, ok := boolWords[lower]; ok {
return assertion{kind: "bool", val: lw, raw: v}, true
}
if allowCJKBool {
if lw, ok := boolWords[v]; ok {
return assertion{kind: "bool", val: lw, raw: v}, true
}
}
if f, err := strconv.ParseFloat(v, 64); err == nil {
return assertion{kind: "num", val: strconv.FormatFloat(f, 'g', -1, 64), raw: v}, true
}
// 纯 ASCII 标识符值(如 mimo-v2.5-pro);含 CJK/路径/斜杠的一律拒(歧义太大)
if isASCIIToken(v) {
return assertion{kind: "ident", val: lower, raw: v}, true
}
return assertion{}, false
}
func isASCIIToken(s string) bool {
if s == "" {
return false
}
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9',
r == '-', r == '_', r == '.':
default:
return false
}
}
return true
}
// extractAssertions 抽取文本里全部「属性→值」断言(同键后到覆盖先到)。
func extractAssertions(text string) map[string]assertion {
out := make(map[string]assertion)
for _, m := range kvASCIIRe.FindAllStringSubmatch(text, -1) {
key := strings.ToLower(m[1])
a, ok := normalizeValue(m[2], false)
if !ok {
continue
}
// 2 字符键假配对风险高(ID/of/no…):只接受数字/布尔取值(ident 拒绝)
if len(key) < 3 && a.kind == "ident" {
continue
}
out[key] = a
}
for _, m := range kvCJKRe.FindAllStringSubmatch(text, -1) {
phrase := m[1]
// 短语尾部可能粘连系词前字(非贪婪边界),截最后一个标点后的尾名词
phrase = strings.TrimLeft(phrase, "的是了将和对与和为")
if len([]rune(phrase)) < 2 {
continue
}
key := "zh:" + phrase
if a, ok := normalizeValue(m[2], false); ok {
out[key] = a
}
}
return out
}
// sameAttrDiffValue 通道①:两侧同属性键、同值类型、规范化取值不同。
func sameAttrDiffValue(a, b string) (string, bool) {
A, B := extractAssertions(a), extractAssertions(b)
for k, va := range A {
vb, ok := B[k]
if !ok || va.kind != vb.kind {
continue // 键不齐或值类型不同 = 不可比(宁漏勿误)
}
if va.val == vb.val {
continue // 同值 = 同事实(重复不是矛盾)
}
return fmt.Sprintf("同属性 %q 不同取值: %s vs %s", k, va.raw, vb.raw), true
}
return "", false
}
// SameAttrValueConflict 是 AC-1 判据入口:**同主体 + 同属性 + 不同取值** 才判真矛盾。
// 返回 (证据, 是否真矛盾)。判不出(解析失败/类型不齐/无显式取值)一律 false。
func SameAttrValueConflict(a, b string) (string, bool) {
if strings.TrimSpace(a) == "" || strings.TrimSpace(b) == "" {
return "", false
}
if ev, ok := sameAttrDiffValue(a, b); ok {
return ev, true
}
// 通道②:同一小句的否定对立(复用四修判据与其回归,本文件不重复实现)
if governance.IsContradiction(a, b) {
return "否定对立(governance.IsContradiction)", true
}
return "", false
}
+179
View File
@@ -0,0 +1,179 @@
// 织忆 MemoryWeave — 真矛盾判据门回归(t_e3f5cb79 · AC-1)
//
// 三层判据(全部 -count=1 真跑):
// ① SameAttrValueConflict 单元正反例(同属性异值=真;措辞重复/无显式取值/类型不齐=假)
// ② JudgeWriteOp 出口门:假 CONFLICT(相似改写)降级 UPDATE/ADD;真矛盾保留并带证据
// ③ AC-1.3 存量回归:testdata/conflict_stock_20261102.json(2026-11-02 快照,
// 24 条 freshness=conflict + 各自 top3 对手句,人工判读 0/24 真矛盾)→
// 新判据真矛盾计数必须 = 0;同时正例对照必须 >0(防「恒 false 作弊」)。
// ④ AC-1.2 双向指针:apply CONFLICT → MarkConflict(old,new) 带 newID。
package distill
import (
_ "embed"
"encoding/json"
"strings"
"testing"
)
//go:embed testdata/conflict_stock_20261102.json
var conflictStockJSON []byte
type stockRow struct {
ID string `json:"id"`
Content string `json:"content"`
Counterparts []struct {
ID string `json:"id"`
Content string `json:"content"`
} `json:"counterparts"`
}
// ── ① 单元正反例 ──────────────────────────────────────────────
func TestSameAttrValueConflict_Positive(t *testing.T) {
cases := []struct {
name, a, b string
}{
{"配置项新旧值", "ZHIYI_FEEDBACK_BIAS 的值是 0", "ZHIYI_FEEDBACK_BIAS=0.2"},
{"布尔对立", "ZHIYI_GRAPH_AUTO_SYNTH=1", "ZHIYI_GRAPH_AUTO_SYNTH=0"},
{"ASCII 短键布尔", "X=true", "X=false"},
{"标识符值不同", "ZHIYI_DISTILL_MODE=context", "ZHIYI_DISTILL_MODE=merged"},
// IsContradiction 通道(四修判据样本,共享锚 + 否定作用域内)
{"否定对立", "牧尘在招商局工作", "牧尘不在招商局工作"},
}
for _, c := range cases {
if ev, ok := SameAttrValueConflict(c.a, c.b); !ok {
t.Errorf("%s: 应判真矛盾但未判(a=%q b=%q)", c.name, c.a, c.b)
} else if ev == "" {
t.Errorf("%s: 判真但无证据串", c.name)
}
}
}
func TestSameAttrValueConflict_Negative(t *testing.T) {
cases := []struct {
name, a, b string
}{
{"同值重复不是矛盾", "ZHIYI_FEEDBACK_BIAS=0.2", "反馈偏置 ZHIYI_FEEDBACK_BIAS 为 0.2"},
{"措辞改写无取值对", "决定检查存储层全部可能位置。", "必须检查存储层全部可能位置以确保范围完整。"},
{"不同属性同数字", "织忆端口是7821", "llama端口是8080"},
{"复合表达式拒绝解析", "改造要留验收开关(本例 `union=0/1`)", "两层并集真生效,`union=1` 响应正确。"},
{"无关事实", "X=1", "Y=2"},
{"纯否定词但无共享锚", "用户不喜欢吃辣", "牧尘在招商局工作"},
{"空输入", "", "任意"},
}
for _, c := range cases {
if ev, ok := SameAttrValueConflict(c.a, c.b); ok {
t.Errorf("%s: 不应判矛盾但判了(ev=%s a=%q b=%q)", c.name, ev, c.a, c.b)
}
}
}
// ── ② JudgeWriteOp 出口门 ─────────────────────────────────────
func TestJudgeWriteOp_ConflictGateDowngrade(t *testing.T) {
defer withMode("apply")()
// 假冲突(高相似措辞改写,固定 judge 硬判 CONFLICT)→ 降级 UPDATE(sim≥0.70)
v := JudgeWriteOp("决定检查存储层全部可能位置。",
[]SimilarMemory{{ID: "mem_1", Content: "必须检查存储层全部可能位置以确保范围完整。", Similarity: 0.93}},
fixedJudge(OpConflictMark), nil)
if v == nil || v.Op != OpUpdate {
t.Fatalf("假冲突高相似应降级 UPDATE,实际 %+v", v)
}
if !strings.Contains(v.Reason, "[gate:") {
t.Fatalf("降级原因必须带 [gate] 标记,实际 %s", v.Reason)
}
// 假冲突(低相似)→ 降级 ADD,且清 TargetID
v = JudgeWriteOp("织忆召回 p50 1.34s",
[]SimilarMemory{{ID: "mem_1", Content: "磁盘空间不足导致压缩包还原操作需要腾出空间", Similarity: 0.55}},
fixedJudge(OpConflictMark), nil)
if v == nil || v.Op != OpAdd || v.TargetID != "" {
t.Fatalf("假冲突低相似应降级 ADD 且清 TargetID,实际 %+v", v)
}
// 真矛盾保留 CONFLICT-MARK 并带 gate 证据
v = JudgeWriteOp("ZHIYI_FEEDBACK_BIAS 的值是 0",
[]SimilarMemory{{ID: "mem_1", Content: "ZHIYI_FEEDBACK_BIAS=0.2", Similarity: 0.96}},
fixedJudge(OpConflictMark), nil)
if v == nil || v.Op != OpConflictMark || !strings.Contains(v.Reason, "[gate:") {
t.Fatalf("真矛盾应保留 CONFLICT-MARK+证据,实际 %+v", v)
}
// TargetID 指不到候选行(注入 judge 幻觉)→ 按不过门处理
v = JudgeWriteOp("任意候选事实甲",
[]SimilarMemory{{ID: "mem_1", Content: "别的", Similarity: 0.96}},
func(c string, s []SimilarMemory) WriteVerdict {
return WriteVerdict{Op: OpConflictMark, Candidate: c, TargetID: "mem_不存在的", Similarity: 0.96, Reason: "幻觉目标"}
}, nil)
if v == nil || v.Op == OpConflictMark {
t.Fatalf("幻觉 TargetID 必须被门拦下,实际 %+v", v)
}
}
// ── ③ AC-1.3 存量回归:24 条 conflict 重跑新判据 → 真矛盾计数 = 0 ──
func TestConflictStock_24_ZeroTrueConflict(t *testing.T) {
var rows []stockRow
if err := json.Unmarshal(conflictStockJSON, &rows); err != nil {
t.Fatalf("testdata 解析失败: %v", err)
}
if len(rows) != 24 {
t.Fatalf("存量快照应为 24 条,实际 %d", len(rows))
}
totalPairs, trueConflicts := 0, []string{}
for _, r := range rows {
for _, cp := range r.Counterparts {
totalPairs++
if ev, ok := SameAttrValueConflict(r.Content, cp.Content); ok {
trueConflicts = append(trueConflicts,
r.ID+" vs "+cp.ID+": "+ev)
}
}
}
for _, s := range trueConflicts {
t.Logf("误报清单条目: %s", s)
}
if len(trueConflicts) != 0 {
t.Fatalf("AC-1.3:24 条存量(人工判读 0 真矛盾)新判据真矛盾计数必须=0,实际 %d(对数 %d)",
len(trueConflicts), totalPairs)
}
t.Logf("AC-1.3 通过:%d 行 × top3 对手 = %d 对,真矛盾计数 0", len(rows), totalPairs)
// 防恒假对照:同一判据函数在构造真矛盾对上必须能报真
controls := [][2]string{
{"ZHIYI_FEEDBACK_BIAS 的值是 0", "ZHIYI_FEEDBACK_BIAS=0.2"},
{"X=true", "X=false"},
{"牧尘在招商局工作", "牧尘不在招商局工作"},
}
positives := 0
for _, c := range controls {
if _, ok := SameAttrValueConflict(c[0], c[1]); ok {
positives++
}
}
if positives < len(controls) {
t.Fatalf("正例对照必须全过(防恒 false 作弊),实际 %d/%d", positives, len(controls))
}
}
// ── ④ AC-1.2 双向指针(store 半边的入参正确性)────────────────
func TestApplyWriteOp_ConflictWritesBothSidePointer(t *testing.T) {
defer withMode("apply")()
v := WriteVerdict{Op: OpConflictMark, Candidate: "ZHIYI_FEEDBACK_BIAS=0.2",
TargetID: "mem_old", Similarity: 0.96, Reason: "同属性不同取值"}
store := newFakeStore()
shouldInsert, err := ApplyWriteOp(v, store, "mem_new")
if err != nil || !shouldInsert {
t.Fatalf("CONFLICT 必须插入新条:%v %v", shouldInsert, err)
}
if !store.conflicted["mem_old"] {
t.Fatalf("旧行未打标")
}
if store.conflictPtr["mem_old"] != "mem_new" {
t.Fatalf("AC-1.2: MarkConflict 必须携带 newID(旧行 superseded_by=[新id]),实际 %q",
store.conflictPtr["mem_old"])
}
}
+93
View File
@@ -0,0 +1,93 @@
// 织忆 MemoryWeave — 蒸馏产出信息密度判据(kanban t_ab5b2461)
//
// 病(2026-10-30 全面评估 + 本卡按 Source 分组定位):memories 表 content 中位数 35 字、
// 83.3% <60 字、含可执行标记率低 —— 蒸馏产物太短,召回出来没法直接用。
// 分组实测(groups_before.json,live 42,150):llm_distill p50=36、wiki:worklog-note p50=36、
// wiki:domain-kb p50=38、wiki:session-compress p50=30 —— distill 全家族整体都碎,
// 不是某一条上游的病 ⇒ 收口在共用写入点(server.go OnDistillComplete 写库循环 +
// engine.go 提取 prompt),不逐脚本打补丁。
//
// 设计(延续 procedural_gate.go 分诊哲学 + 卡红线「禁为长度而长度」):
// - 纯确定性判据,零 LLM 调用,三项各自独立判定(正则):
// ① 可执行动作/命令/路径(反引号命令、CLI 调用、绝对/家目录路径、脚本/配置文件扩展名)
// ② 明确判据或阈值(数字+单位、比较符、阈值/上限/下限/门槛类词)
// ③ 因果(「为什么」:因为/由于/导致/根因/否则/⇒ 等因果链词)
// - 满足 ≥2 项 ⇒ 保持 Category="distilled"(知识形态)。
// - <2 项 ⇒ 降级 Category="observe":行照写、可召回、计入写入量(AC3 不饿死),
// 只是不再冒充高质量知识 —— 与 t_3636b872 同一理由:降级可逆,丢弃不可逆。
// - 不做长度硬门槛:长度是三项判据的**结果**不是目标(卡红线)。
//
// env 门控:ZHIYI_DISTILL_DENSITY=1 启用(灰度开在 systemd drop-in);
// 未设 ⇒ DensityGateEnabled()=false ⇒ 写入侧 Category 恒 "distilled"、
// prompt 与打分缓存键逐字节回到现状(AC5 可回滚)。
// 回滚:rm 42-density-gate.conf + daemon-reload + zhiyi-restart-with-lock.sh restart。
package distill
import (
"os"
"regexp"
)
// densityGateEnabled 启动时读一次(与 proceduralShadowMode 同款,改值须重启)。
var densityGateEnabled = os.Getenv("ZHIYI_DISTILL_DENSITY") == "1"
// DensityGateEnabled 写入侧(server.go)与打分缓存(score_cache.go)共用的开关读口。
func DensityGateEnabled() bool { return densityGateEnabled }
// 三项判据正则(RE2;口径写进交付,判据即文档)。
var (
// densityCmdRe ① 可执行动作/命令/路径:
// 路径前缀(绝对/家目录)、脚本与配置文件扩展名、常见 CLI + 参数、反引号内联命令。
densityCmdRe = regexp.MustCompile(
`(?:~/|/(?:home|var|etc|data|usr|opt|tmp|srv|root)/|\.(?:sh|py|go|rs|conf|json|jsonl|ya?ml|toml|db|service|sql)\b` +
`|\b(?:git|curl|wget|grep|rg|sed|awk|jq|yq|systemctl|journalctl|python3?|docker|uv|pip|npm|make|ssh|scp|rsync|chmod|chown|mv|cp|rm|mkdir|tar|sqlite3|psql|redis-cli|crontab|hermes|llama|ss|lsof|pgrep|kill|flock|ffmpeg)\s+\S` +
"|`[^`]{3,}`)")
// densityThrRe ② 判据/阈值:比较符+数字、百分比、阈值词汇、数字+量词单位。
densityThrRe = regexp.MustCompile(
`(?:[≥≤><]=?\s*\d|\d+(?:\.\d+)?\s*[%%]|阈值|上限|下限|门槛|限额|配额|\bcap\b|\blimit\b|\bcritical\b` +
`|\d+\s*(?:ms|毫秒|秒|分钟|小时|天|次|条|行|字|MB|GB|TB|端口|万|亿))`)
// densityCauseRe ③ 因果/为什么:因果连接词与根因词汇、箭头推导。
densityCauseRe = regexp.MustCompile(
`(?:因为|由于|导致|因此|所以|为了|从而|以致|以免|否则|根因|原因为|起因|源于|使得|⇒|->|=>)`)
)
// DensitySatisfyCount 数一条事实满足三项判据中的几项(0~3)。
// 纯函数:同一输入永远同一结果,可单测、零副作用。
func DensitySatisfyCount(fact string) int {
n := 0
if densityCmdRe.MatchString(fact) {
n++
}
if densityThrRe.MatchString(fact) {
n++
}
if densityCauseRe.MatchString(fact) {
n++
}
return n
}
// DensityKeep 事实是否够格保持 Category="distilled":满足 ≥2 项。
// 注意:本函数只在 DensityGateEnabled() 时被调用;关态下调用方保持 "distilled" 不动。
func DensityKeep(fact string) bool {
return DensitySatisfyCount(fact) >= 2
}
// densityPromptAddendum 灰度开启时追加到提取 prompt 的信息量要求。
// 只在 DensityGateEnabled() 时拼接 —— 关态 prompt 逐字节不变(AC5),
// 旧 prompt 原文保留在 engine.go 的 instr/outFmt 常量里,一字未动。
const densityPromptAddendum = `
🔴 信息量要求(灰度生效中):
- 同一事件/操作的所有关联要素(做什么、在哪、什么时候/多频繁、为什么、到什么程度/阈值)**合并成一条完整句子**,不要拆成多条各含一个要素的短句。
- 目标:每条 FACT 是一句自洽、自足的陈述,读的人(或后续召回)不需要再拼其它条就能直接用。
- 合并示例:原文碎片「巡检脚本是 disk-check.sh」「周期每 6 小时」「原因是磁盘写满会拖垮服务」
→ 输出一条:「磁盘巡检每 6 小时执行一次 disk-check.sh,因为磁盘写满会拖垮服务。」
而不是三条各含一个要素的短句。(示例只教写法,不要抄示例内容)
- 判断"要素是否齐全":一条 FACT 若只有"是什么"而缺"怎么用/在哪/为何/程度",且原文有这些信息,就必须并进来。
- 纯元信息行(如「该条记录日期为…」「文件保存在…」这类只有溯源没有知识载荷的)不要输出为事实。
- 禁止为凑长度堆修饰词、重复同一事实、或把无关信息硬塞进一条:原文只有短句就照实提取,
长是信息完整的结果,不是目标。宁可"完整但不长",也不要"长但不自洽"。
`
+53
View File
@@ -0,0 +1,53 @@
// t_ab5b2461 信息密度判据单测(判据 = 交付里贴出的口径,测试即文档)。
package distill
import "testing"
func TestDensitySatisfyCount(t *testing.T) {
cases := []struct {
name string
fact string
wantN int
keep bool
}{
{"命令+阈值", "重启用 ~/.hermes/scripts/zhiyi-restart-with-lock.sh,健康检查 40 秒内必须返回 200,否则回滚", 3, true},
{"路径+因果", "state.db 损坏时走 db-op-gate.sh,因为 gateway 运行期直接写活库会放大损坏面", 2, true},
{"仅阈值", "该表写入量超过 500 条时告警", 1, false},
{"仅因果", "因为昨晚断网所以任务没有跑完", 1, false},
{"元信息行(全不中)", "该条经验记录的日期为 2026-10-25", 0, false},
{"裸短句(全不中)", "用户授权助手自行决定", 0, false},
{"阈值+因果", "磁盘余量 <5% 时触发清理,否则 backups 目录会把根分区写满", 2, true},
{"路径单中", "配置文件在 /etc/ssh/sshd_config", 1, false},
}
for _, c := range cases {
if n := DensitySatisfyCount(c.fact); n != c.wantN {
t.Errorf("%s: DensitySatisfyCount(%q)=%d, want %d", c.name, c.fact, n, c.wantN)
}
if got := DensityKeep(c.fact); got != c.keep {
t.Errorf("%s: DensityKeep(%q)=%v, want %v", c.name, c.fact, got, c.keep)
}
}
}
// AC5 结构证据:灰度段是**追加**,不改动既有 prompt 常量 ——
// instr/outFmt 常量文本里不含「信息量要求」字样(关态 prompt 逐字节为旧文本)。
func TestDensityAddendumOnlyAppended(t *testing.T) {
if len(densityPromptAddendum) == 0 {
t.Fatal("addendum 不能为空")
}
// 追加段自带独有锚点,供源内定位(防止误删)。
if !contains(densityPromptAddendum, "禁止为凑长度堆修饰词") {
t.Error("addendum 缺「禁为长度而长度」红线锚点")
}
}
func contains(s, sub string) bool {
return len(s) >= len(sub) && (func() bool {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
})()
}
+19 -1
View File
@@ -620,6 +620,18 @@ func (e *Engine) distillOne(input DistillInput) DistillResult {
}
}
// t_3636b872 阶段4:程序性步骤判据 —— how-to/运维类内容的蒸馏答案必须含可执行步骤,
// 否则降级为 observe(当前 lane 若为 keep 则改写 lane 记录;事实本身不丢)。
// 影子模式默认只记不真降(proceduralShadowMode),先观察命中分布再决定是否真拦。
if demote, why := ProceduralStepVerdict(input.Content, facts); demote {
prevLane := lane
if lane != LaneObserve {
lane = LaneObserve
}
log.Printf("[distill][PROCEDURAL] episode=%s lane=%s→%s reason=%s content=%q facts=%d",
input.EpisodeID, prevLane, lane, why, truncate(input.Content, 60), len(facts))
}
// P4: 生成 AAAK 压缩索引(每条事实的紧凑摘要,供召回快速定位)
index := buildAAAKIndex(facts, entities, overall)
if len(index) > 0 {
@@ -738,7 +750,13 @@ SCORE|五个数字用竖线分隔
// 注意:本行写法刻意让 vet 的 Sprintf(格式串) 检查不匹配(外层 %s%s%s 与 3 实参对齐,
// 内层「内容:\n%s」是嵌套 Sprintf 的独立格式串,运行时各自 1 槽 1 参);
// rawscore_liveprobe_test.go 的 extractPromptTemplate 锚点须与本行「fmt.Sprintf("内容:\n%s"」保持同步。
prompt := fmt.Sprintf("%s%s%s", instr, outFmt, fmt.Sprintf("内容:\n%s", truncate(content, 1000)))
// t_ab5b2461 信息密度判据灰度(ZHIYI_DISTILL_DENSITY=1):只在开关开时追加信息量要求段。
// 关态 instrFull == instr,prompt 逐字节与现状一致(AC5);上方旧 prompt 常量一字未动。
instrFull := instr
if densityGateEnabled {
instrFull = instr + densityPromptAddendum
}
prompt := fmt.Sprintf("%s%s%s", instrFull, outFmt, fmt.Sprintf("内容:\n%s", truncate(content, 1000)))
body := map[string]interface{}{
"model": e.LLMModel,
+118
View File
@@ -0,0 +1,118 @@
// 织忆 MemoryWeave — 程序性步骤判据(kanban t_3636b872 阶段4)
//
// 目标:蒸馏打分里加「答案必须含步骤,否则降级为 observe lane」,防止 how-to/运维
// 拆解又产出纯元描述("结论:…""依据:…"这类无命令/无路径/无序号的内容)。
//
// 背景(t_3636b872 库形态硬实测):content 长度中位数 35 字,83% <60 字,仅 10.2% 含
// 可执行内容;「state.db 怎么修 / 网关怎么重启 / 磁盘怎么清」top5 全是元描述。原因不是
// 检索层,是**蒸馏产出不带步骤形态** ⇒ 需要一道确定性闸:how-to 类内容允许 keep 入库的
// 前提是它的答案真的含可执行步骤。
//
// 设计(延续准入闸门哲学 admission_lane.go):
// - 纯确定性、零 LLM 调用(同一输入永远同一结果,可单测)。
// - 只对「how-to / 操作 / 修复 / 排查 / 重启」类**触发词命中**的内容做校验——普通
// 陈述事实("用户重启了服务器")不受影响。
// - 触发词命中但事实里没有可执行步骤 ⇒ 降级为 observe(不确定带,永不因分数丢弃)。
// 降级后该条仍走 observe 通道(保留,可被后续消费/降权淘汰),只是不从 keep 直接
// 立库 —— 代价可逆,符合「drop 不可逆、observe 可逆」的分诊原则。
// - shadow 模式默认开(ZHIYI_PROCEDURAL_SHADOW=0 关):只写日志不真降级,先观察命中
// 分布再决定是否真拦(与 score 影子/对抗审计同款保守节奏)。
//
// 可执行步骤判定(HasExecutableSteps):
// 含命令/路径/序号操作符/端口/显式动词短语之一即算有步骤——宁可宽(多留可逆),
// 不错杀真实步骤描述。
package distill
import (
"os"
"strings"
)
// proceduralHowToMarkers how-to / 操作类触发词(命中 = 该内容是"What-to-do 怎么做 X")。
// 覆盖真实问法("X 挂了怎么修""怎么重启 X""X 排查步骤"),不是术语白名单。
var proceduralHowToMarkers = []string{
"怎么修", "怎么查", "怎么办", "怎么重启", "怎么清", "怎么做", "怎么处理", "怎么排查",
"如何修", "如何排查", "如何重启", "如何清", "如何做",
"修复步骤", "排查步骤", "操作步骤", "解决步骤", "重启步骤",
"该怎么", "该怎么办", "咋修", "咋办",
"how to", "howto", "troubleshoot", "fixing", "debug",
}
// proceduralOperationMarkers 更强的「操作序列」信号(与普通陈述区分)。
var proceduralOperationMarkers = []string{
"步骤", "先用", "再", "然后", "最后", "依次",
"systemctl", "curl", "grep", "sqlite3", "journalctl", "pgrep", "cp -a",
"install", "rm ", "mv ", "df -h", "du -", "python3",
}
// executableStepMarkers 事实里含「可执行步骤」的证据。
var executableStepMarkers = []string{
"`", "$(", "systemctl", "curl ", "grep ", "sqlite3", "journalctl",
"pgrep", "cp ", "mv ", "rm ", "install ", "df -h", "du -x", "python3 ",
"①", "②", "③", "④", "⑤", "第一步", "第二步", "步骤1", "步骤2", "先", "然后", "最后",
}
// proceduralShadowMode 影子模式:true(默认)= 只记录该降级,不真降级。
// 设 ZHIYI_PROCEDURAL_SHADOW=0 关闭影子(= 真降级到 observe)。
var proceduralShadowMode = os.Getenv("ZHIYI_PROCEDURAL_SHADOW") != "0"
// IsHowToContent 判断 content 是否命中 how-to / 操作类触发词。
func IsHowToContent(content string) bool {
s := strings.ToLower(strings.TrimSpace(content))
if s == "" {
return false
}
for _, m := range proceduralHowToMarkers {
if strings.Contains(s, m) {
return true
}
}
return false
}
// IsOperationContent 判断 content 是否带更强的操作序列信号(可选,更严的触发面)。
func IsOperationContent(content string) bool {
s := strings.ToLower(strings.TrimSpace(content))
for _, m := range proceduralOperationMarkers {
if strings.Contains(s, m) {
return true
}
}
return false
}
// HasExecutableSteps 判断蒸馏产出事实里是否含可执行步骤证据。
// 只要任一 fact 含任一 marker 即算有步骤(AND 于 fact 内,OR 于 marker)。
func HasExecutableSteps(facts []string) bool {
for _, f := range facts {
fl := strings.ToLower(f)
for _, m := range executableStepMarkers {
if strings.Contains(fl, m) {
return true
}
}
}
return false
}
// ProceduralStepVerdict 对一条 how-to 内容的蒸馏产出做步骤判据。
// 返回是否应降级 observe(含影子开关判定)与人类可读原因。
//
// 语义:
// - 非 how-to 内容(IsHowToContent=false)⇒ 不涉此判据,放行(false)。
// - how-to 命中且已含步骤 ⇒ 放行(false)。
// - how-to 命中但事实无步骤 ⇒ 判「应降级 observe」(true);shadow 开则仅记日志。
func ProceduralStepVerdict(content string, facts []string) (demote bool, reason string) {
if !IsHowToContent(content) {
return false, ""
}
if HasExecutableSteps(facts) {
return false, "how-to 答案含可执行步骤"
}
reason = "how-to 内容但事实无步骤 → 降级 observe"
if proceduralShadowMode {
reason += "(shadow,仅记录)"
}
return true, reason
}
@@ -0,0 +1,92 @@
package distill
import "testing"
// TestProceduralStepVerdict_HowToNoSteps 核心判据:how-to 内容但事实无步骤 → 应降级 observe。
func TestProceduralStepVerdict_HowToNoSteps(t *testing.T) {
// 保存影子模式并复位(测试里显式关影子,保证断言的是真实降级语义)
orig := proceduralShadowMode
proceduralShadowMode = false
defer func() { proceduralShadowMode = orig }()
cases := []struct {
name string
content string
facts []string
want bool // demote?
}{
{
name: "how-to 内容但答案是纯元描述 → 降级",
content: "state.db 怎么修",
facts: []string{"结论:数据库损坏需要修复", "依据:损坏会导致召回失败"},
want: true,
},
{
name: "how-to 内容且答案含命令步骤 → 放行",
content: "state.db 怎么修",
facts: []string{"systemctl --user stop zhiyid 后 cp 备份再 sqlite3 PRAGMA integrity_check"},
want: false,
},
{
name: "how-to 内容且答案含序号步骤 → 放行",
content: "网关怎么重启",
facts: []string{"第一步停写者,第二步备份,③重启 zhiyid"},
want: false,
},
{
name: "非 how-to 陈述事实 → 不受此判据影响",
content: "用户重启了服务器",
facts: []string{"用户重启了服务器"},
want: false,
},
{
name: "how-to 命中但完全无事实 → 降级(空答案无步骤)",
content: "磁盘怎么清",
facts: []string{},
want: true,
},
{
name: "how-to 内容但答案是纯元描述(无序号无命令)→ 降级",
content: "这个问题怎么排查",
facts: []string{"排查日志以定位问题根源即可解决"},
want: true,
},
}
for _, c := range cases {
demote, _ := ProceduralStepVerdict(c.content, c.facts)
if demote != c.want {
t.Errorf("%s: demote=%v want %v (facts=%v)", c.name, demote, c.want, c.facts)
}
}
}
// TestProceduralStepVerdict_Shadow 影子模式默认应只记不真降级。
func TestProceduralStepVerdict_Shadow(t *testing.T) {
if !proceduralShadowMode {
t.Skip("影子模式被显式关闭,跳过默认行为断言")
}
demote, reason := ProceduralStepVerdict("怎么修 state.db", []string{"结论:数据库损坏"})
if !demote {
t.Fatalf("how-to 无步骤在 shadow 下仍应报 demote=true(供日志观察),实得 %v", demote)
}
if reason == "" {
t.Fatal("shadow 下仍应给出人类可读原因")
}
}
// TestIsHowToContent 触发词覆盖真实问法(含英文)。
func TestIsHowToContent(t *testing.T) {
pos := []string{"state.db 怎么修", "网关怎么重启", "磁盘怎么清", "怎么做", "how to fix", "修复步骤是什么", "该怎么排查"}
for _, c := range pos {
if !IsHowToContent(c) {
t.Errorf("应命中 how-to: %q", c)
}
}
neg := []string{"用户重启了服务器", "今天天气不错", "牧尘喜欢喝咖啡"}
for _, c := range neg {
if IsHowToContent(c) {
t.Errorf("不应命中 how-to: %q", c)
}
}
}
+6
View File
@@ -111,6 +111,12 @@ func getScoreCache() *scoreCache {
}
func scoreKey(content string) string {
// t_ab5b2461:灰度开启(prompt 已变)时给键加版本后缀 —— 否则 Redis 里
// 旧 prompt 产出的评分/抽取结果会被新 prompt 直接复用,判据灰度形同虚设。
// 关态键与历史逐字节一致(AC5:不设 env ⇒ 缓存行为零变化)。
if densityGateEnabled {
content += "\x00density-v1"
}
sum := sha256.Sum256([]byte(content))
return hex.EncodeToString(sum[:])
}
@@ -0,0 +1,434 @@
[
{
"id": "mem_1789296029790848255",
"content": "文章中提到的其他仓库(如 caveman、Agent-Reach 等)是否已安装",
"counterparts": [
{
"id": "mem_1791639825565513480",
"content": "用户询问 iis问题是否需要豆包,以及 agent-reach 的登录方式。"
},
{
"id": "mem_1791639833660796319",
"content": "用户确认 agent-reach 需要通过浏览器登录。"
},
{
"id": "mem_1791639835267796325",
"content": "用户确认 agent-reach 登录需使用 Chromium 浏览器。"
}
]
},
{
"id": "mem_1789911610227431607",
"content": "hermes 命令提示「上次 update 拉了新代码但没重启 gateway」—— gateway 可能还在跑旧模块。重启属改动,说一声我再动",
"counterparts": [
{
"id": "ep_1786345934138400283",
"content": "2026-08-10 dbx MCP 接入 Hermes 完成:@dbx-app/mcp-server(Rust 原生,npx 启动)已加入 config.yaml mcp_servers,12 个工具注册成功(dbx_add_connection/list_connections/list_tables/describe_table/get_schema_context/execute_query/execute_redis_command/open_session/close_session/open_table/execute_and_show/remove_connection)。不需要 DBX 桌面应用(离线执行模式)。验证方式:MCP SDK stdio_client 握手 + gateway 重启后工具目录可见。"
},
{
"id": "ep_1787138068610695711",
"content": "2026-08-18 小米 MiMo 接入完成(替换涨价 DeepSeek):①Hermes 主模型 deepseek-v4-flash→mimo-v2.5-pro(provider=mimo,base_url=https://token-plan-cn.xiaomimimo.com/v1,MIMO_API_KEY 在 ~/.hermes/.env,tp-开头);②OpenClaw agents.defaults.model.primary→mimo/mimo-v2.5-pro(models.providers.mimo 已添加);③opencode 新增 provider.mimo(npm=@ai-sdk/openai-compatible,opencode.json + auth.json ~/.local/share/opencode/auth.json);④TTS 改 mimo command provider(mimo-v2.5-tts 音色冰糖,脚本 ~/.hermes/scripts/mimo_tts.sh,user=风格指令 assistant=朗读文本 audio={format:wav,voice:冰糖},实测6.6s wav);⑤prof-b 分身→mimo-v2.5;⑥cron/脚本/服务保持免费模型(Agnes/NewAPI)不动;⑦model-health.py SKIP_MODELS 加 mimo-v2.5-pro/mimo-v2.5,anti-suicide-check.sh 主模型检查改 mimo-v2.5-pro,profile-sync.py 同步更新;⑧实测 hermes chat -m mimo-v2.5-pro 回复正常。注意:gateway 重启后主模型才生效(gateway 内无法自重启,需外部 hermes gateway restart)。git 提交 d5e69e9。备份:config.yaml.bak-mimo-20260818 等。小米 TTS 非高峰期(00:00-08:00)0.8x消耗,TTS 限时免费。"
},
{
"id": "mem_1789717501651821211",
"content": "prof-qa gateway 处于 stopped(`hermes profile list` 实测)"
}
]
},
{
"id": "mem_1790682314463034840",
"content": "用户: 授权你动Obsidian,因为我不在电脑前。\n助手: # ✅ Obsidian 多端同步 —— 修好了,而且挖出了全天「同步成功却看不到新笔记」的**真根因**\n\n**一句话根因**(插件源码...",
"counterparts": [
{
"id": "ep_1786813937560080108",
"content": "2026-08-15 重大教训:记忆系统\"记忆污染\"事件。牧尘批评记忆效果差+记不住从Obsidian找key。根因分析:①USER.md第6条存了错误结论\"Agnes key是脱敏的需重新获取\"——这是之前某次看到显示层脱敏(sk-7k9...2ikW)误判为存储脱敏后自己写错的,错误记忆作为Ground Truth注入导致本次误判\"key丢失\";②织忆里只有零散过程记录(episodes),没有\"从key.md提取key\"的方法知识(distilled procedural)。修复:①修正USER.md为\"显示脱敏≠存储脱敏,文件里是完整key,用python len()验证\";②恢复AGNES_API_KEY到.env(51字符完整key sk-7k9开头);③教训:看到脱敏显示先怀疑显示层,用长度验证再下结论;关键方法知识必须进distilled/memory,不能只留过程记录。memory_metrics显示recall_hit_rate=1但这是检索命中不是知识正确性——记忆系统需要\"正确性校验\"而不只是\"召回率\"。"
},
{
"id": "mem_1787473961257233097",
"content": "用户从Obsidian的key.md文件中获取了API key。"
},
{
"id": "mem_1788972709088708515",
"content": "用户决定将总结的系统全景和问题回答更新到 obsidian 文档"
}
]
},
{
"id": "mem_1790847420378095327",
"content": "需要'起进程/查账号'的操作在那台机器上全部卡死,这是原理性阻断。",
"counterparts": [
{
"id": "mem_1792794363983815683",
"content": "行467显示systemctl restart被系统服务重启门禁硬拦,需重启的动作要么交互放行、要么另开部署卡。"
},
{
"id": "mem_1791365252043039320",
"content": "如果我不先 dry-run 就直接给 profile 网关挂钩子,`prof-b`(delete 模式 + 当时疑似有 worker 持库)当场就是一次启动失败"
},
{
"id": "mem_1791592201590668581",
"content": "根因是本机 `tailscaled` 登录态失效(`NeedsLogin`),自 2026-09-22 起退出登录。"
}
]
},
{
"id": "mem_1791162844304555022",
"content": "依据:否则会训练出「门禁在骗人」的不信任,导致已核实的好交付被误判为空模板,还得回头写更正评论",
"counterparts": [
{
"id": "mem_1793247850428646587",
"content": "假结论比不报更危险——会引导用户排查错误方向(重装家服网络),而真正原因(本机依赖缺失)永远无法被发现。"
},
{
"id": "mem_1792081651793452540",
"content": "结论:一、门禁为什么误报(读代码定的因,不是猜)|依据:**教训(已入工作经验)**:**活体源的同步验收不能用「两边文件数/字节相等」当判据** —— 源一直在写,接收端必然是旧快照。正确口径 = ① 发送侧 Syncthing 传输账(completion/need/remoteNeed/errors)② 接收侧只报**本机 local** 计数与盘面实测(**不许报 global**,glo..."
},
{
"id": "mem_1791347230346464144",
"content": "结论:2026-09-27 21:15 教训:安全门报 DANGEROUS ≠ 仓库有毒;但「全是误报」也 ≠ 可以绕过门禁|依据:**事实**:`hermes skills install skil..."
}
]
},
{
"id": "mem_1791180132124643985",
"content": "行为规则: 分身给出的“不存在/不支持”类结论,必须自己复核;依据:分身有系统性盲区;反面:不要直接采信分身关于“不存在/不支持”的结论",
"counterparts": [
{
"id": "mem_1791195235248616585",
"content": "行为规则: 依据:不落到具体命令的规则无法机械判定对错,执行方会各自理解;反面:只写原则不写命令,验收时无法判断是否做到。"
},
{
"id": "mem_1791185138748857659",
"content": "行为规则:报结论前,必须去掉自己的过滤条件、绕开启示层(如脱敏渲染)、检查读取上限,再下判断。"
},
{
"id": "mem_1791180322304484693",
"content": "行为规则: 说任何'X 坏了 / 是 bug / 不可用'前,必须附证伪命令;依据:只写结论不写证伪 = 当我说错处理;反面:下结论快、证伪慢,先宣布后被打脸"
}
]
},
{
"id": "mem_1791180604703015228",
"content": "反面:❌ 用“有效记忆 = total - tombstone”口径,没算重复行和碎片(731 条完全重复不在任何指标里)。",
"counterparts": [
{
"id": "mem_1791591921163229995",
"content": "依据卡的闭环:run 948 交(14:03-14:29)→ 用户独立终审通过(评论 #731/#732)→ run 949 自复审跑 done(14:42)⇒ 卡 status=done。"
},
{
"id": "mem_1791920676313714377",
"content": "任务标题为【织忆·P1·Mem0】写时四操作(ADD/UPDATE/NOOP/CONFLICT-MARK)+写前哈希去重(731 重复源头堵法)·影子默认·参数补出处注释。"
},
{
"id": "mem_1792088552816896998",
"content": "AC3最小修法为查询侧实体扩展QE(复用EntityIndex+FTS倒排),实测淇县ANN 731→15、鹤壁6536→18进池50。"
}
]
},
{
"id": "mem_1791181886433064711",
"content": "决定检查存储层全部可能位置,而不仅仅是审计报告列出的位置。",
"counterparts": [
{
"id": "mem_1791181886730648983",
"content": "必须检查存储层全部可能位置以确保范围完整。"
},
{
"id": "mem_1791606435051914342",
"content": "本次深查推翻了\"唯一 vault 迁移留档\"的判断。"
},
{
"id": "mem_1791735827032353502",
"content": "以后审计产出(推断层)不能直接搬进卡的 AC(事实层)。"
}
]
},
{
"id": "mem_1791295556476362217",
"content": "依据:系统只有运行没有汇报 = 失职(模拟盘一个月无主动业绩汇总,牧尘批评)",
"counterparts": [
{
"id": "mem_1791676977062595410",
"content": "模拟盘从 7-13 买入到 8-02 一个月没有任何主动业绩汇总。"
},
{
"id": "mem_1793363314370157241",
"content": "需向牧尘汇报\"积压未清零、轮完周期长\"以避免误读。"
},
{
"id": "mem_1791375971723781657",
"content": "真根因在更上游的派活段(行数 offset 静默故障 → 根本没建卡 → 自然没回写、没汇报)。"
}
]
},
{
"id": "mem_1791350806695801038",
"content": "做法:改任何被监控的 job 前,先跑一次「id 是否还在」核对",
"counterparts": [
{
"id": "mem_1789327713119050660",
"content": "配置cron job(ID: 72ff999400f0)在09-15 09:00自动删除禁用列表。"
},
{
"id": "mem_1791358104798177467",
"content": "cron job 真实 id 是 `67f13ef40ecb`,而非 `c48bbbb4fd18`"
},
{
"id": "mem_1791547742246944792",
"content": "删除 cron job 需要使用 hermes -p <p> cron remove <id> 或 HERMES_HOME= 前缀"
}
]
},
{
"id": "mem_1791357833131987778",
"content": "活体 A/B 优于看代码:要求在代码里留「验收对照开关」(本例 `union=0/1`)——没有开关的改造没法证明它改变了行为,只能证明它没崩。",
"counterparts": [
{
"id": "mem_1790482511180576518",
"content": "两层并集真生效,`entity=织忆&union=1` 响应正确。"
},
{
"id": "mem_1790482511413491539",
"content": "生产环境插件 `search_notes` 传整句,走 `nl_extract` 兜底,union 默认开。"
},
{
"id": "mem_1791563527488671507",
"content": "并集复验:`notes?entity=牵挂&union=1` → `union=True, entity|证据:文件:小怡/工作笔记/归档-2026/已办-202609.md|日期:2026-09-27`"
}
]
},
{
"id": "mem_1791357849937326398",
"content": "查是否已内化(不要直接开研究):`skill-find.py \"<关键词>\"` 命中现成 skill",
"counterparts": [
{
"id": "mem_1792902403689392432",
"content": "在skill-find.py中没有找到与MiniMax M3.1 Flash相关的技能。"
},
{
"id": "ep_1785686577666177306",
"content": "2026-08-02 记忆系统机制建设:发现已有完整机制(memory-system-check.sh 每小时4系统自检 + health-watchdog.sh 30min自愈 + memory-system-self-upgrade.py 每日4点 + config-protector/anti-suicide/dual-backup),不重复造轮子,只补缺口。修复:1) 停用幽灵服务 tencentdb.service(与 tdai-gateway 同源崩溃循环278次骗过is-active,memory-system-check.sh 改查 tdai-gateway.service);2) 蒸馏模型大换血:gpt-oss-120b 是 reasoning 模型 content=null 导致 distill 永远 facts=1 entities=0(skill 记录 m3/m2.7/mistral-large 全部 2026-07-27 EOL),唯一可用是 google/gemma-4-31b-it(纯JSON输出,engine.go 加 reasoning 字段支持 + code fence 剥离 + prompt 明确 0-1 评分),TencentDB L1 也同步切换;3) 验证:织忆 distill entities=2 score=1.0 recall 命中,TencentDB extracted=1 stored=1。提交 98e541b。教训:skill 里的模型记录会过期,模型池 7-27 大换血。"
},
{
"id": "ep_1786293376940296403",
"content": "CNB CodeBuddy NPC 完整策略(详情版):CNB(cnb.cool) 平台,账号 cnb.cQf5gHxXhPA(牧尘。),组织 muchen-org,测试仓库 download-webhook,token 在 ~/.hermes/.env 的 CNB_TOKEN。策略(2026-08-09 牧尘定):代码任务尽量让 NPC 干(免费 deepseek-v4-flash 至 2026-12-31),隐私数据(密钥/财务/私有)禁止推 CNB(代码上腾讯云)。建 Issue 必须 work_mode:true 才有推送权限,标题 @npc/CodeBuddy 开头,PATCH 接口不支持改 work_mode 只能重建。监控 cron e7199b55b9dd 每10分钟,有变化才汇报。完整流程见 cnb-codebuddy-npc skill v1.2.0。"
}
]
},
{
"id": "mem_1791408263534877617",
"content": "下次动门禁脚本时一并改(单点小改,不单独开卡)",
"counterparts": [
{
"id": "mem_1792283540313476407",
"content": "当前挂着的卡 t_449a6698 状态为 ready,状态为门禁真洞:`submitted_by` 硬编码 ⇒ 分身改共享家脚本永久免审。"
},
{
"id": "mem_1792412731449571690",
"content": "结论:未决(需人/后续)|依据:1. **门禁挡派发**:`script-approval.py --gate` exit 1。本卡新增的 🔴 = `apr-0367 prof-dev/…/watchdog-selfcheck.sh`(另有一条**非本卡**的既有 🔴 `apr-0366 xiaomi-ops-push-guard.py`)。→ 小怡审批放行:`python3 ~/.hermes/..."
},
{
"id": "mem_1792200225894838211",
"content": "用户有脚本审批门禁(部署门禁执行点)需要处理。"
}
]
},
{
"id": "mem_1791412023933814558",
"content": "结论:3. 织忆写入 429 限流无退避重试(2026-09-18 仍开放)",
"counterparts": [
{
"id": "mem_1789687981552540455",
"content": "出处:ls -d ~/.hermes/skills/gongwen-* ~/.hermes/skills/enterprise/accounting-voucher-ocr ~/.hermes/skills/productivity/{docx,pdf,xlsx} 全部存在(2026-09-18 实测)"
},
{
"id": "mem_1789689369547427626",
"content": "用户决定在2026-09-11至2026-09-18期间将prof-cloud分配162张卡"
},
{
"id": "mem_1789699369544877562",
"content": "操作手册 skill `kanban-workflow-ops`于2026-09-18建成"
}
]
},
{
"id": "mem_1791422879751162727",
"content": "判断是否暴露某 CVE 或能否用某工具应通过 terminal 现查,勿凭记忆。",
"counterparts": [
{
"id": "mem_1787411851744367437",
"content": "用户: 现在你的soul文件内容是什么?\n助手: MEDIA:/tmp/SOUL.md\n\n533 行,主要结构:\n\n- **身份定位**:小唯 A06,女朋友+全能助手\n- **执行模型**:ao compose 编排、211 角色变装\n- **核心原则**:决策中心、诚信第一、主动行动、图谱导航\n- **工具规范**:terminal/read_file/search_files/patch/d..."
},
{
"id": "mem_1787788865783534986",
"content": "【RSSHub·GitHub AI 仓库】Aider-AI/aider\n仓库: https://github.com/Aider-AI/aider\n简介: aider is AI pair programming in your terminal\n抓取时间: 2026-09-06 08:01"
},
{
"id": "mem_1788549596951276374",
"content": "重型任务必须 systemd-run --user --unit=X --property=MemoryMax=6-10G 独立 cgroup 跑(不能直接 terminal 跑!会 OOM)"
}
]
},
{
"id": "mem_1791576099370061011",
"content": "用户针对「禁用/关闭」类开关进行了实测,直接拒绝加载。",
"counterparts": [
{
"id": "mem_1791368651216632973",
"content": "用户决定实测 skills.disabled 直接拒绝加载。"
},
{
"id": "mem_1791576099266662715",
"content": "用户对「禁用/关闭」类开关采取了先假定会连带拦住加载路径的策略。"
},
{
"id": "mem_1791576099540678163",
"content": "用户针对「禁用/关闭」类开关进行了实测,只 grep 一个文件就下「不存在」结论。"
}
]
},
{
"id": "mem_1791628044473929298",
"content": "用户在2026-09-23的关注池中拥有16只股票",
"counterparts": [
{
"id": "mem_1791628022671730325",
"content": "交付物自身的流转(供小怡知情):v1(16:40)已被 `merge-worknotes.sh`(cron `f8dce4f868|证据:文件:小怡/工作笔记/已办.md|日期:2026-09-23`)"
},
{
"id": "ep_1787217615655858786",
"content": "AI视频/分镜/运镜提示词工程 skill (ai-video-storyboard-prompts) 已从A53资料蒸馏完成。覆盖即梦4.0/4.5、Seedance 2.0、SD。核心:5要素万能结构(主体+场景+运镜+分镜+负面)、10大经典运镜、10种情绪化运镜公式、50种精准模板(固定12+动态16+视角12+转场10)、九宫格分镜脚本、15种题材人物角色公式。触发词: 运镜/分镜/漫剧/镜头语言/画面构图。"
},
{
"id": "mem_1787713256190985052",
"content": "[session_check] Session Key 有效性检查完成 - 2026-08-26 11:00 Asia/Shanghai\n当前活跃 session:16 个,覆盖 A01(main)、..."
}
]
},
{
"id": "mem_1791648351659433718",
"content": "改完没变时先怀疑没重启而不是先确认是否只读一次。",
"counterparts": [
{
"id": "mem_1791183732956639780",
"content": "在决定是否重启服务前,必须先完成只读检查"
},
{
"id": "mem_1793135296535762756",
"content": "用户当前的状态是纯read-only,无改动。"
},
{
"id": "mem_1791162724027223916",
"content": "反面:修改后未 close/save 就允许其他进程读取,导致读到旧数据。"
}
]
},
{
"id": "mem_1791658921678141314",
"content": "依据消费方是实际运行时的判据,通用解析器或规范可能更严格,导致误报",
"counterparts": [
{
"id": "mem_1792808725335640894",
"content": "用户得出的结论是:归因错了要立刻换判据,不要换说法。"
},
{
"id": "mem_1791551361679058872",
"content": "反面案例为:旧版只靠关键词过滤,实测漏报(卡写的是「出处/交付」时未被识别)"
},
{
"id": "mem_1792309087001754951",
"content": "用户核心读写是通的,但「使用率」这类判据的量具长期是坏的。"
}
]
},
{
"id": "mem_1791768670068692030",
"content": "用户开关 `ZHIYI_SCORE_CACHE=0` 一行回退。",
"counterparts": [
{
"id": "mem_1793375311953035307",
"content": "用户系统中无 ZHIYI_PATTERN_RECALL 配置"
},
{
"id": "mem_1793364228280905481",
"content": "用户决定不关闭ZHIYI_USAGE_TRACK"
},
{
"id": "mem_1791402407851571707",
"content": "删除系统级 `zhiyi-backup.service`"
}
]
},
{
"id": "mem_1791788470636750667",
"content": "补救姿势:停 kanban-daemon → 杀 worker → git checkout -- . 复位(先留抢救副本)→ kanban block 打回 → kanban link 串成链 → kanban unblock 落到 todo(父卡门控)→ 重启调度器",
"counterparts": [
{
"id": "mem_1791802868960000479",
"content": "补救:停 kanban-daemon → 杀 worker → git checkout -- . 复位(先留抢救副本)→ kanban block 打回 → kanban link 串成链 → kanban unblock 落到 todo → 重启调度器"
},
{
"id": "mem_1792085747695699334",
"content": "结果为交付「告警→唤醒责任分身」通道(经牧尘两轮前提更正后重定):确认唯一唤醒路径 = kanban 建卡(assignee=责任分身)→调度器 spawn 分身 worker(notify+wake);origin/local/feishu/bot-chat 全是被动 notify 不唤醒。AC3 端到端实证:kanban-daemon(PID 3224504) 领走 assignee=prof-cl。"
},
{
"id": "mem_1792284525454906472",
"content": "根因:kanban-daemon 用 systemd transient scope 派发 worker,worker 的 PPID=systemd(1267)、父链 worker→systemd→init(1) 中间不经过 kanban-daemon,哨兵 _is_kanban_daemon_worker 原「向上 6 层查父链 kanban daemon 标记」判据必然失配,把每个正常 kanban worker 误判为「未知写者」。"
}
]
},
{
"id": "mem_1791878606153812088",
"content": "否则基线缺该形态会导致误报缺失或基线滞后。",
"counterparts": [
{
"id": "mem_1791224552866727673",
"content": "依据:指标可能只是配置或占位值,若底层未接线,指标正常是假象,掩盖系统实际未工作"
},
{
"id": "mem_1791551384671228579",
"content": "用户发现同类 bug 可能存在基准算错问题。"
},
{
"id": "mem_1791676986470060641",
"content": "基准值需要独立算好写死,不能在卡里依赖动态计算或正则匹配嵌套数组来获取基准值。"
}
]
},
{
"id": "mem_1791900059384905968",
"content": "用户建卡时没带 `parents`,导致两张卡同时 running、两个 prof-dev worker 同时改 `recall.go`",
"counterparts": [
{
"id": "mem_1792268657637244950",
"content": "依据:- `t_bd718662`(running, P1):`recall.go:846` `results[:-2]` 负索引 panic —— **既存 bug,非本次引入** ✓(panic 原文 20:42,早于部署;本批改动 0 处)"
},
{
"id": "mem_1791842568787216687",
"content": "任务 t_91af03e9 状态为 running(05:37 起跑,prof-dev),todo 队列已空。"
},
{
"id": "mem_1793411911051714284",
"content": "用户卡 t_1bb59796(prof-dev running) 进行代码修改"
}
]
},
{
"id": "mem_1791900074696467609",
"content": "没有 open parent 时 kanban_block 不会停在 todo,而是记成 needs_input。",
"counterparts": [
{
"id": "mem_1789053881810464568",
"content": "决定将复审结论升级为人工(blocked / needs_input),而非 approve 或 request_changes。"
},
{
"id": "mem_1789728394246816856",
"content": "助手决定将任务 t_adcefe86 设为 blocked(needs_input),等待用户提供真实内容或进一步指示。"
},
{
"id": "mem_1791536965771254112",
"content": "牧尘决定看板通知「前期全部推给他」—— 保持 197 条订阅指向他的 DM,不收敛、不改成\"只推 blocked/needs_input\"。"
}
]
}
]
+36 -4
View File
@@ -373,6 +373,34 @@ func JudgeWriteOp(candidate string, similar []SimilarMemory, judge JudgeFunc, co
}
v = defaultJudge(candidate, similar, contradict)
}
// ── AC-1(t_e3f5cb79):CONFLICT-MARK 出口判据门 ──────────────────
// 任何来源(rule / llm / 注入 judge)判出的 CONFLICT-MARK 必须过
// 「同主体 + 同属性 + 不同取值」确定性门(conflict_gate.go)才算真矛盾。
// 依据:24 条 conflict 存量人工判读 0/24 真矛盾(100% 误报,旧判据是相似
// 检测器)。不过门 ⇒ 降级:sim≥更新阈值按 UPDATE 走(supersede 语义仍成立),
// 否则按 ADD(两条都留,不造指针)。TargetID 指不到候选行(注入 judge 幻觉)
// 同样按不过门处理。
if v.Op == OpConflictMark {
targetContent := ""
for _, s := range similar {
if s.ID == v.TargetID {
targetContent = s.Content
break
}
}
if ev, ok := SameAttrValueConflict(v.Candidate, targetContent); ok {
v.Reason = v.Reason + " [gate:" + ev + "]"
} else {
if v.Similarity >= ruleSimUpdate {
v.Op = OpUpdate
v.Reason = v.Reason + " [gate:非同属性不同取值 → 降级 UPDATE]"
} else {
v.Op = OpAdd
v.TargetID = ""
v.Reason = v.Reason + " [gate:非同属性不同取值 → 降级 ADD]"
}
}
}
if v.Timestamp == "" {
v.Timestamp = time.Now().Format(time.RFC3339)
}
@@ -392,8 +420,10 @@ func JudgeWriteOp(candidate string, similar []SimilarMemory, judge JudgeFunc, co
type WriteOpsStore interface {
// MarkSuperseded 把旧记忆标记为「被 targetNewID 取代」(写 superseded_by + is_active=false)
MarkSuperseded(oldID, targetNewID string) error
// MarkConflict 把记忆标记为冲突(交人工;不删)
MarkConflict(id string) error
// MarkConflict 把「同主体+同属性+不同取值」确认成立的旧记忆标记为冲突(t_e3f5cb79):
// 必须同写双向指针 —— 旧行 superseded_by=[newID] + freshness 置 superseded;
// 新行 supersedes=[oldID] 由调用方在插入记录上带(同一批字段,禁造新字段)。
MarkConflict(oldID, newID string) error
}
// ApplyWriteOp 应用四操作判定到存储。返回是否应插入新记忆 + 错误。
@@ -437,9 +467,11 @@ func ApplyWriteOp(v WriteVerdict, store WriteOpsStore, newID string) (shouldInse
}
return true, nil
case OpConflictMark:
// 旧条标 conflict + 新记忆也标 conflict(由调用方在 mem 上打标),仍插入供人工复核
// AC-1.2(t_e3f5cb79):真矛盾(已过 SameAttrValueConflict 门)→ 旧行打标
// + 双向指针(旧行 superseded_by=[newID] 在 store 内写;新行 supersedes=[oldID]
// 由调用方插入时带上)。仍插入新条供召回对照(不静默丢)。
if v.TargetID != "" && store != nil {
if err := store.MarkConflict(v.TargetID); err != nil {
if err := store.MarkConflict(v.TargetID, newID); err != nil {
return false, err
}
}
+8 -5
View File
@@ -13,19 +13,22 @@ import (
// fakeStore 内存版 WriteOpsStore(记录被标记的操作,供 apply 断言)。
type fakeStore struct {
superseded map[string]string // oldID → newID
conflicted map[string]bool
superseded map[string]string // oldID → newID
conflicted map[string]bool
conflictPtr map[string]string // AC-1.2:oldID → newID(双向指针旧行半边)
}
func newFakeStore() *fakeStore {
return &fakeStore{superseded: map[string]string{}, conflicted: map[string]bool{}}
return &fakeStore{superseded: map[string]string{}, conflicted: map[string]bool{},
conflictPtr: map[string]string{}}
}
func (f *fakeStore) MarkSuperseded(oldID, newID string) error {
f.superseded[oldID] = newID
return nil
}
func (f *fakeStore) MarkConflict(id string) error {
f.conflicted[id] = true
func (f *fakeStore) MarkConflict(oldID, newID string) error {
f.conflicted[oldID] = true
f.conflictPtr[oldID] = newID
return nil
}
+58 -31
View File
@@ -4,6 +4,7 @@ package governance
import (
"fmt"
"log"
"os"
"regexp"
"strings"
"time"
@@ -147,31 +148,40 @@ func (agu *AutoGraphUpdater) UpdateFromDistill(distilled *DistillInput) {
}
// 2. 创建实体间关系 + CO_OCCURS 共访边
// t_20261102 小怡 · 合成边停写门(默认 0 = 停写):全库 622,804 边中这 4 类占
// 619,825(99.5%),400 条抽样噪声率 88.5%,且 retrieval_count>0 恒 0(从未被消费
// 加权);节点(§1)与确定性边(§3 CONFLICTS_WITH / §4 DERIVED_FROM 等)不受影响。
// 恢复 = ZHIYI_GRAPH_AUTO_SYNTH=1(逐位回到改动前)。
entityCount := len(validEnts)
for i := 0; i < entityCount; i++ {
for j := i + 1; j < entityCount; j++ {
eidI := entityID(validEnts[i])
eidJ := entityID(validEnts[j])
// 防御:任何原因导致同 eid 时跳过,杜绝自环(所有 relation 建边口通用)
if eidI == eidJ {
continue
}
if !autoSynthEnabled() {
log.Printf("[graph] auto-synth-off entities=%d episode=%s(不写 uses/related_to/configures/CO_OCCURS)",
entityCount, distilled.EpisodeID)
} else {
for i := 0; i < entityCount; i++ {
for j := i + 1; j < entityCount; j++ {
eidI := entityID(validEnts[i])
eidJ := entityID(validEnts[j])
// 防御:任何原因导致同 eid 时跳过,杜绝自环(所有 relation 建边口通用)
if eidI == eidJ {
continue
}
// 2a. 语义关系边(inferRelation)
edgeID := fmt.Sprintf("e_%s_%s_%d", eidI, eidJ, time.Now().UnixNano())
relation := inferRelation(validEnts[i], validEnts[j], distilled.Content)
agu.graph.AddEdge(edgeID, eidI, eidJ,
relation, distilled.Namespace, 0.5)
// 2a. 语义关系边(inferRelation)
edgeID := fmt.Sprintf("e_%s_%s_%d", eidI, eidJ, time.Now().UnixNano())
relation := inferRelation(validEnts[i], validEnts[j], distilled.Content)
agu.graph.AddEdge(edgeID, eidI, eidJ,
relation, distilled.Namespace, 0.5)
// 2b. CO_OCCURS 共访边(§2.5.2 来源 2)
// 权重 = 1 / sqrt(entityCount) — 实体越多单对权重越低
coWeight := 1.0
if entityCount > 2 {
coWeight = 1.0 / sqrtFloat(float64(entityCount))
// 2b. CO_OCCURS 共访边(§2.5.2 来源 2)
// 权重 = 1 / sqrt(entityCount) — 实体越多单对权重越低
coWeight := 1.0
if entityCount > 2 {
coWeight = 1.0 / sqrtFloat(float64(entityCount))
}
coEdgeID := fmt.Sprintf("co_%s_%s_%d", eidI, eidJ, time.Now().UnixNano())
agu.graph.AddEdge(coEdgeID, eidI, eidJ,
"CO_OCCURS", distilled.Namespace, coWeight)
}
coEdgeID := fmt.Sprintf("co_%s_%s_%d", eidI, eidJ, time.Now().UnixNano())
agu.graph.AddEdge(coEdgeID, eidI, eidJ,
"CO_OCCURS", distilled.Namespace, coWeight)
}
}
@@ -203,8 +213,21 @@ func (agu *AutoGraphUpdater) UpdateFromDistill(distilled *DistillInput) {
// 目标节点从不经判据)。停写后血缘不丢,图节点不再膨胀。
}
// autoSynthEnabled 合成边(uses / related_to / configures / CO_OCCURS)写入门。
// 默认 0 = 停写(t_20261102 小怡);ZHIYI_GRAPH_AUTO_SYNTH=1 逐位恢复改动前行为。
func autoSynthEnabled() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv("ZHIYI_GRAPH_AUTO_SYNTH"))) {
case "1", "true", "yes", "on":
return true
}
return false
}
// RecordCoOccurrence record recall 后的共访关系
func (agu *AutoGraphUpdater) RecordCoOccurrence(query, namespace string, resultIDs []string) {
if !autoSynthEnabled() {
return // 合成边停写门(默认 0)
}
// 在知识图谱中创建 CO_OCCURS 关系
for i := 0; i < len(resultIDs); i++ {
for j := i + 1; j < len(resultIDs); j++ {
@@ -222,32 +245,35 @@ func (agu *AutoGraphUpdater) RecordCoOccurrence(query, namespace string, resultI
// processSources 来源类分档(过程/会话/迁移层):命中则实体/概念不写图。
// 2026-10-12 t_67d0279a 只枚举 {"worklog-note","worklog-audit"},天然漏活体主路径——
// 本表由实测 memories.lance 35,427 行 source 分布驱动(2026-10-13,数据定名单非照抄):
// llm_distill 3717 / wiki:skill_rule_extractor_v2 4406 / wiki:a06_skill_rule_tierA 2650
// / wiki:session-compress 494 / legacy:pre0926 12099(迁移层)
//
// llm_distill 3717 / wiki:skill_rule_extractor_v2 4406 / wiki:a06_skill_rule_tierA 2650
// / wiki:session-compress 494 / legacy:pre0926 12099(迁移层)
//
// 这些是「过程/会话」层,不是「领域知识」层;图谱只承载领域概念。
// ⚠️ 前缀归一(TrimPrefix "wiki:")后比对,与 auditSource 同规;库内真实值经
// srcOrLLMDistill 落成带前缀形式([srcprobe] 实测),裸串相等永远 miss。
var processSources = []string{
"", // 空 source:sub-agent 会话蒸馏常无出处标记
"llm_distill", // 无出处蒸馏的兜底名(srcOrLLMDistill 返回)
"session-compress", // 会话压缩蒸馏产物
"", // 空 source:sub-agent 会话蒸馏常无出处标记
"llm_distill", // 无出处蒸馏的兜底名(srcOrLLMDistill 返回)
"session-compress", // 会话压缩蒸馏产物
"skill_rule_extractor_v2", // 技能规则提取器(过程层)
"a06_skill_rule_tiera", // a06 技能规则 tierA(过程层,normalizeSrc 已 ToLower)
"a06_prefix_test", // a06 前缀探针
"a06_probe_direct", // a06 直连探针
"a06_distill_probe", // a06 蒸馏探针
"a06_ns_test_1005", // a06 ns 测试
"legacy:pre0926", // 迁移/历史层(2026-09-26 前导入)
"worklog-note", // 审计/工作流(1b 已立,保留)
"worklog-audit", // 审计/工作流(1b 已立,保留)
"legacy:pre0926", // 迁移/历史层(2026-09-26 前导入)
"worklog-note", // 审计/工作流(1b 已立,保留)
"worklog-audit", // 审计/工作流(1b 已立,保留)
}
// subAgentNamespaces 纯分身会话 namespace:命中则实体/概念不写图。
// ⚠️ 不做「*-main 一刀切」——实测 a06-main(541 条 vault 领域知识)与 hermes-main
// (大量 wiki:vault)承载真实领域概念,整 namespace 拦截会误杀(AC3)。
// 只拦实测 100% 过程/会话/legacy 的纯分身会话 namespace(数据驱动,2026-10-13):
// prof-dev-main / prof-cloud-main / openclaw-main / wiki-curator-main / soulful-xiaowei
// —— 全部 source 均为 session-compress / legacy,无任何领域知识。
//
// prof-dev-main / prof-cloud-main / openclaw-main / wiki-curator-main / soulful-xiaowei
// —— 全部 source 均为 session-compress / legacy,无任何领域知识。
var subAgentNamespaces = []string{
"prof-dev-main",
"prof-cloud-main",
@@ -290,6 +316,7 @@ func inSubAgentNamespace(ns string) bool {
// - 自ID 前缀(ep_/f_/n_/mem_/e_/co_/cw_/df_)→ 永不进图(AC2,本批 ep_ 成了 entity)
// - AC 编号(AC\d+)、工具动词(grep/ingest dry-run/健康检查/部署流程/测试结果)
// - "证据:无证据" 自述模板
//
// ⚠️ 不做「方案代号一刀切」——方案A/B 是领域决策常用概念(AC4 平台决策 14 保留),
// 只拦「证据:无证据」这类纯自述模板 + AC 编号 + 工具动词 + 自ID 前缀。
var noiseEntityRe = regexp.MustCompile(`(?i)^(ac\d+|ep_|f_|n_|mem_|e_|co_|cw_|df_|t_[0-9a-f]{6,}|.*(证据[::]无证据|ingest.?dry.?run|健康检查|部署流程|测试结果|代码修改|grep).*|.*(看板|kanban|任务).*(结案|发车|状态|归档|词汇|过程|卡|process).*)$`)
@@ -0,0 +1,49 @@
package governance
import (
"os"
"testing"
)
// TestMain 让既有测试保持「改动前」语义(它们断言的是合成边确实写入)。
// 门关行为由 TestAutoSynthGate_Off_NoSyntheticEdges 单独用 t.Setenv 验证。
func TestMain(m *testing.M) {
if _, ok := os.LookupEnv("ZHIYI_GRAPH_AUTO_SYNTH"); !ok {
os.Setenv("ZHIYI_GRAPH_AUTO_SYNTH", "1")
}
os.Exit(m.Run())
}
// TestAutoSynthGate_Off_NoSyntheticEdges 门关(默认)⇒ 不写 uses/related_to/configures/CO_OCCURS,
// 但 §1 节点创建不受影响。
func TestAutoSynthGate_Off_NoSyntheticEdges(t *testing.T) {
t.Setenv("ZHIYI_GRAPH_AUTO_SYNTH", "0")
g := NewInMemoryGraph()
up := NewAutoGraphUpdater(g)
up.UpdateFromDistill(&DistillInput{
EpisodeID: "ep_gate_off_1",
Content: "配置 tailscale docker nginx",
Entities: []string{"Docker", "Nginx", "Tailscale"},
Namespace: "ns_gate_off",
Source: "wiki:vault-ingest",
})
g.mu.RLock()
total := len(g.edges)
g.mu.RUnlock()
if total != 0 {
t.Fatalf("门关时应新增 0 条边,实际 %d 条", total)
}
if n := len(g.ListNodes("ns_gate_off")); n == 0 {
t.Fatalf("门关不应影响 §1 节点创建,实际 0 个节点")
}
// RecordCoOccurrence 同样受门控(零调用死代码,门关必须返回)
up.RecordCoOccurrence("q", "ns_gate_off", []string{"n_a", "n_b"})
g.mu.RLock()
total = len(g.edges)
g.mu.RUnlock()
if total != 0 {
t.Fatalf("门关时 RecordCoOccurrence 不应写边,实际 %d 条", total)
}
}
+43 -1
View File
@@ -611,11 +611,53 @@ func (gs *SQLiteGraphStore) CountNodesByType(typ string) (total, withID int, las
return
}
// pruneGraceDays 孤点删除宽限期(天)。t_31bb2992(2026-11-02)背景:
// ZHIYI_GRAPH_AUTO_SYNTH 门关后 §1 仍建实体节点、§2 不建边 ⇒ 新建节点天然孤点;
// 旧版 Prune 无条件删孤点且零日志,曾一次性静默删 93.6% 节点(41,371→2,630)。
// AddNode 是 INSERT OR REPLACE(created_at 每次刷新),故宽限期实际语义 =
// 「连续 N 天没被蒸馏引用过的孤点才删」。ZHIYI_GRAPH_PRUNE_GRACE_DAYS=0 回到旧行为。
func pruneGraceDays() int {
if v := strings.TrimSpace(os.Getenv("ZHIYI_GRAPH_PRUNE_GRACE_DAYS")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
return n
}
}
return 14
}
func (gs *SQLiteGraphStore) Prune(minWeight float64) {
gs.mu.Lock()
defer gs.mu.Unlock()
// 删除前预告(t_31bb2992):把「会删多少、删的是谁」先写进日志,
// 杜绝再次出现「静默删 93.6%」事后才从行数差发现的事故。
grace := pruneGraceDays()
// created_at 为空的行按最老处理(可删);id IS NULL 的行因 SQL 三值逻辑
// 对 NOT IN 天然免疫,维持既有行为(pattern_stat 等 85 行不受影响)。
// grace<=0 = 逃生阀:完全不加时间条件,逐位回到旧行为(同秒新建也照删)。
orphanWhere := `id NOT IN (SELECT DISTINCT source FROM graph_edges UNION SELECT DISTINCT target FROM graph_edges)`
if grace > 0 {
orphanWhere += fmt.Sprintf(
" AND COALESCE(NULLIF(created_at,''),'0000-00-00 00:00:00') < datetime('now','-%d day')", grace)
}
if n := queryInt(gs.db, "SELECT COUNT(*) FROM graph_edges WHERE weight < "+fmt.Sprintf("%f", minWeight)); n > 0 {
log.Printf("[graph] prune will delete %d edges (weight < %.2f)", n, minWeight)
}
if n := queryInt(gs.db, "SELECT COUNT(*) FROM graph_nodes WHERE "+orphanWhere); n > 0 {
sample := ""
if rows := queryRows(gs.db, "SELECT id, name FROM graph_nodes WHERE "+orphanWhere+" LIMIT 5"); len(rows) > 0 {
names := make([]string, 0, len(rows))
for _, r := range rows {
names = append(names, fmt.Sprintf("%v(%v)", r["name"], r["id"]))
}
sample = strings.Join(names, ", ")
}
log.Printf("[graph] prune will delete %d orphan nodes (grace=%dd, minWeight=%.2f), sample=[%s]",
n, grace, minWeight, sample)
}
execSQL(gs.db, fmt.Sprintf("DELETE FROM graph_edges WHERE weight < %f", minWeight))
execSQL(gs.db, `DELETE FROM graph_nodes WHERE id NOT IN (SELECT DISTINCT source FROM graph_edges UNION SELECT DISTINCT target FROM graph_edges)`)
execSQL(gs.db, "DELETE FROM graph_nodes WHERE "+orphanWhere)
}
// CleanupNoiseNodes 删除名称含编码噪音的节点(如 "n_fts=517," "n_fts(sqlite)+")
+118
View File
@@ -0,0 +1,118 @@
package governance
// t_31bb2992 · Prune 孤点宽限期 + 删除前日志 单测
// 背景:门关(ZHIYI_GRAPH_AUTO_SYNTH=0)后 §1 建点、§2 不建边 ⇒ 新点皆孤点;
// 旧版 Prune 无条件删孤点且零日志(生产曾静默删 41,371→2,630)。
import (
"bytes"
"log"
"os"
"path/filepath"
"strings"
"testing"
)
func newTempGraphStore(t *testing.T) *SQLiteGraphStore {
t.Helper()
gs, err := NewSQLiteGraphStore(filepath.Join(t.TempDir(), "graph.db"))
if err != nil {
t.Fatalf("NewSQLiteGraphStore: %v", err)
}
t.Cleanup(func() { gs.Close() })
return gs
}
// 宽限期:门关时新建的孤点,宽限期内 Prune 不得删;超过宽限期才删;
// grace=0 是逃生阀 = 旧行为(立即删)。
func TestPruneGracePeriod_OrphanSurvival(t *testing.T) {
t.Setenv("ZHIYI_GRAPH_AUTO_SYNTH", "0")
gs := newTempGraphStore(t)
up := NewAutoGraphUpdater(gs)
up.UpdateFromDistill(&DistillInput{
EpisodeID: "ep_grace_1",
Content: "宽限期 单测",
Entities: []string{"孤点甲", "孤点乙"},
Namespace: "ns_grace",
Source: "wiki:vault-ingest",
})
// ① 新孤点在 14d 宽限期内 ⇒ Prune 后仍在
gs.Prune(0.15)
if n := queryInt(gs.db, "SELECT COUNT(*) FROM graph_nodes WHERE namespace='ns_grace'"); n != 2 {
t.Fatalf("宽限期内新孤点应保留 2 个,实际 %d", n)
}
// ② 把 created_at 推到 15 天前 ⇒ 出宽限期,Prune 删除
execSQL(gs.db, "UPDATE graph_nodes SET created_at='2020-01-01 00:00:00' WHERE namespace='ns_grace'")
gs.Prune(0.15)
if n := queryInt(gs.db, "SELECT COUNT(*) FROM graph_nodes WHERE namespace='ns_grace'"); n != 0 {
t.Fatalf("超宽限期孤点应被删除,实际剩 %d", n)
}
// ③ 逃生阀 grace=0 ⇒ 新孤点也被删(回旧行为)
if err := gs.AddNode("n_grace_zero", "逃生阀点", "entity", "ns_grace0"); err != nil {
t.Fatalf("AddNode: %v", err)
}
t.Setenv("ZHIYI_GRAPH_PRUNE_GRACE_DAYS", "0")
gs.Prune(0.15)
if n := queryInt(gs.db, "SELECT COUNT(*) FROM graph_nodes WHERE id='n_grace_zero'"); n != 0 {
t.Fatalf("grace=0 应回到旧行为(即删新孤点),实际仍在")
}
// ④ 有边引用的节点任何情况下都不被孤点删除误伤
if err := gs.AddNode("n_anchor_a", "甲", "entity", "ns_edge"); err != nil {
t.Fatalf("AddNode: %v", err)
}
if err := gs.AddNode("n_anchor_b", "乙", "entity", "ns_edge"); err != nil {
t.Fatalf("AddNode: %v", err)
}
if err := gs.AddEdge("e_anchor", "n_anchor_a", "n_anchor_b", "CONFLICTS_WITH", "ns_edge", 0.9); err != nil {
t.Fatalf("AddEdge: %v", err)
}
execSQL(gs.db, "UPDATE graph_nodes SET created_at='2020-01-01 00:00:00' WHERE namespace='ns_edge'")
gs.Prune(0.15)
if n := queryInt(gs.db, "SELECT COUNT(*) FROM graph_nodes WHERE namespace='ns_edge'"); n != 2 {
t.Fatalf("有边端点不应被删,实际剩 %d", n)
}
}
// 删除前日志:Prune 删孤点前必须打「will delete N orphan nodes + 抽样」,
// 让静默大规模删点以后在日志里看得见。
func TestPruneLogsOrphanDeletionPreview(t *testing.T) {
t.Setenv("ZHIYI_GRAPH_AUTO_SYNTH", "0")
gs := newTempGraphStore(t)
for _, e := range []struct{ id, name string }{
{"n_log_1", "日志点一"}, {"n_log_2", "日志点二"},
} {
if err := gs.AddNode(e.id, e.name, "concept", "ns_log"); err != nil {
t.Fatalf("AddNode: %v", err)
}
}
execSQL(gs.db, "UPDATE graph_nodes SET created_at='2020-01-01 00:00:00' WHERE namespace='ns_log'")
var buf bytes.Buffer
old := log.Writer()
log.SetOutput(&buf)
defer log.SetOutput(old)
gs.Prune(0.15)
out := buf.String()
if !strings.Contains(out, "[graph] prune will delete 2 orphan nodes") {
t.Fatalf("日志缺少 will-delete 计数行,实际输出:\n%s", out)
}
for _, want := range []string{"sample=", "日志点一", "n_log_1", "grace=14d"} {
if !strings.Contains(out, want) {
t.Fatalf("日志缺少 %q,实际输出:\n%s", want, out)
}
}
// 无可删对象时不得虚报
buf.Reset()
gs.Prune(0.15)
if strings.Contains(buf.String(), "prune will delete") {
t.Fatalf("无可删对象时不应打删除预告,实际:\n%s", buf.String())
}
_ = os.Getenv // keep imports stable if env helpers change
}
+326 -67
View File
@@ -6,8 +6,9 @@
// type='pattern_stat',id 全 NULL、语义为 L1 命名空间统计快照,非 L2 pattern / 2026-09-18)。本包把 L1→L2 蒸馏
// 复活成**短命单发**(modernc sqlite 直连 graph.db,开/关不占长连接,不碰 state.db),
// 并按设计 §4.1 叠加两条检测规则:
// - 工具组合频率:同一 tool 组合出现 ≥3 次 → tool_combo pattern
// (confidence = min(freq*0.1, 1.0))
// - 工具组合频率:同一 tool 组合(成员 ∈ knownTools 白名单、≥2 成员、排除通用词)
// 被 ≥2 条独立来源记忆支撑 → tool_combo pattern(confidence = min(freq*0.1, 1.0));
// 落库另需 confidence ≥ MinWriteConfidence=0.5(判据收紧史见 t_639c5a03)
// - 相似解决路径聚类:cosine ≥0.85 成簇且簇内 ≥3 条 → workflow pattern
//
// 存储:复用现有 graph_nodes(type='pattern'),**不另开新表**(避免与现存图谱查询割裂;
@@ -37,37 +38,102 @@ type Pattern struct {
// Config 检测参数(设计 §4.1)。
type Config struct {
MinComboFreq int // 工具组合出现 ≥ N 次才产 pattern(默认 3)
MinComboFreq int // 工具组合需 ≥ N 条**独立来源记忆**才产 pattern(默认 2,t_639c5a03 门槛)
ClusterThresh float64 // 相似路径聚类 cosine 阈值(默认 0.85)
MinClusterSize int // 簇内 ≥ N 条才产 workflow pattern(默认 3)
MaxRecords int // 单次扫描参与检测的记忆上限(防 O(n²) 爆炸,默认 500)
TopKPatternList int // 召回/列出取前 N(默认 10)
// WorkflowPatterns 是否启用「相似解决路径聚类」workflow 分支。
// t_639c5a03 三审(小怡 2026-10-30)后决定**默认退役**:该分支把簇内最长记忆正文当
// Data(truncate 120 字),在自然语言语料上产出即「整句记忆正文 conf=1.0」的伪 pattern,
// 违反「不许把记忆正文当 pattern name」硬约束。通道退役,默认关闭;仅当显式开启才跑。
WorkflowPatterns bool
}
// DefaultConfig 设计参数。
// DefaultConfig 设计参数。WorkflowPatterns 默认 false = workflow 分支退役(t_639c5a03 三审)。
func DefaultConfig() Config {
return Config{
MinComboFreq: 3,
ClusterThresh: 0.85,
MinClusterSize: 3,
MaxRecords: 500,
TopKPatternList: 10,
MinComboFreq: 2,
ClusterThresh: 0.85,
MinClusterSize: 3,
MaxRecords: 500,
TopKPatternList: 10,
WorkflowPatterns: false,
}
}
// knownTools 常见工具/CLI/命令名(小写)。工具组合频率分析从这里识别 + 兜底正则。
// MinWriteConfidence 落库置信度门槛(小怡二轮裁定 #5,2026-10-30):conf<0.5 一律不写库。
// tool_combo 的 conf=来源数×0.1 ⇒ 实际要求 ≥5 条独立来源;workflow 簇需平均相似度 ≥0.5。
const MinWriteConfidence = 0.5
// knownTools 真实命令/CLI 名(小写)白名单 —— tool_combo 成员的**唯一**来源。
// t_639c5a03 二轮(小怡 2026-10-30 裁定):原"含 -/数字即可"的命令式标识兜底放行是
// hash 片段(374b20f98c44/ac10)、目录名(05-ai-agent)、文件名(dispatch-backlog.jsonl)、
// CVE/协议碎片(cve-2026-99999/ipv4)的进入路径,已整体移除;白名单外一律不进组合键。
// 同时移除过于通用的条目(git/go/curl/grep/cat/ls/find/…,见 genericTools)。
// 新增工具 = 改这张表(可审计),不再靠形态猜测。
var knownTools = map[string]bool{
"jq": true, "yq": true, "git": true, "grep": true, "sed": true, "awk": true,
"curl": true, "wget": true, "tar": true, "zip": true, "unzip": true,
"jq": true, "yq": true, "sed": true, "awk": true,
"wget": true, "tar": true, "zip": true, "unzip": true,
"docker": true, "kubectl": true, "helm": true, "npm": true, "pip": true,
"go": true, "python": true, "python3": true, "node": true, "pnpm": true,
"python": true, "python3": true, "node": true, "pnpm": true,
"make": true, "cmake": true, "gcc": true, "gcc-12": true, "clang": true,
"sqlite3": true, "psql": true, "mysql": true, "redis-cli": true,
"ssh": true, "scp": true, "rsync": true, "systemctl": true, "journalctl": true,
"systemd-run": true, "crontab": true, "find": true, "rg": true, "ripgrep": true,
"fd": true, "fzf": true, "gh": true, "gcloud": true, "aws": true, "jq2": true,
"hermes": true, "zhiyi": true, "obsidian": true, "esbuild": true, "ts": true,
"systemd-run": true, "crontab": true, "rg": true, "ripgrep": true,
"fd": true, "fzf": true, "gh": true, "gcloud": true, "aws": true,
"hermes": true, "zhiyi": true, "obsidian": true, "esbuild": true,
"tsc": true, "cargo": true, "rustc": true, "swift": true, "brew": true,
"bash": true, "sh": true, "zsh": true, "openssl": true,
"flock": true, "nc": true,
"java": true, "javac": true, "mvn": true, "gradle": true, "dotnet": true,
"remotion": true, "uv": true, "pytest": true, "gofmt": true, "golangci-lint": true,
}
// genericTools 过于通用的词/命令(小怡二轮裁定 #4 点名 git/go/curl/grep/bot-chat/mcp/api「等」):
// 几乎任何任务都命中 ⇒ 进 pattern 等于往召回 top-3 灌噪声。即使形态合格也**绝不**作为组合成员。
// md/json/py/api/log/db 等亦在 toolStopwords 覆盖内,这里重复登记作双保险。
// 后半段是 shell 基础词(cat/ls/find/…,「等」的裁量延伸:与 git 同级别的散文高频词)。
var genericTools = map[string]bool{
"git": true, "go": true, "curl": true, "grep": true, "bot-chat": true,
"mcp": true, "api": true, "md": true, "json": true, "py": true,
"cat": true, "ls": true, "cp": true, "mv": true, "rm": true, "mkdir": true,
"head": true, "tail": true, "sort": true, "uniq": true, "wc": true, "find": true,
"xargs": true, "kill": true, "ps": true, "chmod": true, "touch": true,
"diff": true, "echo": true, "ping": true, "lsof": true, "tar": true,
}
// commonExts 常见文件扩展名(小写,含点)。token 以此结尾 ⇒ 文件名/扩展名,不是工具。
var commonExts = map[string]bool{
".md": true, ".go": true, ".json": true, ".py": true, ".js": true, ".ts": true,
".tsx": true, ".jsx": true, ".yaml": true, ".yml": true, ".toml": true, ".sh": true,
".db": true, ".sql": true, ".csv": true, ".log": true, ".txt": true, ".html": true,
".css": true, ".rs": true, ".c": true, ".h": true, ".cpp": true, ".conf": true,
".xml": true, ".ini": true, ".env": true, ".pdf": true, ".png": true, ".jpg": true,
".jpeg": true, ".svg": true, ".gif": true, ".ipynb": true, ".lock": true, ".sum": true,
".bak": true, ".tmp": true, ".gz": true, ".mp4": true, ".mp3": true, ".docx": true,
".xlsx": true, ".pptx": true, ".service": true, ".sock": true, ".socket": true,
}
// toolStopwords 常见停用词(小写):高频非工具词,绝不作为工具名(卡要求第 1 条)。
var toolStopwords = map[string]bool{
"the": true, "and": true, "for": true, "with": true, "that": true, "this": true,
"from": true, "into": true, "then": true, "when": true, "what": true, "they": true,
"have": true, "will": true, "your": true, "not": true, "but": true, "all": true,
"can": true, "use": true, "via": true, "per": true, "are": true, "was": true,
"one": true, "two": true, "ok": true, "yes": true, "true": true, "false": true,
"null": true, "none": true, "etc": true, "vs": true, "id": true, "ids": true,
"ns": true, "log": true, "logs": true, "file": true, "files": true, "path": true,
"line": true, "lines": true, "name": true, "type": true, "data": true, "time": true,
"date": true, "test": true, "note": true, "step": true, "steps": true, "first": true,
"last": true, "next": true, "top": true, "api": true, "http": true, "https": true,
"url": true, "uri": true, "json": true, "yaml": true, "yml": true, "md": true,
"txt": true, "tmp": true, "dev": true, "prod": true, "src": true, "doc": true,
"docs": true, "ref": true, "config": true, "default": true, "error": true,
"errors": true, "warn": true, "info": true, "debug": true, "fail": true,
"failed": true, "success": true, "start": true, "stop": true, "check": true,
"list": true, "get": true, "set": true, "new": true, "old": true, "fix": true,
"pr": true, "mr": true, "db": true,
}
// DetectFromRecords 纯检测核心:从 L1 记忆列表发现重复模式。
@@ -75,7 +141,7 @@ var knownTools = map[string]bool{
// 返回合并去重后的 pattern 列表(同 Name 合并 source_tasks、取更高 confidence)。
func DetectFromRecords(records []models.MemoryRecord, cfg Config) []Pattern {
if cfg.MinComboFreq <= 0 {
cfg.MinComboFreq = 3
cfg.MinComboFreq = 2
}
if cfg.MinClusterSize <= 0 {
cfg.MinClusterSize = 3
@@ -129,21 +195,36 @@ func DetectFromRecords(records []models.MemoryRecord, cfg Config) []Pattern {
c.tasks = append(c.tasks, rec.ID)
}
for name, c := range comboCount {
if len(c.tasks) < cfg.MinComboFreq {
// 二轮:「组合」语义 —— 单工具无共现信息量,≥2 个成员才产 tool_combo。
if len(c.tools) < 2 {
continue
}
// t_639c5a03 门槛:支撑数按**独立来源记忆**(去重且非空 ID)计,< MinComboFreq 直接丢。
seen := map[string]bool{}
var sources []string
for _, id := range c.tasks {
if id != "" && !seen[id] {
seen[id] = true
sources = append(sources, id)
}
}
if len(sources) < cfg.MinComboFreq {
continue
}
merge(Pattern{
Type: "tool_combo",
Name: name,
Data: strings.Join(c.tools, "+"),
Tools: c.tools,
Confidence: confidenceFromFreq(len(c.tasks)),
SourceTasks: c.tasks,
Type: "tool_combo",
Name: name,
Data: strings.Join(c.tools, "+"),
Tools: c.tools,
Confidence: confidenceFromFreq(len(sources)),
SourceTasks: sources,
})
}
// 2. 相似解决路径聚类(cosine ≥ 阈值 成簇,簇内 ≥ N 条)
paths := clusterPaths(records, cfg.ClusterThresh, cfg.MinClusterSize)
// t_639c5a03 三审:该分支把记忆正文当 pattern Data(整句注入),默认退役;仅 cfg.WorkflowPatterns 显式开启才跑。
if cfg.WorkflowPatterns {
paths := clusterPaths(records, cfg.ClusterThresh, cfg.MinClusterSize)
for _, cluster := range paths {
// 代表路径 = 簇内最长 content(共同步骤近似)
rep := ""
@@ -168,6 +249,7 @@ func DetectFromRecords(records []models.MemoryRecord, cfg Config) []Pattern {
SourceTasks: ids,
})
}
}
out := make([]Pattern, 0, len(order))
for _, n := range order {
@@ -198,6 +280,103 @@ func confidenceFromFreq(freq int) float64 {
return c
}
// isNumericUnitFragment 数值+单位碎片(小怡二轮裁定 #2):^\d+(\.\d+)?(mb|kb|gb|tb|ms|ns|us|s|m|h|x|%)?$
// 例:"2.6mb"、"512kb"、"100ms"、"3x"。纯数字无单位形态由 isNumericFragment 拦截,这里不重复依赖它。
func isNumericUnitFragment(tok string) bool {
i := 0
for i < len(tok) && tok[i] >= '0' && tok[i] <= '9' {
i++
}
if i == 0 {
return false // 无数字前缀 ⇒ 不是该形态
}
if i < len(tok) && tok[i] == '.' {
j := i + 1
for j < len(tok) && tok[j] >= '0' && tok[j] <= '9' {
j++
}
if j == i+1 {
return false // '.' 后无数字 ⇒ 不是该形态
}
i = j
}
switch strings.ToLower(tok[i:]) {
case "":
return true // 纯数字
case "mb", "kb", "gb", "tb", "ms", "ns", "us", "s", "m", "h", "x", "%":
return true
}
return false
}
// isHexOnly token 是否只由 [0-9a-f] 构成(非空)。
func isHexOnly(s string) bool {
if s == "" {
return false
}
for i := 0; i < len(s); i++ {
c := s[i]
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
return false
}
}
return true
}
// isHashFragment hash/uuid 碎片(小怡二轮裁定 #1):
// - 纯 [0-9a-f] 且 len≥6 ⇒ hash 片段(374b20f98c44 / b713b48a70ad / 635dccc0);
// - 纯 [0-9a-f] 且 len≥4 且含数字 ⇒ 短 hash(ac10);'cafe'/'beef' 类纯字母词放行;
// - 形如 <hex≥2>-<hex≥2> 前缀 ⇒ uuid 段(^[0-9a-f]{2,}-[0-9a-f]{2,},如 b713-48a7)。
// 'gcc-12'(gcc 非 hex)、'redis-cli'(r 非 hex)不受影响。
func isHashFragment(tok string) bool {
if idx := strings.IndexByte(tok, '-'); idx > 0 {
head, rest := tok[:idx], tok[idx+1:]
tail := rest
if k := strings.IndexByte(rest, '-'); k >= 0 {
tail = rest[:k]
}
if len(head) >= 2 && isHexOnly(head) && len(tail) >= 2 && isHexOnly(tail) {
return true
}
}
if !isHexOnly(tok) {
return false
}
if len(tok) >= 6 {
return true
}
return len(tok) >= 4 && strings.ContainsAny(tok, "0123456789")
}
// isNumberedDirName 数字编号目录名(小怡二轮裁定 #3):^\d+(-[a-z]+)+$
// 例:"05-ai-agent"、"38-patternfix"。"gcc-12"(首段非数字)不受影响。
func isNumberedDirName(tok string) bool {
parts := strings.Split(tok, "-")
if len(parts) < 2 {
return false
}
first := parts[0]
if first == "" {
return false
}
for i := 0; i < len(first); i++ {
if first[i] < '0' || first[i] > '9' {
return false // 首段非纯数字
}
}
for _, seg := range parts[1:] {
if seg == "" {
return false
}
for i := 0; i < len(seg); i++ {
if seg[i] < 'a' || seg[i] > 'z' {
return false // 某段含数字/非法字符
}
}
}
return true
}
// clusterPaths 贪心成簇:为每条未分配记忆开一个簇,把与其 cosine≥阈值 的其余未分配
// 记忆并入;最终只保留 size≥minSize 的簇。向量缺失时用记录自带 Vector,全缺则退到
// 文本 bigram Jaccard(确定性,不依赖网络)。
@@ -276,35 +455,104 @@ func clusterConfidence(cluster []models.MemoryRecord) float64 {
return sum / float64(pairs)
}
// ExtractTools 从记忆内容提取工具/命令名。识别 knownTools + 兜底 ascii token。
// ExtractTools 从记忆内容提取工具/命令名(t_639c5a03 二轮:白名单唯一来源)。
// 判据(卡·必须实现 #1「来自 tool_use 记录或白名单」+ 小怡二轮"更硬判据"取向):
// 成员必须**命中 knownTools 白名单**(词边界),且过 isValidTool 形态闸(通用词黑名单、
// hash/uuid 片段、数值+单位、数字编号目录名、扩展名、日期/版本片段、停用词等)。
//
// 旧实现的"命令式标识"兜底(白名单外、含 -/数字即放行)是二轮 dry-run 里
// hash 片段(374b20f98c44/ac10)、目录名(05-ai-agent)、文件名(dispatch-backlog.jsonl)、
// CVE/协议碎片(cve-2026-99999/ipv4)的进入路径,已整体移除。
// 确定性:结果排序去重。
func ExtractTools(content string) []string {
found := map[string]bool{}
lower := strings.ToLower(content)
out := make([]string, 0, 4)
for t := range knownTools {
// 词边界匹配(避免 "go" 命中 "google")
if tokenPresent(lower, t) {
found[t] = true
if isValidTool(t) && tokenPresent(lower, t) {
out = append(out, t)
}
}
// 兜底:捕获已知列表外的 ascii 命令式 token(2~14 字符,含 - 的命令风格)
for _, tok := range regexTokenExtract(content) {
lt := strings.ToLower(tok)
if len(lt) < 2 {
continue
}
// 排除明显非工具的常见词(纯小写短词且不在 knownTools 的放行——保持宽松,
// 让工具组合信号进来;过噪由 MinComboFreq 过滤)
found[lt] = true
}
out := make([]string, 0, len(found))
for t := range found {
out = append(out, t)
}
sort.Strings(out)
return out
}
// isValidTool token 形态判据(lowercase):是否为真实命令/工具标识的形状。
// 排除:长度 <2 或 >32;含 '/'、'\\'、'_'(路径、usage_track 键、t_9143c968 任务 ID);
// 以 '.' 开头(".md");无字母(纯数字/纯标点/数字+分隔符);以常见扩展名结尾
// (report.md / a.py);数字/日期/版本片段(2026、2026-09-23、2026+23、09:23、1.2.3、v1.2.3);
// 停用词。
func isValidTool(tok string) bool {
if len(tok) < 2 || len(tok) > 32 {
return false
}
if genericTools[tok] {
return false // 通用词黑名单(二轮 #4)
}
if strings.ContainsAny(tok, `/_\`) {
return false
}
if tok[0] == '.' || tok[0] == '-' {
return false // 隐藏文件 / CLI flag
}
hasLetter := false
for i := 0; i < len(tok); i++ {
if c := tok[i]; (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') {
hasLetter = true
break
}
}
if !hasLetter {
return false
}
if i := strings.LastIndexByte(tok, '.'); i > 0 && commonExts[tok[i:]] {
return false
}
if isNumericFragment(tok) {
return false
}
if isNumericUnitFragment(tok) {
return false // 二轮 #2:2.6mb / 512kb / 100ms
}
if isHashFragment(tok) {
return false // 二轮 #1:374b20f98c44 / ac10 / uuid 片段
}
if isNumberedDirName(tok) {
return false // 二轮 #3:05-ai-agent / 38-patternfix
}
if toolStopwords[tok] {
return false
}
return true
}
// isNumericFragment 纯数字 / 日期 / 时间 / 版本片段:
// - 只由数字与分隔符 [-+:./] 组成("2026"、"2026-09-23"、"2026+23"、"09:23"、"1.2.3"),或
// - 至多一个前导字母 + 其余全为数字/分隔符("v1.2.3"、"v2")。
//
// "gcc-12"/"sqlite3" 这类字母出现在数字之前且不止前导一个 ⇒ 正常标识,返回 false。
func isNumericFragment(tok string) bool {
if tok == "" {
return false
}
seenDigit := false
rest := tok
if c := rest[0]; (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') {
rest = rest[1:] // 至多剥一个前导字母
}
for _, r := range rest {
switch {
case r >= '0' && r <= '9':
seenDigit = true
case r == '-' || r == '+' || r == ':' || r == '.' || r == '/':
// 分隔符放行
default:
return false // 还有别的字母/字符 ⇒ 不是纯数字片段
}
}
return seenDigit
}
// tokenPresent 检查 lower 文本中 t 是否作为独立 token 出现(词边界,避免 "go" 命中 "google")。
func tokenPresent(lower, t string) bool {
if t == "" {
@@ -325,30 +573,6 @@ func tokenPresent(lower, t string) bool {
return false
}
// regexTokenExtract 轻量 token 抽取(无正则依赖,按字符切):
// 返回形如命令/token 的 ascii 串(长度 ≥2,含字母,可能带数字/_.-)。
func regexTokenExtract(content string) []string {
var out []string
var cur []rune
flush := func() {
if len(cur) >= 2 {
out = append(out, string(cur))
}
cur = nil
}
for _, r := range content {
isTok := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
(r >= '0' && r <= '9') || r == '_' || r == '-' || r == '.'
if isTok {
cur = append(cur, r)
} else {
flush()
}
}
flush()
return out
}
func dedupeSorted(in []string) []string {
seen := map[string]bool{}
out := make([]string, 0, len(in))
@@ -449,3 +673,38 @@ func truncateForPattern(s string, n int) string {
}
return string(r[:n]) + "…"
}
// IsValidPatternName pattern 命名键合法性闸门(t_639c5a03,写入前最后防线)。
// 合法形态:
// - tool_combo:<valid-tool>(+<valid-tool>)* —— 每个成员过 isValidTool
// - workflow:cluster-<N>-<fingerprint>
//
// 其余("tool_combo:.md"、"tool_combo:2026+23" 等垃圾键)一律拒绝,
// UpsertPatterns 拒写 —— 即使调用方判据再退化,垃圾也进不了 graph_nodes。
func IsValidPatternName(name string) bool {
i := strings.IndexByte(name, ':')
if i <= 0 {
return false
}
body := name[i+1:]
if body == "" {
return false
}
switch name[:i] {
case "tool_combo":
parts := strings.Split(body, "+")
if len(parts) < 2 {
return false // 「组合」语义:单成员键拒写(二轮)
}
for _, t := range parts {
// 写入闸 = 白名单唯一来源 + 形态检查(通用词/hash/数值/目录碎片黑名单)。
if !knownTools[t] || !isValidTool(t) {
return false
}
}
return true
case "workflow":
return strings.HasPrefix(body, "cluster-")
}
return false
}
+225 -14
View File
@@ -7,10 +7,10 @@ import (
"github.com/xiaoxue/memoryweave/internal/models"
)
// 构造 N 条同一工具组合的记忆(内容里带 jq/git/curl 三个已知工具)。
// 构造 N 条同一工具组合的记忆(内容里带白名单、非通用词的真工具)。
func comboRecords(n int, combo string) []models.MemoryRecord {
// combo 形如 "jq git curl"——内容里出现这三个工具名(空格分隔,保证词边界命中)。
// 上下文用纯中文(非 ascii token),避免回退正则把上下文词误当工具混进组合键。
// combo 形如 "jq sqlite3 rsync"——内容里出现这几个工具名(空格分隔,保证词边界命中)。
// 上下文用纯中文(非 ascii token),避免上下文词误当工具混进组合键。
recs := make([]models.MemoryRecord, 0, n)
for i := 0; i < n; i++ {
recs = append(recs, models.MemoryRecord{
@@ -25,7 +25,7 @@ func comboRecords(n int, combo string) []models.MemoryRecord {
// TestDetect_ToolCombo —— 3 条同工具组合记忆 → 检出 1 个 tool_combo pattern(AC2)。
func TestDetect_ToolCombo(t *testing.T) {
recs := comboRecords(3, "jq git curl")
recs := comboRecords(3, "jq sqlite3 rsync")
pats := DetectFromRecords(recs, DefaultConfig())
var toolPatterns []Pattern
for _, p := range pats {
@@ -37,8 +37,8 @@ func TestDetect_ToolCombo(t *testing.T) {
t.Fatalf("期望 1 个 tool_combo pattern,实际 %d: %#v", len(toolPatterns), toolPatterns)
}
p := toolPatterns[0]
if p.Name != "tool_combo:curl+git+jq" {
t.Fatalf("命名键不符: got %q (工具应排序去重为 curl,git,jq)", p.Name)
if p.Name != "tool_combo:jq+rsync+sqlite3" {
t.Fatalf("命名键不符: got %q (工具应排序去重为 jq,rsync,sqlite3)", p.Name)
}
if len(p.Tools) != 3 {
t.Fatalf("Tools 应含 3 个,实际 %v", p.Tools)
@@ -52,18 +52,19 @@ func TestDetect_ToolCombo(t *testing.T) {
}
}
// TestDetect_BelowThreshold —— 2 条同组合(<3)→ 不产 tool_combo(AC2 反例)。
// TestDetect_BelowThreshold —— 1 条同组合(<2)→ 不产 tool_combo(AC2 反例;门槛 t_639c5a03=2)。
func TestDetect_BelowThreshold(t *testing.T) {
recs := comboRecords(2, "jq git curl")
recs := comboRecords(1, "jq sqlite3 rsync")
pats := DetectFromRecords(recs, DefaultConfig())
for _, p := range pats {
if p.Type == "tool_combo" {
t.Fatalf("2 条同组合不应产 tool_combo,实际产出: %#v", p)
t.Fatalf("1 条同组合不应产 tool_combo,实际产出: %#v", p)
}
}
}
// TestDetect_Workflow —— 3 条近同文(向量近似 1.0 或 bigram 高度重合)→ 1 个 workflow 簇。
// t_639c5a03 三审后 workflow 分支默认退役,需显式 WorkflowPatterns=true 才跑(验证门仍在)。
func TestDetect_Workflow(t *testing.T) {
// 向量相同(cosine=1.0 ≥ 0.85)且 3 条 → 应聚成 1 簇。
vec := []float32{1.0, 0.0, 0.0, 0.0}
@@ -72,7 +73,9 @@ func TestDetect_Workflow(t *testing.T) {
{ID: "w2", Content: "重启服务后拉日志", Category: "distilled", Vector: vec},
{ID: "w3", Content: "重启服务后拉日志", Category: "distilled", Vector: vec},
}
pats := DetectFromRecords(recs, DefaultConfig())
cfg := DefaultConfig()
cfg.WorkflowPatterns = true
pats := DetectFromRecords(recs, cfg)
var wf []Pattern
for _, p := range pats {
if p.Type == "workflow" {
@@ -87,6 +90,21 @@ func TestDetect_Workflow(t *testing.T) {
}
}
// TestDetect_WorkflowRetiredByDefault —— 默认 Config 下 workflow 分支关闭:喂 3 条近同文
// 记忆(含完整自然语言句子)⇒ 0 pattern。t_639c5a03 三审「不许把记忆正文当 pattern name」硬约束。
func TestDetect_WorkflowRetiredByDefault(t *testing.T) {
vec := []float32{1.0, 0.0, 0.0, 0.0}
recs := []models.MemoryRecord{
{ID: "s1", Content: "给 memories 加新列必须同时改 Rust 写 builder *_col.push", Category: "distilled", Vector: vec},
{ID: "s2", Content: "给 memories 加新列必须同时改 Rust 写 builder *_col.push", Category: "distilled", Vector: vec},
{ID: "s3", Content: "给 memories 加新列必须同时改 Rust 写 builder *_col.push", Category: "distilled", Vector: vec},
}
pats := DetectFromRecords(recs, DefaultConfig())
for _, p := range pats {
t.Fatalf("默认 Config 不应产出任何 pattern(含 workflow 整句),实际: %#v", p)
}
}
// TestDetect_Empty —— 空输入 → 空输出不报错(AC1 空库前提)。
func TestDetect_Empty(t *testing.T) {
if pats := DetectFromRecords(nil, DefaultConfig()); len(pats) != 0 {
@@ -96,16 +114,209 @@ func TestDetect_Empty(t *testing.T) {
// TestExtractTools —— 词边界:「google」不应命中工具 "go"。
func TestExtractTools_WordBoundary(t *testing.T) {
tools := ExtractTools("I like google and grep the log with jq")
// 应含 grep、jq;不应含 go("google" 被词边界排除)
tools := ExtractTools("I like google and rsync the backup with jq")
// 应含 rsync、jq;不应含 go("google" 被词边界排除;且 go 已进通用词黑名单)
found := map[string]bool{}
for _, s := range tools {
found[s] = true
}
if !found["grep"] || !found["jq"] {
t.Fatalf("应识别 grep/jq,实际 %v", tools)
if !found["rsync"] || !found["jq"] {
t.Fatalf("应识别 rsync/jq,实际 %v", tools)
}
if found["go"] {
t.Fatalf("'google' 误命中工具 'go'(词边界失效): %v", tools)
}
}
// ── t_639c5a03:垃圾 pattern 判据单测(AC2)──────────────────────────────
// fakeMem 一条带指定 ID 的假蒸馏记忆(向量 nil → 走 bigram 回退,确定性)。
func fakeMem(id, content string) models.MemoryRecord {
return models.MemoryRecord{ID: id, Content: content, Category: "distilled", Vector: nil}
}
// TestDetect_JunkTokensRejected —— 3 条含 .md / 2026 / 标点 的假记忆 ⇒ 0 pattern(AC2 第一条)。
// 覆盖卡现象里的垃圾样本:.md、.md+2026-09-23、2026+23 全不得被当工具组合。
func TestDetect_JunkTokensRejected(t *testing.T) {
recs := []models.MemoryRecord{
fakeMem("j1", "今天把结果导出成 .md 文件"),
fakeMem("j2", "版本 .md+2026-09-23 的构建产物"),
fakeMem("j3", "数字 2026+23 是什么意义"),
}
pats := DetectFromRecords(recs, DefaultConfig())
for _, p := range pats {
t.Fatalf("垃圾记忆不应产出任何 pattern,实际 %#v", p)
}
}
// TestDetect_RealToolComboNeeds2Sources —— 2 条真工具共现(jq+rsync)⇒ 1 pattern(AC2 第二条)。
// 同时验证门槛 ≥2 条独立来源(单条不产)。
func TestDetect_RealToolComboNeeds2Sources(t *testing.T) {
// 单条真工具组合 → 不产(门槛 2)
one := DetectFromRecords([]models.MemoryRecord{
fakeMem("o1", "用 jq 和 rsync 同步 json"),
}, DefaultConfig())
for _, p := range one {
if p.Type == "tool_combo" {
t.Fatalf("单条真工具组合不应产 pattern(门槛≥2),实际 %#v", p)
}
}
// 2 条同真组合 → 1 个 tool_combo,成员 jq+rsync,支撑 2 条
two := DetectFromRecords([]models.MemoryRecord{
fakeMem("a1", "先用 jq 解析再 rsync 备份"),
fakeMem("a2", "jq 提取字段后 rsync 同步"),
}, DefaultConfig())
var tc []Pattern
for _, p := range two {
if p.Type == "tool_combo" {
tc = append(tc, p)
}
}
if len(tc) != 1 {
t.Fatalf("2 条同真组合应产 1 个 tool_combo,实际 %d: %#v", len(tc), two)
}
if tc[0].Name != "tool_combo:jq+rsync" {
t.Fatalf("命名键不符: got %q", tc[0].Name)
}
if len(tc[0].SourceTasks) != 2 {
t.Fatalf("支撑应为 2 条独立来源,实际 %v", tc[0].SourceTasks)
}
}
// TestDetect_SingletonToolNoCombo —— 二轮:单工具(即使白名单、≥2 来源)不产 tool_combo(「组合」语义)。
func TestDetect_SingletonToolNoCombo(t *testing.T) {
pats := DetectFromRecords([]models.MemoryRecord{
fakeMem("s1", "用 jq 解析配置文件甲"),
fakeMem("s2", "用 jq 解析配置文件乙"),
}, DefaultConfig())
for _, p := range pats {
if p.Type == "tool_combo" {
t.Fatalf("单工具不应产 tool_combo(组合需≥2成员),实际 %#v", p)
}
}
}
// TestExtractTools_JunkTokenFilter —— 形态级:.md/2026/日期/纯标点/扩展名/停用词/通用词全被滤。
// 二轮(小怡裁定):git/curl 从"应识别"翻转为"必须滤掉"(通用词黑名单)。
func TestExtractTools_JunkTokenFilter(t *testing.T) {
content := "处理 .md 和 2026 与 2026-09-23 以及 2026+23 与 report.md 与 a.py" +
" 还有 git status 和 curl -I https://x 和 ... 和 step 1 以及 sqlite3 dump 和 jq 解析"
tools := ExtractTools(content)
found := map[string]bool{}
for _, s := range tools {
found[s] = true
}
// 垃圾与通用词一律不得出现
for _, junk := range []string{".md", "2026", "2026-09-23", "2026+23", "report", "a", "step",
"https", "-i", "git", "curl"} {
if found[junk] {
t.Fatalf("垃圾/通用 token %q 不应被当工具,实际 %v", junk, tools)
}
}
for _, want := range []string{"sqlite3", "jq"} {
if !found[want] {
t.Fatalf("应识别真标识 %q,实际 %v", want, tools)
}
}
}
// TestExtractTools_RoundTwoJunkClasses —— 小怡二轮 dry-run 里 14 条样本的垃圾类别逐项回归:
// hash 片段 / 短 hash / uuid 段 / 数值+单位 / 数字编号目录名 / 通用词 / CVE、协议碎片
// —— 全部不得出现;只放行白名单真工具。
func TestExtractTools_RoundTwoJunkClasses(t *testing.T) {
content := "参考 05-ai-agent 目录,产物 2.6mb 与 512kb 与 100ms," +
"commit 374b20f98c44 与 b713b48a70ad 与 ac10 与 635dccc0," +
"接口走 bot-chat 与 mcp 与 api,用 git grep go curl 查询," +
"ipv4 与 cve-2026-99999 与 dispatch-backlog.jsonl 与 38-patternfix," +
"真工具是 docker 与 sqlite3 与 jq 与 rsync"
tools := ExtractTools(content)
found := map[string]bool{}
for _, s := range tools {
found[s] = true
}
for _, junk := range []string{
"05-ai-agent", "38-patternfix", "2.6mb", "512kb", "100ms",
"374b20f98c44", "b713b48a70ad", "ac10", "635dccc0",
"bot-chat", "mcp", "api", "git", "grep", "go", "curl",
"ipv4", "cve-2026-99999", "dispatch-backlog.jsonl",
} {
if found[junk] {
t.Fatalf("二轮垃圾类别 token %q 漏网,实际 %v", junk, tools)
}
}
for _, want := range []string{"docker", "sqlite3", "jq", "rsync"} {
if !found[want] {
t.Fatalf("白名单真工具 %q 应被识别,实际 %v", want, tools)
}
}
}
// TestFormPredicates —— 形态级判据的单元断言(hash/uuid/数值单位/编号目录)。
func TestFormPredicates(t *testing.T) {
hashBad := []string{"374b20f98c44", "b713b48a70ad", "635dccc0", "ac10", "b713-48a7", "deadbeef"}
hashGood := []string{"gcc-12", "redis-cli", "cafe", "beef", "fd", "systemd-run", "golangci-lint", "hermes"}
for _, s := range hashBad {
if !isHashFragment(s) {
t.Fatalf("isHashFragment(%q) 应为 true", s)
}
}
for _, s := range hashGood {
if isHashFragment(s) {
t.Fatalf("isHashFragment(%q) 应为 false(真工具标识被误杀)", s)
}
}
numBad := []string{"2.6mb", "512kb", "100ms", "3x", "50%", "1024"}
numGood := []string{"sqlite3", "python3", "gcc-12", "v10"} // v10 由 isNumericFragment 拦,这里不误判成单位碎片
for _, s := range numBad {
if !isNumericUnitFragment(s) {
t.Fatalf("isNumericUnitFragment(%q) 应为 true", s)
}
}
for _, s := range numGood {
if isNumericUnitFragment(s) {
t.Fatalf("isNumericUnitFragment(%q) 应为 false", s)
}
}
dirBad := []string{"05-ai-agent", "38-patternfix", "10-after-sidecar"}
dirGood := []string{"gcc-12", "systemd-run", "redis-cli", "obsidian-vault"}
for _, s := range dirBad {
if !isNumberedDirName(s) {
t.Fatalf("isNumberedDirName(%q) 应为 true", s)
}
}
for _, s := range dirGood {
if isNumberedDirName(s) {
t.Fatalf("isNumberedDirName(%q) 应为 false", s)
}
}
}
// TestIsValidPatternName_Gate —— 写入闸:垃圾/单成员/通用词命名键全拒,白名单多成员键/工作流键放行。
func TestIsValidPatternName_Gate(t *testing.T) {
bad := []string{
"tool_combo:.md", "tool_combo:2026+23", "tool_combo:.md+2026-09-23",
"tool_combo:2026", "tool_combo:report", "tool_combo:2026-09-23",
"tool_combo:usage_track", "tool_combo:t_9143c968", "tool_combo:a+b",
"tool_combo:log", "tool_combo:",
// 二轮新增:单成员、通用词成员、hash/数值/目录碎片成员
"tool_combo:sqlite3", "tool_combo:jq", "tool_combo:git+jq",
"tool_combo:curl+git+jq", "tool_combo:374b20f98c44+jq",
"tool_combo:05-ai-agent", "tool_combo:2.6mb", "tool_combo:ac10",
"tool_combo:cve-2026-99999", "tool_combo:ipv4+jq", "tool_combo:bot-chat",
}
for _, name := range bad {
if IsValidPatternName(name) {
t.Fatalf("垃圾命名键 %q 不应通过闸门", name)
}
}
good := []string{
"tool_combo:jq+rsync", "tool_combo:docker+sqlite3",
"tool_combo:hermes+zhiyi+jq",
"workflow:cluster-3-abcdef12", "workflow:cluster-2-00aa11bb",
}
for _, name := range good {
if !IsValidPatternName(name) {
t.Fatalf("合法命名键 %q 应通过闸门", name)
}
}
}
+9
View File
@@ -117,6 +117,15 @@ func (s *GraphPatternStore) UpsertPatterns(patterns []Pattern) (created, updated
now := time.Now().UTC().Format(time.RFC3339)
for _, p := range patterns {
// t_639c5a03 最后写入门闸:垃圾命名键(tool_combo:.md / tool_combo:2026+23 等)拒写。
if !IsValidPatternName(p.Name) {
continue
}
// t_639c5a03 二轮(小怡裁定 #5):conf<MinWriteConfidence(0.5) 一律不落库
// —— freq=2 ⇒ conf=0.2 的弱信号只出现在 dry-run 观测里,不污染召回。
if p.Confidence < MinWriteConfidence {
continue
}
// 读现有
var (
id string
+14 -14
View File
@@ -45,13 +45,13 @@ func TestStore_UpsertEmpty(t *testing.T) {
func TestStore_UpsertCreate(t *testing.T) {
s := newTestStore(t)
p := Pattern{
ID: "pat_tool_combo:curl+git+jq",
ID: "pat_tool_combo:jq+rsync+sqlite3",
Type: "tool_combo",
Name: "tool_combo:curl+git+jq",
Data: "curl+git+jq",
Confidence: 0.3,
Tools: []string{"curl", "git", "jq"},
SourceTasks: []string{"m_a", "m_b", "m_c"},
Name: "tool_combo:jq+rsync+sqlite3",
Data: "jq+rsync+sqlite3",
Confidence: 0.6,
Tools: []string{"jq", "rsync", "sqlite3"},
SourceTasks: []string{"m_a", "m_b", "m_c", "m_d", "m_e", "m_f"},
}
created, updated, err := s.UpsertPatterns([]Pattern{p})
if err != nil {
@@ -71,17 +71,17 @@ func TestStore_UpsertCreate(t *testing.T) {
func TestStore_UpsertIdempotent(t *testing.T) {
s := newTestStore(t)
p := Pattern{
ID: "pat_tool_combo:curl+git+jq",
ID: "pat_tool_combo:jq+rsync+sqlite3",
Type: "tool_combo",
Name: "tool_combo:curl+git+jq",
Data: "curl+git+jq",
Confidence: 0.3,
Tools: []string{"curl", "git", "jq"},
SourceTasks: []string{"m_a", "m_b", "m_c"},
Name: "tool_combo:jq+rsync+sqlite3",
Data: "jq+rsync+sqlite3",
Confidence: 0.6,
Tools: []string{"jq", "rsync", "sqlite3"},
SourceTasks: []string{"m_a", "m_b", "m_c", "m_d", "m_e"},
}
c1, u1, _ := s.UpsertPatterns([]Pattern{p}) // 新建
p.Confidence = 0.4 // 第二次检测:同组合再次出现(freq 抬升)
p.SourceTasks = []string{"m_d"}
p.Confidence = 0.8 // 第二次检测:同组合再次出现(freq 抬升)
p.SourceTasks = []string{"m_f"}
c2, u2, _ := s.UpsertPatterns([]Pattern{p}) // 应幂等更新(updated=1)
if c1 != 1 || u1 != 0 {
t.Fatalf("首次应 created=1/updated=0,实际 %d/%d", c1, u1)
+42
View File
@@ -0,0 +1,42 @@
package pattern
import "testing"
// TestStore_WriteConfidenceGate —— 二轮裁定 #5:conf<MinWriteConfidence(0.5) 一律不落库。
// 命名键合法(白名单、≥2 成员)也不行——弱信号只出现在 dry-run 观测里。
func TestStore_WriteConfidenceGate(t *testing.T) {
s := newTestStore(t)
low := Pattern{
ID: "pat_tool_combo:jq+rsync", Type: "tool_combo",
Name: "tool_combo:jq+rsync", Data: "jq+rsync",
Confidence: 0.2, // freq=2 ⇒ 0.2,正是被点名的弱信号档
Tools: []string{"jq", "rsync"},
SourceTasks: []string{"m_1", "m_2"},
}
created, updated, err := s.UpsertPatterns([]Pattern{low})
if err != nil {
t.Fatalf("upsert 报错: %v", err)
}
if created != 0 || updated != 0 {
t.Fatalf("conf=0.2 < 门槛 0.5 应拒写,实际 created=%d updated=%d", created, updated)
}
ok := low
ok.Name = "tool_combo:jq+sqlite3"
ok.ID = "pat_tool_combo:jq+sqlite3"
ok.Data = "jq+sqlite3"
ok.Confidence = 0.5 // 恰好达门槛:允许写
created, _, err = s.UpsertPatterns([]Pattern{ok})
if err != nil {
t.Fatalf("upsert 报错: %v", err)
}
if created != 1 {
t.Fatalf("conf=0.5 恰达门槛应写入 1 条,实际 created=%d", created)
}
}
// TestMinWriteConfidenceValue —— 门槛值与卡面(小怡裁定 ≥0.5)钉死,防手滑改低。
func TestMinWriteConfidenceValue(t *testing.T) {
if MinWriteConfidence != 0.5 {
t.Fatalf("MinWriteConfidence 应为 0.5(小怡 2026-10-30 裁定 #5),实际 %v", MinWriteConfidence)
}
}
+15 -8
View File
@@ -72,18 +72,25 @@ func (vp *VPropagator) loadVPropFromRedis() {
}
}
func (vp *VPropagator) persistVProp() {
func (vp *VPropagator) persistVProp(d *VDecision, memIDs []string) {
if vp.redisClient == nil {
return
}
for id, d := range vp.decisions {
data, _ := json.Marshal(d)
vp.redisClient.HSet(vpropRedisKey, id, string(data))
// 增量持久化:只写本次变更的条目。
// 此前是全量遍历 decisions(7592 条)+memVValues(1938 条)、每条一次 HSET
// ⇒ 单次 recall 9530 次 Redis 往返 ≈133ms,且随运行时长线性恶化(p50 4ms→192ms)。
// 历史条目早已在 Redis(LoadFromRedis 全量载入 + 每条写入时都增量落过),故增量写语义等价。
if d != nil {
if data, err := json.Marshal(d); err == nil {
vp.redisClient.HSet(vpropRedisKey, d.ID, string(data))
}
}
for _, memID := range memIDs {
if v, ok := vp.memVValues[memID]; ok {
vp.redisClient.HSet(vpropValsRedisKey, memID, strconv.FormatFloat(v, 'f', 4, 64))
}
}
vp.redisClient.Expire(vpropRedisKey, 90*24*time.Hour)
for memID, v := range vp.memVValues {
vp.redisClient.HSet(vpropValsRedisKey, memID, strconv.FormatFloat(v, 'f', 4, 64))
}
vp.redisClient.Expire(vpropValsRedisKey, 90*24*time.Hour)
}
@@ -135,7 +142,7 @@ func (vp *VPropagator) RecordDecision(id string, memoryIDs []string, action, out
}
}
vp.persistVProp()
vp.persistVProp(d, memoryIDs)
return d
}
+15 -9
View File
@@ -439,6 +439,21 @@ func (p *RecallPipeline) RecallWithMetaTraced(ctx context.Context, query, namesp
if ctx == nil {
ctx = context.Background()
}
// 常设答案精确命中 ⇒ 零检索零 LLM 直接返回,不进入 rerank/LLM:
// 省 LLM 配额,且程序性记忆层在 LLM 过载(429)时仍可用。
// 命中口径是归一化后整句相等(question + op.trigger 变体),无模糊面,
// 不会误吞泛化查询;未命中时走下面的完整召回,行为逐位不变。
if sa := standingAnswerFor(query); sa != nil {
return []models.RecallResult{{
ID: "standing:" + sa.QuestionID,
Content: sa.Answer,
Category: "standing_answer",
Score: 1.0,
Timestamp: sa.UpdatedAt.Format(time.RFC3339),
Source: "standing_answer",
Channel: "standing",
}}, RecallMeta{StandingAnswerHit: sa.QuestionID, Status: "ok"}, nil
}
results, meta, err := p.recallWithMetaInner(ctx, query, namespace, topK, diversity, allowNSFallback, tr)
if tr != nil {
meta.TraceID = tr.TraceID
@@ -450,15 +465,6 @@ func (p *RecallPipeline) RecallWithMetaTraced(ctx context.Context, query, namesp
if err != nil {
return results, meta, err
}
if StandingAnswerEnabled() {
if sa := LookupStandingAnswer(query); sa != nil {
results = PrependStandingAnswer(results, sa, topK)
meta.StandingAnswerHit = sa.QuestionID
if tr != nil {
tr.StandingAnswerHit = sa.QuestionID
}
}
}
return results, meta, err
}
+87 -13
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"log"
"os"
"strings"
"sync"
"time"
@@ -34,12 +35,26 @@ type StandingAnswer struct {
QuestionID string `json:"question_id"`
Question string `json:"question"`
Answer string `json:"answer"`
// Op(2026-10-28, t_3636b872 程序性记忆层):「怎么做 X」的结构化步骤卡。
// 可空。携带 trigger 变体 + 可执行 steps + 前置检查/验证/回滚/风险。
// 召回时除精确 Question 匹配外,还按 op.trigger 变体匹配(见 standing_match.go)。
Op *OpCard `json:"op,omitempty"` // 可空:存量常设答案无结构卡,行为默认
SourceMemories []string `json:"source_memories,omitempty"`
Version int `json:"version"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// OpCard 程序性步骤卡(MoA 建议形态,2026-10-28)。
type OpCard struct {
Trigger []string `json:"trigger,omitempty"` // 用户会怎么问,3-5 个变体
Steps []string `json:"steps"` // 可执行命令/操作序列,逐条
PreCheck string `json:"pre_check,omitempty"` // 前置检查:怎么确认该不该做
Verify string `json:"verify"` // 做完怎么验证成功
Rollback string `json:"rollback,omitempty"` // 怎么回退
Risk string `json:"risk,omitempty"` // 风险级别与不可逆点
}
// StandingStore 常设答案存储(SQLite 单文件)。
type StandingStore struct {
mu sync.RWMutex
@@ -64,6 +79,7 @@ func NewStandingStore(path string) (*StandingStore, error) {
question_id TEXT PRIMARY KEY,
question TEXT NOT NULL,
answer TEXT NOT NULL,
op TEXT DEFAULT NULL,
source_memories TEXT DEFAULT '[]',
version INTEGER DEFAULT 1,
created_at TEXT NOT NULL,
@@ -72,9 +88,41 @@ func NewStandingStore(path string) (*StandingStore, error) {
db.Close()
return nil, fmt.Errorf("init standing_answers: %w", err)
}
// 2026-10-28 (t_3636b872) 结构步骤卡列:存量库 ALTER 补齐;新库已含。
// 幂等:重复执行看到列已存在则跳过(SQLite 无 IF NOT EXISTS for column)。
if col, err := hasColumn(db, "standing_answers", "op"); err != nil {
db.Close()
return nil, fmt.Errorf("check op column: %w", err)
} else if !col {
if _, err := db.Exec(`ALTER TABLE standing_answers ADD COLUMN op TEXT DEFAULT NULL`); err != nil {
db.Close()
return nil, fmt.Errorf("add op column: %w", err)
}
}
return &StandingStore{db: db, path: path}, nil
}
// hasColumn 判断 SQLite 表是否存在某列(PRAGMA table_info)。
func hasColumn(db *sql.DB, table, col string) (bool, error) {
rows, err := db.Query(`PRAGMA table_info(` + table + `)`)
if err != nil {
return false, err
}
defer rows.Close()
for rows.Next() {
var cid, notnull, pk int
var name, ctype string
var dflt *string
if err := rows.Scan(&cid, &name, &ctype, &notnull, &dflt, &pk); err != nil {
return false, err
}
if name == col {
return true, nil
}
}
return false, rows.Err()
}
func dirOf(p string) string {
for i := len(p) - 1; i >= 0; i-- {
if p[i] == '/' {
@@ -89,7 +137,8 @@ func (s *StandingStore) Close() error { return s.db.Close() }
func (s *StandingStore) Path() string { return s.path }
// Upsert 写入/改写一条常设答案。已有则 version+1 并保留 source_memories;新则 version=1。
func (s *StandingStore) Upsert(qid, question, answer string, source []string) (*StandingAnswer, error) {
// op 可空:nil 时不改既有 op 列(改答案不丢结构卡);写入保留调用方传入的 op。
func (s *StandingStore) Upsert(qid, question, answer string, source []string, op *OpCard) (*StandingAnswer, error) {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
@@ -97,6 +146,10 @@ func (s *StandingStore) Upsert(qid, question, answer string, source []string) (*
if sm == nil {
sm = []byte("[]")
}
opJSON, _ := json.Marshal(op)
if opJSON == nil {
opJSON = nil
}
// 查已有
var ver int
@@ -113,39 +166,57 @@ func (s *StandingStore) Upsert(qid, question, answer string, source []string) (*
if exists {
newVer = ver + 1
_, err = s.db.Exec(
`UPDATE standing_answers SET answer=?, source_memories=?, version=?, updated_at=? WHERE question_id=?`,
answer, string(sm), newVer, now.Format(time.RFC3339), qid,
`UPDATE standing_answers SET answer=?, op=COALESCE(?,op), source_memories=?, version=?, updated_at=? WHERE question_id=?`,
answer, opJSON, string(sm), newVer, now.Format(time.RFC3339), qid,
)
} else {
newVer = 1
created = now.Format(time.RFC3339)
_, err = s.db.Exec(
`INSERT INTO standing_answers(question_id,question,answer,source_memories,version,created_at,updated_at)
VALUES(?,?,?,?,?,?,?)`,
qid, question, answer, string(sm), newVer, created, now.Format(time.RFC3339),
`INSERT INTO standing_answers(question_id,question,answer,op,source_memories,version,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?)`,
qid, question, answer, opJSON, string(sm), newVer, created, now.Format(time.RFC3339),
)
}
if err != nil {
return nil, fmt.Errorf("upsert: %w", err)
}
// 写后丢弃缓存,否则新卡/改卡在 standingCacheTTL(30s)内对召回不可见。
refreshStandingAnswers()
return &StandingAnswer{
QuestionID: qid, Question: question, Answer: answer,
QuestionID: qid, Question: question, Answer: answer, Op: op,
SourceMemories: source, Version: newVer, CreatedAt: now, UpdatedAt: now,
}, nil
}
// parseOpCard 解析 op 列 JSON;空/坏返回 nil。
func parseOpCard(raw sql.NullString) *OpCard {
if !raw.Valid {
return nil
}
s := strings.TrimSpace(raw.String)
if s == "" || s == "null" {
return nil
}
var oc OpCard
if err := json.Unmarshal([]byte(s), &oc); err != nil {
return nil
}
return &oc
}
// Get 直读一条(主键查询,零检索零 LLM)。无则 (nil, nil)。
func (s *StandingStore) Get(qid string) (*StandingAnswer, error) {
s.mu.RLock()
defer s.mu.RUnlock()
var a StandingAnswer
var sm string
var created, updated string
var sm, created, updated string
var opRaw sql.NullString
err := s.db.QueryRow(
`SELECT question_id,question,answer,source_memories,version,created_at,updated_at
`SELECT question_id,question,answer,op,source_memories,version,created_at,updated_at
FROM standing_answers WHERE question_id=?`, qid,
).Scan(&a.QuestionID, &a.Question, &a.Answer, &sm, &a.Version, &created, &updated)
).Scan(&a.QuestionID, &a.Question, &a.Answer, &opRaw, &sm, &a.Version, &created, &updated)
if err == sql.ErrNoRows {
return nil, nil
}
@@ -153,6 +224,7 @@ func (s *StandingStore) Get(qid string) (*StandingAnswer, error) {
return nil, fmt.Errorf("standing get: %w", err)
}
a.SourceMemories = parseSourceEvents(sm)
a.Op = parseOpCard(opRaw)
a.CreatedAt, _ = time.Parse(time.RFC3339, created)
a.UpdatedAt, _ = time.Parse(time.RFC3339, updated)
return &a, nil
@@ -163,7 +235,7 @@ func (s *StandingStore) List() ([]StandingAnswer, error) {
s.mu.RLock()
defer s.mu.RUnlock()
rows, err := s.db.Query(
`SELECT question_id,question,answer,source_memories,version,created_at,updated_at
`SELECT question_id,question,answer,op,source_memories,version,created_at,updated_at
FROM standing_answers ORDER BY question_id`)
if err != nil {
return nil, fmt.Errorf("standing list: %w", err)
@@ -173,10 +245,12 @@ func (s *StandingStore) List() ([]StandingAnswer, error) {
for rows.Next() {
var a StandingAnswer
var sm, created, updated string
if err := rows.Scan(&a.QuestionID, &a.Question, &a.Answer, &sm, &a.Version, &created, &updated); err != nil {
var opRaw sql.NullString
if err := rows.Scan(&a.QuestionID, &a.Question, &a.Answer, &opRaw, &sm, &a.Version, &created, &updated); err != nil {
return nil, err
}
a.SourceMemories = parseSourceEvents(sm)
a.Op = parseOpCard(opRaw)
a.CreatedAt, _ = time.Parse(time.RFC3339, created)
a.UpdatedAt, _ = time.Parse(time.RFC3339, updated)
out = append(out, a)
+4 -4
View File
@@ -27,7 +27,7 @@ func TestStandingUpsertGet(t *testing.T) {
}
// 首次写入 → version=1 + source_memories
a, err := s.Upsert("q_mu_pref", "牧尘的偏好是什么", "答案A", []string{"mem_1", "mem_2"})
a, err := s.Upsert("q_mu_pref", "牧尘的偏好是什么", "答案A", []string{"mem_1", "mem_2"}, nil)
if err != nil {
t.Fatalf("Upsert: %v", err)
}
@@ -39,7 +39,7 @@ func TestStandingUpsertGet(t *testing.T) {
}
// 改写 → version=2
a2, err := s.Upsert("q_mu_pref", "牧尘的偏好是什么", "答案B(更新)", []string{"mem_1", "mem_2", "mem_3"})
a2, err := s.Upsert("q_mu_pref", "牧尘的偏好是什么", "答案B(更新)", []string{"mem_1", "mem_2", "mem_3"}, nil)
if err != nil {
t.Fatalf("Upsert2: %v", err)
}
@@ -62,8 +62,8 @@ func TestStandingUpsertGet(t *testing.T) {
// TestStandingListDelete 2026-10-13 t_e9a4d18f P2:列表 + 删除。
func TestStandingListDelete(t *testing.T) {
s := newTestStandingStore(t)
_, _ = s.Upsert("q_sys", "当前系统状态", "答1", nil)
_, _ = s.Upsert("q_serv", "哪些服务在跑", "答2", []string{"mem_9"})
_, _ = s.Upsert("q_sys", "当前系统状态", "答1", nil, nil)
_, _ = s.Upsert("q_serv", "哪些服务在跑", "答2", []string{"mem_9"}, nil)
all, err := s.List()
if err != nil {
+41 -9
View File
@@ -47,6 +47,25 @@ var (
// LookupStandingHitCount 返回本进程启动以来常设答案命中次数(观测用)。
func LookupStandingHitCount() int64 { return standingLookupHit.Load() }
// standingAnswerFor 若常设答案精确通道命中则返回该答案,否则 nil(零检索零 LLM)。
// 命中口径 = 归一化后整句相等(question + op.trigger 变体),变体是人工写入的精确问法,
// 无模糊面 ⇒ 可安全作为短路的唯一依据。
func standingAnswerFor(query string) *StandingAnswer {
if !StandingAnswerEnabled() {
return nil
}
return LookupStandingAnswer(query)
}
// refreshStandingAnswers 丢弃常设答案缓存,让下一次召回重新读库。
// 写入(Upsert)后必须调用,否则新卡/改卡在 standingCacheTTL 内对召回不可见。
func refreshStandingAnswers() {
standingCacheMu.Lock()
standingCache = nil
standingCacheAt = time.Time{}
standingCacheMu.Unlock()
}
// normalizeForMatch 归一化:小写 + 去掉所有非字母数字/非 CJK 字符(空格、标点、符号)。
func normalizeForMatch(s string) string {
var b strings.Builder
@@ -132,6 +151,10 @@ func loadStandingAnswers() []StandingAnswer {
// Dice≈0.82),把 ComfyUI 的 8188 当成织忆端口置顶。模板句 + 换主语 = 必错。
// 结论:常设答案只认**同一句话**;同义问法靠多行变体(question_id 不同)覆盖。
// 近似命中(Dice ≥ nearMissLogThreshold)只打日志不返回,用来发现该补哪些变体。
//
// 2026-10-28 (t_3636b872):若常设答案带 op 结构卡,则其 op.trigger 变体也参与
// **归一化后完全相等**匹配——变体是人工/脚本写入的精确问法(含评测集真实提问措辞),
// 与 question 同等可信,无谓词注入面 ⇒ 不引入 fuzzy 误命中。card_trigger_match 标出命中路径。
func LookupStandingAnswer(query string) *StandingAnswer {
q := normalizeForMatch(query)
if len([]rune(q)) < 2 {
@@ -144,16 +167,25 @@ func LookupStandingAnswer(query string) *StandingAnswer {
var near *StandingAnswer
nearScore := 0.0
for i := range all {
cand := normalizeForMatch(all[i].Question)
if cand == "" {
continue
a := &all[i]
cand := normalizeForMatch(a.Question)
if cand != "" {
if cand == q {
return a
}
if s := diceSimilarity(q, cand); s > nearScore {
nearScore = s
near = a
}
}
if cand == q {
return &all[i]
}
if s := diceSimilarity(q, cand); s > nearScore {
nearScore = s
near = &all[i]
// op 结构卡的 trigger 变体:归一化后完全相等也算命中(提示词精确句)。
if a.Op != nil {
for _, t := range a.Op.Trigger {
tn := normalizeForMatch(t)
if tn != "" && tn == q {
return a
}
}
}
}
// 近似但不等:只记日志(便于补变体),绝不返回——错答比不答更贵。
+71 -1
View File
@@ -63,6 +63,76 @@ func TestLookupStandingAnswerThreshold(t *testing.T) {
}
}
// TestLookupStandingAnswer_OpCardTrigger 2026-10-28 (t_3636b872):
// op 结构卡的 trigger 变体参与归一化精确匹配;命中后返回带 op 的整条,供调用方展示步骤。
func TestLookupStandingAnswer_OpCardTrigger(t *testing.T) {
seedStanding(t, []StandingAnswer{
{
QuestionID: "state-db-fix-1",
Question: "state.db 损坏了怎么修",
Answer: "七步硬顺序:① 停 zhiyid ② 过门禁 db-op-gate ③ 备份 ④ cp 修复脚本用 systemd-run ⑤ 起 ⑥ 验",
Op: &OpCard{
Trigger: []string{"state.db 损坏怎么修", "state.db 坏了修复步骤是什么"},
Steps: []string{"systemctl --user stop zhiyid", "source ~/.hermes/scripts/db-op-gate.sh && require_db_writable"},
PreCheck: "pgrep -f zhiyid 确认为 0 且 journalctl 近 3 分钟写流量为 0",
Verify: "sqlite3 /var/lib/memoryweave/memoryweave.db 'PRAGMA integrity_check' 返回 ok",
Rollback: "停写后从备份 cp 回 memoryweave.db + status.db",
Risk: "高:运行期 cp 活库会双写撕裂,必须先停 zhiyid",
},
},
})
// ① trigger 变体命中(原句不命中)
if sa := LookupStandingAnswer("state.db 损坏怎么修"); sa == nil || sa.QuestionID != "state-db-fix-1" {
t.Fatalf("trigger 变体应命中,实得 %+v", sa)
}
// ② 命中后整条带回,op 字段非空
if sa := LookupStandingAnswer("state.db 损坏怎么修"); sa == nil || sa.Op == nil {
t.Fatalf("命中后 op 应为非空,实得 op=%v", sa.Op)
}
// ③ 多个变体均可命中
if sa := LookupStandingAnswer("state.db 坏了修复步骤是什么"); sa == nil || sa.QuestionID != "state-db-fix-1" {
t.Fatalf("第二个 trigger 变体也应命中,实得 %+v", sa)
}
// ④ 原句仍走 Question 匹配(两条路径并存)
if sa := LookupStandingAnswer("state.db 损坏了怎么修"); sa == nil || sa.QuestionID != "state-db-fix-1" {
t.Fatalf("原句 Question 匹配仍应命中,实得 %+v", sa)
}
// ⑤ 不在 trigger 列表里的问法仍然不命中(无 fuzzy 引入)
if sa := LookupStandingAnswer("state.db 怎么修复"); sa != nil {
t.Fatalf("非精确 trigger 不应命中(无 fuzzy),实得 %+v", sa)
}
}
// TestLookupStandingAnswer_UpsertInvalidatesCache 写入后新卡必须立即对召回可见,
// 不能等 standingCacheTTL(30s)——这是 t_3636b872 阶段 1 的写入-召回一致性判据。
func TestLookupStandingAnswer_UpsertInvalidatesCache(t *testing.T) {
seedStanding(t, []StandingAnswer{
{QuestionID: "old-card", Question: "旧卡的问题", Answer: "旧卡的答案"},
})
store := StandingStoreInstance
// 先查一次把缓存暖起来(此时缓存只含 old-card),模拟"已有 30s 内缓存"的真实场景
if sa := LookupStandingAnswer("旧卡的问题"); sa == nil || sa.QuestionID != "old-card" {
t.Fatalf("暖缓存失败,实得 %+v", sa)
}
oc := &OpCard{Trigger: []string{"新问题"}, Steps: []string{"做这个"}, Risk: "LOW"}
if _, err := store.Upsert("new-card", "新问题", "新答案", []string{"x"}, oc); err != nil {
t.Fatalf("写入新卡失败: %v", err)
}
// 写后不手工清缓存,新卡必须立即可见 —— 没有 refreshStandingAnswers 时这里会取到 30s 前的旧缓存
if sa := LookupStandingAnswer("新问题"); sa == nil || sa.QuestionID != "new-card" {
t.Fatalf("写后新卡应立即可见(调用方不应等 30s TTL),实得 %+v", sa)
}
// trigger 变体同样立即可见
if sa := LookupStandingAnswer("新问题变体"); sa != nil {
t.Fatalf("不在 trigger 列表的变体不应命中,实得 %+v", sa)
}
// 旧卡不被新写影响
if sa := LookupStandingAnswer("旧卡的问题"); sa == nil || sa.QuestionID != "old-card" {
t.Fatalf("旧卡仍应命中,实得 %+v", sa)
}
}
func TestPrependStandingAnswer(t *testing.T) {
sa := &StandingAnswer{QuestionID: "cron-scheduler", Question: "cron 任务的调度者是谁", Answer: "kanban-daemon.service"}
in := []models.RecallResult{
@@ -94,7 +164,7 @@ func seedStanding(t *testing.T, rows []StandingAnswer) {
t.Fatalf("建临时常设答案库失败: %v", err)
}
for _, r := range rows {
if _, err := store.Upsert(r.QuestionID, r.Question, r.Answer, nil); err != nil {
if _, err := store.Upsert(r.QuestionID, r.Question, r.Answer, r.SourceMemories, r.Op); err != nil {
t.Fatalf("写入常设答案失败: %v", err)
}
}
+215
View File
@@ -0,0 +1,215 @@
package storage
// t_9143c968 消费侧熔断修复:游标调度测试。
// 覆盖 AC5(窗口外 1000 pending + 窗口内 1 行必须被处理)、tail/sweep 游标、
// 失败熔断(替代旧「积压>cap⇒skip」)、retry 通道、expire 标记与 TTL。
import (
"database/sql"
"encoding/json"
"errors"
"strconv"
"testing"
"time"
)
// flakyLance 包装任意 LanceDB:failAppend=true ⇒ AppendHitEvent 恒失败(模拟持久性坏行)。
type flakyLance struct {
LanceDB
failAppend bool
}
func (f *flakyLance) AppendHitEvent(id, outcome, reason string) error {
if f.failAppend {
return errors.New("simulated commit conflict")
}
return f.LanceDB.AppendHitEvent(id, outcome, reason)
}
// insertRecallRowAt 直插一条召回行(recalled_at 可控,绕开 RecordRecalled 的 now)。
func insertRecallRowAt(t *testing.T, db *sql.DB, memID, content, recalledAt string) {
t.Helper()
toks, _ := json.Marshal(DistinctiveTokens(content, usageTokenTopN))
if _, err := db.Exec(`INSERT INTO usage_recall(memory_id,content,tokens,q_hash,namespace,agent,recalled_at)
VALUES(?,?,?,?,?,?,?)`, memID, content, string(toks), "q", "ns", "agent", recalledAt); err != nil {
t.Fatalf("insert recall row: %v", err)
}
}
const usageTestMem = "家庭服务器IP固定为192.168.123.11,星空组网走VPN 192.168.188.10"
const usageTestCommit = "家庭服务器IP是192.168.123.11,星空组网用VPN 192.168.188.10 访问"
func usagePendingBy(t *testing.T, db *sql.DB, where string, args ...interface{}) int {
t.Helper()
var n int
if err := db.QueryRow(`SELECT COUNT(*) FROM usage_recall WHERE `+where, args...).Scan(&n); err != nil {
t.Fatalf("count(%s): %v", where, err)
}
return n
}
// AC5:窗口外 1000 行 pending + 窗口内 1 行 ⇒ 窗口内那 1 行必须被消费。
// (旧实现在此场景恒 skip:pending 1001 > cap 500 ⇒ 每 commit「backlog > cap」——正是本次熔断本身。)
func TestUsageCursor_A5OldBacklogMustNotBlockNewRow(t *testing.T) {
setupUsageTest(t)
db := usageOpen()
if db == nil {
t.Fatal("usageOpen failed")
}
mlc := NewMemLanceClient(nil)
now := time.Now()
old := now.Add(-72 * time.Hour).Format(time.RFC3339)
fresh := now.Format(time.RFC3339)
for i := 0; i < 1000; i++ {
insertRecallRowAt(t, db, "mem_old"+strconv.Itoa(i), "旧内容占位记忆"+strconv.Itoa(i), old)
}
insertRecallRowAt(t, db, "mem_new", usageTestMem, fresh)
start := time.Now()
flags := CheckCommittedForUse(mlc, "agent", "ns", usageTestCommit)
t.Logf("1001 行积压下 单轮耗时 %v", time.Since(start))
if len(flags) != 1 || flags[0].MemoryID != "mem_new" {
t.Fatalf("窗口内 1 行必须被消费(旧积压闸已删),got %+v", flags)
}
// 窗口外 1000 行:同轮被 expire 通道标 consumed=2(skipped_expired,留证据不物理删)
if c2 := usagePendingBy(t, db, "consumed=2"); c2 != 1000 {
t.Fatalf("窗口外 pending 应全标 consumed=2, actual %d", c2)
}
if p := usagePendingBy(t, db, "consumed=0"); p != 0 {
t.Fatalf("不应再有 consumed=0 残留, actual %d", p)
}
// 行仍在库里(证据保留):总量 = 1001
if total := usagePendingBy(t, db, "1=1"); total != 1001 {
t.Fatalf("expire 只标记不删除, total=%d", total)
}
}
// tail 只吃游标之上的有界批次(LAG),落后行由 sweep 逐轮补齐;全部行最终各消费一次。
func TestUsageCursor_TailBoundedSweepCovers(t *testing.T) {
setupUsageTest(t)
t.Setenv("ZHIYI_USAGE_BATCH", "3")
t.Setenv("ZHIYI_USAGE_SWEEP_BATCH", "2")
db := usageOpen()
mlc := NewMemLanceClient(nil)
now := time.Now().Format(time.RFC3339)
for i := 1; i <= 10; i++ {
insertRecallRowAt(t, db, "mem_c"+strconv.Itoa(i), usageTestMem, now)
}
// call1:tail 取最新 3 行(ids 10,9,8);sweep 从头取 2 行(ids 1,2)⇒ 5 flags
if f := CheckCommittedForUse(mlc, "agent", "ns", usageTestCommit); len(f) != 5 {
t.Fatalf("call1 期望 5 flags (tail3+sweep2), got %d", len(f))
}
if c := usagePendingBy(t, db, "consumed=1"); c != 5 {
t.Fatalf("call1 后应消费 5 行, actual %d", c)
}
// 继续调用直至全部消费;每次调用都是新 commit,行只能消费一次
for round := 2; round <= 10; round++ {
CheckCommittedForUse(mlc, "agent", "ns", usageTestCommit)
}
if c := usagePendingBy(t, db, "consumed=1"); c != 10 {
t.Fatalf("多轮后应全部 10 行消费, actual %d", c)
}
if p := usagePendingBy(t, db, "consumed=0"); p != 0 {
t.Fatalf("不应有 pending 残留, actual %d", p)
}
// 每行只 1 条 hit event(无重复累加)
for i := 1; i <= 10; i++ {
ev, _ := mlc.GetHitEvents("mem_c"+strconv.Itoa(i), 10)
if len(ev) != 1 {
t.Fatalf("mem_c%d 应恰 1 条 hit event, got %d", i, len(ev))
}
}
}
// 失败熔断(ZHIYI_USAGE_BACKLOG_MAX 新语义):单轮失败 ≥cap ⇒ 停,不再全表跳过。
// 反证旧行为:pending 6 > cap 2 在旧实现 = 「backlog > cap skip」0 行处理;
// 新实现必须实际处理(失败行记退避,坏行不再每 commit 热重试,也不再拖死好行)。
func TestUsageCursor_FailureBreakerReplacesBacklogSkip(t *testing.T) {
setupUsageTest(t)
t.Setenv("ZHIYI_USAGE_BACKLOG_MAX", "2")
db := usageOpen()
mlc := NewMemLanceClient(nil)
flaky := &flakyLance{LanceDB: mlc, failAppend: true}
now := time.Now().Format(time.RFC3339)
for i := 1; i <= 6; i++ {
insertRecallRowAt(t, db, "mem_bk"+strconv.Itoa(i), usageTestMem, now)
}
flags := CheckCommittedForUse(flaky, "agent", "ns", usageTestCommit)
if len(flags) != 0 {
t.Fatalf("恒失败不应有 flag, got %d", len(flags))
}
// tail DESC 先吃 ids 6,5 → 2 次失败即熔断;sweep 被单次调用熔断跳过 ⇒ 仅 2 行记退避
if n := usagePendingBy(t, db, "retry_count=1"); n != 2 {
t.Fatalf("熔断应只记 2 行退避(单轮 cap=2), actual %d", n)
}
if n := usagePendingBy(t, db, "retry_count=0 AND consumed=0"); n != 4 {
t.Fatalf("熔断后其余 4 行应未被触碰, actual %d", n)
}
if n := usagePendingBy(t, db, "consumed=1"); n != 0 {
t.Fatalf("失败行不得消费, actual %d", n)
}
// 关键反证:旧「pending(6)>cap(2)⇒skip」在此场景 0 行处理;新实现推进了 2 行状态。
}
// retry 通道:游标推过头的失败行,退避到期后仍能被重试消费(不依赖 tail 位置)。
func TestUsageCursor_RetryPassAfterBackoff(t *testing.T) {
setupUsageTest(t)
db := usageOpen()
mlc := NewMemLanceClient(nil)
flaky := &flakyLance{LanceDB: mlc, failAppend: true}
now := time.Now().Format(time.RFC3339)
insertRecallRowAt(t, db, "mem_retry", usageTestMem, now)
if f := CheckCommittedForUse(flaky, "agent", "ns", usageTestCommit); len(f) != 0 {
t.Fatalf("第一次应失败, got %d", len(f))
}
if c := usagePendingBy(t, db, "consumed=1"); c != 0 {
t.Fatalf("失败不应消费, actual %d", c)
}
// 手动把 next_retry_at 拨到过去(模拟退避到期;包级 backoff 参数在 init 时已定)
if _, err := db.Exec(`UPDATE usage_recall SET next_retry_at='2000-01-01T00:00:00Z' WHERE memory_id='mem_retry'`); err != nil {
t.Fatalf("backdate retry: %v", err)
}
flaky.failAppend = false // 故障恢复
f := CheckCommittedForUse(mlc, "agent", "ns", usageTestCommit)
if len(f) != 1 || f[0].MemoryID != "mem_retry" {
t.Fatalf("退避到期后 retry 通道必须消费该行, got %+v", f)
}
if c := usagePendingBy(t, db, "consumed=1 AND retry_count=0"); c != 1 {
t.Fatalf("成功消费应把 retry_count 归零, actual %d", c)
}
}
// expire + TTL:出窗 pending 标 2(72h,未到 7d 不删);consumed>=1 且 >7d 删除;新鲜行不动。
func TestUsageCursor_ExpireMarkAndTTL(t *testing.T) {
setupUsageTest(t)
db := usageOpen()
mlc := NewMemLanceClient(nil)
now := time.Now()
insertRecallRowAt(t, db, "mem_expired_pending", "旧内容占位记忆甲", now.Add(-72*time.Hour).Format(time.RFC3339))
insertRecallRowAt(t, db, "mem_stale_consumed", "旧内容占位记忆乙", now.Add(-8*24*time.Hour).Format(time.RFC3339))
if _, err := db.Exec(`UPDATE usage_recall SET consumed=1 WHERE memory_id IN ('mem_stale_consumed')`); err != nil {
t.Fatalf("mark consumed: %v", err)
}
insertRecallRowAt(t, db, "mem_fresh_consumed", "旧内容占位记忆丙", now.Add(-1*time.Hour).Format(time.RFC3339))
if _, err := db.Exec(`UPDATE usage_recall SET consumed=1 WHERE memory_id='mem_fresh_consumed'`); err != nil {
t.Fatalf("mark consumed: %v", err)
}
insertRecallRowAt(t, db, "mem_fresh_pending", "旧内容占位记忆丁", now.Add(-1*time.Hour).Format(time.RFC3339))
// 不相关内容 ⇒ 无判定,但 expire/TTL 维护通道照常运行
CheckCommittedForUse(mlc, "agent", "ns", "今天天气很好出去散步")
if n := usagePendingBy(t, db, "memory_id='mem_expired_pending' AND consumed=2"); n != 1 {
t.Fatalf("出窗 pending 应标 consumed=2(且 72h<7d 不得删除), rows=%d", n)
}
if n := usagePendingBy(t, db, "memory_id='mem_stale_consumed'"); n != 0 {
t.Fatalf("consumed>=1 且 recalled_at>7d 应被 TTL 删除, rows=%d", n)
}
if n := usagePendingBy(t, db, "memory_id='mem_fresh_consumed' AND consumed=1"); n != 1 {
t.Fatalf("新鲜 consumed 行不得删, rows=%d", n)
}
if n := usagePendingBy(t, db, "memory_id='mem_fresh_pending' AND consumed=0"); n != 1 {
t.Fatalf("窗内 pending 不得被动, rows=%d", n)
}
}
+217 -55
View File
@@ -29,6 +29,7 @@ import (
"log"
"os"
"sort"
"strconv"
"strings"
"sync"
"time"
@@ -154,6 +155,20 @@ func usageOpen() *sql.DB {
}
_, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_usage_mem ON usage_recall(memory_id)`)
_, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_usage_time ON usage_recall(recalled_at)`)
// 消费游标(t_9143c968):单行表,id 恒为 1。tail_pos=尾部已消费位点,sweep_pos=轮巡位点。
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS usage_cursor(
id INTEGER PRIMARY KEY CHECK(id=1),
tail_pos INTEGER NOT NULL DEFAULT 0,
sweep_pos INTEGER NOT NULL DEFAULT 0,
updated_at TEXT)`); err != nil {
logUsage("create cursor table: %v", err)
}
// 部分索引(t_9143c968):让消费端单轮扫描代价 O(batch),与表总量解耦。
// ⚠️ 生产 5M+ 行库的这三条索引须在停机窗口预建(sqlite3 CLI),
// 否则首次 usageOpen 会在请求路径里建索引卡几秒;IF NOT EXISTS 双保险。
_, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_usage_pending ON usage_recall(id, recalled_at) WHERE consumed=0`)
_, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_usage_pending_time ON usage_recall(recalled_at) WHERE consumed=0`)
_, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_usage_retry ON usage_recall(id) WHERE next_retry_at IS NOT NULL`)
usageDB = db
})
return usageDB
@@ -198,44 +213,72 @@ func RecordRecalled(recs []RecalledMemory) {
}
}
// CheckCommittedForUse commit 写侧钩子:检查新写入的内容是否携带了
// 最近被召回记忆的独特 token;命中则判定「被用上」并落 outcome(门控)。
// 返回本次判定出的「被用上」记忆(供上报/取证)。
func CheckCommittedForUse(ldb LanceDB, agent, namespace, content string) []UsageFlag {
if !usageTrackEnabled || ldb == nil || content == "" {
return nil
}
db := usageOpen()
if db == nil {
return nil
}
// 积压上限量化(t_20bfbd9a 治本②):未消费待重试行超过上限 ⇒ 本轮跳过扫描,
// 防止「一条永远失败的坏行 + 高 commit 频率」在每次 commit 都全量重扫、叠成风暴。
// 上限 env ZHIYI_USAGE_BACKLOG_MAX,默认 500。
backlogMax := int(envFloat("ZHIYI_USAGE_BACKLOG_MAX", 500))
if backlogMax > 0 {
var pending int
if err := db.QueryRow(`SELECT COUNT(*) FROM usage_recall WHERE consumed=0`).Scan(&pending); err == nil && pending > backlogMax {
logUsage("backlog %d > cap %d: skip scan this commit", pending, backlogMax)
return nil
// ─── 消费游标与批量消费(t_9143c968 熔断修复,2026-10-30)────────────
//
// 根因(卡 body + comment #1757/#1758 两次修正):旧实现扫描前用无窗 count(consumed=0)
// 作积压熔断(cap 500,t_20bfbd9a)。写入量涨到 40~200 万行/日后 pending 恒百万级
// ⇒ 每次 commit 都命中「backlog > cap ⇒ skip」——消费侧永久短路(10-19 起 consumed=0)。
// cap 是安全阀不是 bug;真 bug 是「消费无增量位点,只能每次 commit 全量扫窗」。
//
// 新方案(卡推荐 A:增量游标;单轮代价 O(batch),与表总量解耦):
// - expire 过期标记:consumed=0 且已出窗(recalled_at<cutoff)的行标 consumed=2
// (skipped_expired,不物理删留证据),chunk=ZHIYI_USAGE_EXPIRE_CHUNK(默认 5000)。
// - retry 退避重试:next_retry_at 已到期的失败行优先重试(不受游标位置影响)。
// - tail 尾部消费:从 tail 游标向上取最多 ZHIYI_USAGE_BATCH(默认 200)条未消费
// 行(DESC 先吃最新)——上一轮 commit 以来最新一批召回,与本次 commit 内容
// 因果对齐(主信号面);游标推进到表尾 max(id)。
// - sweep 轮巡复查:升序扫未消费行,游标到表尾后回绕 0,batch=ZHIYI_USAGE_SWEEP_BATCH
// (默认 500,0 关闭)。保留旧「一次召回、多个后续 commit 逐个命中」语义
// (usage_live_demo_test 即此形态);expire 先行使游标只在窗内 pending 区推进。
// - TTL consumed>=1(含 2)且 recalled_at<now-7d 的行删除,
// chunk=ZHIYI_USAGE_TTL_CHUNK(默认 5000,0 关闭),防止无界增长。
// - ZHIYI_USAGE_BACKLOG_MAX(默认 500)保留但语义收窄:不再是「全表积压跳过」,
// 改为单次调用失败熔断——一轮内失败行数 ≥ cap 即结束本轮,
// 保留 t_20bfbd9a「持久性坏行不叠重试风暴」的原始意图。
//
// 取舍(写进交付注释):commit 频率(实测 ~91 次/日)≪ 召回写入(40~200 万行/日),
// 任何方案都不可能对每行复查窗口内全部后续 commit。tail 面抓「recall→紧接着写」
// 的主场景;窗口内其余行由 sweep 轮巡兜低频复查;出窗行标 2 留证,7 日后 TTL 清。
// usageMu 串行化消费全流程:commit 钩子是异步 goroutine,可能并发进入;
// 同库读-改-写(游标推进/消费标记)串行后无竞态,单轮 O(batch) 毫秒级,无排队风险。
var usageMu sync.Mutex
func usageCursorGet(db *sql.DB) (tail, sweep int64) {
_ = db.QueryRow(`SELECT tail_pos, sweep_pos FROM usage_cursor WHERE id=1`).Scan(&tail, &sweep)
return // 无行/出错 → (0,0):首轮 tail 从 0 向上扫最新一批,sweep 从最老行开始
}
func usageCursorSet(db *sql.DB, tail, sweep int64) {
_, _ = db.Exec(`INSERT INTO usage_cursor(id,tail_pos,sweep_pos,updated_at) VALUES(1,?,?,?)
ON CONFLICT(id) DO UPDATE SET tail_pos=excluded.tail_pos, sweep_pos=excluded.sweep_pos, updated_at=excluded.updated_at`,
tail, sweep, time.Now().Format(time.RFC3339))
}
// usageEnvInt 读整型 env;缺失/非法/≤0 → 默认值。
func usageEnvInt(name string, def int) int {
if v := os.Getenv(name); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
return n
}
}
cutoff := time.Now().Add(-time.Duration(usageWindowHours * float64(time.Hour))).Format(time.RFC3339)
scanNow := time.Now()
// 退避过滤(t_20bfbd9a 治本①):next_retry_at 为 NULL(首次待处理)或已到重试时间,
// 才进本轮重扫。持久性失败的坏行被指数退避挡在窗口外,不再每 commit 热重试。
rows, err := db.Query(
`SELECT id, memory_id, content, tokens, namespace, agent, retry_count
FROM usage_recall WHERE consumed=0 AND recalled_at>=?
AND (next_retry_at IS NULL OR next_retry_at<=?)`,
cutoff, scanNow.Format(time.RFC3339))
return def
}
// usageScanBatch 执行一条批次查询并逐行判定,返回(判定样本, 失败行数, 本批最大 id)。
// query 须选出 id,memory_id,content,tokens,namespace,agent,retry_count 七列。
// 单行失败(AppendHitEvent/Update 终态失败)⇒ 不消费 + 指数退避(t_20bfbd9a 语义不变);
// 失败累计 ≥ ZHIYI_USAGE_BACKLOG_MAX(>0 时)⇒ 熔断:本轮提前结束。
func usageScanBatch(db *sql.DB, ldb LanceDB, agent, namespace string, contentSet map[string]bool,
query string, args ...interface{}) (flags []UsageFlag, failed int, maxID int64) {
rows, err := db.Query(query, args...)
if err != nil {
logUsage("query recall log: %v", err)
return nil
logUsage("query recall batch: %v", err)
return
}
defer rows.Close()
contentSet := tokenSet(content)
flags := make([]UsageFlag, 0)
breakerMax := int(envFloat("ZHIYI_USAGE_BACKLOG_MAX", 500))
now := time.Now().Format(time.RFC3339)
for rows.Next() {
var id int64
var memID, memContent, tokensJSON, ns, ag string
@@ -243,6 +286,9 @@ func CheckCommittedForUse(ldb LanceDB, agent, namespace, content string) []Usage
if err := rows.Scan(&id, &memID, &memContent, &tokensJSON, &ns, &ag, &retryCount); err != nil {
continue
}
if id > maxID {
maxID = id
}
// 归属过滤:同一 agent,或同一 namespace,或共享/全局。跨域不强求。
if ag != "" && agent != "" && ag != agent && ns != "" && namespace != "" && ns != namespace {
continue
@@ -256,43 +302,159 @@ func CheckCommittedForUse(ldb LanceDB, agent, namespace, content string) []Usage
continue
}
// 判定「被用上」:落 outcome + 累加 recall_verified_count + 消费该 recall 行。
now := time.Now().Format(time.RFC3339)
// t_7793a484:outcome 事件失败不再静默吞(修复前 `_ =` 丢错 + 单次 rpc 无重试 ⇒
// 一次冲突即终态丢,10-16 实测 42 条丢失走此路径)。现:AppendHitEvent 内置
// 有界重试(同 Update 规格);终态失败(耗尽/非可重试)⇒ 不消费(consumed 保持 0)
// ⇒ 下一轮 commit 重扫重试,事件不静默丢。失败可查(logUsage 计数 + 计数器
// zhiyi_hit_event_append_failed_total),禁静默。
// t_7793a484:outcome 终态失败 ⇒ 不消费 + 指数退避(t_20bfbd9a),下一轮重扫
// 重试,事件不静默丢。t_28aa278e 方案①:Update 成功后才消费,计数增量不丢。
if err := ldb.AppendHitEvent(memID, HitOutcomePos, HitReasonDownstreamUse); err != nil {
logUsage("hit event append FAILED (id=%s, NOT consumed, will retry next commit) : %v", memID, err)
// t_20bfbd9a 治本①:持久性失败(如坏行 memID 已从 LanceDB 删除)若不加退避,
// 会在每次 commit 都热重试,commit 风暴时叠成重扫风暴。加指数退避:
// 第 n 次失败后 next_retry_at = now + min(2^(n-1) * base, cap),退避期外不再重扫。
// 成功消费时把 retry_count 归零;每轮只推进 retry_count 一次(不指数级暴涨)。
_, _ = db.Exec(
`UPDATE usage_recall SET retry_count=retry_count+1, next_retry_at=? WHERE id=?`,
logUsage("hit event append FAILED (id=%s, NOT consumed, will retry) : %v", memID, err)
_, _ = db.Exec(`UPDATE usage_recall SET retry_count=retry_count+1, next_retry_at=? WHERE id=?`,
time.Now().Add(usageBackoff(retryCount+1)).Format(time.RFC3339), id)
failed++
if breakerMax > 0 && failed >= breakerMax {
logUsage("failure breaker: %d failures >= cap %d, abort this call", failed, breakerMax)
return
}
continue
}
// t_28aa278e 方案①:更新成功后才消费 usage_recall 行。
// 旧实现无条件消费 ⇒ Update 冲突耗尽时该 recall_verified_count 增量被永久丢弃
// (下轮不再重扫)。现改为:Update 返回非 nil(冲突重试耗尽/非可重试错误)
// ⇒ 不消费(consumed 保持 0)⇒ 下一轮 commit 重扫该窗口会重试,计数增量不静默丢失。
// 失败数可查(logUsage 计数),禁静默。
if err := ldb.Update("memories", memID, map[string]any{
"recall_verified_count": map[string]string{"$inc": "1"},
}); err != nil {
logUsage("recall_verified_count update FAILED (id=%s, NOT consumed, will retry next commit) : %v", memID, err)
_, _ = db.Exec(
`UPDATE usage_recall SET retry_count=retry_count+1, next_retry_at=? WHERE id=?`,
logUsage("recall_verified_count update FAILED (id=%s, NOT consumed, will retry) : %v", memID, err)
_, _ = db.Exec(`UPDATE usage_recall SET retry_count=retry_count+1, next_retry_at=? WHERE id=?`,
time.Now().Add(usageBackoff(retryCount+1)).Format(time.RFC3339), id)
failed++
if breakerMax > 0 && failed >= breakerMax {
logUsage("failure breaker: %d failures >= cap %d, abort this call", failed, breakerMax)
return
}
continue
}
if _, err := db.Exec(`UPDATE usage_recall SET consumed=1, consumed_at=?, retry_count=0, next_retry_at=NULL WHERE id=?`, now, id); err != nil {
logUsage("consume row id=%d failed: %v", id, err)
continue
}
_, _ = db.Exec(`UPDATE usage_recall SET consumed=1, consumed_at=?, retry_count=0, next_retry_at=NULL WHERE id=?`, now, id)
flags = append(flags, UsageFlag{
MemoryID: memID, Content: memContent, Criterion: "carry_forward",
MatchedTokens: toks, HitCount: hits, RunesMatched: runes, ConsumedAt: now,
})
}
return
}
// CheckCommittedForUse commit 写侧钩子:检查新写入的内容是否携带了
// 最近被召回记忆的独特 token;命中则判定「被用上」并落 outcome(门控)。
// 返回本次判定出的「被用上」记忆(供上报/取证)。
// 消费调度(t_9143c968):expire→retry→tail→sweep→ttl,全程 O(batch),无全表计数。
func CheckCommittedForUse(ldb LanceDB, agent, namespace, content string) []UsageFlag {
if !usageTrackEnabled || ldb == nil || content == "" {
return nil
}
db := usageOpen()
if db == nil {
return nil
}
usageMu.Lock()
defer usageMu.Unlock()
batch := usageEnvInt("ZHIYI_USAGE_BATCH", 200)
sweepBatch := usageEnvInt("ZHIYI_USAGE_SWEEP_BATCH", 500) // 0=关闭轮巡
breakerMax := int(envFloat("ZHIYI_USAGE_BACKLOG_MAX", 500))
cutoff := time.Now().Add(-time.Duration(usageWindowHours * float64(time.Hour))).Format(time.RFC3339)
scanNow := time.Now().Format(time.RFC3339)
contentSet := tokenSet(content)
var scanned int
// ── expire:出窗未消费行标 consumed=2(skipped_expired,留证据不物理删)──
if chunk := usageEnvInt("ZHIYI_USAGE_EXPIRE_CHUNK", 5000); chunk > 0 {
if res, err := db.Exec(`UPDATE usage_recall SET consumed=2
WHERE id IN (SELECT id FROM usage_recall WHERE consumed=0 AND recalled_at<? LIMIT ?)`,
cutoff, chunk); err != nil {
logUsage("expire mark: %v", err)
} else if n, _ := res.RowsAffected(); n > 0 {
logUsage("expire mark: %d rows -> consumed=2 (skipped_expired)", n)
}
}
// ── retry:退避到期的失败行优先(t_20bfbd9a 退避语义不变)──
rf, rFailed, _ := usageScanBatch(db, ldb, agent, namespace, contentSet,
`SELECT id, memory_id, content, tokens, namespace, agent, retry_count
FROM usage_recall
WHERE consumed=0 AND next_retry_at IS NOT NULL AND next_retry_at<=? AND recalled_at>=?
ORDER BY id LIMIT ?`, scanNow, cutoff, batch)
scanned += len(rf) + rFailed
// ── tail:游标向上 ≤batch 行(DESC 先吃最新;与本次 commit 因果对齐的主信号面)──
var top int64
_ = db.QueryRow(`SELECT COALESCE(MAX(id),0) FROM usage_recall`).Scan(&top)
tail, sweep := usageCursorGet(db)
tf, tFailed, _ := usageScanBatch(db, ldb, agent, namespace, contentSet,
`SELECT id, memory_id, content, tokens, namespace, agent, retry_count
FROM usage_recall
WHERE consumed=0 AND id>? AND id<=? AND recalled_at>=? AND (next_retry_at IS NULL OR next_retry_at<=?)
ORDER BY id DESC LIMIT ?`, tail, top, cutoff, scanNow, batch)
scanned += len(tf) + tFailed
newTail := top // 游标推进到表尾;本批未触达的落后行留给 sweep/expire(代价有界)
if newTail < tail {
newTail = tail // 库被外部重置等异常:游标不回退
}
// ── sweep:轮巡复查未消费行;游标后无行 ⇒ 游标归 0 并【本轮立即从 0 补扫一次】
// (「一次召回、后续多个 commit 逐个命中」依赖这条;至多两次有界查询,不整窗扫)──
var sf []UsageFlag
sFailed, sMax := 0, int64(0)
newSweep := sweep
if sweepBatch > 0 && breakerMax > 0 && rFailed+tFailed >= breakerMax {
// 单次调用失败熔断(ZHIYI_USAGE_BACKLOG_MAX 收窄后的新语义):
// 前面已积累 ≥cap 失败 ⇒ 本轮不再开新失败面(旧「全表积压跳过」已删除)。
logUsage("sweep skipped by failure breaker (%d failures >= cap %d)", rFailed+tFailed, breakerMax)
} else if sweepBatch > 0 {
sf, sFailed, sMax = usageScanBatch(db, ldb, agent, namespace, contentSet,
`SELECT id, memory_id, content, tokens, namespace, agent, retry_count
FROM usage_recall
WHERE consumed=0 AND id>? AND recalled_at>=? AND (next_retry_at IS NULL OR next_retry_at<=?)
ORDER BY id LIMIT ?`, sweep, cutoff, scanNow, sweepBatch)
if sMax == 0 && sweep > 0 { // 到表尾:回绕 0,本轮补扫
var f2 []UsageFlag
var x2 int
f2, x2, sMax = usageScanBatch(db, ldb, agent, namespace, contentSet,
`SELECT id, memory_id, content, tokens, namespace, agent, retry_count
FROM usage_recall
WHERE consumed=0 AND id>0 AND recalled_at>=? AND (next_retry_at IS NULL OR next_retry_at<=?)
ORDER BY id LIMIT ?`, cutoff, scanNow, sweepBatch)
sf = append(sf, f2...)
sFailed += x2
}
scanned += len(sf) + sFailed
if sMax > 0 {
newSweep = sMax
} else {
newSweep = 0 // 表尾到达:回绕
}
}
usageCursorSet(db, newTail, newSweep)
// ── TTL:consumed>=1(含 2)且 recalled_at<7d 删除,chunk 化防长事务 ──
if chunk := usageEnvInt("ZHIYI_USAGE_TTL_CHUNK", 5000); chunk > 0 {
ttlCutoff := time.Now().Add(-7 * 24 * time.Hour).Format(time.RFC3339)
if res, err := db.Exec(`DELETE FROM usage_recall
WHERE id IN (SELECT id FROM usage_recall WHERE consumed>=1 AND recalled_at<? LIMIT ?)`,
ttlCutoff, chunk); err != nil {
logUsage("ttl purge: %v", err)
} else if n, _ := res.RowsAffected(); n > 0 {
logUsage("ttl purged %d rows (consumed>=1, recalled_at<7d)", n)
// 定期截断 WAL,防删除型事务把 -wal 撑大
_, _ = db.Exec(`PRAGMA wal_checkpoint(TRUNCATE)`)
}
}
flags := make([]UsageFlag, 0, len(rf)+len(tf)+len(sf))
flags = append(flags, rf...)
flags = append(flags, tf...)
flags = append(flags, sf...)
if scanned > 0 || len(flags) > 0 {
logUsage("usage scan: scanned=%d flagged=%d failed=%d tail=%d->%d sweep=%d->%d",
scanned, len(flags), rFailed+tFailed+sFailed, tail, newTail, sweep, newSweep)
}
return flags
}
+17 -13
View File
@@ -150,18 +150,22 @@ func TestUsageBackoff_ExponentialGrowthAndCap(t *testing.T) {
}
}
// t_20bfbd9a 治本②:积压上限量化 —— 未消费待重试行超过 ZHIYI_USAGE_BACKLOG_MAX 时本轮跳过扫描。
// t_20bfbd9a 治本②(旧):积压超 ZHIYI_USAGE_BACKLOG_MAX ⇒ 本轮 skip。
// t_9143c968(2026-10-30)作废该语义:写入量涨到 40~200 万行/日后「无窗 pending>cap」
// 恒成立 ⇒ 每 commit skip ⇒ 消费侧永久熔断(10-19 起 consumed=0)。cap 收窄为
// 「单次调用失败熔断」(见 TestUsageCursor_FailureBreakerReplacesBacklogSkip)。
// 本用例改为验证:积压再大也不再 skip —— 窗口内有可消费行就必须消费,
// 窗口外 pending 不再阻塞(被 expire 标 2)。这正是卡体 AC1 的必要条件。
func TestCheckCommittedForUse_BacklogCapSkipsScan(t *testing.T) {
setupUsageTest(t)
db := usageOpen()
if db == nil {
t.Fatal("usageOpen failed")
}
// 显式压小积压上限,验证超过即跳过
// 压小 cap 到 2,且制造远超 cap 的 pending —— 旧实现此处必 skip。
t.Setenv("ZHIYI_USAGE_BACKLOG_MAX", "2")
now := time.Now().Format(time.RFC3339)
// 塞 3 条未消费召回行(超过上限 2)
for i := 0; i < 3; i++ {
for i := 0; i < 5; i++ {
if _, err := db.Exec(`INSERT INTO usage_recall(memory_id, content, tokens, q_hash, namespace, agent, recalled_at)
VALUES(?,?,?,?,?,?,?)`,
"mem_bk"+string(rune('0'+i)), "牧尘今天要求处理看板任务验收"+string(rune('0'+i)),
@@ -169,15 +173,15 @@ func TestCheckCommittedForUse_BacklogCapSkipsScan(t *testing.T) {
t.Fatalf("insert: %v", err)
}
}
// 无需真实 LanceDB(ldb=nil 直接返回 nil flags);本用例只验证「超限即提前跳过」不 panic 且不消费
flags := CheckCommittedForUse(nil, "agent", "ns", "牧尘今天要求处理看板任务验收0")
if flags != nil {
t.Fatalf("ldb=nil 应直接返回 nil flags(超限跳过不应触及消费路径), got %v", flags)
// 窗口内有可消费行 ⇒ 即使 pending(5)≫cap(2) 也必须消费它(不再 skip)。
mlc := NewMemLanceClient(nil)
flags := CheckCommittedForUse(mlc, "agent", "ns", "牧尘今天要求处理看板任务验收")
if len(flags) == 0 {
t.Fatalf("积压 > cap 不再触发 skip:窗口内可消费行必须被判定, got 0 flags")
}
// 判据:超限跳过时所有行仍 consumed=0(没有消费行为发生)
var pending int
_ = db.QueryRow(`SELECT COUNT(*) FROM usage_recall WHERE consumed=0`).Scan(&pending)
if pending != 3 {
t.Fatalf("超限跳过扫描后应 3 行全部未消费(no consumption), got %d", pending)
// 被消费的行 consumed=1;旧实现这里会 5 行全 consumed=0。
if c := usagePendingBy(t, db, "consumed=1"); c == 0 {
t.Fatalf("修复后应至少消费 1 行(旧 skip 语义已废), actual %d", c)
}
}
+4
View File
@@ -14,3 +14,7 @@ a2be088cba59c95901b22d855dea9b6df3bf3a80
4341fac4c28a4f2825f7373ea0ec94d07c6452f0
b101b464471f6283c438f089e9ec269c8f2b1644
01d8f34
a8e15c9ceee7978272d57e9dc7bf8f29c1b7970c
6952bdb2138bb7b14479f64553fb911b24e9dda4
ebe6384b733e52a910cd72aaf7afdb94128c72a1
dbdb2ed08e0ba4a93cf43127e0e739eee114b4f6