Round3 review: data source (LanceDB memory body text) has no parseable structured tool_use sequence; DetectFromRecords guessing tool combos from natural language = cooking without ingredients. Path A (structured tool_use) proven absent: MemoryRecord 0 tool fields, episodes/hit_events schema no tool field, Scan projects no tool col. Decided Path B (honest retirement). This commit = retirement hygiene: workflow branch emits cluster-longest body as Data (120-char cut) producing 'whole-sentence memory conf=1.0' pseudo-pattern, violating hard constraint 'memory body must not be pattern name'. - Config adds WorkflowPatterns, default false (channel retired) - DetectFromRecords runs workflow clustering only when explicitly enabled - unit test: TestDetect_WorkflowRetiredByDefault feeds the exact sentence from review => 0 pattern - TestDetect_Workflow now opts into WorkflowPatterns=true (gate still verified) tool_combo branch (whitelist-only + conf>=0.5 + >=2 sources) kept: returns 0 on this data, no pollution. PATTERN_RECALL stays OFF (80-pattern-recall.conf.disabled-20261030 untouched, no env key).
711 lines
23 KiB
Go
711 lines
23 KiB
Go
// 织忆 MemoryWeave — L2 模式检测引擎(t_d32e5f8a)
|
||
//
|
||
// 背景:MemOS 血统的 L2「跨任务重复模式」检测。原实现活在 Python daemon 的
|
||
// main_loop(30s tick),2026-09-24 为 state-db 写安全整体退役后 L2 pattern 蒸馏
|
||
// 停摆(graph_nodes type='pattern' 停为 0 行;旧 Python daemon 残留的 85 行是
|
||
// type='pattern_stat',id 全 NULL、语义为 L1 命名空间统计快照,非 L2 pattern / 2026-09-18)。本包把 L1→L2 蒸馏
|
||
// 复活成**短命单发**(modernc sqlite 直连 graph.db,开/关不占长连接,不碰 state.db),
|
||
// 并按设计 §4.1 叠加两条检测规则:
|
||
// - 工具组合频率:同一 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'),**不另开新表**(避免与现存图谱查询割裂;
|
||
// 表内另有 85 行 type='pattern_stat' 属旧 daemon 统计快照,与 L2 pattern 无关)。
|
||
// namespace='daemon-distill' 沿用旧 daemon 约定。
|
||
package pattern
|
||
|
||
import (
|
||
"fmt"
|
||
"math"
|
||
"sort"
|
||
"strings"
|
||
|
||
"github.com/xiaoxue/memoryweave/internal/models"
|
||
)
|
||
|
||
// Pattern 一个被检测出的 L2 模式。
|
||
type Pattern struct {
|
||
ID string
|
||
Type string // "tool_combo" | "workflow"
|
||
Name string // 稳定命名键(同组合去重)
|
||
Data string // tool_combo=排序后工具串;workflow=共同步骤/代表路径摘要
|
||
Confidence float64
|
||
SourceTasks []string // 支撑该 pattern 的记忆 ID(设计 source_tasks)
|
||
Tools []string // tool_combo 专用
|
||
}
|
||
|
||
// Config 检测参数(设计 §4.1)。
|
||
type Config struct {
|
||
MinComboFreq int // 工具组合需 ≥ N 条**独立来源记忆**才产 pattern(默认 2,t_639c5a03 门槛)
|
||
ClusterThresh float64 // 相似路径聚类 cosine 阈值(默认 0.85)
|
||
MinClusterSize int // 簇内 ≥ N 条才产 workflow pattern(默认 3)
|
||
MaxRecords int // 单次扫描参与检测的记忆上限(防 O(n²) 爆炸,默认 500)
|
||
TopKPatternList int // 召回/列出取前 N(默认 10)
|
||
// WorkflowPatterns 是否启用「相似解决路径聚类」workflow 分支。
|
||
// t_639c5a03 三审(小怡 2026-10-30)后决定**默认退役**:该分支把簇内最长记忆正文当
|
||
// Data(truncate 120 字),在自然语言语料上产出即「整句记忆正文 conf=1.0」的伪 pattern,
|
||
// 违反「不许把记忆正文当 pattern name」硬约束。通道退役,默认关闭;仅当显式开启才跑。
|
||
WorkflowPatterns bool
|
||
}
|
||
|
||
// DefaultConfig 设计参数。WorkflowPatterns 默认 false = workflow 分支退役(t_639c5a03 三审)。
|
||
func DefaultConfig() Config {
|
||
return Config{
|
||
MinComboFreq: 2,
|
||
ClusterThresh: 0.85,
|
||
MinClusterSize: 3,
|
||
MaxRecords: 500,
|
||
TopKPatternList: 10,
|
||
WorkflowPatterns: false,
|
||
}
|
||
}
|
||
|
||
// 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, "sed": true, "awk": true,
|
||
"wget": true, "tar": true, "zip": true, "unzip": true,
|
||
"docker": true, "kubectl": true, "helm": true, "npm": true, "pip": 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, "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 记忆列表发现重复模式。
|
||
// 不读 LanceDB / 不写库,只做「记录 → pattern 候选」,便于确定性单测。
|
||
// 返回合并去重后的 pattern 列表(同 Name 合并 source_tasks、取更高 confidence)。
|
||
func DetectFromRecords(records []models.MemoryRecord, cfg Config) []Pattern {
|
||
if cfg.MinComboFreq <= 0 {
|
||
cfg.MinComboFreq = 2
|
||
}
|
||
if cfg.MinClusterSize <= 0 {
|
||
cfg.MinClusterSize = 3
|
||
}
|
||
if cfg.ClusterThresh <= 0 {
|
||
cfg.ClusterThresh = 0.85
|
||
}
|
||
lim := cfg.MaxRecords
|
||
if lim <= 0 {
|
||
lim = 500
|
||
}
|
||
if len(records) > lim {
|
||
records = records[:lim]
|
||
}
|
||
|
||
combined := make(map[string]*Pattern) // Name → merged pattern
|
||
order := []string{}
|
||
|
||
merge := func(p Pattern) {
|
||
if ex, ok := combined[p.Name]; ok {
|
||
ex.SourceTasks = append(ex.SourceTasks, p.SourceTasks...)
|
||
if p.Confidence > ex.Confidence {
|
||
ex.Confidence = p.Confidence
|
||
}
|
||
return
|
||
}
|
||
combined[p.Name] = &p
|
||
order = append(order, p.Name)
|
||
}
|
||
|
||
// 1. 工具组合频率分析
|
||
comboCount := map[string]*struct {
|
||
tools []string
|
||
tasks []string
|
||
}{}
|
||
for _, rec := range records {
|
||
tools := ExtractTools(rec.Content)
|
||
if len(tools) == 0 {
|
||
continue
|
||
}
|
||
tools = dedupeSorted(tools)
|
||
key := "tool_combo:" + strings.Join(tools, "+")
|
||
c := comboCount[key]
|
||
if c == nil {
|
||
c = &struct {
|
||
tools []string
|
||
tasks []string
|
||
}{tools: tools}
|
||
comboCount[key] = c
|
||
}
|
||
c.tasks = append(c.tasks, rec.ID)
|
||
}
|
||
for name, c := range comboCount {
|
||
// 二轮:「组合」语义 —— 单工具无共现信息量,≥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(sources)),
|
||
SourceTasks: sources,
|
||
})
|
||
}
|
||
|
||
// 2. 相似解决路径聚类(cosine ≥ 阈值 成簇,簇内 ≥ N 条)
|
||
// t_639c5a03 三审:该分支把记忆正文当 pattern Data(整句注入),默认退役;仅 cfg.WorkflowPatterns 显式开启才跑。
|
||
if cfg.WorkflowPatterns {
|
||
paths := clusterPaths(records, cfg.ClusterThresh, cfg.MinClusterSize)
|
||
for _, cluster := range paths {
|
||
// 代表路径 = 簇内最长 content(共同步骤近似)
|
||
rep := ""
|
||
var ids []string
|
||
for _, r := range cluster {
|
||
ids = append(ids, r.ID)
|
||
if len(r.Content) > len(rep) {
|
||
rep = r.Content
|
||
}
|
||
}
|
||
conf := clusterConfidence(cluster) // 平均两两 cosine,封顶 1.0
|
||
if conf > 1.0 {
|
||
conf = 1.0
|
||
}
|
||
// 簇名带代表内容哈希前 8 位 —— 同 size 不同内容簇不碰撞(否则 cluster-3 撞簇)
|
||
name := fmt.Sprintf("workflow:cluster-%d-%s", len(cluster), contentFingerprint(rep))
|
||
merge(Pattern{
|
||
Type: "workflow",
|
||
Name: name,
|
||
Data: truncateForPattern(rep, 120),
|
||
Confidence: conf,
|
||
SourceTasks: ids,
|
||
})
|
||
}
|
||
}
|
||
|
||
out := make([]Pattern, 0, len(order))
|
||
for _, n := range order {
|
||
p := *combined[n]
|
||
p.SourceTasks = dedupeStrings(p.SourceTasks)
|
||
if p.Type == "tool_combo" {
|
||
p.ID = "pat_" + p.Name
|
||
} else {
|
||
p.ID = "pat_" + p.Name
|
||
}
|
||
out = append(out, p)
|
||
}
|
||
sort.SliceStable(out, func(i, j int) bool {
|
||
if out[i].Type != out[j].Type {
|
||
return out[i].Type < out[j].Type
|
||
}
|
||
return out[i].Name < out[j].Name
|
||
})
|
||
return out
|
||
}
|
||
|
||
// confidenceFromFreq 设计公式:min(freq*0.1, 1.0)。
|
||
func confidenceFromFreq(freq int) float64 {
|
||
c := float64(freq) * 0.1
|
||
if c > 1.0 {
|
||
c = 1.0
|
||
}
|
||
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(确定性,不依赖网络)。
|
||
func clusterPaths(records []models.MemoryRecord, thresh float64, minSize int) [][]models.MemoryRecord {
|
||
n := len(records)
|
||
if n < minSize {
|
||
return nil
|
||
}
|
||
type sig struct {
|
||
vec []float32
|
||
big map[string]bool
|
||
text string
|
||
}
|
||
sigs := make([]sig, n)
|
||
for i, r := range records {
|
||
sigs[i] = sig{vec: r.Vector, text: r.Content}
|
||
}
|
||
sim := func(a, b int) float64 {
|
||
sa, sb := sigs[a], sigs[b]
|
||
if len(sa.vec) > 0 && len(sb.vec) > 0 {
|
||
return cosineF32(sa.vec, sb.vec)
|
||
}
|
||
if len(sa.vec) == 0 && len(sb.vec) == 0 {
|
||
return bigramJaccard(sa.text, sb.text)
|
||
}
|
||
return 0
|
||
}
|
||
assigned := make([]bool, n)
|
||
var clusters [][]models.MemoryRecord
|
||
for i := 0; i < n; i++ {
|
||
if assigned[i] {
|
||
continue
|
||
}
|
||
cluster := []models.MemoryRecord{records[i]}
|
||
assigned[i] = true
|
||
for j := i + 1; j < n; j++ {
|
||
if assigned[j] {
|
||
continue
|
||
}
|
||
if sim(i, j) >= thresh {
|
||
cluster = append(cluster, records[j])
|
||
assigned[j] = true
|
||
}
|
||
}
|
||
if len(cluster) >= minSize {
|
||
clusters = append(clusters, cluster)
|
||
}
|
||
}
|
||
return clusters
|
||
}
|
||
|
||
// clusterConfidence 簇内平均两两相似度(cosine 或 Jaccard)。
|
||
func clusterConfidence(cluster []models.MemoryRecord) float64 {
|
||
n := len(cluster)
|
||
if n < 2 {
|
||
return 1.0
|
||
}
|
||
sum := 0.0
|
||
pairs := 0
|
||
for i := 0; i < n; i++ {
|
||
for j := i + 1; j < n; j++ {
|
||
var s float64
|
||
a, b := cluster[i], cluster[j]
|
||
if len(a.Vector) > 0 && len(b.Vector) > 0 {
|
||
s = cosineF32(a.Vector, b.Vector)
|
||
} else {
|
||
s = bigramJaccard(a.Content, b.Content)
|
||
}
|
||
sum += s
|
||
pairs++
|
||
}
|
||
}
|
||
if pairs == 0 {
|
||
return 1.0
|
||
}
|
||
return sum / float64(pairs)
|
||
}
|
||
|
||
// 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 {
|
||
lower := strings.ToLower(content)
|
||
out := make([]string, 0, 4)
|
||
for t := range knownTools {
|
||
// 词边界匹配(避免 "go" 命中 "google")
|
||
if isValidTool(t) && tokenPresent(lower, t) {
|
||
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 == "" {
|
||
return false
|
||
}
|
||
// 切词精确匹配
|
||
for _, w := range strings.Fields(lower) {
|
||
// 剥掉包裹的引号/逗号/括号
|
||
w = strings.Trim(w, `"',()[]|;:`)
|
||
if w == t {
|
||
return true
|
||
}
|
||
}
|
||
// 空格/首尾边界
|
||
if strings.Contains(lower, " "+t+" ") || strings.HasPrefix(lower, t+" ") || strings.HasSuffix(lower, " "+t) {
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
func dedupeSorted(in []string) []string {
|
||
seen := map[string]bool{}
|
||
out := make([]string, 0, len(in))
|
||
for _, s := range in {
|
||
if !seen[s] {
|
||
seen[s] = true
|
||
out = append(out, s)
|
||
}
|
||
}
|
||
sort.Strings(out)
|
||
return out
|
||
}
|
||
|
||
func dedupeStrings(in []string) []string {
|
||
seen := map[string]bool{}
|
||
out := make([]string, 0, len(in))
|
||
for _, s := range in {
|
||
if !seen[s] {
|
||
seen[s] = true
|
||
out = append(out, s)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func cosineF32(a, b []float32) float64 {
|
||
if len(a) == 0 || len(b) == 0 || len(a) != len(b) {
|
||
return 0
|
||
}
|
||
var dot, na, nb float64
|
||
for i := range a {
|
||
x, y := float64(a[i]), float64(b[i])
|
||
dot += x * y
|
||
na += x * x
|
||
nb += y * y
|
||
}
|
||
if na == 0 || nb == 0 {
|
||
return 0
|
||
}
|
||
c := dot / (float64(math.Sqrt(na)) * float64(math.Sqrt(nb)))
|
||
if c > 1.0 {
|
||
c = 1.0
|
||
}
|
||
if c < -1.0 {
|
||
c = -1.0
|
||
}
|
||
return c
|
||
}
|
||
|
||
// bigramJaccard 字符 bigram Jaccard 相似度(无向量时的确定性回退)。
|
||
func bigramJaccard(a, b string) float64 {
|
||
bg := func(s string) map[string]bool {
|
||
m := make(map[string]bool)
|
||
r := []rune(s)
|
||
for i := 0; i+1 < len(r); i++ {
|
||
m[string(r[i:i+2])] = true
|
||
}
|
||
return m
|
||
}
|
||
ma, mb := bg(a), bg(b)
|
||
if len(ma) == 0 && len(mb) == 0 {
|
||
return 1.0
|
||
}
|
||
if len(ma) == 0 || len(mb) == 0 {
|
||
return 0.0
|
||
}
|
||
inter := 0
|
||
for k := range ma {
|
||
if mb[k] {
|
||
inter++
|
||
}
|
||
}
|
||
uni := len(ma) + len(mb) - inter
|
||
if uni == 0 {
|
||
return 0
|
||
}
|
||
return float64(inter) / float64(uni)
|
||
}
|
||
|
||
// contentFingerprint 取 content 稳定短指纹(FNV 前 8 hex 位)——同 size 不同内容簇去重。
|
||
func contentFingerprint(s string) string {
|
||
if s == "" {
|
||
return "empty"
|
||
}
|
||
// FNV-1a 32
|
||
h := uint32(2166136261)
|
||
for i := 0; i < len(s); i++ {
|
||
h ^= uint32(s[i])
|
||
h *= 16777619
|
||
}
|
||
return fmt.Sprintf("%08x", h)
|
||
}
|
||
|
||
func truncateForPattern(s string, n int) string {
|
||
r := []rune(s)
|
||
if len(r) <= n {
|
||
return s
|
||
}
|
||
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
|
||
}
|