Files
Agent-Intent/intent/registry.go
T
weichen b58595fb38 Implement the intent core
Details (original message):
Implement the intent core, conformance suite, catalog alignment, examples, and the intentctl diagnostics CLI.
2026-09-29 15:35:18 +00:00

658 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package intent
import (
"context"
"fmt"
"io/fs"
"math"
"regexp"
"sort"
"strings"
"sync"
"sync/atomic"
"logging-system/logsys"
)
// Options 注册表与识别器的共享选项。零值字段在构建时填入默认值;
// GuardDomains / ProtectedDomains 决定快照的阶段归属与 overlay 保护范围。
type Options struct {
// ProtectedDomains 保护域:overlay 覆盖或禁用这些域内的 spec 会整批失败(fail-closed)。
ProtectedDomains []string
// GuardDomains 守卫域:这些域内的 spec 在 Stage 0 演练,命中即短路返回 GuardHit。
GuardDomains []string
// TopN 结果候选数上限(默认 5)。
TopN int
// AmbiguityMargin 前两名分差小于该值判 Ambiguous(默认 0.1)。
AmbiguityMargin float64
// MaxInputLen 输入字节长度上限(默认 4096),超过返回 InvalidArgument。
MaxInputLen int
// MaxSpecs 快照内 spec 总数上限,含禁用(默认 2048)。
MaxSpecs int
// MaxPatternsPerSpec 单 spec 正则数量上限(默认 32)。
MaxPatternsPerSpec int
// MaxSlotsPerSpec 单 spec 槽位数量上限(默认 16)。
MaxSlotsPerSpec int
// Log 可选日志器;nil 时使用 Nop。识别日志不记录原始输入。
Log *logsys.Logger
}
// Options 字段默认值。
const (
DefaultTopN = 5
DefaultAmbiguityMargin = 0.1
DefaultMaxInputLen = 4096
DefaultMaxSpecs = 2048
DefaultMaxPatterns = 32
DefaultMaxSlots = 16
)
func (o Options) normalized() Options {
n := o
if n.TopN <= 0 {
n.TopN = DefaultTopN
}
if n.AmbiguityMargin <= 0 {
n.AmbiguityMargin = DefaultAmbiguityMargin
}
if n.MaxInputLen <= 0 {
n.MaxInputLen = DefaultMaxInputLen
}
if n.MaxSpecs <= 0 {
n.MaxSpecs = DefaultMaxSpecs
}
if n.MaxPatternsPerSpec <= 0 {
n.MaxPatternsPerSpec = DefaultMaxPatterns
}
if n.MaxSlotsPerSpec <= 0 {
n.MaxSlotsPerSpec = DefaultMaxSlots
}
if n.Log == nil {
n.Log = logsys.Nop()
}
return n
}
func (o Options) protectedSet() map[string]bool {
return domainSet(o.ProtectedDomains)
}
func (o Options) guardSet() map[string]bool {
return domainSet(o.GuardDomains)
}
func domainSet(domains []string) map[string]bool {
m := make(map[string]bool, len(domains))
for _, d := range domains {
m[d] = true
}
return m
}
func (o Options) limits() limits {
return limits{maxSpecs: o.MaxSpecs, maxPatternsPerSpec: o.MaxPatternsPerSpec, maxSlotsPerSpec: o.MaxSlotsPerSpec}
}
// ---- 编译产物 ----
type compiledSlot struct {
key string
required bool
re *regexp.Regexp
group int
}
type compiledSpec struct {
spec IntentSpec
termsAny []string // 已按 case_sensitive 折叠
termsAll []string
termsEx []string
patterns []*regexp.Regexp
slots []compiledSlot
guard bool
index int // 合并序,确定性次序裁决用
base float64
bonus float64
threshold float64
}
// matchOutcome 单个 spec 对一次输入的评估结果。
type matchOutcome struct {
score float64
passed bool
hitTerms []string
hitPat []string
reject string // 非 passed 时的排除原因
}
// evaluate 按文档化的计分规则评估。input 为原文,folded 为小写折叠后文本。
func (cs *compiledSpec) evaluate(input, folded string) matchOutcome {
hay := input
if !cs.spec.Matcher.CaseSensitive {
hay = folded
}
for _, t := range cs.termsEx {
if strings.Contains(hay, t) {
return matchOutcome{reject: "excluded_term:" + t}
}
}
for _, t := range cs.termsAll {
if !strings.Contains(hay, t) {
return matchOutcome{reject: "missing_includes_all:" + t}
}
}
var hitTerms, hitPat []string
positive := false
for _, t := range cs.termsAny {
if strings.Contains(hay, t) {
positive = true
hitTerms = append(hitTerms, t)
}
}
for i, p := range cs.patterns {
if p.MatchString(input) {
positive = true
hitPat = append(hitPat, cs.spec.Matcher.Patterns[i])
}
}
if !positive && (len(cs.termsAny) > 0 || len(cs.patterns) > 0) {
return matchOutcome{reject: "no_positive_match"}
}
score := cs.base
if len(hitPat) > 0 {
score += cs.bonus
}
if score > 1 {
score = 1
}
// 固定精度舍入:避免浮点噪声进入结果与 margin 比较。
return matchOutcome{score: math.Round(score*10000) / 10000, passed: score >= cs.threshold, hitTerms: hitTerms, hitPat: hitPat}
}
// extractSlots 从原文抽取槽位;返回缺失的必填槽位键。
func (cs *compiledSpec) extractSlots(input string) (map[string]string, []string) {
if len(cs.slots) == 0 {
return nil, nil
}
slots := make(map[string]string, len(cs.slots))
var missing []string
for _, s := range cs.slots {
m := s.re.FindStringSubmatch(input)
if m != nil && s.group < len(m) && m[s.group] != "" {
slots[s.key] = m[s.group]
continue
}
if s.required {
missing = append(missing, s.key)
}
}
return slots, missing
}
func compileSpec(spec IntentSpec, guard bool, index int) (*compiledSpec, error) {
m := spec.Matcher
fold := func(terms []string) []string {
out := make([]string, len(terms))
for i, t := range terms {
if m.CaseSensitive {
out[i] = t
} else {
out[i] = strings.ToLower(t)
}
}
return out
}
cs := &compiledSpec{
spec: spec,
termsAny: fold(m.IncludesAny),
termsAll: fold(m.IncludesAll),
termsEx: fold(m.ExcludesAny),
patterns: make([]*regexp.Regexp, len(m.Patterns)),
guard: guard,
index: index,
base: spec.Confidence.Base,
bonus: spec.Confidence.PatternBonus,
threshold: spec.Confidence.Threshold,
}
if cs.base == 0 {
cs.base = DefaultBase
}
if cs.bonus == 0 {
cs.bonus = DefaultPatternBonus
}
if cs.threshold == 0 {
cs.threshold = DefaultThreshold
}
for i, p := range m.Patterns {
re, err := regexp.Compile(p)
if err != nil {
return nil, newError(InvalidSpec, "pattern 编译失败", "field", fmt.Sprintf("matcher.patterns[%d]", i), "id", spec.ID, "error", err.Error())
}
cs.patterns[i] = re
}
for _, s := range spec.Slots {
re, err := regexp.Compile(s.Pattern)
if err != nil {
return nil, newError(InvalidSpec, "slot pattern 编译失败", "field", "slots", "key", s.Key, "id", spec.ID, "error", err.Error())
}
cs.slots = append(cs.slots, compiledSlot{key: s.Key, required: s.Required, re: re, group: effectiveGroup(s.Group)})
}
return cs, nil
}
// ---- 快照 ----
// Snapshot 是合并产物的不可变快照:全部 matcher 已编译,识别全程无锁读。
// 同一快照对同样输入给出同样结果;revision 单调递增。
type Snapshot struct {
rev int64
margin float64
topN int
maxInput int
specs []*compiledSpec // 仅启用 spec,按合并序
byID map[string]int
guardIdx []int
disabled []string // 已禁用 id(overlay disable 与 spec.disabled 两个入口)
sources map[string]string
total int // 含禁用
}
// Revision 返回快照版本号,随每次成功 Reload 递增。
func (s *Snapshot) Revision() int64 { return s.rev }
// SpecIDs 返回启用中的 spec id,按合并序(确定性次序裁决的注册序)。
func (s *Snapshot) SpecIDs() []string {
ids := make([]string, len(s.specs))
for i, cs := range s.specs {
ids[i] = cs.spec.ID
}
return ids
}
// DisabledIDs 返回被禁用的 spec id,字典序。
func (s *Snapshot) DisabledIDs() []string {
out := make([]string, len(s.disabled))
copy(out, s.disabled)
sort.Strings(out)
return out
}
// Source 返回 spec 的来源描述("builtin" 或 "<file>#intents[i]")。
func (s *Snapshot) Source(id string) (string, bool) {
src, ok := s.sources[id]
return src, ok
}
// SpecByID 返回指定 id 的启用 spec 定义副本。不存在或已禁用时第二个返回值为 false。
func (s *Snapshot) SpecByID(id string) (IntentSpec, bool) {
if idx, ok := s.byID[id]; ok {
return s.specs[idx].spec, true
}
return IntentSpec{}, false
}
// Conflict 诊断信息:两个启用 spec 共享同一 includes_any 词条。
// 共享不一定是错误(计分与确定性次序会裁决),用于配置评审提示。
type Conflict struct {
Kind string
A string
B string
Term string
}
// Conflicts 报告非守卫 spec 之间共享的 includes_any 词条,确定性排序。
func (s *Snapshot) Conflicts() []Conflict {
termOwners := make(map[string][]string)
for _, cs := range s.specs {
if cs.guard {
continue
}
for _, t := range cs.spec.Matcher.IncludesAny {
termOwners[t] = append(termOwners[t], cs.spec.ID)
}
}
terms := make([]string, 0, len(termOwners))
for t, owners := range termOwners {
if len(owners) > 1 {
terms = append(terms, t)
}
}
sort.Strings(terms)
var out []Conflict
for _, t := range terms {
owners := append([]string(nil), termOwners[t]...)
sort.Strings(owners)
for i := 0; i < len(owners); i++ {
for j := i + 1; j < len(owners); j++ {
out = append(out, Conflict{Kind: "shared_term", A: owners[i], B: owners[j], Term: t})
}
}
}
return out
}
// specHit 规则阶段的内部命中记录。
type specHit struct {
cs *compiledSpec
score float64
terms []string
pats []string
}
func lessHit(a, b specHit) bool {
if a.score != b.score {
return a.score > b.score
}
if a.cs.spec.Priority != b.cs.spec.Priority {
return a.cs.spec.Priority > b.cs.spec.Priority
}
return a.cs.index < b.cs.index
}
func sortHits(hits []specHit) {
sort.SliceStable(hits, func(i, j int) bool { return lessHit(hits[i], hits[j]) })
}
// evalGuard 评估守卫域 spec,返回过阈命中(已排序)。
func (s *Snapshot) evalGuard(input, folded string) []specHit {
var hits []specHit
for _, idx := range s.guardIdx {
cs := s.specs[idx]
if o := cs.evaluate(input, folded); o.passed {
hits = append(hits, specHit{cs: cs, score: o.score, terms: o.hitTerms, pats: o.hitPat})
}
}
sortHits(hits)
return hits
}
// evalIDs 按给定 id 列表评估(挂起预期阶段用),返回过阈命中(已排序)。
func (s *Snapshot) evalIDs(ids []string, input, folded string) []specHit {
var hits []specHit
for _, id := range ids {
idx, ok := s.byID[id]
if !ok {
continue
}
cs := s.specs[idx]
if o := cs.evaluate(input, folded); o.passed {
hits = append(hits, specHit{cs: cs, score: o.score, terms: o.hitTerms, pats: o.hitPat})
}
}
sortHits(hits)
return hits
}
// evalRules 评估全部非守卫 spec,返回过阈命中(已排序)。
func (s *Snapshot) evalRules(input, folded string) []specHit {
var hits []specHit
for _, cs := range s.specs {
if cs.guard {
continue
}
if o := cs.evaluate(input, folded); o.passed {
hits = append(hits, specHit{cs: cs, score: o.score, terms: o.hitTerms, pats: o.hitPat})
}
}
sortHits(hits)
return hits
}
func (h specHit) reason() string {
var parts []string
if len(h.terms) > 0 {
parts = append(parts, "terms="+strings.Join(h.terms, ","))
}
if len(h.pats) > 0 {
parts = append(parts, "patterns="+strings.Join(h.pats, ","))
}
return strings.Join(parts, " ")
}
func (h specHit) candidate(input string, withSlots bool) Candidate {
cs := h.cs
c := Candidate{
ID: cs.spec.ID,
Domain: cs.spec.Domain,
Version: cs.spec.Version,
Confidence: h.score,
Priority: cs.spec.Priority,
Risk: cs.spec.Risk,
RequiresConfirm: cs.spec.RequiresConfirm,
Handler: cs.spec.Handler,
Reason: h.reason(),
}
if c.Risk == "" {
c.Risk = RiskLow
}
if len(cs.spec.Metadata) > 0 {
c.Metadata = make(map[string]string, len(cs.spec.Metadata))
for k, v := range cs.spec.Metadata {
c.Metadata[k] = v
}
}
if withSlots {
c.Slots, c.MissingRequiredSlots = cs.extractSlots(input)
}
return c
}
func candidatesOf(hits []specHit, input string, topSlots bool) []Candidate {
out := make([]Candidate, 0, len(hits))
for i, h := range hits {
out = append(out, h.candidate(input, topSlots && i == 0))
}
return out
}
// ---- 构建与合并 ----
// RegistryBuilder 收集 Go 内置基线 spec 并构建 Registry。
type RegistryBuilder struct {
opts Options
builtin []IntentSpec
seen map[string]bool
}
// NewRegistryBuilder 创建构建器。opts 零值字段填默认。
func NewRegistryBuilder(opts Options) *RegistryBuilder {
return &RegistryBuilder{opts: opts.normalized(), seen: make(map[string]bool)}
}
// Register 登记内置基线 spec,立即校验;重复 id 报 InvalidSpec。
func (b *RegistryBuilder) Register(specs ...IntentSpec) error {
lim := b.opts.limits()
for _, spec := range specs {
if b.seen[spec.ID] {
return newError(InvalidSpec, "内置 spec id 重复", "id", spec.ID)
}
if err := validateSpec(spec, lim); err != nil {
return err
}
b.seen[spec.ID] = true
b.builtin = append(b.builtin, spec)
}
return nil
}
// Build 仅用内置基线构建(rev 1)。此后 Reload 会报错(无配置来源)。
func (b *RegistryBuilder) Build() (*Registry, error) {
return b.buildWithSource(nil, nil)
}
// BuildFS 用内置基线 + JSON overlay 来源构建,并记住来源供 Reload 重读。
// roots 按给定顺序应用;目录 root 内按文件名字典序加载 *.json。
func (b *RegistryBuilder) BuildFS(ctx context.Context, fsys fs.FS, roots ...string) (*Registry, error) {
if fsys == nil {
return nil, newError(InvalidArgument, "fsys 为 nil")
}
if len(roots) == 0 {
return nil, newError(InvalidArgument, "至少提供一个配置 root")
}
return b.buildWithSource(fsys, roots)
}
func (b *RegistryBuilder) buildWithSource(fsys fs.FS, roots []string) (*Registry, error) {
var entries []overlayEntry
if fsys != nil {
var err error
entries, err = loadOverlays(fsys, roots)
if err != nil {
return nil, err
}
}
snap, err := buildSnapshot(b.opts, b.builtin, entries, 1)
if err != nil {
return nil, err
}
r := &Registry{
opts: b.opts,
builtin: append([]IntentSpec(nil), b.builtin...),
fsys: fsys,
roots: append([]string(nil), roots...),
hasSource: fsys != nil,
log: b.opts.Log,
}
r.snap.Store(snap)
return r, nil
}
// Registry 持有当前快照与配置来源,支持原子 Reload。
type Registry struct {
opts Options
builtin []IntentSpec
fsys fs.FS
roots []string
hasSource bool
mu sync.Mutex // 串行化 Reload
snap atomic.Pointer[Snapshot]
log *logsys.Logger
}
// Snapshot 返回当前生效快照。永不为 nil。
func (r *Registry) Snapshot() *Snapshot { return r.snap.Load() }
// Options 返回生效选项(已填默认值)。
func (r *Registry) Options() Options { return r.opts }
// Reload 重读配置来源并构建新快照;成功后原子替换并返回新快照。
// 任一步失败保留旧快照,revision 不变,返回带来源与原因的错误。
func (r *Registry) Reload(ctx context.Context) (*Snapshot, error) {
r.mu.Lock()
defer r.mu.Unlock()
old := r.snap.Load()
if !r.hasSource {
err := newError(LoadFailed, "没有登记配置来源;用 BuildFS 构建后才能 Reload", "revision", old.rev)
r.log.Module("intent").Error("intent config rejected", err)
return nil, err
}
entries, err := loadOverlays(r.fsys, r.roots)
if err != nil {
r.log.Module("intent").Error("intent config rejected", err)
return nil, err
}
ns, err := buildSnapshot(r.opts, r.builtin, entries, old.rev+1)
if err != nil {
r.log.Module("intent").Error("intent config rejected", err)
return nil, err
}
r.snap.Store(ns)
r.log.Module("intent").Info("intent snapshot reloaded",
"revision", ns.rev, "specs", len(ns.specs), "disabled", len(ns.disabled))
return ns, nil
}
// buildSnapshot 执行双源合并与编译。任一错误整批失败,不产生半套快照。
func buildSnapshot(opts Options, baseline []IntentSpec, entries []overlayEntry, rev int64) (*Snapshot, error) {
opts = opts.normalized()
protected := opts.protectedSet()
guards := opts.guardSet()
lim := opts.limits()
ordered := append([]IntentSpec(nil), baseline...)
pos := make(map[string]int, len(ordered))
sources := make(map[string]string, len(ordered))
for i, spec := range ordered {
pos[spec.ID] = i
sources[spec.ID] = "builtin"
}
var disabledList []string
for _, e := range entries {
if e.disable {
idx, ok := pos[e.id]
if !ok {
return nil, newError(InvalidSpec, "disable 目标不存在", "id", e.id, "source", e.source)
}
if protected[ordered[idx].Domain] {
return nil, newError(SpecProtected, "overlay 试图禁用保护域 spec",
"id", e.id, "domain", ordered[idx].Domain, "source", e.source)
}
ordered = append(ordered[:idx], ordered[idx+1:]...)
for id, p := range pos {
if p > idx {
pos[id] = p - 1
}
}
delete(pos, e.id)
disabledList = append(disabledList, e.id)
sources[e.id] = e.source + " (disabled)"
continue
}
spec := e.spec
if err := validateSpec(spec, lim); err != nil {
return nil, err.(*Error).with("source", e.source)
}
if idx, ok := pos[spec.ID]; ok {
old := ordered[idx]
if protected[old.Domain] {
return nil, newError(SpecProtected, "overlay 试图覆盖保护域 spec",
"id", spec.ID, "domain", old.Domain, "source", e.source)
}
if spec.Version <= old.Version {
return nil, newError(InvalidSpec, "覆盖必须递增 version",
"id", spec.ID, "old_version", old.Version, "new_version", spec.Version, "source", e.source)
}
ordered[idx] = spec
} else {
ordered = append(ordered, spec)
pos[spec.ID] = len(ordered) - 1
}
sources[spec.ID] = e.source
}
total := len(ordered) + len(disabledList)
if total > lim.maxSpecs {
return nil, newError(InvalidSpec, "spec 总数超过上限", "total", total, "max", lim.maxSpecs)
}
snap := &Snapshot{
rev: rev,
margin: opts.AmbiguityMargin,
topN: opts.TopN,
maxInput: opts.MaxInputLen,
byID: make(map[string]int, len(ordered)),
disabled: disabledList,
sources: sources,
total: total,
}
nextIndex := 0
for _, spec := range ordered {
if spec.Disabled {
snap.disabled = append(snap.disabled, spec.ID)
continue
}
cs, err := compileSpec(spec, guards[spec.Domain], nextIndex)
if err != nil {
return nil, err
}
snap.specs = append(snap.specs, cs)
snap.byID[spec.ID] = len(snap.specs) - 1
if cs.guard {
snap.guardIdx = append(snap.guardIdx, len(snap.specs)-1)
}
nextIndex++
}
return snap, nil
}