Compare commits

..
6 Commits
Author SHA1 Message Date
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
19 changed files with 1493 additions and 198 deletions
+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())
+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
+12
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 {
+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)
}
}
}
+311 -61
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,7 +38,7 @@ 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)
@@ -47,7 +48,7 @@ type Config struct {
// DefaultConfig 设计参数。
func DefaultConfig() Config {
return Config{
MinComboFreq: 3,
MinComboFreq: 2,
ClusterThresh: 0.85,
MinClusterSize: 3,
MaxRecords: 500,
@@ -55,19 +56,78 @@ func DefaultConfig() Config {
}
}
// 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 +135,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,16 +189,29 @@ 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,
})
}
@@ -198,6 +271,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 +446,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 +564,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 +664,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
}
+206 -13
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,13 +52,13 @@ 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)
}
}
}
@@ -96,16 +96,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 -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