From a430a2b60207bb6bbcf5d4926c8db79dec602ca5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E5=94=AF?= Date: Mon, 9 Nov 2026 01:12:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(skillgen):=20SkillEvaluator=20=E5=9B=9B?= =?UTF-8?q?=E7=BB=B4=E8=AF=84=E4=BC=B0=E5=99=A8=20+=20SkillEvolver=20?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=20+=20=E8=A7=84=E5=88=99=E8=BF=87=E6=BB=A4?= =?UTF-8?q?=E5=B7=AE=E8=B7=9D=E5=88=86=E6=9E=90=E6=8E=A2=E9=92=88=20(t=5F4?= =?UTF-8?q?594b632)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go/cmd/rulefilter-impact/main.go | 137 +++++++++++ go/cmd/skilleval-live/main.go | 33 +++ go/cmd/skilleval-probe/main.go | 34 +++ go/internal/api/routes/patterns.go | 26 +- go/internal/api/server.go | 3 + go/internal/skillgen/evaluator.go | 326 +++++++++++++++++++++++++ go/internal/skillgen/evaluator_test.go | 143 +++++++++++ go/internal/skillgen/evolver.go | 36 +++ go/internal/skillgen/evolver_test.go | 22 ++ 9 files changed, 759 insertions(+), 1 deletion(-) create mode 100644 go/cmd/rulefilter-impact/main.go create mode 100644 go/cmd/skilleval-live/main.go create mode 100644 go/cmd/skilleval-probe/main.go create mode 100644 go/internal/skillgen/evaluator.go create mode 100644 go/internal/skillgen/evaluator_test.go create mode 100644 go/internal/skillgen/evolver.go create mode 100644 go/internal/skillgen/evolver_test.go diff --git a/go/cmd/rulefilter-impact/main.go b/go/cmd/rulefilter-impact/main.go new file mode 100644 index 0000000..3f1a133 --- /dev/null +++ b/go/cmd/rulefilter-impact/main.go @@ -0,0 +1,137 @@ +// impact-probe — 候选池规则过滤影响面实测(t_4594b632 第二步) +// 读:sidecar IPC lancedb_scan_all 全量存活行(与 pattern-fullscan-probe 同通道)。 +// 算:对每条 content 逐条跑候选规则,统计每条规则会拦掉多少条 / 占比 / 前10条样本。 +// 规则纯函数、零 LLM、零写库。用于决定「真缺才写 / 已覆盖 / 不该有」。 +package main + +import ( + "flag" + "fmt" + "os" + "regexp" + "sort" + "strings" + "unicode" + "unicode/utf8" + + "github.com/xiaoxue/memoryweave/internal/models" + "github.com/xiaoxue/memoryweave/internal/storage" +) + +type ruleResult struct { + name string + hit []string // 样本(content 前 80 字) +} + +func main() { + sock := flag.String("sock", "", "LANCEDB socket") + flag.Parse() + s := *sock + if s == "" { + if env := os.Getenv("LANCEDB_SOCKET"); env != "" { + s = env + } else { + s = "/tmp/zhiyi-ipc.sock" + } + } + rc := storage.NewRustLanceDBClient(s, nil) + recs, err := rc.ScanAll() + if err != nil { + fmt.Println("scan_all error:", err) + return + } + // 两个池都统计:mem_(L2 pattern 候选)+ ep_(原始 episode) + var cand, ep []models.MemoryRecord + for _, r := range recs { + if strings.HasPrefix(r.ID, "mem_") { + cand = append(cand, r) + } else if strings.HasPrefix(r.ID, "ep_") { + ep = append(ep, r) + } + } + fmt.Printf("scan_all=%d candidate_pool(mem_)=%d episodes(ep_)=%d\n", len(recs), len(cand), len(ep)) + + rules := []struct { + name string + fn func(string) bool + }{ + {"纯数字(全数字)", isPureDigits}, + {"散列(长hex>24)", isLongHex}, + {"散列(长base64/字母数字>24)", isLongAlnum}, + {"超长无空格(>80,日志/粘贴)", isLongNoSpace}, + } + + for _, pool := range []struct { + name string + recs []models.MemoryRecord + }{{"mem_", cand}, {"ep_", ep}} { + fmt.Printf("\n########## POOL=%s (%d) ##########\n", pool.name, len(pool.recs)) + for _, r := range rules { + hits := []string{} + for _, rec := range pool.recs { + if r.fn(rec.Content) { + hits = append(hits, rec.Content) + } + } + pct := 100.0 * float64(len(hits)) / float64(len(pool.recs)) + sort.Slice(hits, func(i, j int) bool { return len(hits[i]) > len(hits[j]) }) + fmt.Printf("\n=== rule %q → 拦截 %d/%d (%.2f%%) ===\n", r.name, len(hits), len(pool.recs), pct) + for i, h := range hits { + if i >= 10 { + break + } + fmt.Printf(" [%d] len=%d %.80s\n", i, len(h), h) + } + } + } +} + +// 纯数字:整串只含 0-9(允许 . : - 空格 分隔,如 IP/端口/日期;纯数字才算) +func isPureDigits(s string) bool { + t := strings.TrimSpace(s) + if t == "" || utf8.RuneCountInString(t) < 8 { + return false // 太短不算 + } + for _, r := range t { + if (r < '0' || r > '9') && r != '.' && r != ':' && r != '-' && r != ' ' && r != '/' && r != '#' { + return false + } + } + return true +} + +// 长 hex:≥24 字符,全部 0-9a-fA-F(无空格) +var hexRe = regexp.MustCompile(`^[0-9a-fA-F]+$`) + +func isLongHex(s string) bool { + t := strings.TrimSpace(s) + if len(t) < 24 { + return false + } + return hexRe.MatchString(t) +} + +// 长字母数字:≥24 字符,无空格,且不含中文 +var alnumRe = regexp.MustCompile(`^[0-9A-Za-z_\-+/=]+$`) + +func isLongAlnum(s string) bool { + t := strings.TrimSpace(s) + if len(t) < 24 { + return false + } + return alnumRe.MatchString(t) +} + +// 超长无空格:≥80 字符且不含空格(日志/粘贴/路径粘连) +func isLongNoSpace(s string) bool { + t := strings.TrimSpace(s) + if len(t) < 80 { + return false + } + for _, r := range t { + if unicode.IsSpace(r) { + return false + } + } + return true +} \ No newline at end of file diff --git a/go/cmd/skilleval-live/main.go b/go/cmd/skilleval-live/main.go new file mode 100644 index 0000000..18a8ad6 --- /dev/null +++ b/go/cmd/skilleval-live/main.go @@ -0,0 +1,33 @@ +// skilleval-live — 验证 LLMEvaluator 调本地 2B(8081) 重写理由 + 确定性兜底(t_4594b632) +// 只读草稿 + 调 2B,零写库。单发手动验证。 +package main + +import ( + "encoding/json" + "fmt" + "os" + "time" + + "github.com/xiaoxue/memoryweave/internal/pattern" + "github.com/xiaoxue/memoryweave/internal/skillgen" +) + +func main() { + path := os.Args[1] + content, err := os.ReadFile(path) + if err != nil { + fmt.Println("read error:", err) + os.Exit(1) + } + p := pattern.Pattern{ID: "pat_x", Type: "tool_combo", Name: "tool_combo:hermes+python", Confidence: 1.0, Tools: []string{"hermes", "python"}} + ev := skillgen.NewLLMEvaluator() + start := time.Now() + r, err := ev.Evaluate(string(content), p) + if err != nil { + fmt.Println("eval error:", err) + os.Exit(1) + } + b, _ := json.MarshalIndent(r, "", " ") + fmt.Println(string(b)) + fmt.Println("elapsed:", time.Since(start).Round(time.Millisecond)) +} \ No newline at end of file diff --git a/go/cmd/skilleval-probe/main.go b/go/cmd/skilleval-probe/main.go new file mode 100644 index 0000000..2d07f6b --- /dev/null +++ b/go/cmd/skilleval-probe/main.go @@ -0,0 +1,34 @@ +// skilleval-probe — 对 t_d8830204 产出的 skill 草稿跑 SkillEvaluator 打分(t_4594b632 AC4) +// 只读草稿文件 + 评分,零写库。输出固定 JSON。 +package main + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/xiaoxue/memoryweave/internal/pattern" + "github.com/xiaoxue/memoryweave/internal/skillgen" +) + +func main() { + path := os.Args[1] + content, err := os.ReadFile(path) + if err != nil { + fmt.Println("read error:", err) + os.Exit(1) + } + // 来源 pattern:tool_combo:hermes+python(与 t_d8830204 实测一致) + p := pattern.Pattern{ + ID: "pat_tool_combo:hermes+python", Type: "tool_combo", + Name: "tool_combo:hermes+python", Confidence: 1.0, Tools: []string{"hermes", "python"}, + } + ev := skillgen.NewRuleEvaluator() + r, err := ev.Evaluate(string(content), p) + if err != nil { + fmt.Println("eval error:", err) + os.Exit(1) + } + b, _ := json.MarshalIndent(r, "", " ") + fmt.Println(string(b)) +} \ No newline at end of file diff --git a/go/internal/api/routes/patterns.go b/go/internal/api/routes/patterns.go index 848286c..5c5ba7a 100644 --- a/go/internal/api/routes/patterns.go +++ b/go/internal/api/routes/patterns.go @@ -11,6 +11,7 @@ package routes import ( "database/sql" "encoding/json" + "log" "net/http" "net/url" "os" @@ -35,6 +36,9 @@ type PatternAPI struct { SkillStore *skillgen.Store Generator skillgen.Generator AttachmentsDir string + + // L3 skill 草稿评估(t_4594b632):promote 前生成评审摘要(人仍拍板,不自动 promote)。 + Evaluator skillgen.Evaluator } // NewPatternAPI 构造 L2 模式端点。 @@ -200,11 +204,27 @@ func (pa *PatternAPI) Draft(w http.ResponseWriter, r *http.Request, id string) { } // POST /api/v1/patterns/{id}/promote — 人工确认后 draft→active。 +// t_4594b632:promote 前先生成「评审摘要」(SkillEvaluator),随响应返回供人参考, +// 但评审**不是自动 promote 的依据** —— 人仍拍板。Evaluator 未装配时跳过评审,照常 promote。 func (pa *PatternAPI) Promote(w http.ResponseWriter, r *http.Request, id string) { if pa.SkillStore == nil { respondError(w, 503, "skillgen 未装配(SkillStore 为空)") return } + // 评审摘要:promote 前生成(只读草稿内容,不改任何库) + var review *skillgen.SkillReview + if pa.Evaluator != nil { + if sk, gerr := pa.SkillStore.Get(id); gerr == nil { + pat, perr := pa.Store.GetPattern(id) + if perr == nil { + if rv, verr := pa.Evaluator.Evaluate(sk.Content, pat); verr == nil { + review = &rv + } else { + log.Printf("[pattern] promote review failed for %s: %v", id, verr) + } + } + } + } if err := pa.SkillStore.Promote(id); err != nil { if err == sql.ErrNoRows { respondError(w, 404, "no skill draft for pattern: "+id) @@ -218,5 +238,9 @@ func (pa *PatternAPI) Promote(w http.ResponseWriter, r *http.Request, id string) respondError(w, 500, "read skill after promote: "+gerr.Error()) return } - respond(w, 200, map[string]interface{}{"skill": sk}) + resp := map[string]interface{}{"skill": sk} + if review != nil { + resp["review"] = review // 评审摘要(信息参考,非 promote 依据) + } + respond(w, 200, resp) } diff --git a/go/internal/api/server.go b/go/internal/api/server.go index 0b139bb..3a5d2f6 100644 --- a/go/internal/api/server.go +++ b/go/internal/api/server.go @@ -204,6 +204,9 @@ func NewServer() http.Handler { patternAPI.SkillStore = skillgen.NewStore(graphPath) patternAPI.Generator = skillgen.NewLocal2BGenerator() patternAPI.AttachmentsDir = os.Getenv("ZHIYI_SKILL_ATTACHMENTS") + // t_4594b632:promote 前评审摘要(SkillEvaluator)。本地 2B 重写理由、确定性四维分兜底; + // 默认装配(评审信息参考,非 promote 依据)。2B 失败自动回退确定性理由,不阻塞 promote。 + patternAPI.Evaluator = skillgen.NewLLMEvaluator() if os.Getenv("ZHIYI_PATTERN_RECALL") == "1" { storage.PatternRecallProvider = patternProvider.Match log.Printf("[zhiyid] L2 模式召回注入已启用(ZHIYI_PATTERN_RECALL=1)") diff --git a/go/internal/skillgen/evaluator.go b/go/internal/skillgen/evaluator.go new file mode 100644 index 0000000..251a3f5 --- /dev/null +++ b/go/internal/skillgen/evaluator.go @@ -0,0 +1,326 @@ +// 织忆 MemoryWeave — L3 skill 草稿评估器(t_4594b632 第三步,MemOS SkillEvaluator 借鉴落地) +// +// 输入:skillgen 的 draft(markdown 正文 + 来源 pattern)。 +// 输出:结构化四维分 + 总评 + 一句话理由,固定 JSON 形态(SkillReview)。 +// - 可执行性 Executability:步骤是否具体到能照做(有无编号步骤/内联命令/命令块) +// - 复用度 Reusability :是否绑定一次性上下文(一次/本次/刚才 等时间词 → 扣分) +// - 覆盖度 Coverage :是否覆盖 pattern 的全部触发条件(draft 提及的工具 vs pattern.Tools) +// - 风险 Risk :是否含危险动作/密钥/删数据(rm -rf / DROP TABLE / sk- / password 等) +// - 总评 Total :加权四维,风险为扣分项(最高砍 60%) +// +// 设计(对齐 skillgen.Generator 的「接口化 + 本地 2B + 测试确定性 fake」范式): +// - RuleEvaluator:**纯确定性**核心(零网络、零 LLM),四维分全部可复算 —— 生产安全兜底 + 单测判据。 +// - LLMEvaluator :包装 RuleEvaluator,调本地 2B(LLM_ENDPOINT 同 skillgen.Generator)重写一句话理由; +// 任何失败都回退到 RuleEvaluator 的确定性结果(不改分、不阻塞)。 +// - 接入点:POST /patterns/{id}/promote 前生成评审摘要写进响应(人仍拍板,绝不自动 promote)。 +package skillgen + +import ( + "bytes" + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "regexp" + "strings" + "time" + + "github.com/xiaoxue/memoryweave/internal/pattern" +) + +// SkillReview 结构化评审结果(固定 JSON 形态,≥4 字段,AC4 判据)。 +type SkillReview struct { + Executability float64 `json:"executability"` // 0.0~1.0 + Reusability float64 `json:"reusability"` // 0.0~1.0 + Coverage float64 `json:"coverage"` // 0.0~1.0 + Risk float64 `json:"risk"` // 0.0~1.0(高=危险) + Total float64 `json:"total"` // 0.0~1.0(总评,风险为扣分项) + Reason string `json:"reason"` // 一句话理由 +} + +// Evaluator 评估 skill 草稿的接口。生产用本地 2B 重写理由,测试用确定性实现。 +type Evaluator interface { + // Evaluate 对一份草稿 + 来源 pattern 产出结构化评审。 + // 返回的 SkillReview 必须字段完整(四维分 + 总评 + 理由)。 + Evaluate(draft string, p pattern.Pattern) (SkillReview, error) +} + +// ─── 确定性判据(RuleEvaluator)────────────────────────────────────────── + +var ( + // 可执行性信号:编号步骤 / 内联命令 / 命令块 + reStepNums = regexp.MustCompile(`(?m)^\s*(?:[1-9][0-9]*)[.、.)\s]`) + reInlineCmd = regexp.MustCompile("`[^`\\n]{3,}`") + reCmdBlock = regexp.MustCompile("(?m)^\\s{2,}(?:[-*]\\s+)?(?:`[^`]+`|\\$\\s|\\w+(?:\\s+-{1,2}\\w+)?\\s)`?") + // 可执行性:标题/词表含步骤/命令/操作 + reStepWords = []string{"步骤", "命令", "操作", "执行", "做法", "要点"} + + // 复用度负信号:一次性上下文绑定词 + reOneTime = []string{"一次性", "本次", "刚才", "当时", "这次", "当前这次", "这一个", "此次", "针对这次"} + + // 复用度正信号:通用/可复用框架 + reGeneric = []string{"适用于", "通用", "可复用", "未来", "一般", "通常", "标准", "所有"} + + // 风险信号 + reDestructive = []string{"rm -rf", "rm -fr", "DROP TABLE", "DROP DATABASE", "DELETE FROM", + "mkfs", "fdisk", "dd if=", "> /dev/sd", "sudo rm", "git push -f", "git push --force"} + reSecret = []string{"api_key", "apikey", "password", "passwd", "secret", "token=", + "authorization: bearer", "sk-", "AKIA", "PRIVATE KEY", "BEGIN RSA"} + reForce = []string{"--force", "--yes", "-y ", "chmod 777", "sudo su", "rm -r /", "rm /"} +) + +// NewRuleEvaluator 构造确定性评估器(无副作用、无网络)。 +func NewRuleEvaluator() *RuleEvaluator { return &RuleEvaluator{} } + +// RuleEvaluator 纯确定性评估器:同一输入永远同一输出(可单测、可复算)。 +type RuleEvaluator struct{} + +// Evaluate 用确定性判据给四维分 + 总评 + 一句话理由。 +func (e *RuleEvaluator) Evaluate(draft string, p pattern.Pattern) (SkillReview, error) { + exec := scoreExecutability(draft) + reuse := scoreReusability(draft) + cov := scoreCoverage(draft, p) + risk, riskWhy := scoreRisk(draft) + total := (0.35*exec + 0.30*reuse + 0.35*cov) * (1 - 0.6*risk) + if total < 0 { + total = 0 + } + if total > 1 { + total = 1 + } + reason := buildReason(exec, reuse, cov, risk, riskWhy) + return SkillReview{ + Executability: exec, Reusability: reuse, Coverage: cov, + Risk: risk, Total: total, Reason: reason, + }, nil +} + +// scoreExecutability 可执行性:编号步骤/内联命令/命令块/步骤词越多越高(上限 1.0)。 +func scoreExecutability(draft string) float64 { + steps := len(reStepNums.FindAllString(draft, -1)) + cmds := len(reInlineCmd.FindAllString(draft, -1)) + blocks := len(reCmdBlock.FindAllString(draft, -1)) + wordHits := 0 + for _, w := range reStepWords { + if strings.Contains(draft, w) { + wordHits++ + } + } + // 信号加权:每个编号步骤/内联命令/命令块计 0.15;出现 ≥3 个词表计 0.1 + score := 0.15*float64(steps) + 0.15*float64(cmds) + 0.10*float64(blocks) + 0.1*float64(wordHits) + if score > 1 { + score = 1 + } + return score +} + +// scoreReusability 复用度:一次上下文负信号扣分,通用框架正信号加分。 +func scoreReusability(draft string) float64 { + score := 0.5 // 中性起步 + oneTime := 0 + for _, w := range reOneTime { + oneTime += strings.Count(draft, w) + } + generic := 0 + for _, w := range reGeneric { + generic += strings.Count(draft, w) + } + score -= 0.2 * float64(oneTime) + score += 0.15 * float64(generic) + if score < 0 { + score = 0 + } + if score > 1 { + score = 1 + } + return score +} + +// scoreCoverage 覆盖度:draft 提及的工具覆盖 pattern.Tools 的比例。 +// pattern 无工具(workflow 类)→ 以是否含「适用场景」判通用覆盖。 +func scoreCoverage(draft string, p pattern.Pattern) float64 { + if len(p.Tools) == 0 { + // workflow 类:无工具要求,看是否有适用场景/触发说明 + if strings.Contains(draft, "适用场景") || strings.Contains(draft, "触发") { + return 0.8 + } + return 0.5 + } + lower := strings.ToLower(draft) + hit := 0 + for _, t := range p.Tools { + // 工具名按词边界出现在草稿中算覆盖(避免 'python' 撞 'python3' 误判,用词尾判) + if regexp.MustCompile(`\b` + regexp.QuoteMeta(strings.ToLower(t)) + `(?:3|\b)`).MatchString(lower) { + hit++ + } + } + return float64(hit) / float64(len(p.Tools)) +} + +// scoreRisk 风险:命中危险动作/密钥/强制操作 → 风险升高(0~1)。返回原因摘要。 +func scoreRisk(draft string) (float64, string) { + d := strings.ToLower(draft) + hits := []string{} + for _, pat := range reDestructive { + if strings.Contains(d, pat) { + hits = append(hits, "删数据:"+pat) + } + } + for _, pat := range reSecret { + if strings.Contains(d, pat) { + hits = append(hits, "密钥:"+pat) + } + } + for _, pat := range reForce { + if strings.Contains(d, pat) { + hits = append(hits, "危险:"+pat) + } + } + if len(hits) == 0 { + return 0, "" + } + // 去掉重复 key(pat 可能被多组命中) + seen := map[string]bool{} + uniq := []string{} + for _, h := range hits { + if !seen[h] { + seen[h] = true + uniq = append(uniq, h) + } + } + r := 0.5 + if len(uniq) >= 2 { + r = 0.9 + } + if strings.Contains(d, "rm -rf") || strings.Contains(d, "drop table") || strings.Contains(d, "delete from") { + r = 1.0 + } + return r, strings.Join(uniq, ";") +} + +// buildReason 一句话理由:按最弱维 + 风险标记拼装。 +func buildReason(exec, reuse, cov, risk float64, riskWhy string) string { + if risk > 0 { + return fmt.Sprintf("风险较高(%s),总评%.2f,需人工复核后再 promote", riskWhy, 0.35*exec+0.30*reuse+0.35*cov) + } + weak, val := "可执行性", exec + if reuse < val { + weak, val = "复用度", reuse + } + if cov < val { + weak, val = "覆盖度", cov + } + verdict := "草稿基本可用" + if val < 0.4 { + verdict = "草稿偏薄弱" + } + return fmt.Sprintf("%s(%s=%.2f)偏低,%s,建议完善后人工复核", weak, map[string]string{ + "可执行性": "执行", "复用度": "复用", "覆盖度": "覆盖", + }[weak], val, verdict) +} + +// ─── 本地 2B 包装(LLMEvaluator)──────────────────────────────────────── + +// NewLLMEvaluator 读 env 构造本地 2B 评估器(失败回退 RuleEvaluator)。 +func NewLLMEvaluator() *LLMEvaluator { + return &LLMEvaluator{ + endpoint: os.Getenv("LLM_ENDPOINT"), + model: os.Getenv("LLM_MODEL"), + rule: NewRuleEvaluator(), + } +} + +// LLMEvaluator 用本地 2B 重写一句话理由,四维分以 RuleEvaluator 为准(确定性)。 +type LLMEvaluator struct { + endpoint string + model string + rule *RuleEvaluator +} + +// Evaluate 先跑确定性四维分,再请本地 2B 生成一句话理由;2B 失败回退确定性理由。 +func (e *LLMEvaluator) Evaluate(draft string, p pattern.Pattern) (SkillReview, error) { + r, err := e.rule.Evaluate(draft, p) + if err != nil { + return r, err + } + // 本地 2B 只为理由服务 —— 分数始终来自确定性判据(可复算,不被模型抖动)。 + llmReason, lerr := e.llmReason(draft, r) + if lerr == nil && llmReason != "" { + r.Reason = llmReason + } + return r, nil +} + +func (e *LLMEvaluator) llmReason(draft string, r SkillReview) (string, error) { + endpoint := e.endpoint + if endpoint == "" { + endpoint = "http://127.0.0.1:8081/v1/chat/completions" + } + model := e.model + if model == "" { + model = "local/minicpm5-2b" + } + prompt := fmt.Sprintf( + "你是技能草稿评审。给定一份 skill 草稿的确定性四维分(可执行性=%.2f 复用度=%.2f 覆盖度=%.2f 风险=%.2f 总评=%.2f),"+ + "用不超过 40 字的一句话中文说明「该不该 promote 的理由」。草稿:%s", r.Executability, r.Reusability, r.Coverage, r.Risk, r.Total, + truncateDraft(draft, 400)) + body, _ := json.Marshal(map[string]interface{}{ + "model": model, "messages": []map[string]string{{"role": "user", "content": prompt}}, + "max_tokens": 80, "temperature": 0.2, + }) + client := &http.Client{Timeout: 60 * time.Second} + req, err := http.NewRequest("POST", endpoint, bytes.NewReader(body)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + resp, err := client.Do(req) + if err != nil { + log.Printf("[skilleval] 本地 2B 调用失败,回退确定性理由: %v", err) + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("本地 2B 返回 %d", resp.StatusCode) + } + var out struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return "", err + } + if len(out.Choices) == 0 || out.Choices[0].Message.Content == "" { + return "", fmt.Errorf("本地 2B 空输出") + } + return strings.TrimSpace(out.Choices[0].Message.Content), nil +} + +func truncateDraft(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "…" +} + +// ─── 确定性 fake(测试专用)────────────────────────────────────────────── + +// FakeEvaluator 确定性评估器:测试用,不走网络。返回固定评审。 +type FakeEvaluator struct { + Review SkillReview + Err error + Call int // 调用计数 +} + +// Evaluate 返回固定评审(含调用计数累加)。 +func (f *FakeEvaluator) Evaluate(draft string, p pattern.Pattern) (SkillReview, error) { + f.Call++ + if f.Err != nil { + return SkillReview{}, f.Err + } + return f.Review, nil +} \ No newline at end of file diff --git a/go/internal/skillgen/evaluator_test.go b/go/internal/skillgen/evaluator_test.go new file mode 100644 index 0000000..b5972d3 --- /dev/null +++ b/go/internal/skillgen/evaluator_test.go @@ -0,0 +1,143 @@ +package skillgen + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/xiaoxue/memoryweave/internal/pattern" +) + +// helper 构造一个 tool_combo pattern +func patTools(tools ...string) pattern.Pattern { + return pattern.Pattern{ + ID: "pat_test", Type: "tool_combo", Name: "tool_combo:" + strings.Join(tools, "+"), + Data: strings.Join(tools, "+"), Confidence: 1.0, Tools: tools, + } +} + +// TestRuleEvaluator_HighScore AC4:确定性 evaluator 对「可复用 + 覆盖全部工具 + 无风险」的草稿给高分。 +func TestRuleEvaluator_HighScore(t *testing.T) { + draft := `## 适用场景 +适用于需要结合 hermes 与 python 进行自动化脚本处理的通用场景。 + +## 关键步骤 +1. 确认 hermes 与 python 环境已安装 +2. 导入模块并调用 hermes 辅助 +3. 用 ` + "`python -m venv venv`" + ` 建虚拟环境 +4. 执行 ` + "`pip install hermes`" + ` 安装依赖 + +## 注意事项 +定期更新版本以保持稳定。` + ev := NewRuleEvaluator() + r, err := ev.Evaluate(draft, patTools("hermes", "python")) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if r.Executability < 0.6 { + t.Errorf("高分草稿可执行性应≥0.6, got %.2f", r.Executability) + } + if r.Reusability < 0.6 { + t.Errorf("高分草稿复用度应≥0.6 (有'适用于/通用'), got %.2f", r.Reusability) + } + if r.Coverage < 0.6 { + t.Errorf("高分草稿覆盖度应≥0.6 (提及 hermes+python), got %.2f", r.Coverage) + } + if r.Risk > 0 { + t.Errorf("高分草稿风险应为0, got %.2f", r.Risk) + } + if r.Total < 0.6 { + t.Errorf("高分草稿总评应≥0.6, got %.2f", r.Total) + } + if r.Reason == "" { + t.Error("reason 不能为空") + } +} + +// TestRuleEvaluator_LowScore AC4:确定性 evaluator 对「一次性上下文 + 无步骤 + 无覆盖」的草稿给低分。 +func TestRuleEvaluator_LowScore(t *testing.T) { + draft := `本次针对刚才那次会话做的调整,一次性处理完。` // 无步骤/无命令/无工具覆盖 + ev := NewRuleEvaluator() + r, err := ev.Evaluate(draft, patTools("docker", "kubectl")) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if r.Executability > 0.3 { + t.Errorf("低分草稿可执行性应≤0.3 (无步骤无命令), got %.2f", r.Executability) + } + if r.Reusability > 0.5 { + t.Errorf("低分草稿复用度应≤0.5 (含本次/刚才/一次性), got %.2f", r.Reusability) + } + if r.Coverage != 0 { + t.Errorf("低分草稿覆盖度应为0 (未提及 docker/kubectl), got %.2f", r.Coverage) + } + if r.Total > 0.4 { + t.Errorf("低分草稿总评应≤0.4, got %.2f", r.Total) + } +} + +// TestRuleEvaluator_RiskyAction AC4:确定性 evaluator 对含删数据/密钥的草稿给高风险分并砍总评。 +func TestRuleEvaluator_RiskyAction(t *testing.T) { + draft := "## 关键步骤\n1. `rm -rf /var/lib/old`\n2. DROP TABLE memories\n3. 使用密钥 sk-abc123def456" + ev := NewRuleEvaluator() + r, err := ev.Evaluate(draft, patTools("python")) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if r.Risk < 0.5 { + t.Errorf("危险草稿风险应≥0.5 (rm -rf/DROP TABLE/sk-), got %.2f", r.Risk) + } + if r.Total > 0.6 { + t.Errorf("危险草稿总评应≤0.6 (风险砍分), got %.2f", r.Total) + } + if !strings.Contains(r.Reason, "风险") { + t.Errorf("危险草稿 reason 应含'风险', got %q", r.Reason) + } +} + +// TestRuleEvaluator_Deterministic AC:同输入恒同输出(机械可复算)。 +func TestRuleEvaluator_Deterministic(t *testing.T) { + draft := `## 关键步骤 +1. ` + "`git status`" + ` +2. ` + "`hermes kanban list`" + ` +## 注意事项 +适用于所有分身的通用流程。` + p := patTools("hermes", "git") + ev := NewRuleEvaluator() + r1, _ := ev.Evaluate(draft, p) + r2, _ := ev.Evaluate(draft, p) + if r1 != r2 { + t.Fatalf("确定性被破坏: %+v vs %+v", r1, r2) + } + if r1.Coverage != 1 { + t.Errorf("工具全覆盖覆盖度应为1, got %.2f", r1.Coverage) + } +} + +// TestFakeEvaluator_Interface AC4:确定性 fake 走同一 Evaluator 接口(零网络)。 +func TestFakeEvaluator_Interface(t *testing.T) { + f := &FakeEvaluator{Review: SkillReview{Executability: 0.9, Reusability: 0.8, Coverage: 0.7, Risk: 0, Total: 0.8, Reason: "fake"}} + got, err := f.Evaluate("any", patTools("x")) + if err != nil { + t.Fatalf("fake err: %v", err) + } + if got.Total != 0.8 || got.Reason != "fake" || f.Call != 1 { + t.Fatalf("fake 返回不符: %+v call=%d", got, f.Call) + } +} + +// TestRuleEvaluator_JSONFields AC4:输出序列化为固定 JSON 且 ≥4 字段。 +func TestRuleEvaluator_JSONFields(t *testing.T) { + ev := NewRuleEvaluator() + r, _ := ev.Evaluate("## 适用场景\n1. `python3 x.py`", patTools("python")) + b, err := json.Marshal(r) + if err != nil { + t.Fatalf("json err: %v", err) + } + s := string(b) + for _, f := range []string{`"executability"`, `"reusability"`, `"coverage"`, `"risk"`, `"total"`, `"reason"`} { + if !strings.Contains(s, f) { + t.Errorf("JSON 缺字段 %s: %s", f, s) + } + } +} \ No newline at end of file diff --git a/go/internal/skillgen/evolver.go b/go/internal/skillgen/evolver.go new file mode 100644 index 0000000..8b82361 --- /dev/null +++ b/go/internal/skillgen/evolver.go @@ -0,0 +1,36 @@ +// 织忆 MemoryWeave — SkillEvolver 接口与数据模型(t_4594b632,MemOS SkillEvolver 借鉴) +// +// MemOS 的 SkillEvolver = 升级已有 skill(不只评估草稿)。本卡红线:**只做评估器 + 数据模型**, +// 升级动作留接口不实现 —— 因为「不能让 Agent 自己决定改技能库」(2026-09 裁决)。 +// 实际升级走人工(skill 治理),本接口仅作为未来可落地的契约,不接任何自动路径。 +package skillgen + +// EvolveVerdict SkillEvolver 对「升级已有 skill」的评审结论(数据模型,人工拍板的依据)。 +type EvolveVerdict struct { + SkillName string `json:"skill_name"` // 待升级的既有 skill 名 + SourceDraft string `json:"source_draft"` // 提供新知识的草稿/来源 pattern 名 + ChangeSummary string `json:"change_summary"` // 拟变更内容一句话摘要 + Preserved []string `json:"preserved"` // 需保留的既有内容(不可丢) + Risks []string `json:"risks"` // 升级风险(覆盖丢失/行为变化/依赖断裂) +} + +// Evolver SkillEvolver 接口。当前**不实现**(本卡只建契约)。 +// 一旦实现,必须:只产出评审建议、绝不自动改技能库、输出经人工审核。 +type Evolver interface { + // EvaluateEvolve 对「用 sourceDraft 升级 skillName」产出评审结论。 + // 返回的 EvolveVerdict 供人工拍板;本接口承诺不写库、不改技能文件。 + EvaluateEvolve(skillName string, sourceDraft *Skill, existingContent string) (EvolveVerdict, error) +} + +// NoopEvolver 空实现:任何输入都返回「未实现,需人工评审」——保证 Evolver 有确定性落点, +// 但不产生任何升级动作。用于装配点未决定时兜底,满足「留接口不实现」。 +type NoopEvolver struct{} + +// EvaluateEvolve 恒返回 not-implemented 结论(绝不自动改技能库)。 +func (NoopEvolver) EvaluateEvolve(skillName string, sourceDraft *Skill, existingContent string) (EvolveVerdict, error) { + return EvolveVerdict{ + SkillName: skillName, + ChangeSummary: "SkillEvolver 未启用:升级须经人工 skill 治理,本接口只作契约预留。", + Risks: []string{"自动改技能库被禁用(2026-09 裁决);升级须人工拍板"}, + }, nil +} \ No newline at end of file diff --git a/go/internal/skillgen/evolver_test.go b/go/internal/skillgen/evolver_test.go new file mode 100644 index 0000000..e930be0 --- /dev/null +++ b/go/internal/skillgen/evolver_test.go @@ -0,0 +1,22 @@ +package skillgen + +import ( + "strings" + "testing" +) + +// TestNoopEvolver_DoesNotAutoUpgrade 红线守卫:SkillEvolver 接口不得自动改技能库。 +// NoopEvolver 恒返回 not-implemented,绝不产生升级动作。 +func TestNoopEvolver_DoesNotAutoUpgrade(t *testing.T) { + var ev Evolver = NoopEvolver{} + vd, err := ev.EvaluateEvolve("some-skill", &Skill{SourcePattern: "pat_x", Content: "draft"}, "old content") + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if !strings.Contains(vd.ChangeSummary, "未启用") { + t.Errorf("NoopEvolver 应声明未启用, got %q", vd.ChangeSummary) + } + if len(vd.Risks) == 0 { + t.Error("NoopEvolver 应标注升级风险(人工治理)") + } +} \ No newline at end of file