Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99cb22ba93 | ||
|
|
5709af0bfa | ||
|
|
f178594402 | ||
|
|
ca6be0063b | ||
|
|
40c27f4a95 | ||
|
|
ff92b0578a |
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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, ¬null, &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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 近似但不等:只记日志(便于补变体),绝不返回——错答比不答更贵。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,3 +14,7 @@ a2be088cba59c95901b22d855dea9b6df3bf3a80
|
||||
4341fac4c28a4f2825f7373ea0ec94d07c6452f0
|
||||
b101b464471f6283c438f089e9ec269c8f2b1644
|
||||
01d8f34
|
||||
a8e15c9ceee7978272d57e9dc7bf8f29c1b7970c
|
||||
6952bdb2138bb7b14479f64553fb911b24e9dda4
|
||||
ebe6384b733e52a910cd72aaf7afdb94128c72a1
|
||||
dbdb2ed08e0ba4a93cf43127e0e739eee114b4f6
|
||||
|
||||
Reference in New Issue
Block a user