feat(skillgen): SkillEvaluator 四维评估器 + SkillEvolver 接口 + 规则过滤差距分析探针 (t_4594b632)

This commit is contained in:
小唯
2026-11-09 01:12:49 +08:00
parent 714df5d2a8
commit a430a2b602
9 changed files with 759 additions and 1 deletions
+137
View File
@@ -0,0 +1,137 @@
// impact-probe — 候选池规则过滤影响面实测(t_4594b632 第二步)
// 读:sidecar IPC lancedb_scan_all 全量存活行(与 pattern-fullscan-probe 同通道)。
// 算:对每条 content 逐条跑候选规则,统计每条规则会拦掉多少条 / 占比 / 前10条样本。
// 规则纯函数、零 LLM、零写库。用于决定「真缺才写 / 已覆盖 / 不该有」。
package main
import (
"flag"
"fmt"
"os"
"regexp"
"sort"
"strings"
"unicode"
"unicode/utf8"
"github.com/xiaoxue/memoryweave/internal/models"
"github.com/xiaoxue/memoryweave/internal/storage"
)
type ruleResult struct {
name string
hit []string // 样本(content 前 80 字)
}
func main() {
sock := flag.String("sock", "", "LANCEDB socket")
flag.Parse()
s := *sock
if s == "" {
if env := os.Getenv("LANCEDB_SOCKET"); env != "" {
s = env
} else {
s = "/tmp/zhiyi-ipc.sock"
}
}
rc := storage.NewRustLanceDBClient(s, nil)
recs, err := rc.ScanAll()
if err != nil {
fmt.Println("scan_all error:", err)
return
}
// 两个池都统计:mem_(L2 pattern 候选)+ ep_(原始 episode)
var cand, ep []models.MemoryRecord
for _, r := range recs {
if strings.HasPrefix(r.ID, "mem_") {
cand = append(cand, r)
} else if strings.HasPrefix(r.ID, "ep_") {
ep = append(ep, r)
}
}
fmt.Printf("scan_all=%d candidate_pool(mem_)=%d episodes(ep_)=%d\n", len(recs), len(cand), len(ep))
rules := []struct {
name string
fn func(string) bool
}{
{"纯数字(全数字)", isPureDigits},
{"散列(长hex>24)", isLongHex},
{"散列(长base64/字母数字>24)", isLongAlnum},
{"超长无空格(>80,日志/粘贴)", isLongNoSpace},
}
for _, pool := range []struct {
name string
recs []models.MemoryRecord
}{{"mem_", cand}, {"ep_", ep}} {
fmt.Printf("\n########## POOL=%s (%d) ##########\n", pool.name, len(pool.recs))
for _, r := range rules {
hits := []string{}
for _, rec := range pool.recs {
if r.fn(rec.Content) {
hits = append(hits, rec.Content)
}
}
pct := 100.0 * float64(len(hits)) / float64(len(pool.recs))
sort.Slice(hits, func(i, j int) bool { return len(hits[i]) > len(hits[j]) })
fmt.Printf("\n=== rule %q → 拦截 %d/%d (%.2f%%) ===\n", r.name, len(hits), len(pool.recs), pct)
for i, h := range hits {
if i >= 10 {
break
}
fmt.Printf(" [%d] len=%d %.80s\n", i, len(h), h)
}
}
}
}
// 纯数字:整串只含 0-9(允许 . : - 空格 分隔,如 IP/端口/日期;纯数字才算)
func isPureDigits(s string) bool {
t := strings.TrimSpace(s)
if t == "" || utf8.RuneCountInString(t) < 8 {
return false // 太短不算
}
for _, r := range t {
if (r < '0' || r > '9') && r != '.' && r != ':' && r != '-' && r != ' ' && r != '/' && r != '#' {
return false
}
}
return true
}
// 长 hex:≥24 字符,全部 0-9a-fA-F(无空格)
var hexRe = regexp.MustCompile(`^[0-9a-fA-F]+$`)
func isLongHex(s string) bool {
t := strings.TrimSpace(s)
if len(t) < 24 {
return false
}
return hexRe.MatchString(t)
}
// 长字母数字:≥24 字符,无空格,且不含中文
var alnumRe = regexp.MustCompile(`^[0-9A-Za-z_\-+/=]+$`)
func isLongAlnum(s string) bool {
t := strings.TrimSpace(s)
if len(t) < 24 {
return false
}
return alnumRe.MatchString(t)
}
// 超长无空格:≥80 字符且不含空格(日志/粘贴/路径粘连)
func isLongNoSpace(s string) bool {
t := strings.TrimSpace(s)
if len(t) < 80 {
return false
}
for _, r := range t {
if unicode.IsSpace(r) {
return false
}
}
return true
}
+33
View File
@@ -0,0 +1,33 @@
// skilleval-live — 验证 LLMEvaluator 调本地 2B(8081) 重写理由 + 确定性兜底(t_4594b632)
// 只读草稿 + 调 2B,零写库。单发手动验证。
package main
import (
"encoding/json"
"fmt"
"os"
"time"
"github.com/xiaoxue/memoryweave/internal/pattern"
"github.com/xiaoxue/memoryweave/internal/skillgen"
)
func main() {
path := os.Args[1]
content, err := os.ReadFile(path)
if err != nil {
fmt.Println("read error:", err)
os.Exit(1)
}
p := pattern.Pattern{ID: "pat_x", Type: "tool_combo", Name: "tool_combo:hermes+python", Confidence: 1.0, Tools: []string{"hermes", "python"}}
ev := skillgen.NewLLMEvaluator()
start := time.Now()
r, err := ev.Evaluate(string(content), p)
if err != nil {
fmt.Println("eval error:", err)
os.Exit(1)
}
b, _ := json.MarshalIndent(r, "", " ")
fmt.Println(string(b))
fmt.Println("elapsed:", time.Since(start).Round(time.Millisecond))
}
+34
View File
@@ -0,0 +1,34 @@
// skilleval-probe — 对 t_d8830204 产出的 skill 草稿跑 SkillEvaluator 打分(t_4594b632 AC4)
// 只读草稿文件 + 评分,零写库。输出固定 JSON。
package main
import (
"encoding/json"
"fmt"
"os"
"github.com/xiaoxue/memoryweave/internal/pattern"
"github.com/xiaoxue/memoryweave/internal/skillgen"
)
func main() {
path := os.Args[1]
content, err := os.ReadFile(path)
if err != nil {
fmt.Println("read error:", err)
os.Exit(1)
}
// 来源 pattern:tool_combo:hermes+python(与 t_d8830204 实测一致)
p := pattern.Pattern{
ID: "pat_tool_combo:hermes+python", Type: "tool_combo",
Name: "tool_combo:hermes+python", Confidence: 1.0, Tools: []string{"hermes", "python"},
}
ev := skillgen.NewRuleEvaluator()
r, err := ev.Evaluate(string(content), p)
if err != nil {
fmt.Println("eval error:", err)
os.Exit(1)
}
b, _ := json.MarshalIndent(r, "", " ")
fmt.Println(string(b))
}
+25 -1
View File
@@ -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)
}
+3
View File
@@ -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)")
+326
View File
@@ -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
}
+143
View File
@@ -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)
}
}
}
+36
View File
@@ -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
}
+22
View File
@@ -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 应标注升级风险(人工治理)")
}
}