Files
Agent-Intent/intent/spec.go
T
weichen 3674dc4bfd
ci / vet-and-test (push) Failing after 2s
ci / docker-checkout-hostgateway (push) Successful in 2s
Add deterministic slot constraints with labels and questions, and the planner-facing capability catalog.
2026-09-29 16:32:50 +00:00

215 lines
9.1 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 (
"fmt"
"regexp"
"strings"
)
// 风险等级。透传给宿主做授权与确认判断,本模块不解释。
const (
RiskLow = "low"
RiskMedium = "medium"
RiskHigh = "high"
)
// ConfidenceSpec 各字段为零值时使用包默认值。
const (
DefaultBase = 0.7
DefaultPatternBonus = 0.2
DefaultThreshold = 0.6
)
// MatchSpec 声明式匹配规则。
// 计分:score = base + (任一 pattern 命中 ? pattern_bonus : 0),上限 1.0。
// 命中条件:excludes_any 全不命中 && includes_all 全命中 &&(includes_any 至少一词命中
// 或任一 pattern 命中;仅配置 includes_all 时以全命中为准)。
// case_sensitive 只作用于 includes/excludes 词表;pattern 自行用 (?i) 表达大小写。
type MatchSpec struct {
IncludesAny []string `json:"includes_any,omitempty"`
IncludesAll []string `json:"includes_all,omitempty"`
ExcludesAny []string `json:"excludes_any,omitempty"`
Patterns []string `json:"patterns,omitempty"`
CaseSensitive bool `json:"case_sensitive,omitempty"`
}
// SlotSpec 槽位抽取规则。group 未填默认取 1(第一个捕获组)。
// label/question 供宿主做 HITL 提问与目录展示;enum/format/normalize/validator
// 构成确定性校验链:抽取 → 归一化 → 校验,校验失败进 Candidate.InvalidSlots。
type SlotSpec struct {
Key string `json:"key"`
Required bool `json:"required,omitempty"`
Pattern string `json:"pattern"`
Group int `json:"group,omitempty"`
// Label 槽位的人类可读名(目录/HITL 展示用)。
Label string `json:"label,omitempty"`
// Question 建议的澄清提问(HITL 生成用),如"要查询哪个运行?请提供 run 编号"。
Question string `json:"question,omitempty"`
// Enum 合法值枚举(归一化后校验)。
Enum []string `json:"enum,omitempty"`
// Format 整段匹配正则(校验,区别于 Pattern 的抽取用途)。
Format string `json:"format,omitempty"`
// Normalize 归一化步骤,按序应用:trim | lower | upper。
Normalize []string `json:"normalize,omitempty"`
// Validator 宿主经 RegisterSlotValidator 注册的校验器名。
Validator string `json:"validator,omitempty"`
}
// ConfidenceSpec 置信度参数。Base 初始分;PatternBonus 模式命中加成;
// Threshold 为该 spec 自身的过阈线,score ≥ threshold 才进入候选。
type ConfidenceSpec struct {
Base float64 `json:"base,omitempty"`
PatternBonus float64 `json:"pattern_bonus,omitempty"`
Threshold float64 `json:"threshold,omitempty"`
}
// IntentSpec 意图定义,Go 注册与 JSON overlay 共用同一结构。
// Handler 与 Metadata 由宿主解释(路由通道、动作键等),本模块只透传。
type IntentSpec struct {
ID string `json:"id"`
Domain string `json:"domain"`
Version int `json:"version"`
Description string `json:"description,omitempty"`
Priority int `json:"priority,omitempty"`
Disabled bool `json:"disabled,omitempty"`
Matcher MatchSpec `json:"matcher"`
Slots []SlotSpec `json:"slots,omitempty"`
Confidence ConfidenceSpec `json:"confidence,omitempty"`
Risk string `json:"risk,omitempty"`
RequiresConfirm bool `json:"requires_confirm,omitempty"`
Handler string `json:"handler,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
}
var (
domainRe = regexp.MustCompile(`^[a-z][a-z0-9-]{1,31}$`)
segmentRe = regexp.MustCompile(`^[a-z][a-z0-9_-]{0,63}$`)
slotKeyRe = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`)
)
// limits 在 Options 中声明,防止配置膨胀拖垮识别与构建。
type limits struct {
maxSpecs int
maxPatternsPerSpec int
maxSlotsPerSpec int
}
// validateSpec 校验单条 spec 的结构合法性,错误可定位到字段。limits 为零值时跳过数量上限。
func validateSpec(spec IntentSpec, lim limits) error {
if spec.Domain == "" {
return newError(InvalidSpec, "domain 为空", "field", "domain")
}
if !domainRe.MatchString(spec.Domain) {
return newError(InvalidSpec, "domain 格式不合法(^[a-z][a-z0-9-]{1,31}$)", "field", "domain", "domain", spec.Domain)
}
if spec.ID == "" {
return newError(InvalidSpec, "id 为空", "field", "id")
}
// id 形如 <domain>.<mnemonic>,mnemonic 可继续分级(demo.run.status_query)。
segments := strings.Split(spec.ID, ".")
if len(segments) < 2 {
return newError(InvalidSpec, "id 至少包含 domain 与记忆名两段", "field", "id", "id", spec.ID)
}
if segments[0] != spec.Domain {
return newError(InvalidSpec, "id 首段必须等于 domain", "field", "id", "id", spec.ID, "domain", spec.Domain)
}
for _, seg := range segments[1:] {
if !segmentRe.MatchString(seg) {
return newError(InvalidSpec, "id 段格式不合法(^[a-z][a-z0-9_-]{0,63}$)", "field", "id", "id", spec.ID)
}
}
if spec.Version < 1 {
return newError(InvalidSpec, "version 必须 ≥ 1", "field", "version", "version", spec.Version)
}
switch spec.Risk {
case "", RiskLow, RiskMedium, RiskHigh:
default:
return newError(InvalidSpec, "risk 只能是 low/medium/high 或留空", "field", "risk", "risk", spec.Risk)
}
m := spec.Matcher
for _, terms := range []struct {
name string
value []string
}{{"includes_any", m.IncludesAny}, {"includes_all", m.IncludesAll}, {"excludes_any", m.ExcludesAny}} {
for _, t := range terms.value {
if strings.TrimSpace(t) == "" {
return newError(InvalidSpec, "matcher 词条为空", "field", "matcher."+terms.name)
}
}
}
if len(m.IncludesAny) == 0 && len(m.IncludesAll) == 0 && len(m.Patterns) == 0 {
return newError(InvalidSpec, "matcher 缺少正向条件(includes_any / includes_all / patterns 至少一项)", "field", "matcher", "id", spec.ID)
}
if lim.maxPatternsPerSpec > 0 && len(m.Patterns) > lim.maxPatternsPerSpec {
return newError(InvalidSpec, "patterns 数量超过上限", "field", "matcher.patterns", "count", len(m.Patterns), "max", lim.maxPatternsPerSpec, "id", spec.ID)
}
for i, p := range m.Patterns {
if _, err := regexp.Compile(p); err != nil {
return newError(InvalidSpec, "pattern 编译失败", "field", fmt.Sprintf("matcher.patterns[%d]", i), "pattern", p, "id", spec.ID, "error", err.Error())
}
}
if lim.maxSlotsPerSpec > 0 && len(spec.Slots) > lim.maxSlotsPerSpec {
return newError(InvalidSpec, "slots 数量超过上限", "field", "slots", "count", len(spec.Slots), "max", lim.maxSlotsPerSpec, "id", spec.ID)
}
for i, s := range spec.Slots {
if !slotKeyRe.MatchString(s.Key) {
return newError(InvalidSpec, "slot key 格式不合法(^[a-z][a-z0-9_]{0,63}$)", "field", fmt.Sprintf("slots[%d].key", i), "key", s.Key)
}
re, err := regexp.Compile(s.Pattern)
if err != nil {
return newError(InvalidSpec, "slot pattern 编译失败", "field", fmt.Sprintf("slots[%d].pattern", i), "key", s.Key, "error", err.Error())
}
// group 约定:未填默认取 1(第一个捕获组);需整段匹配时在 pattern 里写捕获组即可。
g := effectiveGroup(s.Group)
if g < 1 || g > re.NumSubexp() {
return newError(InvalidSpec, "slot group 超出捕获组范围", "field", fmt.Sprintf("slots[%d].group", i), "key", s.Key, "group", s.Group, "groups", re.NumSubexp())
}
for _, e := range s.Enum {
if strings.TrimSpace(e) == "" {
return newError(InvalidSpec, "slot enum 含空值", "field", fmt.Sprintf("slots[%d].enum", i), "key", s.Key)
}
}
if s.Format != "" {
if _, err := regexp.Compile(s.Format); err != nil {
return newError(InvalidSpec, "slot format 编译失败", "field", fmt.Sprintf("slots[%d].format", i), "key", s.Key, "error", err.Error())
}
}
for _, op := range s.Normalize {
if _, ok := builtinNormalizers[op]; !ok {
return newError(InvalidSpec, "slot normalize 步骤不合法(trim|lower|upper)", "field", fmt.Sprintf("slots[%d].normalize", i), "key", s.Key, "op", op)
}
}
if s.Validator != "" {
if _, ok := LookupSlotValidator(s.Validator); !ok {
return newError(InvalidSpec, "slot validator 未注册", "field", fmt.Sprintf("slots[%d].validator", i), "key", s.Key, "validator", s.Validator, "hint", "先调用 intent.RegisterSlotValidator 再注册/加载 spec")
}
}
}
c := spec.Confidence
if c.Base < 0 || c.Base > 1 {
return newError(InvalidSpec, "confidence.base 须在 (0,1],0/留空取默认", "field", "confidence.base", "id", spec.ID)
}
if c.PatternBonus < 0 || c.PatternBonus >= 1 {
return newError(InvalidSpec, "confidence.pattern_bonus 须在 [0,1),留空取默认", "field", "confidence.pattern_bonus", "id", spec.ID)
}
if c.Threshold < 0 || c.Threshold > 1 {
return newError(InvalidSpec, "confidence.threshold 须在 (0,1],0/留空取默认", "field", "confidence.threshold", "id", spec.ID)
}
for k := range spec.Metadata {
if strings.TrimSpace(k) == "" {
return newError(InvalidSpec, "metadata 键为空", "field", "metadata", "id", spec.ID)
}
}
return nil
}
// effectiveGroup 槽位 group 的缺省规则:未填(0)默认取 1。
func effectiveGroup(g int) int {
if g == 0 {
return 1
}
return g
}